Second review follow-up. Audit gate: a flat `npm audit --audit-level=<x>` cannot work here at any level - the tree reports 102 advisories (12 low, 38 moderate, 52 high) and `--omit=dev` does not change the counts, because gatsby itself is a runtime dependency of the build. Gating on the tree as a whole stops publishing permanently; ignoring it lets a new advisory through. scripts/audit-gate.mjs fails on any critical/high advisory that is not in scripts/audit-baseline.json, which carries an owner and a reason, so today's set is a tracked exception and anything new blocks the deploy. Verified both ways: exit 0 on the committed baseline, exit 1 with an entry removed. Lint and typecheck: the deleted GitHub workflow was the only place they ran, so `npm run typecheck` and `npm run lint` are now steps in the publish workflow, ahead of the build. Lint could not simply be switched on - prettier reported 8 pre-existing files on main, all single-line indentation - so those are fixed here and the check is clean rather than baselined. Verified locally in the order the workflow runs: audit gate 0, typecheck clean, lint clean (8 files reformatted, indentation only), build 136 pages.
108 lines
4.3 KiB
JavaScript
108 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Audit gate: fail the build on any advisory that is not a tracked exception.
|
|
*
|
|
* `npm audit --audit-level=<x>` is all-or-nothing, and this tree cannot pass a
|
|
* flat gate: the Gatsby 5 build toolchain carries ~100 advisories at
|
|
* low/moderate/high with no non-breaking fix, because gatsby is a runtime
|
|
* dependency of the build. Blocking on any of those stops publishing
|
|
* permanently; ignoring the tree lets a genuinely new advisory through. So the
|
|
* known set is committed to audit-baseline.json with an owner and a reason,
|
|
* and this script fails only when something NEW appears at or above the
|
|
* threshold.
|
|
*
|
|
* Usage: node scripts/audit-gate.mjs [--write-baseline]
|
|
*/
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const BASELINE = join(HERE, 'audit-baseline.json');
|
|
const BLOCKING = new Set(['critical', 'high']);
|
|
|
|
/** npm audit exits non-zero when it finds anything, so ignore the status. */
|
|
function auditReport() {
|
|
let out;
|
|
try {
|
|
out = execFileSync('npm', ['audit', '--json', '--omit=optional'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
maxBuffer: 32 * 1024 * 1024
|
|
});
|
|
} catch (err) {
|
|
out = err.stdout;
|
|
if (!out) throw err;
|
|
}
|
|
return JSON.parse(out);
|
|
}
|
|
|
|
/** Flatten the report to one key per package/severity/advisory triple. */
|
|
function currentFindings(report) {
|
|
const findings = new Map();
|
|
for (const [pkg, v] of Object.entries(report.vulnerabilities ?? {})) {
|
|
if (!BLOCKING.has(v.severity)) continue;
|
|
const advisories = (v.via ?? []).filter((x) => typeof x === 'object');
|
|
if (advisories.length === 0) {
|
|
// via is a chain of parent package names - attribute to the package.
|
|
findings.set(`${pkg}|${v.severity}|transitive`, { pkg, severity: v.severity, advisory: 'transitive' });
|
|
continue;
|
|
}
|
|
for (const a of advisories) {
|
|
const id = (a.url ?? '').split('/').pop() || a.title || 'unknown';
|
|
findings.set(`${pkg}|${v.severity}|${id}`, { pkg, severity: v.severity, advisory: id, title: a.title, range: a.range });
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
const report = auditReport();
|
|
const current = currentFindings(report);
|
|
|
|
if (process.argv.includes('--write-baseline')) {
|
|
const accepted = [...current.values()].sort((a, b) => `${a.pkg}${a.advisory}`.localeCompare(`${b.pkg}${b.advisory}`));
|
|
writeFileSync(
|
|
BASELINE,
|
|
JSON.stringify(
|
|
{
|
|
$comment:
|
|
'Advisories at critical/high accepted for this tree. scripts/audit-gate.mjs fails the build on anything not listed here, so removing an entry re-arms the gate for that advisory. Regenerate with: node scripts/audit-gate.mjs --write-baseline',
|
|
owner: 'Site maintainers (allan@4gl.io)',
|
|
reason:
|
|
'Gatsby 5 build toolchain: no non-breaking fix available upstream, and these are build-time dependencies of a statically generated site. Re-check on each Gatsby major bump.',
|
|
accepted
|
|
},
|
|
null,
|
|
2
|
|
) + '\n'
|
|
);
|
|
console.log(`wrote ${accepted.length} accepted advisories to ${BASELINE}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const baseline = JSON.parse(readFileSync(BASELINE, 'utf8'));
|
|
const accepted = new Set((baseline.accepted ?? []).map((a) => `${a.pkg}|${a.severity}|${a.advisory}`));
|
|
|
|
const fresh = [...current.entries()].filter(([key]) => !accepted.has(key));
|
|
const resolved = [...accepted].filter((key) => !current.has(key));
|
|
|
|
for (const [, f] of fresh) {
|
|
console.error(`NEW ${f.severity.toUpperCase()}: ${f.pkg} - ${f.title ?? f.advisory} (${f.advisory})`);
|
|
}
|
|
|
|
if (resolved.length > 0) {
|
|
// Not a failure: a bumped or removed dependency closes advisories, and the
|
|
// baseline should be trimmed in the same change that closes them.
|
|
console.log(`${resolved.length} baseline entries no longer present - consider re-running with --write-baseline:`);
|
|
for (const key of resolved) console.log(` stale: ${key}`);
|
|
}
|
|
|
|
if (fresh.length > 0) {
|
|
console.error(`\n${fresh.length} untracked advisory/ies at critical or high.`);
|
|
console.error('Fix it, or add it to scripts/audit-baseline.json with a reason and an owner.');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`audit gate: no new critical/high advisories (${current.size} tracked in baseline)`);
|