diff --git a/CHANGELOG.md b/CHANGELOG.md index 064c01de23b..bd8376ab7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07. - Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337. +- Security/config-audit: redact CLI argv and execArgv secrets before persisting config audit records, covering write, observe, and recovery paths. Fixes #60826. Thanks @koshaji. - Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches `pnpm install` after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan. - Discord: retry queued REST 429s against learned bucket/global cooldowns and reacquire fresh voice upload URLs after CDN upload rate limits, so outbound sends recover without reusing stale single-use upload URLs. Thanks @discord. - TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens. diff --git a/src/config/io.audit.test.ts b/src/config/io.audit.test.ts index b0322e86d27..db3614d6f27 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,231 @@ 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 unknown but credential-suffixed flags via the heuristic classifier", () => { + const argv = [ + "node", + "openclaw", + "--custom-api-key", + "real-tenant-key-AB12CD34EF56GH78", + "--alibaba-model-studio-api-key=plain-value-xyz-12345", + "--app-token", + "another-secret-value", + "--frobnicate-credential=hidden", + ]; + const result = redactConfigAuditArgv(argv); + expect(result).toEqual([ + "node", + "openclaw", + "--custom-api-key", + "***", + "--alibaba-model-studio-api-key=***", + "--app-token", + "***", + "--frobnicate-credential=***", + ]); + }); + + it("redacts key-valued secret flags (Nostr --private-key, Matrix --recovery-key)", () => { + const argv = [ + "node", + "openclaw", + "channels", + "add", + "--channel", + "nostr", + "--private-key", + "nsec1realnostrprivatekeyvaluexyz1234567890", + "--recovery-key=EsTb-ABCD-1234-EFGH-5678-IJKL-9012-MNOP", + ]; + const result = redactConfigAuditArgv(argv); + expect(result).toEqual([ + "node", + "openclaw", + "channels", + "add", + "--channel", + "nostr", + "--private-key", + "***", + "--recovery-key=***", + ]); + }); + + it("redacts unknown *-key flags via the heuristic classifier (private/signing/master/etc.)", () => { + const argv = [ + "node", + "openclaw", + "--my-plugin-private-key", + "tenant-private-key-material-zzz", + "--rotated-signing-key=PEM-LIKE-MATERIAL", + "--ops-master-key", + "ABCDEF1234567890", + ]; + const result = redactConfigAuditArgv(argv); + expect(result).toEqual([ + "node", + "openclaw", + "--my-plugin-private-key", + "***", + "--rotated-signing-key=***", + "--ops-master-key", + "***", + ]); + }); + + it("masks the next arg after a secret flag even when it looks like another option", () => { + const argv = ["openclaw", "--token", "--port", "8080"]; + expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--token", "***", "8080"]); + }); + + it("redacts dash-leading secret values after bare secret flags", () => { + const argv = ["openclaw", "--password", "-secret-value"]; + expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--password", "***"]); + }); + + it("does not mask when a secret flag is the final arg with no value", () => { + const argv = ["openclaw", "--token"]; + expect(redactConfigAuditArgv(argv)).toEqual(["openclaw", "--token"]); + }); + + it("caps caller-supplied processInfo argv at 8 entries before redaction", () => { + const longArgv = [ + "node", + "openclaw", + "--api-key", + "secret", + "--port", + "8080", + "--bind", + "lan", + "--leaks-here-token", + "this-must-not-land-in-audit-1234567890", + ]; + 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: longArgv, + execArgv: [], + }, + }); + expect(base.argv).toHaveLength(8); + expect(base.argv).not.toContain("this-must-not-land-in-audit-1234567890"); + expect(base.argv).not.toContain("--leaks-here-token"); + }); + + 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..4066640acfd 100644 --- a/src/config/io.audit.ts +++ b/src/config/io.audit.ts @@ -1,6 +1,136 @@ import path from "node:path"; +import { redactToolPayloadText } from "../logging/redact.js"; import { resolveStateDir } from "./paths.js"; +const CONFIG_AUDIT_ARGV_CAP = 8; + +// Conservative list of credential-bearing flags. The heuristic suffix +// classifier below catches the long tail (`--custom-api-key`, +// `--alibaba-model-studio-api-key`, plugin-defined `cliFlag` values, etc.) +// without needing every name enumerated here. +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", + "--app-token", + "--remote-token", + "--push-token", + "--webhook-secret", + "--webhook-token", + "--service-account-token", + "--op-service-account-token", + "--bearer", + "--bearer-token", + "--pat", + "--personal-access-token", + "--oauth-token", + "--id-token", + "--identity-token", + "--session-token", + "--service-token", + "--private-key", + "--recovery-key", + "--gateway-key", + "--session-key", + "--active-key", +]); + +// Suffix-based heuristic. Any `--…-(token|secret|password|passwd|api-key| +// apikey|api-secret|webhook|credential|bearer|pat|private-key|recovery-key| +// signing-key|encryption-key|master-key|session-key|gateway-key|service-key| +// hook-key)` is treated as a secret flag in addition to the explicit list. +// The leading `--` is required so we don't mismatch arbitrary positional args. +const SECRET_FLAG_SUFFIX_PATTERN = + /^--(?:[a-z0-9]+(?:-[a-z0-9]+)*-)?(?:token|secret|password|passwd|api[-_]?key|api[-_]?secret|webhook|credential|bearer|pat|private[-_]?key|recovery[-_]?key|signing[-_]?key|encryption[-_]?key|master[-_]?key|session[-_]?key|gateway[-_]?key|service[-_]?key|hook[-_]?key)$/; + +function isSecretFlagName(flagName: string | null): boolean { + if (flagName === null) { + return false; + } + if (SECRET_FLAG_NAMES.has(flagName)) { + return true; + } + return SECRET_FLAG_SUFFIX_PATTERN.test(flagName); +} + +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. +// Layers, applied per element: +// 1. `--flag=value` form for any name matching the explicit list or the +// suffix heuristic — mask the value half. +// 2. value following a bare `--flag` form — emit `***` instead of the +// next arg, even if it starts with `-`. Command parsers accept +// dash-leading values for required options, and this persistent audit +// log should fail closed. +// 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[] = []; + let redactNext = false; + for (let i = 0; i < argv.length; i++) { + const current = argv[i]; + if (typeof current !== "string") { + result.push(current); + redactNext = false; + continue; + } + if (redactNext) { + redactNext = false; + result.push("***"); + continue; + } + const currentFlag = parseFlagName(current); + if (currentFlag !== null && isSecretFlagName(currentFlag)) { + if (current.includes("=")) { + const eq = current.indexOf("="); + result.push(`${current.slice(0, eq + 1)}***`); + continue; + } + result.push(current); + redactNext = true; + continue; + } + result.push(redactToolPayloadText(current)); + } + return result; +} + +function capArgv(argv: readonly string[] | undefined): string[] { + if (!Array.isArray(argv)) { + return []; + } + return argv.slice(0, CONFIG_AUDIT_ARGV_CAP); +} + +export function snapshotConfigAuditProcessInfo(): ConfigAuditProcessInfo { + return { + pid: process.pid, + ppid: process.ppid, + cwd: process.cwd(), + argv: redactConfigAuditArgv(capArgv(process.argv)), + execArgv: redactConfigAuditArgv(capArgv(process.execArgv)), + }; +} + const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl"; export type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed" | "rejected"; @@ -163,15 +293,13 @@ function resolveConfigAuditProcessInfo( processInfo?: ConfigAuditProcessInfo, ): ConfigAuditProcessInfo { if (processInfo) { - return processInfo; + return { + ...processInfo, + argv: redactConfigAuditArgv(capArgv(processInfo.argv)), + execArgv: redactConfigAuditArgv(capArgv(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,