diff --git a/CHANGELOG.md b/CHANGELOG.md index b1610a06181..7ef13eb7687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/logging.md b/docs/logging.md index 4d7a3e015b8..563a0d2f111 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -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 diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts index b314cf1c2d7..a0c5146ca66 100644 --- a/src/logging/console-capture.test.ts +++ b/src/logging/console-capture.test.ts @@ -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 }, diff --git a/src/logging/console.ts b/src/logging/console.ts index 422d1a26f1c..e1371932646 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -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; diff --git a/src/logging/logger-redaction-behavior.test.ts b/src/logging/logger-redaction-behavior.test.ts new file mode 100644 index 00000000000..127a3a47417 --- /dev/null +++ b/src/logging/logger-redaction-behavior.test.ts @@ -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); + }); +}); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 5be11d17177..455d4883698 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -423,7 +423,7 @@ function buildLogger(settings: ResolvedSettings): TsLogger { 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;