feat(diagnostics): export security events to OTLP logs

This commit is contained in:
Vincent Koc
2026-06-16 17:47:43 +08:00
parent 6456d03868
commit f3a1d1fcb0
3 changed files with 330 additions and 0 deletions

View File

@@ -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<string, unknown>;
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 });

View File

@@ -1009,6 +1009,171 @@ function assignOtelLogEventAttributes(
}
}
function assignOtelSecurityEventAttributes(
attributes: Record<string, string | number | boolean>,
eventAttributes: Record<string, string | number | boolean> | 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<DiagnosticEventPayload, { type: "security.event" }>["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<string, string | number | boolean>,
evt: Extract<DiagnosticEventPayload, { type: "security.event" }>,
): 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<DiagnosticEventPayload, { type: "security.event" }>,
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<string, string | number | boolean>;
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;

View File

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