// audit-trail.test.mjs — Tests for structured JSONL audit trail // v8.0.0: configured via policy.json `audit.log_path`, not LLM_SECURITY_AUDIT_LOG. import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { writeAuditEvent, isAuditEnabled, _resetForTest } from '../../scanners/lib/audit-trail.mjs'; import { _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs'; const TEST_LOG = join(tmpdir(), `llm-security-audit-test-${Date.now()}.jsonl`); // v8.0.0: the audit-log path comes from the policy.json key `audit.log_path` // (LLM_SECURITY_AUDIT_LOG was removed). audit-trail runs in-process, so the // policy is supplied through a throwaway CLAUDE_PROJECT_ROOT. let projectRoot = null; /** Point audit.log_path at `logPath` for the next writeAuditEvent/isAuditEnabled. */ function enableAudit(logPath) { writeFileSync( join(projectRoot, '.llm-security', 'policy.json'), JSON.stringify({ audit: { log_path: logPath } }) ); _resetCacheForTest(); _resetForTest(); } /** Leave audit.log_path unset (the default). */ function disableAudit() { writeFileSync(join(projectRoot, '.llm-security', 'policy.json'), JSON.stringify({})); _resetCacheForTest(); _resetForTest(); } describe('audit-trail', () => { beforeEach(() => { projectRoot = mkdtempSync(join(tmpdir(), 'llmsec-audit-root-')); mkdirSync(join(projectRoot, '.llm-security'), { recursive: true }); process.env.CLAUDE_PROJECT_ROOT = projectRoot; disableAudit(); // Clean up test file try { unlinkSync(TEST_LOG); } catch {} }); afterEach(() => { _resetForTest(); _resetCacheForTest(); delete process.env.CLAUDE_PROJECT_ROOT; if (projectRoot) rmSync(projectRoot, { recursive: true, force: true }); projectRoot = null; try { unlinkSync(TEST_LOG); } catch {} }); it('is disabled when audit.log_path is not set', () => { assert.equal(isAuditEnabled(), false); }); it('is enabled when audit.log_path is a writable path', () => { enableAudit(TEST_LOG); assert.equal(isAuditEnabled(), true); }); it('no-op when audit.log_path is not set', () => { writeAuditEvent({ event_type: 'test', severity: 'info', source: 'test' }); assert.equal(existsSync(TEST_LOG), false); }); it('writes valid JSONL when enabled', () => { enableAudit(TEST_LOG); writeAuditEvent({ event_type: 'trifecta_warning', severity: 'high', source: 'post-session-guard', details: { window_size: 20 }, owasp: ['ASI01', 'ASI02'], action_taken: 'warned', }); const content = readFileSync(TEST_LOG, 'utf8').trim(); const entry = JSON.parse(content); assert.equal(entry.event_type, 'trifecta_warning'); assert.equal(entry.severity, 'high'); assert.equal(entry.source, 'post-session-guard'); assert.deepEqual(entry.owasp, ['ASI01', 'ASI02']); assert.equal(entry.action_taken, 'warned'); assert.ok(entry.timestamp.match(/^\d{4}-\d{2}-\d{2}T/), 'Expected ISO timestamp'); assert.ok(entry.session_id, 'Expected session_id'); }); it('appends multiple events as separate lines', () => { enableAudit(TEST_LOG); writeAuditEvent({ event_type: 'event1', severity: 'info', source: 'test' }); writeAuditEvent({ event_type: 'event2', severity: 'medium', source: 'test' }); writeAuditEvent({ event_type: 'event3', severity: 'high', source: 'test' }); const lines = readFileSync(TEST_LOG, 'utf8').trim().split('\n'); assert.equal(lines.length, 3); const e1 = JSON.parse(lines[0]); const e3 = JSON.parse(lines[2]); assert.equal(e1.event_type, 'event1'); assert.equal(e3.event_type, 'event3'); }); it('events contain all required fields', () => { enableAudit(TEST_LOG); writeAuditEvent({ event_type: 'test', severity: 'info', source: 'test-hook' }); const entry = JSON.parse(readFileSync(TEST_LOG, 'utf8').trim()); const required = ['timestamp', 'session_id', 'event_type', 'severity', 'source', 'details', 'owasp', 'action_taken']; for (const field of required) { assert.ok(field in entry, `Missing required field: ${field}`); } }); it('provides defaults for optional fields', () => { enableAudit(TEST_LOG); writeAuditEvent({ event_type: 'minimal' }); const entry = JSON.parse(readFileSync(TEST_LOG, 'utf8').trim()); assert.equal(entry.severity, 'info'); assert.equal(entry.source, 'unknown'); assert.deepEqual(entry.details, {}); assert.deepEqual(entry.owasp, []); assert.equal(entry.action_taken, 'warned'); }); it('does not crash on invalid path', () => { enableAudit('/nonexistent/dir/audit.jsonl'); // Should not throw — gracefully logs to stderr assert.doesNotThrow(() => { writeAuditEvent({ event_type: 'test', severity: 'info', source: 'test' }); }); }); });