mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:31:06 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user