Add isWaived() predicate: a waiver with a past 'expires' date is treated as absent so the High/Critical advisory re-blocks. Applied in both selectBlocking and partitionAdvisories. Add expired-waiver unit tests. isMain now compares fully-resolved real paths (mirrors index.ts).
156 lines
5.6 KiB
JavaScript
156 lines
5.6 KiB
JavaScript
/**
|
|
* check-audit.mjs — pnpm audit wrapper for CI dependency gate (D-04 / D-05).
|
|
*
|
|
* Exports pure filter functions (selectBlocking, partitionAdvisories) so the
|
|
* logic can be unit-tested without spawning pnpm. The main body (run only when
|
|
* invoked directly via import.meta.url) reads the committed allowlist, runs
|
|
* `pnpm audit --json`, and exits 1 if any unwaived High/Critical advisories exist.
|
|
*
|
|
* Rules:
|
|
* - Uses `pnpm audit --json` with NO --audit-level (captures all severities).
|
|
* --audit-level would filter the JSON output itself (Pitfall 1).
|
|
* - Waives advisories listed in scripts/audit-allowlist.json by github_advisory_id.
|
|
* - Exits 0 when all High/Critical advisories are waived (or there are none).
|
|
* - Prints moderate/low advisory list to stdout as advisory-only info before exiting 0.
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { resolve, dirname } from 'node:path';
|
|
|
|
const BLOCKING_SEVERITIES = new Set(['high', 'critical']);
|
|
|
|
/**
|
|
* Decides whether an advisory is currently waived by the allowlist.
|
|
*
|
|
* A waiver entry suppresses the advisory ONLY while it is in force: an entry
|
|
* with no `expires` field, or with an `expires` date strictly in the future,
|
|
* waives the advisory. An entry whose `expires` date is in the past (≤ now) is
|
|
* treated as absent — the advisory re-blocks. This makes the time-boxed waiver
|
|
* actually time-boxed (CR-01).
|
|
*
|
|
* @param {{severity: string, github_advisory_id: string}} adv
|
|
* @param {Record<string, {reason: string, reviewer: string, expires?: string}>} allowlist
|
|
* @returns {boolean}
|
|
*/
|
|
export function isWaived(adv, allowlist) {
|
|
const w = allowlist[adv.github_advisory_id];
|
|
if (!w) return false;
|
|
// No expiry or future expiry → waived; past (or equal) expiry → NOT waived (re-blocks).
|
|
if (w.expires && Date.parse(w.expires) <= Date.now()) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* From an advisories map (keyed by numeric id), returns the subset that
|
|
* are High or Critical AND whose github_advisory_id is NOT currently waived.
|
|
*
|
|
* @param {Record<string, {severity: string, github_advisory_id: string, module_name: string, title: string}>} advisories
|
|
* @param {Record<string, {reason: string, reviewer: string, expires?: string}>} allowlist
|
|
* @returns {Array<{severity: string, github_advisory_id: string, module_name: string, title: string}>}
|
|
*/
|
|
export function selectBlocking(advisories, allowlist) {
|
|
return Object.values(advisories).filter(
|
|
(adv) => BLOCKING_SEVERITIES.has(adv.severity) && !isWaived(adv, allowlist),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Partitions all advisories into blocking (unwaived High/Critical) and
|
|
* advisory-only (moderate/low, or currently-waived High/Critical).
|
|
*
|
|
* @param {Record<string, {severity: string, github_advisory_id: string, module_name: string, title: string}>} advisories
|
|
* @param {Record<string, {reason: string, reviewer: string, expires?: string}>} allowlist
|
|
* @returns {{ blocking: Array, advisory: Array }}
|
|
*/
|
|
export function partitionAdvisories(advisories, allowlist) {
|
|
const blocking = [];
|
|
const advisory = [];
|
|
|
|
for (const adv of Object.values(advisories)) {
|
|
if (BLOCKING_SEVERITIES.has(adv.severity) && !isWaived(adv, allowlist)) {
|
|
blocking.push(adv);
|
|
} else {
|
|
advisory.push(adv);
|
|
}
|
|
}
|
|
|
|
return { blocking, advisory };
|
|
}
|
|
|
|
// Main body — only runs when invoked directly (not when imported as a module).
|
|
// IN-01: compare fully-resolved real paths (mirrors index.ts isMainModule) so a
|
|
// symlinked or non-canonical entrypoint does not silently skip the gate.
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
function isMainModule() {
|
|
if (!process.argv[1]) return false;
|
|
try {
|
|
return __filename === realpathSync(process.argv[1]);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
const isMain = isMainModule();
|
|
|
|
if (isMain) {
|
|
const __dirname = dirname(__filename);
|
|
const allowlistPath = resolve(__dirname, 'audit-allowlist.json');
|
|
|
|
// Load the committed waiver allowlist.
|
|
let allowlist;
|
|
try {
|
|
allowlist = JSON.parse(readFileSync(allowlistPath, 'utf8'));
|
|
} catch (err) {
|
|
console.error(`[check-audit] Failed to read allowlist at ${allowlistPath}: ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
// Run pnpm audit --json without --audit-level so all severities appear in output.
|
|
// stderr is suppressed (pnpm writes progress/warnings there); we only need stdout JSON.
|
|
let auditOutput;
|
|
try {
|
|
auditOutput = execSync('pnpm audit --json', {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
});
|
|
} catch (err) {
|
|
// pnpm audit exits non-zero when advisories exist — capture stdout from the error.
|
|
auditOutput = err.stdout ?? '';
|
|
}
|
|
|
|
let auditData;
|
|
try {
|
|
auditData = JSON.parse(auditOutput);
|
|
} catch (err) {
|
|
console.error(`[check-audit] Failed to parse pnpm audit JSON output: ${err.message}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const advisories = auditData.advisories ?? {};
|
|
const { blocking, advisory } = partitionAdvisories(advisories, allowlist);
|
|
|
|
if (blocking.length > 0) {
|
|
console.error('BLOCKING advisories (High/Critical, not in allowlist):');
|
|
for (const adv of blocking) {
|
|
console.error(
|
|
` ${adv.github_advisory_id ?? '(no GHSA)'} [${adv.severity}] ${adv.module_name}: ${adv.title}`,
|
|
);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('Audit PASS — no unwaived High/Critical advisories.');
|
|
|
|
if (advisory.length > 0) {
|
|
console.log('Advisory (non-blocking) findings:');
|
|
for (const adv of advisory) {
|
|
console.log(
|
|
` ${adv.github_advisory_id ?? '(no GHSA)'} [${adv.severity}] ${adv.module_name}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|