mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
feat(diagnostics): export security events to OTLP logs
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user