diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be963506b9..0a5240b9ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. +- Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 6a7620c54bf..73a3bad0b4c 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -293,4 +293,127 @@ describe("diagnostics-otel service", () => { expect(options?.url).toBe("https://collector.example.com/v1/Traces"); await service.stop?.(ctx); }); + + test("redacts sensitive data from log messages before export", async () => { + const registeredTransports: Array<(logObj: Record) => void> = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + + const service = createDiagnosticsOtelService(); + const ctx: OpenClawPluginServiceContext = { + config: { + diagnostics: { + enabled: true, + otel: { + enabled: true, + endpoint: "http://otel-collector:4318", + protocol: "http/protobuf", + logs: true, + }, + }, + }, + logger: createLogger(), + stateDir: "/tmp/openclaw-diagnostics-otel-test", + }; + await service.start(ctx); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.({ + 0: "Using API key sk-1234567890abcdef1234567890abcdef", + _meta: { logLevelName: "INFO", date: new Date() }, + }); + + expect(logEmit).toHaveBeenCalled(); + const emitCall = logEmit.mock.calls[0]?.[0]; + expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef"); + expect(emitCall?.body).toContain("sk-123"); + expect(emitCall?.body).toContain("…"); + await service.stop?.(ctx); + }); + + test("redacts sensitive data from log attributes before export", async () => { + const registeredTransports: Array<(logObj: Record) => void> = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + + const service = createDiagnosticsOtelService(); + const ctx: OpenClawPluginServiceContext = { + config: { + diagnostics: { + enabled: true, + otel: { + enabled: true, + endpoint: "http://otel-collector:4318", + protocol: "http/protobuf", + logs: true, + }, + }, + }, + logger: createLogger(), + stateDir: "/tmp/openclaw-diagnostics-otel-test", + }; + await service.start(ctx); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.({ + 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', + 1: "auth configured", + _meta: { logLevelName: "DEBUG", date: new Date() }, + }); + + expect(logEmit).toHaveBeenCalled(); + const emitCall = logEmit.mock.calls[0]?.[0]; + const tokenAttr = emitCall?.attributes?.["openclaw.token"]; + expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); + if (typeof tokenAttr === "string") { + expect(tokenAttr).toContain("…"); + } + await service.stop?.(ctx); + }); + + test("redacts sensitive reason in session.state metric attributes", async () => { + const service = createDiagnosticsOtelService(); + const ctx: OpenClawPluginServiceContext = { + config: { + diagnostics: { + enabled: true, + otel: { + enabled: true, + endpoint: "http://otel-collector:4318", + protocol: "http/protobuf", + metrics: true, + traces: false, + logs: false, + }, + }, + }, + logger: createLogger(), + stateDir: "/tmp/openclaw-diagnostics-otel-test", + }; + await service.start(ctx); + + emitDiagnosticEvent({ + type: "session.state", + state: "waiting", + reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", + }); + + const sessionCounter = telemetryState.counters.get("openclaw.session.state"); + expect(sessionCounter?.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + "openclaw.reason": expect.stringContaining("…"), + }), + ); + const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record | undefined; + expect(typeof attrs?.["openclaw.reason"]).toBe("string"); + expect(String(attrs?.["openclaw.reason"])).not.toContain( + "ghp_abcdefghijklmnopqrstuvwxyz123456", + ); + await service.stop?.(ctx); + }); }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 78975eb36e2..a36341c8421 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -10,7 +10,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node"; import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk"; -import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk"; +import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "openclaw/plugin-sdk"; const DEFAULT_SERVICE_NAME = "openclaw"; @@ -54,6 +54,14 @@ function formatError(err: unknown): string { } } +function redactOtelAttributes(attributes: Record) { + const redactedAttributes: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + redactedAttributes[key] = typeof value === "string" ? redactSensitiveText(value) : value; + } + return redactedAttributes; +} + export function createDiagnosticsOtelService(): OpenClawPluginService { let sdk: NodeSDK | null = null; let logProvider: LoggerProvider | null = null; @@ -336,11 +344,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { attributes["openclaw.code.location"] = meta.path.filePathWithLine; } + // OTLP can leave the host boundary, so redact string fields before export. otelLogger.emit({ - body: message, + body: redactSensitiveText(message), severityText: logLevelName, severityNumber, - attributes, + attributes: redactOtelAttributes(attributes), timestamp: meta?.date ?? new Date(), }); } catch (err) { @@ -469,9 +478,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (!tracesEnabled) { return; } + const redactedError = redactSensitiveText(evt.error); const spanAttrs: Record = { ...attrs, - "openclaw.error": evt.error, + "openclaw.error": redactedError, }; if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); @@ -479,7 +489,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { const span = tracer.startSpan("openclaw.webhook.error", { attributes: spanAttrs, }); - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError }); span.end(); }; @@ -524,11 +534,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { spanAttrs["openclaw.messageId"] = String(evt.messageId); } if (evt.reason) { - spanAttrs["openclaw.reason"] = evt.reason; + spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason); } const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); - if (evt.outcome === "error") { - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + if (evt.outcome === "error" && evt.error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) }); } span.end(); }; @@ -557,7 +567,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { ) => { const attrs: Record = { "openclaw.state": evt.state }; if (evt.reason) { - attrs["openclaw.reason"] = evt.reason; + attrs["openclaw.reason"] = redactSensitiveText(evt.reason); } sessionStateCounter.add(1, attrs); }; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 01e890c8bad..17958205d04 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -501,3 +501,6 @@ export type { ProcessedLineMessage } from "../line/markdown-to-line.js"; // Media utilities export { loadWebMedia, type WebMediaResult } from "../web/media.js"; + +// Security utilities +export { redactSensitiveText } from "../logging/redact.js";