openclaw/src/security/audit-log.test.ts
Mike 2bbbfc8735 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)
2026-01-29 19:58:24 +08:00

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