fix(logging): redact console and file sinks

This commit is contained in:
Vincent Koc
2026-04-25 21:41:01 -07:00
parent 1a8f765147
commit 4cba24a4c3
6 changed files with 108 additions and 13 deletions

View File

@@ -71,6 +71,9 @@ Docs: https://docs.openclaw.ai
- Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd.
- Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd.
- Logging: redact configured secret patterns at console and file-log sink exits
so credentials that reach the logger are masked before terminal display or
JSONL persistence. Fixes #67953. Thanks @Ziy1-Tan.
- Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI.
- macOS/Node: keep native remote app nodes from advertising `browser.proxy`,
start browser-capable CLI node services through the restored

View File

@@ -167,7 +167,9 @@ Tool summaries can redact sensitive tokens before they hit the console:
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
- `logging.redactPatterns`: list of regex strings to override the default set
Redaction affects **console output only** and does not alter file logs.
Redaction applies at the logging sinks for **console output**, **stderr-routed
console diagnostics**, and **file logs**. File logs stay JSONL, but matching
secret values are masked before the line is written to disk.
## Diagnostics and OpenTelemetry

View File

@@ -47,6 +47,8 @@ afterAll(async () => {
});
describe("enableConsoleCapture", () => {
const secret = "sk-testsecret1234567890abcd";
it("swallows EIO from stderr writes", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
vi.spyOn(process.stderr, "write").mockImplementation(() => {
@@ -123,6 +125,50 @@ describe("enableConsoleCapture", () => {
expect(stdoutWrite).toHaveBeenCalledWith('{\n "ok": true\n}\n');
});
it("redacts credentials before forwarding console output", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const log = vi.fn();
console.log = log;
enableConsoleCapture();
console.log("apiKey:", secret);
expect(log).toHaveBeenCalledTimes(1);
const line = String(log.mock.calls[0]?.[0] ?? "");
expect(line).toContain("apiKey:");
expect(line).not.toContain(secret);
});
it("redacts credentials before writing forced stderr console output", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
routeLogsToStderr();
enableConsoleCapture();
console.error(`Authorization: Bearer ${secret}`);
expect(stderrWrite).toHaveBeenCalledTimes(1);
const line = String(stderrWrite.mock.calls[0]?.[0] ?? "");
expect(line).toContain("Authorization: Bearer");
expect(line).not.toContain(secret);
});
it("redacts credentials when timestamp prefixing console output", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const warn = vi.fn();
console.warn = warn;
setConsoleTimestampPrefix(true);
enableConsoleCapture();
console.warn(`token=${secret}`);
expect(warn).toHaveBeenCalledTimes(1);
const line = String(warn.mock.calls[0]?.[0] ?? "");
expect(line).toMatch(/^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T)/);
expect(line).toContain("token=");
expect(line).not.toContain(secret);
});
it.each([
{ name: "stdout", stream: process.stdout },
{ name: "stderr", stream: process.stderr },

View File

@@ -7,6 +7,7 @@ import { resolveEnvLogLevelOverride } from "./env-log-level.js";
import { type LogLevel, normalizeLogLevel } from "./levels.js";
import { getLogger } from "./logger.js";
import { resolveNodeRequireFromMeta } from "./node-require.js";
import { redactSensitiveText } from "./redact.js";
import { loggingState } from "./state.js";
import { formatLocalIsoWithOffset, formatTimestamp } from "./timestamps.js";
import type { ConsoleStyle, LoggerSettings } from "./types.js";
@@ -275,7 +276,8 @@ export function enableConsoleCapture(): void {
if (loggingState.forceConsoleToStderr) {
// In --json mode, all console.* writes are diagnostics and should stay off stdout.
try {
const line = timestamp ? `${timestamp} ${formatted}` : formatted;
const redacted = redactSensitiveText(formatted);
const line = timestamp ? `${timestamp} ${redacted}` : redacted;
process.stderr.write(`${line}\n`);
} catch (err) {
if (isEpipeError(err)) {
@@ -285,19 +287,16 @@ export function enableConsoleCapture(): void {
}
} else {
try {
const redacted = redactSensitiveText(formatted);
if (!timestamp) {
orig.apply(console, args as []);
if (args.length === 0) {
orig.apply(console, args as []);
return;
}
orig.call(console, redacted);
return;
}
if (args.length === 0) {
orig.call(console, timestamp);
return;
}
if (typeof args[0] === "string") {
orig.call(console, `${timestamp} ${args[0]}`, ...args.slice(1));
return;
}
orig.call(console, timestamp, ...args);
orig.call(console, redacted ? `${timestamp} ${redacted}` : timestamp);
} catch (err) {
if (isEpipeError(err)) {
return;

View File

@@ -0,0 +1,45 @@
import fs from "node:fs";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { getLogger, resetLogger, setLoggerOverride } from "../logging.js";
import { createSuiteLogPathTracker } from "./log-test-helpers.js";
const secret = "sk-testsecret1234567890abcd";
const logPathTracker = createSuiteLogPathTracker("openclaw-log-redaction-");
beforeAll(async () => {
await logPathTracker.setup();
});
afterEach(() => {
resetLogger();
setLoggerOverride(null);
});
afterAll(async () => {
await logPathTracker.cleanup();
});
describe("file log redaction", () => {
it("redacts credential fields before writing JSONL file logs", () => {
const logPath = logPathTracker.nextPath();
setLoggerOverride({ level: "info", file: logPath });
getLogger().info({ apiKey: secret, message: "provider configured" });
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("provider configured");
expect(content).toContain('"apiKey"');
expect(content).not.toContain(secret);
});
it("redacts bearer tokens in file log message strings", () => {
const logPath = logPathTracker.nextPath();
setLoggerOverride({ level: "info", file: logPath });
getLogger().warn({ message: `Authorization: Bearer ${secret}` });
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("Authorization: Bearer");
expect(content).not.toContain(secret);
});
});

View File

@@ -423,7 +423,7 @@ function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
logger.attachTransport((logObj: LogObj) => {
try {
const time = formatTimestamp(logObj.date ?? new Date(), { style: "long" });
const line = JSON.stringify({ ...logObj, time });
const line = redactSensitiveText(JSON.stringify({ ...logObj, time }));
const payload = `${line}\n`;
const payloadBytes = Buffer.byteLength(payload, "utf8");
const nextBytes = currentFileBytes + payloadBytes;