fix(security): redact sensitive data in OTEL log exports (CWE-532) (#18182)

* fix(security): redact sensitive data in OTEL log exports (CWE-532)

The diagnostics-otel plugin exports ALL application logs to external
OTLP collectors without filtering. This leaks API keys, tokens, and
other sensitive data to third-party observability platforms.

Changes:
- Export redactSensitiveText from plugin-sdk for extension use
- Apply redaction to log messages before OTEL export
- Apply redaction to string attribute values
- Add tests for API key and token redaction

The existing redactSensitiveText function handles common patterns:
- API keys (sk-*, ghp_*, gsk_*, AIza*, etc.)
- Bearer tokens
- PEM private keys
- ENV-style assignments (KEY=value)
- JSON credential fields

Fixes #12542

* fix: also redact error/reason in trace spans

Address Greptile feedback:
- Redact evt.error in webhook.error span attributes and status
- Redact evt.reason in message.processed span attributes
- Redact evt.error in message.processed span status

* fix: handle undefined evt.error in type guard

* fix: redact session.state reason in OTEL metrics

Addresses Greptile feedback - session.state reason field now goes
through redactSensitiveText() like message.processed reason.

* test(diagnostics-otel): update service context for stateDir API change

* OTEL diagnostics: redact sensitive values before export

* OTEL diagnostics tests: cover message, attribute, and session reason redaction

* Changelog: note OTEL sensitive-data redaction fix

* Changelog: move OTEL redaction entry to current unreleased

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
brandonwise
2026-02-23 01:35:32 -05:00
committed by GitHub
parent 61db3d4a16
commit 7fab4d128a
4 changed files with 146 additions and 9 deletions

View File

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

View File

@@ -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<string, unknown>) => 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<string, unknown>) => 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<string, unknown> | undefined;
expect(typeof attrs?.["openclaw.reason"]).toBe("string");
expect(String(attrs?.["openclaw.reason"])).not.toContain(
"ghp_abcdefghijklmnopqrstuvwxyz123456",
);
await service.stop?.(ctx);
});
});

View File

@@ -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<string, string | number | boolean>) {
const redactedAttributes: Record<string, string | number | boolean> = {};
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<string, string | number> = {
...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<string, string> = { "openclaw.state": evt.state };
if (evt.reason) {
attrs["openclaw.reason"] = evt.reason;
attrs["openclaw.reason"] = redactSensitiveText(evt.reason);
}
sessionStateCounter.add(1, attrs);
};

View File

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