Project-Template/docs/architecture/scripts/audit-gate.mjs

188 lines
7.6 KiB
JavaScript
Raw Permalink Normal View History

#!/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 <BrowserRouter> (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)` : ''),
);