Phase 16: CI dependency audit & security checks #15

Merged
luckberg merged 40 commits from gsd/phase-16-ci-dependency-audit-and-security-checks into main 2026-06-13 10:01:01 -04:00
Showing only changes of commit baf2e3ad1b - Show all commits
+180
View File
@@ -0,0 +1,180 @@
/**
* check-outdated.mjs — pnpm outdated advisory-only tiered report (D-06 / OQ-01).
*
* Classifies all outdated packages into four tiers in priority order:
* 1. AUDIT-ADVISORY — the package's current version carries a known advisory
* 2. MAJOR-BEHIND-INTENTIONAL — latest major > current major, pin reason exists in outdated-pins.json
* 3. MAJOR-BEHIND-UNPINNED — latest major > current major, no pin reason (potential liability)
* 4. ROUTINE-DRIFT — same major, minor/patch behind (low priority)
*
* This script ALWAYS exits 0 — it is advisory-only and never gates the build (D-06).
*/
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve, dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Run a command and return stdout, tolerating non-zero exit codes.
* pnpm outdated exits non-zero when any package is outdated — we need the output anyway.
*
* @param {string} cmd
* @returns {string}
*/
function captureOutput(cmd) {
try {
return execSync(cmd, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
} catch (err) {
// Non-zero exit — pnpm outdated does this when packages are outdated
return err.stdout ?? '';
}
}
/**
* Parse the major version integer from a semver string.
* Returns 0 if parsing fails.
*
* @param {string} version
* @returns {number}
*/
function majorOf(version) {
const n = parseInt((version ?? '').split('.')[0], 10);
return isNaN(n) ? 0 : n;
}
// ── Load pin reasons ─────────────────────────────────────────────────────────
const pinsPath = resolve(__dirname, 'outdated-pins.json');
let pins = {};
try {
pins = JSON.parse(readFileSync(pinsPath, 'utf8'));
} catch {
// Gracefully degrade — no pins means everything is treated as unpinned
console.warn('[check-outdated] Warning: could not read outdated-pins.json; treating all pins as unknown');
}
// ── Run pnpm audit to collect vulnerable module names ───────────────────────
let vulnerableModules = new Set();
try {
const auditOutput = captureOutput('pnpm audit --json');
const auditData = JSON.parse(auditOutput);
const advisories = auditData.advisories ?? {};
for (const adv of Object.values(advisories)) {
if (adv.module_name) {
vulnerableModules.add(adv.module_name);
}
}
} catch {
// Audit parse failure is non-fatal for the outdated report
console.warn('[check-outdated] Warning: could not parse pnpm audit output; AUDIT-ADVISORY cross-check skipped');
}
// ── Run pnpm outdated ────────────────────────────────────────────────────────
const outdatedOutput = captureOutput('pnpm outdated --format json -r');
let outdatedData = {};
if (outdatedOutput.trim()) {
try {
outdatedData = JSON.parse(outdatedOutput);
} catch {
console.warn('[check-outdated] Warning: could not parse pnpm outdated JSON output');
}
}
// ── Classify entries into tiers ──────────────────────────────────────────────
const tiers = {
auditAdvisory: [],
majorBehindIntentional: [],
majorBehindUnpinned: [],
routineDrift: [],
};
for (const [pkgName, info] of Object.entries(outdatedData)) {
const current = info.current ?? '';
const latest = info.latest ?? '';
const currentMajor = majorOf(current);
const latestMajor = majorOf(latest);
const isMajorBehind = latestMajor > currentMajor;
const pinReason = pins[pkgName];
const hasAdvisory = vulnerableModules.has(pkgName);
const entry = {
name: pkgName,
current,
latest,
dependencyType: info.dependencyType ?? '',
dependentPackages: info.dependentPackages,
};
// Priority 1: the package has an active advisory on the pinned version
if (hasAdvisory) {
tiers.auditAdvisory.push(entry);
// Priority 2: major behind + intentional pin
} else if (isMajorBehind && pinReason) {
tiers.majorBehindIntentional.push({ ...entry, reason: pinReason });
// Priority 3: major behind without a pin reason — possible liability
} else if (isMajorBehind) {
tiers.majorBehindUnpinned.push(entry);
// Priority 4: same major, minor/patch drift
} else {
tiers.routineDrift.push(entry);
}
}
// ── Print human-readable report ──────────────────────────────────────────────
console.log('');
console.log('=== DEPENDENCY HEALTH REPORT ===');
console.log('');
// Tier 1: AUDIT-ADVISORY
console.log('[AUDIT-ADVISORY] Packages with active advisories on the pinned version:');
if (tiers.auditAdvisory.length === 0) {
console.log(' (none)');
} else {
for (const pkg of tiers.auditAdvisory) {
console.log(` ${pkg.name} ${pkg.current}${pkg.latest} (${pkg.dependencyType}) *** ADVISORY ON CURRENT VERSION ***`);
}
}
console.log('');
// Tier 2: MAJOR-BEHIND / INTENTIONAL PIN
console.log('[MAJOR-BEHIND / INTENTIONAL PIN] Packages behind due to a known constraint:');
if (tiers.majorBehindIntentional.length === 0) {
console.log(' (none)');
} else {
for (const pkg of tiers.majorBehindIntentional) {
console.log(` ${pkg.name} ${pkg.current}${pkg.latest} (${pkg.dependencyType})`);
console.log(` reason: ${pkg.reason}`);
}
}
console.log('');
// Tier 3: MAJOR-BEHIND / UNPINNED
console.log('[MAJOR-BEHIND / UNPINNED] Packages >1 major behind without a pin reason:');
if (tiers.majorBehindUnpinned.length === 0) {
console.log(' (none)');
} else {
for (const pkg of tiers.majorBehindUnpinned) {
console.log(` ${pkg.name} ${pkg.current}${pkg.latest} (${pkg.dependencyType})`);
}
}
console.log('');
// Tier 4: ROUTINE-DRIFT
console.log('[ROUTINE-DRIFT] Patch/minor updates (low priority):');
if (tiers.routineDrift.length === 0) {
console.log(' (none)');
} else {
const items = tiers.routineDrift.map((p) => `${p.name} ${p.current}${p.latest}`);
console.log(' ' + items.join(', '));
}
console.log('');
// Advisory-only — NEVER gates the build (D-06)
process.exit(0);