#!/usr/bin/env node /** * Production dependency advisories, with an allowlist npm does not give you. * * node scripts/audit-gate.mjs # fail on unassessed high/critical * node scripts/audit-gate.mjs --list # print what is found, exit 0 regardless * * 0 no unassessed high/critical advisories in production dependencies * 1 unassessed advisories found — they are listed * 2 NOTHING WAS CHECKED — audit did not run, or its output was not the shape * this understands. Not a pass, and CI must not treat it as one. * * ## Why this exists rather than `npm audit --audit-level=high` * * npm audit has no allowlist. So when an advisory lands that provably cannot * reach the application — a server-side CSRF in a router the app only uses * declaratively, say — there are exactly two options in plain npm, and both are * bad. Lower the threshold, which throws away a gate that has already caught * real issues. Or let CI sit red, which for an advisory with no fixed version * means indefinitely, and a permanently red gate is a gate everybody has * learned to ignore. That is `GUARDS.md` rule 6 arriving through the back door. * * The third option is to say **which** advisories have been assessed and why, * in a file somebody reviews. That is this. * * ## `--omit=dev` is the boundary, and it is a real one * * A build-time advisory in a test runner does not ship. The container holds * production dependencies, so those are what this asks about. Widening it to * dev pulls in advisories nothing can reach from outside and drowns the ones * that can. * * ## Rules for adding an entry — all four, or it is not an assessment * * - **reason** — why it cannot reach THIS app, concretely, naming the file or * the API that would have to be in use for it to matter. "Low risk" is not * an assessment, it is an adjective. * - **reachableIf** — what would make it reachable. This is the falsifiable * half: without it, nobody can ever tell whether the waiver still holds. * - **recheck** — the event that should retire the entry, not a date. * - **Mirror it in the project's SECURITY document**, so an operator reading * about the system's posture sees the same list a developer does. An * exception that lives only in a script is one the security review misses. * * Delete an entry the moment a fix ships. An allowlist nobody prunes stops * being a record of decisions and becomes a list of things nobody looks at. * * =========================================================================== * TEMPLATE COPY — configure this before the first run * =========================================================================== * * Copy to `scripts/audit-gate.mjs`, add `"audit": "node scripts/audit-gate.mjs"` * to package.json, and — this is the step that matters — put it in whatever * command the gate actually runs. A guard wired to nothing is documentation. * * Set SECURITY_DOC below to where this project keeps its security document, so * the failure message names a path that exists. `docs/security/SECURITY.md` is * the template's location; a repository that keeps it elsewhere and does not * change this line prints a path a reader cannot open, which is exactly the * class of stale doc-claim `doc-claims.sh` exists to catch. * * ALLOWED starts empty, deliberately. An allowlist inherited from another * project waives advisories against an application that was never assessed — * and it waives them silently, which is the worst available outcome. */ // =========================================================================== // CONFIGURATION // =========================================================================== /** Where this project's security document lives, for the failure message. */ const SECURITY_DOC = 'docs/security/SECURITY.md'; /** * Assessed advisories, keyed by the package name npm reports. * * Empty on purpose — see the header. A worked example of the shape, from the * project this came from: * * 'react-router': { * reason: * 'Advisory is RSC Mode CSRF (action execution before a 400). This app ' + * 'is a Vite SPA using declarative (client/main.tsx) — ' + * 'no RSC mode, no framework/data router, no createStaticHandler, no ' + * '@react-router/* server package. No fixed version exists: the ' + * 'vulnerable range extends past the newest published release, and the ' + * 'only "fix" npm offers is a downgrade.', * reachableIf: 'the app adopts RSC mode or a react-router server runtime', * recheck: 'when react-router publishes a release above the vulnerable range', * }, * * Note what makes that one an assessment rather than a shrug: it names the * file, the four APIs that would have to be in use, and the release that * retires it. */ const ALLOWED = {}; // =========================================================================== import { execFileSync } from 'node:child_process'; const listOnly = process.argv.includes('--list'); const die = (code, message) => { console.error(`[audit-gate] ${message}`); process.exit(code); }; let raw; try { raw = execFileSync('npm', ['audit', '--omit=dev', '--json'], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, }); } catch (err) { // npm audit exits non-zero whenever it finds anything at all, so a throw here // is the normal path and the JSON is still on stdout. An empty stdout is the // abnormal one — npm itself failed, and nothing was measured. raw = err.stdout; if (!raw) { die(2, `npm audit produced no output (${err.message}) — nothing was checked`); } } let report; try { report = JSON.parse(raw); } catch { die(2, 'npm audit output was not JSON — nothing was checked'); } // Distinguishing "no vulnerabilities" from "no vulnerabilities key" is the whole // of GUARDS.md rule 5 applied to this script's own instrument. npm's audit JSON // has changed shape between major versions before; a run against a shape this // does not understand must not render as a clean bill of health. if (typeof report.vulnerabilities !== 'object' || report.vulnerabilities === null) { die(2, 'npm audit output had no `vulnerabilities` object — nothing was checked'); } const blocking = []; const waived = []; for (const [name, entry] of Object.entries(report.vulnerabilities)) { if (entry.severity !== 'high' && entry.severity !== 'critical') continue; (ALLOWED[name] ? waived : blocking).push({ name, severity: entry.severity, range: entry.range, }); } for (const { name, severity, range } of waived) { console.log(`[audit-gate] WAIVED ${name} (${severity}, ${range}) — ${ALLOWED[name].reason}`); } if (blocking.length && !listOnly) { console.error(`\n[audit-gate] FAILED — ${blocking.length} unassessed high/critical advisory(ies):`); for (const { name, severity, range } of blocking) { console.error(` ✗ ${name} (${severity}, ${range})`); } console.error( `\nFix it, or — only if it provably cannot reach this app — add an assessed entry\n` + `to this script and mirror it in ${SECURITY_DOC}. All four fields, or it is\n` + `not an assessment.`, ); process.exit(1); } if (blocking.length) { console.log(`[audit-gate] --list: ${blocking.length} unassessed high/critical advisory(ies)`); for (const { name, severity, range } of blocking) { console.log(` ✗ ${name} (${severity}, ${range})`); } } console.log( `[audit-gate] OK — 0 unassessed high/critical advisories in production dependencies` + (waived.length ? ` (${waived.length} assessed and waived)` : ''), );