diff --git a/src/security/audit-log.test.ts b/src/security/audit-log.test.ts new file mode 100644 index 000000000..2acc716d6 --- /dev/null +++ b/src/security/audit-log.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, readFileSync, unlinkSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { AuditLogger, initAuditLogger, getAuditLogger, audit, type AuditLogConfig } from "./audit-log.js"; + +const TEST_DIR = join(process.cwd(), ".test-audit-logs"); +const TEST_LOG_PATH = join(TEST_DIR, "test-audit.log"); + +describe("AuditLogger", () => { + beforeEach(() => { + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }); + } + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + describe("basic logging", () => { + it("should create log file and write entries", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + }); + + logger.log({ + category: "auth", + action: "auth.login", + message: "User logged in", + actor: { type: "user", id: "user123" }, + }); + + await logger.close(); + + expect(existsSync(TEST_LOG_PATH)).toBe(true); + const content = readFileSync(TEST_LOG_PATH, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.category).toBe("auth"); + expect(entry.action).toBe("auth.login"); + expect(entry.message).toBe("User logged in"); + expect(entry.actor.id).toBe("user123"); + expect(entry.seq).toBe(1); + }); + + it("should increment sequence numbers", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + }); + + logger.log({ category: "auth", action: "auth.login", message: "First" }); + logger.log({ category: "auth", action: "auth.logout", message: "Second" }); + logger.log({ category: "exec", action: "exec.run", message: "Third" }); + + await logger.close(); + + const lines = readFileSync(TEST_LOG_PATH, "utf-8").trim().split("\n"); + expect(lines.length).toBe(3); + expect(JSON.parse(lines[0]).seq).toBe(1); + expect(JSON.parse(lines[1]).seq).toBe(2); + expect(JSON.parse(lines[2]).seq).toBe(3); + }); + }); + + describe("filtering", () => { + it("should filter by category", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + categories: ["auth", "exec"], + }); + + logger.log({ category: "auth", action: "auth.login", message: "Auth event" }); + logger.log({ category: "config", action: "config.write", message: "Config event" }); + logger.log({ category: "exec", action: "exec.run", message: "Exec event" }); + + await logger.close(); + + const lines = readFileSync(TEST_LOG_PATH, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + expect(JSON.parse(lines[0]).category).toBe("auth"); + expect(JSON.parse(lines[1]).category).toBe("exec"); + }); + + it("should filter by severity", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + minSeverity: "warn", + }); + + logger.log({ category: "auth", action: "auth.login", severity: "info", message: "Info" }); + logger.log({ category: "auth", action: "auth.failed", severity: "warn", message: "Warn" }); + logger.log({ category: "exec", action: "exec.blocked", severity: "critical", message: "Critical" }); + + await logger.close(); + + const lines = readFileSync(TEST_LOG_PATH, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + expect(JSON.parse(lines[0]).severity).toBe("warn"); + expect(JSON.parse(lines[1]).severity).toBe("critical"); + }); + }); + + describe("PII redaction", () => { + it("should redact email addresses", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + redactPii: true, + }); + + logger.log({ + category: "auth", + action: "auth.login", + message: "User test@example.com logged in", + }); + + await logger.close(); + + const content = readFileSync(TEST_LOG_PATH, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.message).toBe("User [REDACTED] logged in"); + }); + + it("should redact API keys", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + redactPii: true, + }); + + logger.log({ + category: "config", + action: "config.write", + message: "Saved config", + context: { token: "sk-ant-abc123xyz" }, + }); + + await logger.close(); + + const content = readFileSync(TEST_LOG_PATH, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.context.token).toBe("[REDACTED]"); + }); + + it("should not redact when disabled", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + redactPii: false, + }); + + logger.log({ + category: "auth", + action: "auth.login", + message: "User test@example.com logged in", + }); + + await logger.close(); + + const content = readFileSync(TEST_LOG_PATH, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.message).toBe("User test@example.com logged in"); + }); + }); + + describe("tamper-evident chain", () => { + it("should include prev_hash when enabled", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + enableChain: true, + }); + + logger.log({ category: "auth", action: "auth.login", message: "First" }); + logger.log({ category: "auth", action: "auth.logout", message: "Second" }); + + await logger.close(); + + const lines = readFileSync(TEST_LOG_PATH, "utf-8").trim().split("\n"); + const first = JSON.parse(lines[0]); + const second = JSON.parse(lines[1]); + + expect(first.prev_hash).toBeUndefined(); + expect(second.prev_hash).toBeDefined(); + expect(second.prev_hash.length).toBe(16); + }); + }); + + describe("convenience methods", () => { + it("should log auth events", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + }); + + logger.auth("auth.login", { + message: "User authenticated", + actor: { type: "user", id: "user123" }, + }); + + await logger.close(); + + const content = readFileSync(TEST_LOG_PATH, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.category).toBe("auth"); + expect(entry.action).toBe("auth.login"); + }); + + it("should log exec events", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + }); + + logger.exec("exec.run", { + message: "Executed command", + command: "ls -la", + actor: { type: "agent", id: "main" }, + result: { success: true, duration_ms: 50 }, + }); + + await logger.close(); + + const content = readFileSync(TEST_LOG_PATH, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.category).toBe("exec"); + expect(entry.target.id).toBe("ls -la"); + expect(entry.result.duration_ms).toBe(50); + }); + + it("should log tool invocations", async () => { + const logger = new AuditLogger({ + enabled: true, + path: TEST_LOG_PATH, + }); + + logger.tool("tool.invoke", { + toolName: "web_search", + message: "Invoked web search", + context: { query: "test query" }, + }); + + await logger.close(); + + const content = readFileSync(TEST_LOG_PATH, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.category).toBe("tool"); + expect(entry.target.id).toBe("web_search"); + }); + }); + + describe("global instance", () => { + it("should use singleton pattern", () => { + const logger1 = getAuditLogger(); + const logger2 = getAuditLogger(); + expect(logger1).toBe(logger2); + }); + + it("should allow reinitialization", async () => { + const logger1 = initAuditLogger({ path: TEST_LOG_PATH }); + const logger2 = initAuditLogger({ path: join(TEST_DIR, "other.log") }); + expect(logger1).not.toBe(logger2); + await logger2.close(); + }); + }); + + describe("audit shorthand", () => { + beforeEach(() => { + initAuditLogger({ enabled: true, path: TEST_LOG_PATH }); + }); + + afterEach(async () => { + await getAuditLogger().close(); + }); + + it("should provide shorthand methods", async () => { + audit.auth("auth.login", { message: "Test" }); + audit.exec("exec.run", { message: "Test", command: "echo" }); + audit.tool("tool.invoke", { toolName: "test", message: "Test" }); + + await getAuditLogger().close(); + + const lines = readFileSync(TEST_LOG_PATH, "utf-8").trim().split("\n"); + expect(lines.length).toBe(3); + }); + }); +}); diff --git a/src/security/audit-log.ts b/src/security/audit-log.ts new file mode 100644 index 000000000..bdbef2aae --- /dev/null +++ b/src/security/audit-log.ts @@ -0,0 +1,507 @@ +/** + * Audit Logging System + * + * Records security-sensitive operations for compliance, debugging, and forensics. + * + * Features: + * - Structured JSON log format + * - Configurable log levels and categories + * - File rotation support + * - Tamper-evident checksums (optional) + * - PII redaction support + * + * @module security/audit-log + */ + +import { createWriteStream, existsSync, mkdirSync, statSync, renameSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { createHash } from "node:crypto"; +import type { WriteStream } from "node:fs"; + +// ============================================================================ +// Types +// ============================================================================ + +export type AuditCategory = + | "auth" // Authentication events (login, logout, token refresh) + | "config" // Configuration changes + | "exec" // Command execution (shell, elevated) + | "tool" // Tool invocations + | "message" // Message handling (send, receive) + | "file" // File operations (read, write, delete) + | "session" // Session events (create, destroy, switch) + | "channel" // Channel events (connect, disconnect) + | "cron" // Cron job events + | "pairing" // Device pairing events + | "admin"; // Administrative actions + +export type AuditSeverity = "info" | "warn" | "critical"; + +export type AuditAction = + // Auth actions + | "auth.login" + | "auth.logout" + | "auth.token_refresh" + | "auth.token_expired" + | "auth.failed" + // Config actions + | "config.read" + | "config.write" + | "config.apply" + | "config.reset" + // Exec actions + | "exec.run" + | "exec.elevated" + | "exec.blocked" + | "exec.timeout" + // Tool actions + | "tool.invoke" + | "tool.denied" + | "tool.error" + // Message actions + | "message.receive" + | "message.send" + | "message.blocked" + // File actions + | "file.read" + | "file.write" + | "file.delete" + | "file.denied" + // Session actions + | "session.create" + | "session.destroy" + | "session.switch" + | "session.timeout" + // Channel actions + | "channel.connect" + | "channel.disconnect" + | "channel.error" + // Cron actions + | "cron.run" + | "cron.add" + | "cron.remove" + | "cron.error" + // Pairing actions + | "pairing.request" + | "pairing.approve" + | "pairing.reject" + | "pairing.revoke" + // Admin actions + | "admin.restart" + | "admin.update" + | "admin.shutdown"; + +export interface AuditLogEntry { + /** ISO 8601 timestamp */ + ts: string; + /** Monotonic sequence number for ordering */ + seq: number; + /** Event category */ + category: AuditCategory; + /** Specific action */ + action: AuditAction; + /** Severity level */ + severity: AuditSeverity; + /** Human-readable description */ + message: string; + /** Actor who triggered the event */ + actor?: { + type: "user" | "system" | "cron" | "agent"; + id?: string; + channel?: string; + ip?: string; + }; + /** Target of the action */ + target?: { + type: string; + id?: string; + path?: string; + }; + /** Action result */ + result?: { + success: boolean; + error?: string; + duration_ms?: number; + }; + /** Additional context (redacted if needed) */ + context?: Record; + /** SHA-256 of previous entry for tamper detection */ + prev_hash?: string; +} + +export interface AuditLogConfig { + /** Enable audit logging */ + enabled: boolean; + /** Log file path */ + path: string; + /** Categories to log (empty = all) */ + categories?: AuditCategory[]; + /** Minimum severity to log */ + minSeverity?: AuditSeverity; + /** Enable tamper-evident chain */ + enableChain?: boolean; + /** Max file size before rotation (bytes) */ + maxFileSize?: number; + /** Max rotated files to keep */ + maxFiles?: number; + /** Redact PII from logs */ + redactPii?: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_CONFIG: AuditLogConfig = { + enabled: true, + path: "audit.log", + minSeverity: "info", + enableChain: false, + maxFileSize: 10 * 1024 * 1024, // 10MB + maxFiles: 5, + redactPii: true, +}; + +const SEVERITY_ORDER: Record = { + info: 0, + warn: 1, + critical: 2, +}; + +const PII_PATTERNS = [ + /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, // Email + /\b\+?[1-9]\d{1,14}\b/g, // Phone numbers + /\b\d{3}-\d{2}-\d{4}\b/g, // SSN + /\b(?:sk-|ghp_|gho_|github_pat_)[A-Za-z0-9_-]+\b/g, // API keys +]; + +// ============================================================================ +// Audit Logger Class +// ============================================================================ + +export class AuditLogger { + private config: AuditLogConfig; + private stream: WriteStream | null = null; + private seq = 0; + private lastHash: string | null = null; + private closed = false; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + if (this.config.enabled) { + this.initStream(); + } + } + + private initStream(): void { + const dir = dirname(this.config.path); + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + this.maybeRotate(); + this.stream = createWriteStream(this.config.path, { flags: "a" }); + } + + private maybeRotate(): void { + if (!existsSync(this.config.path)) return; + + const stats = statSync(this.config.path); + if (stats.size < (this.config.maxFileSize ?? DEFAULT_CONFIG.maxFileSize!)) { + return; + } + + // Rotate files + const maxFiles = this.config.maxFiles ?? DEFAULT_CONFIG.maxFiles!; + for (let i = maxFiles - 1; i >= 1; i--) { + const oldPath = `${this.config.path}.${i}`; + const newPath = `${this.config.path}.${i + 1}`; + if (existsSync(oldPath)) { + if (i === maxFiles - 1) { + // Delete oldest + require("node:fs").unlinkSync(oldPath); + } else { + renameSync(oldPath, newPath); + } + } + } + renameSync(this.config.path, `${this.config.path}.1`); + } + + private shouldLog(category: AuditCategory, severity: AuditSeverity): boolean { + if (!this.config.enabled || this.closed) return false; + + // Check category filter + if (this.config.categories && this.config.categories.length > 0) { + if (!this.config.categories.includes(category)) return false; + } + + // Check severity filter + const minSeverity = this.config.minSeverity ?? "info"; + if (SEVERITY_ORDER[severity] < SEVERITY_ORDER[minSeverity]) return false; + + return true; + } + + private redactPii(text: string): string { + if (!this.config.redactPii) return text; + let result = text; + for (const pattern of PII_PATTERNS) { + result = result.replace(pattern, "[REDACTED]"); + } + return result; + } + + private redactObject(obj: Record): Record { + if (!this.config.redactPii) return obj; + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string") { + result[key] = this.redactPii(value); + } else if (typeof value === "object" && value !== null) { + result[key] = this.redactObject(value as Record); + } else { + result[key] = value; + } + } + return result; + } + + private computeHash(entry: AuditLogEntry): string { + const content = JSON.stringify(entry); + return createHash("sha256").update(content).digest("hex").slice(0, 16); + } + + /** + * Log an audit event + */ + log(params: { + category: AuditCategory; + action: AuditAction; + severity?: AuditSeverity; + message: string; + actor?: AuditLogEntry["actor"]; + target?: AuditLogEntry["target"]; + result?: AuditLogEntry["result"]; + context?: Record; + }): void { + const severity = params.severity ?? "info"; + if (!this.shouldLog(params.category, severity)) return; + + this.seq += 1; + + const entry: AuditLogEntry = { + ts: new Date().toISOString(), + seq: this.seq, + category: params.category, + action: params.action, + severity, + message: this.redactPii(params.message), + actor: params.actor, + target: params.target, + result: params.result, + context: params.context ? this.redactObject(params.context) : undefined, + }; + + // Add chain hash if enabled + if (this.config.enableChain && this.lastHash) { + entry.prev_hash = this.lastHash; + } + + // Compute hash for next entry + if (this.config.enableChain) { + this.lastHash = this.computeHash(entry); + } + + // Write to file + if (this.stream) { + this.stream.write(JSON.stringify(entry) + "\n"); + } + } + + // ============================================================================ + // Convenience Methods + // ============================================================================ + + /** Log authentication event */ + auth(action: "auth.login" | "auth.logout" | "auth.token_refresh" | "auth.token_expired" | "auth.failed", params: { + message: string; + actor?: AuditLogEntry["actor"]; + result?: AuditLogEntry["result"]; + context?: Record; + }): void { + this.log({ + category: "auth", + action, + severity: action === "auth.failed" ? "warn" : "info", + ...params, + }); + } + + /** Log exec event */ + exec(action: "exec.run" | "exec.elevated" | "exec.blocked" | "exec.timeout", params: { + message: string; + command?: string; + actor?: AuditLogEntry["actor"]; + result?: AuditLogEntry["result"]; + context?: Record; + }): void { + const severity: AuditSeverity = + action === "exec.elevated" ? "warn" : + action === "exec.blocked" ? "warn" : + "info"; + + this.log({ + category: "exec", + action, + severity, + message: params.message, + actor: params.actor, + target: params.command ? { type: "command", id: params.command } : undefined, + result: params.result, + context: params.context, + }); + } + + /** Log tool invocation */ + tool(action: "tool.invoke" | "tool.denied" | "tool.error", params: { + toolName: string; + message: string; + actor?: AuditLogEntry["actor"]; + result?: AuditLogEntry["result"]; + context?: Record; + }): void { + const severity: AuditSeverity = + action === "tool.denied" ? "warn" : + action === "tool.error" ? "warn" : + "info"; + + this.log({ + category: "tool", + action, + severity, + message: params.message, + actor: params.actor, + target: { type: "tool", id: params.toolName }, + result: params.result, + context: params.context, + }); + } + + /** Log config change */ + config(action: "config.read" | "config.write" | "config.apply" | "config.reset", params: { + message: string; + actor?: AuditLogEntry["actor"]; + result?: AuditLogEntry["result"]; + context?: Record; + }): void { + const severity: AuditSeverity = + action === "config.write" || action === "config.apply" ? "warn" : + "info"; + + this.log({ + category: "config", + action, + severity, + ...params, + }); + } + + /** Log file operation */ + file(action: "file.read" | "file.write" | "file.delete" | "file.denied", params: { + path: string; + message: string; + actor?: AuditLogEntry["actor"]; + result?: AuditLogEntry["result"]; + context?: Record; + }): void { + const severity: AuditSeverity = + action === "file.delete" ? "warn" : + action === "file.denied" ? "warn" : + "info"; + + this.log({ + category: "file", + action, + severity, + message: params.message, + actor: params.actor, + target: { type: "file", path: params.path }, + result: params.result, + context: params.context, + }); + } + + /** Log critical security event */ + critical(params: { + category: AuditCategory; + action: AuditAction; + message: string; + actor?: AuditLogEntry["actor"]; + target?: AuditLogEntry["target"]; + result?: AuditLogEntry["result"]; + context?: Record; + }): void { + this.log({ ...params, severity: "critical" }); + } + + /** + * Close the audit logger + */ + close(): Promise { + return new Promise((resolve) => { + this.closed = true; + if (this.stream) { + this.stream.end(() => resolve()); + } else { + resolve(); + } + }); + } +} + +// ============================================================================ +// Singleton Instance +// ============================================================================ + +let globalAuditLogger: AuditLogger | null = null; + +/** + * Initialize the global audit logger + */ +export function initAuditLogger(config: Partial = {}): AuditLogger { + if (globalAuditLogger) { + globalAuditLogger.close(); + } + globalAuditLogger = new AuditLogger(config); + return globalAuditLogger; +} + +/** + * Get the global audit logger instance + */ +export function getAuditLogger(): AuditLogger { + if (!globalAuditLogger) { + globalAuditLogger = new AuditLogger(); + } + return globalAuditLogger; +} + +/** + * Shorthand for logging audit events + */ +export const audit = { + auth: (action: Parameters[0], params: Parameters[1]) => + getAuditLogger().auth(action, params), + exec: (action: Parameters[0], params: Parameters[1]) => + getAuditLogger().exec(action, params), + tool: (action: Parameters[0], params: Parameters[1]) => + getAuditLogger().tool(action, params), + config: (action: Parameters[0], params: Parameters[1]) => + getAuditLogger().config(action, params), + file: (action: Parameters[0], params: Parameters[1]) => + getAuditLogger().file(action, params), + critical: (params: Parameters[0]) => + getAuditLogger().critical(params), + log: (params: Parameters[0]) => + getAuditLogger().log(params), +};