llm-security/scanners/lib/audit-trail.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

86 lines
2.7 KiB
JavaScript

// audit-trail.mjs — Structured JSONL audit trail writer
// Resolves the audit-log path from the policy.json key `audit.log_path`.
// v8.0.0: the LLM_SECURITY_AUDIT_LOG env-var was removed after its v7.3.0
// deprecation runway; policy.json is now the only source.
// No-op when policy provides no path. Zero external dependencies.
import { appendFileSync, writeFileSync, accessSync, constants } from 'node:fs';
import { dirname } from 'node:path';
import { getPolicyValue } from './policy-loader.mjs';
let auditPath = null;
let initialized = false;
/**
* Initialize audit trail. Validates the path is writable on first call.
* @returns {boolean} true if audit trail is enabled and writable
*/
function initAuditTrail() {
if (initialized) return auditPath !== null;
initialized = true;
const resolved = getPolicyValue('audit', 'log_path', null);
if (!resolved) return false;
try {
// Ensure parent directory exists and is writable
const dir = dirname(resolved);
accessSync(dir, constants.W_OK);
// Touch file if it doesn't exist
try { accessSync(resolved); } catch { writeFileSync(resolved, ''); }
auditPath = resolved;
return true;
} catch (err) {
process.stderr.write(`[llm-security] Audit trail path not writable: ${resolved} (${err.message})\n`);
return false;
}
}
/**
* Write a structured audit event as one JSON line.
* No-op when the policy.json key audit.log_path is not set.
*
* @param {object} event
* @param {string} event.event_type - e.g. trifecta_warning, injection_detected
* @param {string} event.severity - critical|high|medium|low|info
* @param {string} event.source - hook or scanner name
* @param {object} [event.details] - event-specific payload
* @param {string[]} [event.owasp] - OWASP categories
* @param {string} [event.action_taken] - blocked|warned|allowed
*/
export function writeAuditEvent(event) {
if (!initAuditTrail()) return;
const entry = {
timestamp: new Date().toISOString(),
session_id: String(process.ppid || process.pid),
event_type: event.event_type || 'unknown',
severity: event.severity || 'info',
source: event.source || 'unknown',
details: event.details || {},
owasp: event.owasp || [],
action_taken: event.action_taken || 'warned',
};
try {
appendFileSync(auditPath, JSON.stringify(entry) + '\n');
} catch (err) {
process.stderr.write(`[llm-security] Audit trail write failed: ${err.message}\n`);
}
}
/**
* Check whether audit trail is enabled (for guard clauses in hooks).
* @returns {boolean}
*/
export function isAuditEnabled() {
return initAuditTrail();
}
/**
* Reset internal state (for testing only).
*/
export function _resetForTest() {
auditPath = null;
initialized = false;
}