feat(security): add audit logging system

Add comprehensive audit logging for security-sensitive operations.

Features:
- Structured JSON log format with ISO 8601 timestamps
- Configurable categories: auth, config, exec, tool, message, file, session, channel, cron, pairing, admin
- Severity levels: info, warn, critical
- PII redaction (emails, phone numbers, API keys)
- Optional tamper-evident hash chain
- File rotation support
- Singleton pattern with global instance

Usage:
  import { audit } from './security/audit-log';

  audit.auth('auth.login', { message: 'User logged in', actor: { type: 'user', id: '123' } });
  audit.exec('exec.run', { message: 'Command executed', command: 'ls -la' });
  audit.tool('tool.invoke', { toolName: 'web_search', message: 'Search invoked' });

Part of security hardening initiative step 6: Audit logging

Related: #3927 (security hardening roadmap)
This commit is contained in:
Mike 2026-01-29 19:58:24 +08:00
parent e4523991e3
commit 2bbbfc8735
2 changed files with 799 additions and 0 deletions

View File

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

507
src/security/audit-log.ts Normal file
View File

@ -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<string, unknown>;
/** 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<AuditSeverity, number> = {
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<AuditLogConfig> = {}) {
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<string, unknown>): Record<string, unknown> {
if (!this.config.redactPii) return obj;
const result: Record<string, unknown> = {};
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<string, unknown>);
} 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<string, unknown>;
}): 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<string, unknown>;
}): 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<string, unknown>;
}): 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<string, unknown>;
}): 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<string, unknown>;
}): 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<string, unknown>;
}): 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<string, unknown>;
}): void {
this.log({ ...params, severity: "critical" });
}
/**
* Close the audit logger
*/
close(): Promise<void> {
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<AuditLogConfig> = {}): 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<AuditLogger["auth"]>[0], params: Parameters<AuditLogger["auth"]>[1]) =>
getAuditLogger().auth(action, params),
exec: (action: Parameters<AuditLogger["exec"]>[0], params: Parameters<AuditLogger["exec"]>[1]) =>
getAuditLogger().exec(action, params),
tool: (action: Parameters<AuditLogger["tool"]>[0], params: Parameters<AuditLogger["tool"]>[1]) =>
getAuditLogger().tool(action, params),
config: (action: Parameters<AuditLogger["config"]>[0], params: Parameters<AuditLogger["config"]>[1]) =>
getAuditLogger().config(action, params),
file: (action: Parameters<AuditLogger["file"]>[0], params: Parameters<AuditLogger["file"]>[1]) =>
getAuditLogger().file(action, params),
critical: (params: Parameters<AuditLogger["critical"]>[0]) =>
getAuditLogger().critical(params),
log: (params: Parameters<AuditLogger["log"]>[0]) =>
getAuditLogger().log(params),
};