/** * 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} allowlist * @returns {boolean} */ export function isWaived(adv, allowlist) { const w = allowlist[adv.github_advisory_id]; if (!w) return false; // No expiry → waived; future expiry → waived; past/equal expiry → NOT waived. // An unparseable `expires` (typo) fails CLOSED: treated as expired so a malformed // date can never grant an indefinite waiver (same failure class as CR-01). if (w.expires) { const ts = Date.parse(w.expires); if (Number.isNaN(ts) || ts <= 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} advisories * @param {Record} 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} advisories * @param {Record} 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); }