llm-security/tests/lib/audit-trail.test.mjs
Kjell Tore Guttormsen b6af9b46df feat(llm-security)!: v8 Phase 3 step 1 - remove the deprecated mode env-vars
BREAKING CHANGE: the four LLM_SECURITY_* configuration env-vars deprecated in
v7.3.0 are removed. .llm-security/policy.json is now the only source:

  LLM_SECURITY_INJECTION_MODE     -> injection.mode
  LLM_SECURITY_TRIFECTA_MODE      -> trifecta.mode
  LLM_SECURITY_ESCALATION_WINDOW  -> trifecta.escalation_window
  LLM_SECURITY_AUDIT_LOG          -> audit.log_path
  LLM_SECURITY_DEPRECATION_QUIET  -> dies with the mechanism it silenced

Setting a removed var is now inert - it does not warn, and it does not
configure. Env-vars with no policy equivalent (PRECOMPACT_MODE,
PRECOMPACT_MAX_BYTES, UPDATE_CHECK, MCP_CACHE_FILE, IDE_ROOTS) are unaffected.

getPolicyValueWithEnvWarn and its one-shot stderr warning are deleted from
policy-loader.mjs, along with the module-scoped warned-var Set. The four call
sites collapse to getPolicyValue. getPolicyValue's JSDoc claimed "environment
variables ALWAYS take precedence" - it never read env itself, so that line
described the shim, and it is corrected rather than deleted.

User-facing hook strings that advertised a removed var as the escape hatch now
name the policy key instead: the inject-scan block reason, its warn-mode note,
both escalation-window advisories, and the trifecta block message. A blocked
user following the old text would have set a var that does nothing.

Tests. tests/lib/v8-env-removal.test.mjs is the regression gate and was written
failing first (8 of 12 red before the change). It pins the NEGATIVE - setting a
removed var does not alter the outcome - because that is the half that rots
silently: a re-introduced process.env read would leave every migrated positive
test green, since those configure through policy.json and never set the var at
all. One assertion walks hooks/scripts and scanners for `process.env.<removed>`
so the re-introduction is caught structurally, not only behaviourally.

The 44 env-driven test occurrences (18 inject-scan, 13+4 session-guard, 9
audit-trail) migrate to a throwaway .llm-security/policy.json via a new
runHookWithPolicy helper in hook-helper.mjs; audit-trail runs in-process, so it
supplies the same policy through CLAUDE_PROJECT_ROOT. The D3 mechanism tests in
policy-loader.test.mjs are deleted with the mechanism.

Suite 2039 tests, 2037 pass (+12 gate, -7 D3 mechanism). The 2 failures are the
known parallel-load timing flakes (pre-compact-scan size-cap,
pre-install-supply-chain F-3); both green when run isolated.

Remaining in Phase 3: posture-scanner TRIFECTA_MODE heuristic, riskScoreV1
removal, ghost-var cleanup, docs + migration note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
2026-08-09 10:17:47 +02:00

137 lines
4.9 KiB
JavaScript

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