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)
293 lines
8.7 KiB
TypeScript
293 lines
8.7 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|