mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user