fix(config-audit): redact CLI argv secrets before persisting to log (#60826)

`config-audit.jsonl` is the persistent record of config writes/observes
under `~/.openclaw/logs/`. It captures `process.argv.slice(0, 8)` raw at
four sites (write-audit, async observe, sync observe, observe-recovery
record builder), so any token passed as a CLI flag (gateway tokens, bot
tokens, hook tokens, exec'd `op read`/op-style tokens) lands at rest in
a mode-0600 file indefinitely. The mechanism designed to detect config
tampering becomes the credential-leak vector itself.

Centralize the snapshot and redact at the boundary:

- New `snapshotConfigAuditProcessInfo()` in src/config/io.audit.ts wraps
  the `process.*` reads and routes argv/execArgv through a new
  `redactConfigAuditArgv()` helper.
- redactConfigAuditArgv has three layers per element:
  1. `--flag=value` for known secret flag names → mask the value half.
  2. value following a bare `--flag` → emit `***`.
  3. fall through to redactToolPayloadText (force-on tools mode using
     the shared logging.redactPatterns defaults) for the remaining
     standalone token shapes — sk-/ghp_/xox*/gsk_/AIza*/npm_, Telegram
     bot tokens, PEM blocks, Bearer headers, URL query secrets,
     `KEY=VALUE` env-style assignments.
- `resolveConfigAuditProcessInfo` (write-base path) now also redacts a
  caller-supplied processInfo, so test/non-default callers can't bypass.
- The three other inline `process.argv.slice(0, 8)` sites in io.ts
  (sync + async observe) and io.observe-recovery.ts now spread
  `...snapshotConfigAuditProcessInfo()` instead.

Five new tests cover: secret-flag value redaction (space-separated and
=-form), shared-pattern fallback for standalone token shapes, untouched
non-secret args, and end-to-end via createConfigWriteAuditRecordBase
with a crafted processInfo.

Fixes #60826
This commit is contained in:
koshaji
2026-04-30 23:36:02 +10:00
committed by sallyom
parent 38da2ac6f8
commit 77c3f0669e
4 changed files with 177 additions and 23 deletions

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,