diff --git a/src/config/io.audit.test.ts b/src/config/io.audit.test.ts index b0322e86d27..5290d39f335 100644 --- a/src/config/io.audit.test.ts +++ b/src/config/io.audit.test.ts @@ -7,6 +7,7 @@ import { createConfigWriteAuditRecordBase, finalizeConfigWriteAuditRecord, formatConfigOverwriteLogMessage, + redactConfigAuditArgv, resolveConfigAuditLogPath, } from "./io.audit.js"; @@ -195,6 +196,95 @@ describe("config io audit helpers", () => { }); }); + it("redacts argv values that follow known secret flag names", () => { + const argv = [ + "node", + "openclaw", + "gateway", + "--token", + "super-secret-gateway-token-12345", + "--api-key", + "sk-very-real-looking-openai-api-key-AB12CD34", + "--port", + "8080", + ]; + const result = redactConfigAuditArgv(argv); + expect(result).toEqual([ + "node", + "openclaw", + "gateway", + "--token", + "***", + "--api-key", + "***", + "--port", + "8080", + ]); + }); + + it("redacts the value half of `--flag=value` for secret flags", () => { + const argv = ["openclaw", "--token=ghp_realgithubtoken1234567890ABCD", "--port=8080"]; + expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--token=***", "--port=8080"]); + }); + + it("redacts standalone token shapes via the shared logging redaction patterns", () => { + const argv = [ + "node", + "openclaw", + "ghp_realgithubtoken1234567890ABCD", + "AIzaSyD-very-real-looking-google-api-key-123", + "987654321:AAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ]; + const result = redactConfigAuditArgv(argv); + expect(result[0]).toBe("node"); + expect(result[1]).toBe("openclaw"); + for (const masked of result.slice(2)) { + expect(masked).not.toContain("ghp_realgithubtoken"); + expect(masked).not.toContain("AIzaSyD-very-real-looking"); + expect(masked).not.toMatch(/AAAAAAAAAAAAAA/); + } + }); + + it("leaves non-secret arguments untouched", () => { + const argv = ["node", "openclaw", "gateway", "--port", "8080", "--bind", "lan"]; + expect(redactConfigAuditArgv(argv)).toEqual(argv); + }); + + it("redacts processInfo.argv when explicitly supplied to createConfigWriteAuditRecordBase", () => { + const base = createConfigWriteAuditRecordBase({ + configPath: "/tmp/openclaw.json", + env: {} as NodeJS.ProcessEnv, + existsBefore: true, + previousHash: "prev", + nextHash: "next", + previousBytes: 1, + nextBytes: 2, + previousMetadata: { + dev: null, + ino: null, + mode: null, + nlink: null, + uid: null, + gid: null, + }, + changedPathCount: 0, + hasMetaBefore: true, + hasMetaAfter: true, + gatewayModeBefore: "local", + gatewayModeAfter: "local", + suspicious: [], + now: "2026-04-30T00:00:00.000Z", + processInfo: { + pid: 1, + ppid: 1, + cwd: "/work", + argv: ["node", "openclaw", "--token", "leaked-but-not-anymore-12345"], + execArgv: [], + }, + }); + expect(base.argv).toEqual(["node", "openclaw", "--token", "***"]); + }); + it("also accepts flattened audit record params from legacy call sites", async () => { const home = await suiteRootTracker.make("append-flat"); const record = createRenameAuditRecord(home); diff --git a/src/config/io.audit.ts b/src/config/io.audit.ts index 9bef1969fc4..f7db6c79f46 100644 --- a/src/config/io.audit.ts +++ b/src/config/io.audit.ts @@ -1,6 +1,82 @@ import path from "node:path"; +import { redactToolPayloadText } from "../logging/redact.js"; import { resolveStateDir } from "./paths.js"; +const SECRET_FLAG_NAMES = new Set([ + "--token", + "--api-key", + "--apikey", + "--secret", + "--password", + "--passwd", + "--auth-token", + "--access-token", + "--refresh-token", + "--client-secret", + "--hook-token", + "--gateway-token", + "--bot-token", + "--webhook-secret", + "--service-account-token", + "--op-service-account-token", +]); + +function parseFlagName(arg: string): string | null { + if (typeof arg !== "string" || !arg.startsWith("--")) { + return null; + } + const eq = arg.indexOf("="); + return (eq === -1 ? arg : arg.slice(0, eq)).toLowerCase(); +} + +// Redacts CLI argv before it lands in the persistent config-audit log. +// Three layers, applied per element: +// 1. `--flag=value` form for known secret flag names — mask the value half. +// 2. value following a bare `--flag` form — emit `***` instead of the next arg. +// 3. fall back to redactToolPayloadText for everything else, which catches +// `KEY=VALUE` env-style assignments, raw token shapes (sk-, ghp_, xox*, +// gsk_, AIza*, npm_, Telegram bot tokens, PEM blocks, Bearer headers, +// URL query secrets) using the shared redaction patterns. +export function redactConfigAuditArgv(argv: readonly string[]): string[] { + const result: string[] = []; + for (let i = 0; i < argv.length; i++) { + const current = argv[i]; + if (typeof current !== "string") { + result.push(current); + continue; + } + const currentFlag = parseFlagName(current); + if (currentFlag !== null && SECRET_FLAG_NAMES.has(currentFlag) && current.includes("=")) { + const eq = current.indexOf("="); + result.push(`${current.slice(0, eq + 1)}***`); + continue; + } + const previous = i > 0 ? argv[i - 1] : undefined; + const previousFlag = typeof previous === "string" ? parseFlagName(previous) : null; + if ( + previousFlag !== null && + SECRET_FLAG_NAMES.has(previousFlag) && + typeof previous === "string" && + !previous.includes("=") + ) { + result.push("***"); + continue; + } + result.push(redactToolPayloadText(current)); + } + return result; +} + +export function snapshotConfigAuditProcessInfo(): ConfigAuditProcessInfo { + return { + pid: process.pid, + ppid: process.ppid, + cwd: process.cwd(), + argv: redactConfigAuditArgv(process.argv.slice(0, 8)), + execArgv: redactConfigAuditArgv(process.execArgv.slice(0, 8)), + }; +} + const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl"; export type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed" | "rejected"; @@ -163,15 +239,13 @@ function resolveConfigAuditProcessInfo( processInfo?: ConfigAuditProcessInfo, ): ConfigAuditProcessInfo { if (processInfo) { - return processInfo; + return { + ...processInfo, + argv: redactConfigAuditArgv(processInfo.argv), + execArgv: redactConfigAuditArgv(processInfo.execArgv), + }; } - return { - pid: process.pid, - ppid: process.ppid, - cwd: process.cwd(), - argv: process.argv.slice(0, 8), - execArgv: process.execArgv.slice(0, 8), - }; + return snapshotConfigAuditProcessInfo(); } export function resolveConfigAuditLogPath(env: NodeJS.ProcessEnv, homedir: () => string): string { diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index 9d595bdc339..51a11b3db07 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -4,6 +4,7 @@ import { isRecord } from "../utils.js"; import { appendConfigAuditRecord, appendConfigAuditRecordSync, + snapshotConfigAuditProcessInfo, type ConfigObserveAuditRecord, } from "./io.audit.js"; import { formatConfigIssueSummary } from "./issue-format.js"; @@ -143,11 +144,7 @@ function createConfigObserveAuditRecord(params: { event: "config.observe", phase: "read", configPath: params.configPath, - pid: process.pid, - ppid: process.ppid, - cwd: process.cwd(), - argv: process.argv.slice(0, 8), - execArgv: process.execArgv.slice(0, 8), + ...snapshotConfigAuditProcessInfo(), exists: true, valid: params.valid, hash: params.current.hash, diff --git a/src/config/io.ts b/src/config/io.ts index 36f38362ed6..4864407976a 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -52,6 +52,7 @@ import { createConfigWriteAuditRecordBase, finalizeConfigWriteAuditRecord, formatConfigOverwriteLogMessage, + snapshotConfigAuditProcessInfo, type ConfigWriteAuditResult, } from "./io.audit.js"; import { throwInvalidConfig } from "./io.invalid-config.js"; @@ -713,11 +714,7 @@ async function observeConfigSnapshot( event: "config.observe", phase: "read", configPath: snapshot.path, - pid: process.pid, - ppid: process.ppid, - cwd: process.cwd(), - argv: process.argv.slice(0, 8), - execArgv: process.execArgv.slice(0, 8), + ...snapshotConfigAuditProcessInfo(), exists: true, valid: snapshot.valid, hash: current.hash, @@ -847,11 +844,7 @@ function observeConfigSnapshotSync( event: "config.observe", phase: "read", configPath: snapshot.path, - pid: process.pid, - ppid: process.ppid, - cwd: process.cwd(), - argv: process.argv.slice(0, 8), - execArgv: process.execArgv.slice(0, 8), + ...snapshotConfigAuditProcessInfo(), exists: true, valid: snapshot.valid, hash: current.hash,