feat(audit): add correlation ids and redaction
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
ToolSuccessEvent,
|
||||
ToolErrorEvent,
|
||||
ToolDeniedEvent,
|
||||
ToolApprovalEvent,
|
||||
SkillsInstallerExecutionBlockedEvent,
|
||||
SkillsInstallerCommandResultEvent,
|
||||
SessionCreateEvent,
|
||||
@@ -88,6 +89,11 @@ export class AuditLogger {
|
||||
this.write({ level: 'warn', event_type: 'tool.denied', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
toolApproval(event: ToolApprovalEvent): void {
|
||||
if (!this.shouldLog('tools', 'debug')) {return;}
|
||||
this.write({ level: 'debug', event_type: 'tool.approval', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
skillsInstallerExecutionBlocked(event: SkillsInstallerExecutionBlockedEvent): void {
|
||||
if (!this.shouldLog('tools', 'warn')) {return;}
|
||||
this.write({
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
const SECRET_KEY_REGEX = /(api[_-]?key|auth|authorization|token|secret|password|cookie)/i;
|
||||
|
||||
export interface RedactionResult<T = unknown> {
|
||||
value: T;
|
||||
redactions: number;
|
||||
}
|
||||
|
||||
function redactString(input: string): RedactionResult<string> {
|
||||
let redactions = 0;
|
||||
let output = input;
|
||||
|
||||
// Redact common Authorization header patterns.
|
||||
const beforeAuth = output;
|
||||
output = output.replace(/\bBearer\s+[A-Za-z0-9._\-~=+/]+\b/g, '[REDACTED_BEARER]');
|
||||
if (output !== beforeAuth) {
|
||||
redactions += 1;
|
||||
}
|
||||
|
||||
// Redact very long high-entropy tokens (heuristic).
|
||||
const beforeLong = output;
|
||||
output = output.replace(/\b[A-Za-z0-9_\-]{32,}\b/g, '[REDACTED_TOKEN]');
|
||||
if (output !== beforeLong) {
|
||||
redactions += 1;
|
||||
}
|
||||
|
||||
return { value: output, redactions };
|
||||
}
|
||||
|
||||
function shouldRedactKey(key: string): boolean {
|
||||
return SECRET_KEY_REGEX.test(key);
|
||||
}
|
||||
|
||||
export function redactForAudit(value: unknown, opts?: { maxDepth?: number }): RedactionResult {
|
||||
const maxDepth = opts?.maxDepth ?? 6;
|
||||
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
const walk = (v: unknown, depth: number): RedactionResult => {
|
||||
if (depth > maxDepth) {
|
||||
return { value: '[TRUNCATED_DEPTH]', redactions: 0 };
|
||||
}
|
||||
|
||||
if (typeof v === 'string') {
|
||||
return redactString(v);
|
||||
}
|
||||
|
||||
if (v === null || v === undefined) {
|
||||
return { value: v, redactions: 0 };
|
||||
}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
return { value: v, redactions: 0 };
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
let redactions = 0;
|
||||
const arr: unknown[] = [];
|
||||
for (const item of v) {
|
||||
const r = walk(item, depth + 1);
|
||||
arr.push(r.value);
|
||||
redactions += r.redactions;
|
||||
}
|
||||
return { value: arr, redactions };
|
||||
}
|
||||
|
||||
if (typeof v === 'object') {
|
||||
const obj = v as Record<string, unknown>;
|
||||
if (seen.has(obj)) {
|
||||
return { value: '[CYCLE]', redactions: 0 };
|
||||
}
|
||||
seen.add(obj);
|
||||
|
||||
let redactions = 0;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(obj)) {
|
||||
if (shouldRedactKey(k)) {
|
||||
out[k] = '[REDACTED]';
|
||||
redactions += 1;
|
||||
continue;
|
||||
}
|
||||
const r = walk(val, depth + 1);
|
||||
out[k] = r.value;
|
||||
redactions += r.redactions;
|
||||
}
|
||||
return { value: out, redactions };
|
||||
}
|
||||
|
||||
// bigint/symbol/function - stringify to be safe.
|
||||
return { value: String(v), redactions: 0 };
|
||||
};
|
||||
|
||||
return walk(value, 0);
|
||||
}
|
||||
|
||||
export function containsSecretLikeKeys(value: unknown, opts?: { maxDepth?: number }): boolean {
|
||||
const maxDepth = opts?.maxDepth ?? 6;
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
const walk = (v: unknown, depth: number): boolean => {
|
||||
if (depth > maxDepth) {
|
||||
return false;
|
||||
}
|
||||
if (v === null || v === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return v.some((item) => walk(item, depth + 1));
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
const obj = v as Record<string, unknown>;
|
||||
if (seen.has(obj)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(obj);
|
||||
|
||||
for (const [k, val] of Object.entries(obj)) {
|
||||
if (shouldRedactKey(k)) {
|
||||
return true;
|
||||
}
|
||||
if (walk(val, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return walk(value, 0);
|
||||
}
|
||||
+28
-1
@@ -2,7 +2,7 @@ export type AuditLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export type AuditEventType =
|
||||
// Tool execution
|
||||
| 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied'
|
||||
| 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied' | 'tool.approval'
|
||||
// Skills installer
|
||||
| 'skills.installer.execution_blocked' | 'skills.installer.command_result'
|
||||
// Session lifecycle
|
||||
@@ -49,6 +49,10 @@ export interface AuditQuery {
|
||||
export interface ToolStartEvent {
|
||||
tool_name: string;
|
||||
tool_args: unknown;
|
||||
execution_id?: string;
|
||||
execution_environment?: 'host' | 'sandbox';
|
||||
skill_name?: string;
|
||||
redactions_applied?: number;
|
||||
session_id?: string;
|
||||
channel?: string;
|
||||
sender?: string;
|
||||
@@ -59,6 +63,10 @@ export interface ToolSuccessEvent {
|
||||
tool_name: string;
|
||||
result: { success: boolean; output: string; error?: string };
|
||||
duration_ms: number;
|
||||
execution_id?: string;
|
||||
execution_environment?: 'host' | 'sandbox';
|
||||
skill_name?: string;
|
||||
redactions_applied?: number;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
@@ -67,16 +75,35 @@ export interface ToolErrorEvent {
|
||||
error: string;
|
||||
duration_ms: number;
|
||||
reason?: string;
|
||||
execution_id?: string;
|
||||
execution_environment?: 'host' | 'sandbox';
|
||||
skill_name?: string;
|
||||
redactions_applied?: number;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface ToolDeniedEvent {
|
||||
tool_name: string;
|
||||
reason: string;
|
||||
execution_id?: string;
|
||||
execution_environment?: 'host' | 'sandbox';
|
||||
skill_name?: string;
|
||||
redactions_applied?: number;
|
||||
session_id?: string;
|
||||
denial_type: 'policy' | 'hook' | 'not_found' | 'autonomy_override';
|
||||
}
|
||||
|
||||
export interface ToolApprovalEvent {
|
||||
tool_name: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
execution_id?: string;
|
||||
execution_environment?: 'host' | 'sandbox';
|
||||
skill_name?: string;
|
||||
redactions_applied?: number;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface SkillsInstallerExecutionBlockedEvent {
|
||||
skill_name: string;
|
||||
phase: 'install' | 'execute';
|
||||
|
||||
Reference in New Issue
Block a user