diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 7786038705da..686215009fc4 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -173,6 +173,7 @@ import { import { emitDiagnosticEventWithTrustedTraceContext, emitInternalDiagnosticEventForTest, + emitTrustedSecurityEvent, logMessageDispatchStarted, logMessageProcessed, onTrustedInternalDiagnosticEvent, @@ -953,6 +954,119 @@ describe("diagnostics-otel service", () => { await service.stop?.(ctx); }); + test("exports trusted security events as bounded OTLP logs", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true }); + const trace = createDiagnosticTraceContext({ + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: "01", + }); + + await service.start(ctx); + emitTrustedSecurityEvent({ + eventId: "security-event-1", + category: "tool", + action: "tool.execution.blocked", + outcome: "denied", + severity: "medium", + reason: "tools.deny", + actor: { + kind: "agent", + idHash: "agent-hash-1", + role: "operator", + scopes: ["operator.read", "operator.approvals"], + }, + target: { + kind: "tool", + name: "browser", + owner: "browser-tools", + }, + policy: { + id: "tools.exec", + decision: "deny", + reason: "allowlist.miss", + }, + control: { + id: "exec-approval", + family: "approval", + }, + attributes: { + params_kind: "object", + secretish: "token sk-test-secret", + [PROTO_KEY]: "blocked", + }, + trace, + }); + await flushDiagnosticEvents(); + + const emitCall = mockCallArg(logEmit, 0) as { + attributes?: Record; + body?: string; + context?: unknown; + severityNumber?: number; + severityText?: string; + }; + expect(emitCall.body).toBe("openclaw.security.event"); + expect(emitCall.severityText).toBe("WARN"); + expect(emitCall.severityNumber).toBe(13); + expect(emitCall.attributes).toMatchObject({ + "openclaw.security.event_id": "security-event-1", + "openclaw.security.category": "tool", + "openclaw.security.action": "tool.execution.blocked", + "openclaw.security.outcome": "denied", + "openclaw.security.severity": "medium", + "openclaw.security.reason": "tools.deny", + "openclaw.security.actor.kind": "agent", + "openclaw.security.actor.id_hash": "agent-hash-1", + "openclaw.security.actor.role": "operator", + "openclaw.security.actor.scopes": "operator.read,operator.approvals", + "openclaw.security.target.kind": "tool", + "openclaw.security.target.name": "browser", + "openclaw.security.target.owner": "browser-tools", + "openclaw.security.policy.id": "tools.exec", + "openclaw.security.policy.decision": "deny", + "openclaw.security.policy.reason": "allowlist.miss", + "openclaw.security.control.id": "exec-approval", + "openclaw.security.control.family": "approval", + "openclaw.security.attribute.params_kind": "object", + "openclaw.security.attribute.secretish": "unknown", + }); + expect(emitCall.context).toEqual({ + spanContext: { + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: 1, + isRemote: true, + }, + }); + expect(Object.hasOwn(emitCall.attributes ?? {}, "openclaw.security.attribute.__proto__")).toBe( + false, + ); + expect(JSON.stringify(emitCall)).not.toContain("sk-test-secret"); + + await service.stop?.(ctx); + }); + + test("does not export security events when OTLP logs are disabled", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: false, metrics: true }); + + await service.start(ctx); + emitTrustedSecurityEvent({ + eventId: "security-event-logs-disabled", + category: "auth", + action: "gateway.auth.failed", + outcome: "failure", + severity: "high", + }); + await flushDiagnosticEvents(); + + expect(logEmit).not.toHaveBeenCalled(); + + await service.stop?.(ctx); + }); + test("records liveness warning diagnostics", async () => { const service = createDiagnosticsOtelService(); const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 57c387c2e668..1d9bc0607ed4 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -1009,6 +1009,171 @@ function assignOtelLogEventAttributes( } } +function assignOtelSecurityEventAttributes( + attributes: Record, + eventAttributes: Record | undefined, +): void { + if (!eventAttributes) { + return; + } + for (const rawKey in eventAttributes) { + if (Object.keys(attributes).length >= MAX_OTEL_LOG_ATTRIBUTE_COUNT) { + break; + } + if (!Object.hasOwn(eventAttributes, rawKey)) { + continue; + } + const key = rawKey.trim(); + if (BLOCKED_OTEL_LOG_ATTRIBUTE_KEYS.has(key)) { + continue; + } + if (redactSensitiveText(key) !== key) { + continue; + } + if (!OTEL_LOG_RAW_ATTRIBUTE_KEY_RE.test(key)) { + continue; + } + const value = eventAttributes[rawKey]; + assignOtelLogAttribute( + attributes, + `openclaw.security.attribute.${key}`, + typeof value === "string" ? lowCardinalityAttr(value) : value, + ); + } +} + +function securitySeverityText( + severity: Extract["severity"], +) { + switch (severity) { + case "critical": + return "FATAL"; + case "high": + return "ERROR"; + case "medium": + return "WARN"; + case "info": + case "low": + return "INFO"; + } +} + +function assignOtelSecurityAttributes( + attributes: Record, + evt: Extract, +): void { + assignOtelLogAttribute(attributes, "openclaw.security.event_id", evt.eventId); + assignOtelLogAttribute(attributes, "openclaw.security.category", evt.category); + assignOtelLogAttribute(attributes, "openclaw.security.action", lowCardinalityAttr(evt.action)); + assignOtelLogAttribute(attributes, "openclaw.security.outcome", evt.outcome); + assignOtelLogAttribute(attributes, "openclaw.security.severity", evt.severity); + if (evt.reason) { + assignOtelLogAttribute(attributes, "openclaw.security.reason", lowCardinalityAttr(evt.reason)); + } + if (evt.actor) { + assignOtelLogAttribute(attributes, "openclaw.security.actor.kind", evt.actor.kind); + if (evt.actor.idHash) { + assignOtelLogAttribute( + attributes, + "openclaw.security.actor.id_hash", + lowCardinalityAttr(evt.actor.idHash), + ); + } + if (evt.actor.deviceIdHash) { + assignOtelLogAttribute( + attributes, + "openclaw.security.actor.device_id_hash", + lowCardinalityAttr(evt.actor.deviceIdHash), + ); + } + if (evt.actor.channel) { + assignOtelLogAttribute( + attributes, + "openclaw.security.actor.channel", + lowCardinalityAttr(evt.actor.channel), + ); + } + if (evt.actor.role) { + assignOtelLogAttribute( + attributes, + "openclaw.security.actor.role", + lowCardinalityAttr(evt.actor.role), + ); + } + if (evt.actor.scopes?.length) { + assignOtelLogAttribute( + attributes, + "openclaw.security.actor.scopes", + evt.actor.scopes.map((scope) => lowCardinalityAttr(scope)).join(","), + ); + } + } + if (evt.target) { + assignOtelLogAttribute(attributes, "openclaw.security.target.kind", evt.target.kind); + if (evt.target.idHash) { + assignOtelLogAttribute( + attributes, + "openclaw.security.target.id_hash", + lowCardinalityAttr(evt.target.idHash), + ); + } + if (evt.target.name) { + assignOtelLogAttribute( + attributes, + "openclaw.security.target.name", + lowCardinalityAttr(evt.target.name), + ); + } + if (evt.target.owner) { + assignOtelLogAttribute( + attributes, + "openclaw.security.target.owner", + lowCardinalityAttr(evt.target.owner), + ); + } + } + if (evt.policy) { + if (evt.policy.id) { + assignOtelLogAttribute( + attributes, + "openclaw.security.policy.id", + lowCardinalityAttr(evt.policy.id), + ); + } + if (evt.policy.decision) { + assignOtelLogAttribute( + attributes, + "openclaw.security.policy.decision", + evt.policy.decision, + ); + } + if (evt.policy.reason) { + assignOtelLogAttribute( + attributes, + "openclaw.security.policy.reason", + lowCardinalityAttr(evt.policy.reason), + ); + } + } + if (evt.control) { + if (evt.control.id) { + assignOtelLogAttribute( + attributes, + "openclaw.security.control.id", + lowCardinalityAttr(evt.control.id), + ); + } + if (evt.control.family) { + assignOtelLogAttribute( + attributes, + "openclaw.security.control.family", + evt.control.family, + ); + } + } + assignOtelSecurityEventAttributes(attributes, evt.attributes); +} + function traceFlagsToOtel(traceFlags: string | undefined): TraceFlags { const parsed = Number.parseInt(traceFlags ?? "00", 16); return (parsed & TraceFlags.SAMPLED) !== 0 ? TraceFlags.SAMPLED : TraceFlags.NONE; @@ -1585,6 +1750,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { metadata: DiagnosticEventMetadata, ) => void) | undefined; + let recordSecurityEvent: + | (( + evt: Extract, + metadata: DiagnosticEventMetadata, + ) => void) + | undefined; if (logsEnabled) { let logRecordExportFailureLastReportedAt = Number.NEGATIVE_INFINITY; const logExporter = new OTLPLogExporter({ @@ -1662,6 +1833,47 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { } } }; + recordSecurityEvent = (evt, metadata) => { + if (!metadata.trusted) { + return; + } + try { + const severityText = securitySeverityText(evt.severity); + const attributes = Object.create(null) as Record; + assignOtelSecurityAttributes(attributes, evt); + + const logRecord: LogRecord = { + body: "openclaw.security.event", + severityText, + severityNumber: logSeverityMap[severityText] ?? (9 as SeverityNumber), + attributes: redactOtelAttributes(attributes), + timestamp: evt.ts, + }; + const logContext = contextForTrustedTraceContext(evt, metadata); + if (logContext) { + logRecord.context = logContext; + } + otelLogger.emit(logRecord); + } catch (err) { + emitExporterEvent({ + exporter: "diagnostics-otel", + signal: "logs", + status: "failure", + reason: "emit_failed", + errorCategory: errorCategory(err), + }); + const now = Date.now(); + if ( + now - logRecordExportFailureLastReportedAt >= + LOG_RECORD_EXPORT_FAILURE_REPORT_INTERVAL_MS + ) { + logRecordExportFailureLastReportedAt = now; + ctx.logger.error( + `diagnostics-otel: security event export failed: ${formatError(err)}`, + ); + } + } + }; } const spanWithDuration = ( @@ -3443,6 +3655,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { case "log.record": recordLogRecord?.(evt, metadata); return; + case "security.event": + recordSecurityEvent?.(evt, metadata); + return; case "tool.loop": recordToolLoop(evt); return; diff --git a/src/plugin-sdk/plugin-test-runtime.ts b/src/plugin-sdk/plugin-test-runtime.ts index 4492fd6b04f3..66acf8b7f435 100644 --- a/src/plugin-sdk/plugin-test-runtime.ts +++ b/src/plugin-sdk/plugin-test-runtime.ts @@ -17,6 +17,7 @@ export { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; export { emitDiagnosticEventWithTrustedTraceContext, emitInternalDiagnosticEvent as emitInternalDiagnosticEventForTest, + emitTrustedSecurityEvent, } from "../infra/diagnostic-events.js"; export { runWithDiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; export { logMessageDispatchStarted, logMessageProcessed } from "../logging/diagnostic.js";