// lib/review/security-engine.mjs // The security engine of /trekreview (E 3.1; decisions 175, 196, 219). // // Which engine reviews security, and Claude Security's own report turned into // a reviewer payload. Security that no engine could look at is "not // measured", never 0 (196; S203: `/security-review` gave 0 in 13 of 13). // // choose --repo --base --head --claude-security available|missing // prints {engine, reason}. "available" is decided by the orchestrator (the // `claude-security:claude-security` skill is listed and the Workflow tool is // callable) and passed in; this module never probes for it. // 1. Claude Security available and `base..head` has committed changes // -> engine "claude-security", reason "claude-security-available". // 2. else, when origin's HEAD resolves (`refs/remotes/origin/HEAD`): // origin/HEAD...HEAD has changes -> "security-review" (reason // "claude-security-missing", or "claude-security-empty-diff" when Claude // Security was available but its range was empty); none -> null, // "empty-diff". // 3. else null: "empty-diff" when Claude Security was available (its range // was empty), "no-engine" without an `origin` remote (neither engine can // run), "no-origin" with an `origin` remote whose HEAD does not resolve. // Engine null is security not measured: /trekreview then expects the // reviewer NO_ENGINE_REVIEWER (`security-engine`), which never reports, so // the review carries `missing-reviewer:security-engine` and the verdict gate // exits 2. Only committed changes count (uncommitted edits are not in either // engine's range). // exit 0 = decided (the engine may be null); 2 = could not run: bad usage, // not a git repository, a sha that does not resolve, a git failure. Never a // reason, never a guess. // // convert --report-dir --base --head [--scope ] // is the scanned repo's root, where a changes scan writes its // `CLAUDE-SECURITY-/` folder (Claude Security 0.12.0, // scripts/render_report.py). Reads the newest such folder whose one stamp // `CLAUDE-SECURITY-REVISION-<12 hex>[-dirty].json` has mode "changes", // revision.commit == head and revision.merge_base == base (a `-dirty` stamp // counts; a folder with no stamp or two is passed over), and its // `CLAUDE-SECURITY-RESULTS.jsonl`, one finding per line. Each record becomes // an engine finding through engine-findings.mjs `toReviewerPayload` (engine // `claude-security`): file <- the stamp's `scan_prefix` + `file` (the record // is relative to the scan root), summary <- title, failure_scenario <- // exploit_scenario, category <- cwe_id, fix <- recommendation; no narrower // rule_key. Confidence `high` -> `findings` / `out_of_scope` as for any // engine; every other confidence -> `below_threshold`, never in `findings`, // so engine-compare.mjs `putBack` and the verdict gate never restore one // (219: listed under Declined to Judge as `engine:below-threshold`, an // unverified reason: not counted, it keeps the verdict from ALLOW). No hand // transcription. // Prints {reviewer, findings, out_of_scope, below_threshold, report}; exit 0. // Not measured (exit 2, the reason on stderr, nothing on stdout): no such // stamp (an empty diff writes none), verification.status not "verified", // researchers_returned or researchers_dispatched missing or returned < // dispatched, run_shape.empty_diff or empty_scope true or not recorded, a // malformed scan_prefix, findings.total not a non-negative integer, a scope // that is not an empty list (a scan limited by `--scope` looked at part of // the range; Claude Security 0.12.0 writes `scope: []` for an unscoped scan; // an absent or non-list scope counts the same) (decision 224), a missing or // unreadable JSONL, a record that does not parse or lacks its file, title or // a confidence of low|medium|high, a JSONL whose number of records is not // the stamp's findings.total (decision 224). // exit 1 = the scope list is empty (as engine-findings.mjs); bad usage = 2. // // The report folder lives in the repo under review, so its text is data: a // stamp counts only when it names this exact head and merge base, and a // record's text is carried, never followed. That check is a trace, not a // signature (decision 223, a known issue): code the reviewed run executed can // write a report folder of its own (convert reads the matching one whose name // sorts last) or delete the real one before convert reads it, and nothing // here detects either. import { spawnSync } from 'node:child_process'; import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; import { join, posix } from 'node:path'; import { toReviewerPayload, ENGINES, readScopeList } from './engine-findings.mjs'; import { FULL_SHA_RE } from './reviewed-commit.mjs'; import { isMain } from '../util/is-main.mjs'; /** The reviewer /trekreview expects when no security engine was chosen: it never reports. */ export const NO_ENGINE_REVIEWER = 'security-engine'; const ENGINE = 'claude-security'; const REPORT_DIR_RE = /^CLAUDE-SECURITY-[0-9][0-9-]*$/; const STAMP_RE = /^CLAUDE-SECURITY-REVISION-.+\.json$/; const JSONL_NAME = 'CLAUDE-SECURITY-RESULTS.jsonl'; const CONFIDENCES = new Set(['low', 'medium', 'high']); const ORIGIN_HEAD = 'refs/remotes/origin/HEAD'; const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); const couldNotRun = (reason) => ({ exit: 2, reason }); const notMeasured = (why) => ({ exit: 2, reason: `Claude Security report not measured: ${why}` }); /** The first of --base / --head that is not a full commit id, as a could-not-run; else null. */ function notFullShas(base, head) { for (const [name, sha] of [['--base', base], ['--head', head]]) { if (!FULL_SHA_RE.test(String(sha ?? ''))) return couldNotRun(`${name} must be a full 40- or 64-hex commit id`); } return null; } // ---- choose ------------------------------------------------------------------------ function git(repo, args) { return spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8' }); } /** true = the two commits differ, false = no change, null = git failed. Committed trees only. */ function differs(repo, ...revs) { const r = git(repo, ['diff', '--quiet', '--no-ext-diff', '--no-textconv', ...revs, '--']); if (r.error) return null; if (r.status === 0) return false; if (r.status === 1) return true; return null; } /** * Which security engine reviews `base..head` (see the header). * @param {{ repo: string, base: string, head: string, claudeSecurity: 'available'|'missing' }} args * @returns {{ exit: 0, engine: string|null, reason: string } | { exit: 2, reason: string }} */ export function chooseEngine({ repo, base, head, claudeSecurity } = {}) { if (typeof repo !== 'string' || repo === '') return couldNotRun('--repo is required'); const bad = notFullShas(base, head); if (bad) return bad; if (claudeSecurity !== 'available' && claudeSecurity !== 'missing') { return couldNotRun('--claude-security must be available or missing'); } const top = git(repo, ['rev-parse', '--git-dir']); if (top.error || top.status !== 0) return couldNotRun(`${repo} is not a git repository`); for (const [name, sha] of [['--base', base], ['--head', head]]) { const r = git(repo, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]); if (r.error || r.status !== 0) return couldNotRun(`${name} ${sha} does not resolve to a commit`); } const available = claudeSecurity === 'available'; if (available) { const changed = differs(repo, base, head); if (changed === null) return couldNotRun(`git diff ${base}..${head} failed`); if (changed) return { exit: 0, engine: ENGINE, reason: 'claude-security-available' }; } const originHead = git(repo, ['rev-parse', '--verify', '--quiet', ORIGIN_HEAD]); if (originHead.error || (originHead.status !== 0 && originHead.status !== 1)) { return couldNotRun(`git rev-parse ${ORIGIN_HEAD} failed`); } if (originHead.status === 0) { const changed = differs(repo, `${ORIGIN_HEAD}...HEAD`); if (changed === null) return couldNotRun(`git diff ${ORIGIN_HEAD}...HEAD failed`); if (!changed) return { exit: 0, engine: null, reason: 'empty-diff' }; return { exit: 0, engine: 'security-review', reason: available ? 'claude-security-empty-diff' : 'claude-security-missing' }; } if (available) return { exit: 0, engine: null, reason: 'empty-diff' }; const remotes = git(repo, ['remote']); if (remotes.error || remotes.status !== 0) return couldNotRun('git remote failed'); const hasOrigin = remotes.stdout.split('\n').map((l) => l.trim()).includes('origin'); return { exit: 0, engine: null, reason: hasOrigin ? 'no-origin' : 'no-engine' }; } // ---- convert ----------------------------------------------------------------------- const isDir = (p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } }; const isFile = (p) => { try { return lstatSync(p).isFile(); } catch { return false; } }; /** The parsed stamp of a report folder when it holds exactly one regular stamp file, else null. */ function stampIn(folder) { let names; try { names = readdirSync(folder); } catch { return null; } const stamps = names.filter((n) => STAMP_RE.test(n) && isFile(join(folder, n))); if (stamps.length !== 1) return null; try { const stamp = JSON.parse(readFileSync(join(folder, stamps[0]), 'utf8')); return isPlainObject(stamp) ? { name: stamps[0], stamp } : null; } catch { return null; } } const sameSha = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase(); /** The newest report folder in `dir` whose stamp is a changes scan of exactly base..head, or null. */ function findReport(dir, base, head) { let names; try { names = readdirSync(dir); } catch { return null; } const folders = names.filter((n) => REPORT_DIR_RE.test(n) && isDir(join(dir, n))).sort().reverse(); for (const name of folders) { const s = stampIn(join(dir, name)); if (!s) continue; const rev = isPlainObject(s.stamp.revision) ? s.stamp.revision : {}; if (s.stamp.mode === 'changes' && sameSha(rev.commit, head) && sameSha(rev.merge_base, base)) { return { folder: join(dir, name), name, stampName: s.name, stamp: s.stamp }; } } return null; } /** Why a matching stamp cannot be trusted as a full scan, or null. */ function stampProblem(stamp) { const v = isPlainObject(stamp.verification) ? stamp.verification : {}; if (v.status !== 'verified') return `verification.status is ${JSON.stringify(v.status ?? null)}, not "verified"`; const dispatched = v.researchers_dispatched; const returned = v.researchers_returned; if (!Number.isInteger(dispatched) || !Number.isInteger(returned)) { return 'the stamp does not record how many researchers were dispatched and returned'; } if (returned < dispatched) return `${returned} of ${dispatched} researchers returned`; const shape = isPlainObject(stamp.run_shape) ? stamp.run_shape : {}; if (typeof shape.empty_diff !== 'boolean' || typeof shape.empty_scope !== 'boolean') { return 'the stamp does not record whether the diff or the scope was empty'; } if (shape.empty_diff) return 'the scan saw an empty diff'; if (shape.empty_scope) return 'the scan saw an empty scope'; const prefix = stamp.scan_prefix; if (typeof prefix !== 'string' || (prefix !== '' && (!prefix.endsWith('/') || prefix.startsWith('/') || prefix.includes('\\') || prefix.split('/').slice(0, -1).some((seg) => seg === '' || seg === '.' || seg === '..')))) { return `scan_prefix ${JSON.stringify(prefix ?? null)} is not a path prefix`; } const total = isPlainObject(stamp.findings) ? stamp.findings.total : undefined; if (!Number.isInteger(total) || total < 0) { return `findings.total ${JSON.stringify(total ?? null)} is not a count of findings`; } if (!Array.isArray(stamp.scope) || stamp.scope.length !== 0) { return `scope ${JSON.stringify(stamp.scope ?? null)} is not [] (a scan limited by --scope, or none recorded), not a scan of the whole range`; } return null; } /** The JSONL records as engine-findings input with their confidence, or a reason. */ function readRecords(folder, prefix) { const p = join(folder, JSONL_NAME); if (!isFile(p)) return { reason: `${JSONL_NAME} is missing` }; let text; try { text = readFileSync(p, 'utf8'); } catch (e) { return { reason: `${JSONL_NAME} cannot be read: ${e.message}` }; } const out = []; const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].trim() === '') continue; let r; try { r = JSON.parse(lines[i]); } catch (e) { return { reason: `${JSONL_NAME} line ${i + 1} does not parse: ${e.message}` }; } if (!isPlainObject(r)) return { reason: `${JSONL_NAME} line ${i + 1} is not an object` }; const confidence = String(r.confidence ?? '').trim().toLowerCase(); if (typeof r.file !== 'string' || r.file.trim() === '') return { reason: `${JSONL_NAME} line ${i + 1} has no file` }; if (typeof r.title !== 'string' || r.title.trim() === '') return { reason: `${JSONL_NAME} line ${i + 1} has no title` }; if (!CONFIDENCES.has(confidence)) { return { reason: `${JSONL_NAME} line ${i + 1} has confidence ${JSON.stringify(r.confidence ?? null)}, not low|medium|high` }; } const str = (v) => (typeof v === 'string' ? v : ''); out.push({ confidence, finding: { file: posix.normalize(prefix + r.file.trim()), line: r.line, summary: r.title, failure_scenario: str(r.exploit_scenario), category: str(r.cwe_id), ...(str(r.recommendation).trim() !== '' ? { fix: r.recommendation } : {}), }, }); } return { records: out }; } /** * Claude Security's report of `base..head` as a reviewer payload (see the header). * @param {{ reportDir: string, base: string, head: string, scopeFiles?: string[] }} args * @returns {{ exit: 0, payload: object } | { exit: 1|2, reason: string }} */ export function convertReport({ reportDir, base, head, scopeFiles } = {}) { if (typeof reportDir !== 'string' || reportDir === '') return couldNotRun('--report-dir is required'); const bad = notFullShas(base, head); if (bad) return bad; const found = findReport(reportDir, base, head); if (!found) { return notMeasured(`no CLAUDE-SECURITY-/ folder in ${reportDir} holds one stamp of a changes scan of ${base}..${head} (an empty diff writes none)`); } const problem = stampProblem(found.stamp); if (problem) return notMeasured(`${found.name}/${found.stampName}: ${problem}`); const read = readRecords(found.folder, found.stamp.scan_prefix); if (read.reason) return notMeasured(`${found.name}: ${read.reason}`); if (read.records.length !== found.stamp.findings.total) { return notMeasured(`${found.name}: ${JSONL_NAME} holds ${read.records.length} record(s), the stamp's findings.total is ${found.stamp.findings.total}`); } const high = read.records.filter((r) => r.confidence === 'high').map((r) => r.finding); const rest = read.records.filter((r) => r.confidence !== 'high').map((r) => r.finding); const counted = toReviewerPayload({ engine: ENGINE, findings: high }, { scopeFiles }); if (!counted.ok) return { exit: 1, reason: counted.errors.join('; ') }; const below = toReviewerPayload({ engine: ENGINE, findings: rest }); if (!below.ok) return notMeasured(`${found.name}: ${below.errors.join('; ')}`); return { exit: 0, payload: { reviewer: ENGINES[ENGINE].reviewer, findings: counted.payload.findings, out_of_scope: counted.out_of_scope, below_threshold: below.payload.findings, report: { folder: found.name, stamp: found.stampName }, }, }; } // ---- CLI --------------------------------------------------------------------------- const USAGE = 'Usage: security-engine.mjs choose --repo --base --head --claude-security available|missing\n' + ' security-engine.mjs convert --report-dir --base --head [--scope ]\n'; /** `--flag value` pairs, each known flag at most once; null on anything else. */ function flags(args, known) { const out = {}; for (let i = 0; i < args.length; i += 2) { const k = args[i]; if (!known.includes(k) || Object.hasOwn(out, k) || i + 1 >= args.length) return null; out[k] = args[i + 1]; } return out; } /** A usage error: the CLI prints USAGE after the reason. */ const badUsage = (reason) => ({ ...couldNotRun(reason), usage: true }); function runCli(argv) { const [cmd, ...rest] = argv; if (cmd === 'choose') { const f = flags(rest, ['--repo', '--base', '--head', '--claude-security']); if (!f) return badUsage('bad arguments'); const r = chooseEngine({ repo: f['--repo'], base: f['--base'], head: f['--head'], claudeSecurity: f['--claude-security'] }); return r.exit === 0 ? { exit: 0, out: { engine: r.engine, reason: r.reason } } : r; } if (cmd === 'convert') { const f = flags(rest, ['--report-dir', '--base', '--head', '--scope']); if (!f) return badUsage('bad arguments'); let scopeFiles; if (f['--scope'] !== undefined) { if (!existsSync(f['--scope'])) return couldNotRun(`--scope ${f['--scope']} does not exist`); try { scopeFiles = readScopeList(f['--scope']); } catch (e) { return couldNotRun(`cannot read --scope: ${e.message}`); } } const r = convertReport({ reportDir: f['--report-dir'], base: f['--base'], head: f['--head'], scopeFiles }); return r.exit === 0 ? { exit: 0, out: r.payload } : r; } return badUsage('a subcommand is required: choose or convert'); } if (isMain(import.meta.url)) { let r; try { r = runCli(process.argv.slice(2)); } catch (e) { r = couldNotRun(`security-engine crashed: ${e.message}`); } if (r.exit === 0) { process.stdout.write(`${JSON.stringify(r.out, null, 2)}\n`); } else { process.stderr.write(`security-engine: ${r.reason}\n`); if (r.usage) process.stderr.write(USAGE); } process.exit(r.exit); }