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:
parent
e4523991e3
commit
2bbbfc8735
292
src/security/audit-log.test.ts
Normal file
292
src/security/audit-log.test.ts
Normal 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
507
src/security/audit-log.ts
Normal 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),
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user