fix(security): stop leaking credentials and tighten on-disk permissions

- config set: mask api_key/access_token/access_key_id/access_key_secret in the
  confirmation echo. It previously printed the stored secret verbatim to stdout
  (CI logs, pipes, screen shares), unlike `config show` / `auth status` which
  already maskToken().
- http / knowledge retrieve: use maskToken() in --verbose request logs instead
  of printing the first 8 chars of the bearer token / AccessKey id.
- telemetry: write telemetry.jsonl with mode 0600 (was created world-readable
  by default), matching the other credential-area writers.
- ensureConfigDir: chmod 0700 after mkdir, so a pre-existing ~/.bailian created
  by an older build/another tool (where mkdir's mode is ignored) holding
  cleartext credentials gets locked down too. Best-effort; never fatal.

https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1
This commit is contained in:
Claude
2026-05-29 12:34:19 +00:00
parent 55f9fa21cf
commit 3e4f1f0ebf
5 changed files with 24 additions and 4 deletions
+10 -1
View File
@@ -1,6 +1,7 @@
import {
defineCommand,
detectOutputFormat,
maskToken,
readConfigFile,
writeConfigFile,
BailianError,
@@ -28,6 +29,11 @@ const VALID_KEYS = [
"workspace_id",
];
// Keys whose values are secrets. Their stored value must never be echoed back in
// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the
// same policy `config show` and `auth status` already follow.
const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]);
// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
const KEY_ALIASES: Record<string, string> = {
"base-url": "base_url",
@@ -120,7 +126,10 @@ export default defineCommand({
await writeConfigFile(existing);
if (!config.quiet) {
emitResult({ [resolvedKey]: existing[resolvedKey] }, format);
const shown = SECRET_KEYS.has(resolvedKey)
? maskToken(String(existing[resolvedKey]))
: existing[resolvedKey];
emitResult({ [resolvedKey]: shown }, format);
}
},
});
@@ -2,6 +2,7 @@ import {
defineCommand,
signRequest,
detectOutputFormat,
maskToken,
type Config,
type GlobalFlags,
type KnowledgeRetrieveRequest,
@@ -105,7 +106,7 @@ export default defineCommand({
if (config.verbose) {
process.stderr.write(`> POST ${url}\n`);
process.stderr.write(`> AK: ${accessKeyId.slice(0, 8)}...\n`);
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
+2 -1
View File
@@ -4,6 +4,7 @@ import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { resolveCredential } from "../auth/resolver.ts";
import { mapApiError } from "../errors/api.ts";
import { maskToken } from "../utils/token.ts";
import { SOURCE_CONFIG, trackingHeaders } from "./headers.ts";
export interface RequestOpts {
@@ -58,7 +59,7 @@ export async function request(config: Config, opts: RequestOpts): Promise<Respon
if (config.verbose) {
console.error(`> ${opts.method ?? "GET"} ${opts.url}`);
console.error(`> Auth: ${credential.token.slice(0, 8)}...`);
console.error(`> Auth: ${maskToken(credential.token)}`);
console.error(`> x-dashscope-source-config: ${SOURCE_CONFIG}`);
}
}
+9
View File
@@ -20,4 +20,13 @@ export async function ensureConfigDir(): Promise<void> {
const dir = getConfigDir();
const fs = await import("fs/promises");
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
// `mkdir`'s `mode` only applies to directories it creates (and is masked by
// umask). A config dir created by an older build or another tool may still be
// world/group-readable while holding cleartext credentials, so tighten it
// explicitly. Best-effort: never let a chmod failure break the command.
try {
await fs.chmod(dir, 0o700);
} catch {
/* best effort */
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ export async function localSink(event: TrackingEvent): Promise<void> {
// 文件还不存在,忽略
}
appendFileSync(path, JSON.stringify(event) + "\n");
appendFileSync(path, JSON.stringify(event) + "\n", { mode: 0o600 });
} catch {
// 埋点逻辑任何异常都不能影响 CLI 主流程
}