/** * TASK-0134 — dependency audit + license allowlist (spec bullets: "deps" * suite, `docs/security/license-allowlist.md`, "new high vuln ... fails the * build", "the license allowlist ... or be a documented exception"). * * This is the machine-checked half of * `docs/security/license-allowlist.md` (the doc holds the policy + the dated * exceptions; this file hardcodes the SAME sets and fails CI on any * divergence). Three suites: * * 1. `Runtime audit gate` — `pnpm audit --json --prod` from the repo root. * The set of live high/critical advisories (keyed by GHSA id) must EQUAL * `AUDIT_EXCEPTIONS` (§4.2). A NEW high/critical advisory therefore fails * the build (the DoD "new high vuln fails the build" gate), and a * RESOLVED one forces the stale exception to be closed in the doc + here * (exact equality, both directions) — the gate re-arms on any tree * change, per the doc. * 2. `License allowlist` — `pnpm licenses ls --json` (the full installed * tree). Every distinct license must be in `ALLOWED_LICENSES` (§1) or in * `LICENSE_EXCEPTIONS` (§4.1); anything else fails the build. * 3. `New-high fixture` — a scratch workspace under `os.tmpdir()` pinned to * a known-vulnerable `tar@4.4.16` (critical advisory) must be flagged by * `npm audit --audit-level=high` — proving the audit machinery used in * suite 1 genuinely fails on a NEW high-severity finding (fixture * demonstration required by the DoD). */ import { describe, expect, it } from "vitest"; import { execFileSync } from "node:child_process"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); // --------------------------------------------------------------------------- // Policy sets — MIRRORS docs/security/license-allowlist.md §1 + §4. // Keep the doc and these constants in sync (change one, change both). // --------------------------------------------------------------------------- /** §1 — allowed permissive licenses. */ const ALLOWED_LICENSES = new Set([ "MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD", "BlueOak-1.0.0", "Unlicense" ]); /** §4.1 — documented license exceptions (license → justification in doc). */ const LICENSE_EXCEPTIONS = new Set([ "MIT-0", // @csstools/color-helpers — permissive, MIT-equivalent "LGPL-3.0-or-later", // @img/sharp-libvips-* — isolated native lib, not linked/distributed "Python-2.0", // argparse — OSI permissive, dev-only transitive "MPL-2.0", // axe-core — file-level copyleft, dev-only test dep "CC-BY-4.0", // caniuse-lite — data-only, attribution carried by the package "CC0-1.0", // language-subtag-registry — public-domain dedication "(MIT OR WTFPL)", // expand-template — MIT option satisfies the allowlist "(BSD-2-Clause OR MIT OR Apache-2.0)", // rc — every disjunct is allowed "MIT License" // xml-escape — SPDX-inconsistent label for MIT ]); /** §4.2 — high/critical audit exceptions (GHSA ids, uppercased — re-issued * ids live; normalized via advisoryKey().toUpperCase()). */ const AUDIT_EXCEPTIONS = new Set([ // next@14.2.35 (apps/web) — patched only in the 15.x/16.x line; a major // Next upgrade is a tracked migration. "GHSA-P293-QW3H-JR36", // critical — unauth RCE on windows-hosted servers "GHSA-G89C-P67H-R497", // critical — unauth RCE in Image Optimization (AVIF) "GHSA-83FC-FQCC-2HMG", // high — RSC HTTP deserialization DoS "GHSA-Q4GF-8MX6-V5V3", // high — DoS with Server Components "GHSA-RV78-F8RC-XRXH", // high — DoS with Server Components "GHSA-C4J6-FC7J-M34R", // high — SSRF via WebSocket upgrades "GHSA-36QX-FR4F-26G5", // high — Middleware/Proxy bypass (Pages Router i18n) "GHSA-M99W-X7HQ-7VFJ", // high — DoS in App Router Server Actions "GHSA-89XV-2M56-2M9X", // high — SSRF in Server Actions on custom servers "GHSA-P9J2-GV94-2WF4", // high — SSRF in rewrites destination // multer@2.0.2 (apps/api, via @nestjs/platform-express) — pinned NestJS // 10.4 stack; override pinned in a follow-up task. "GHSA-XF7R-HGR6-V32P", // high — DoS via incomplete cleanup "GHSA-V52C-386H-88MC", // high — DoS via resource exhaustion "GHSA-5528-5VMV-3XC2", // high — DoS via uncontrolled recursion "GHSA-72GW-MP4G-V24J", // high — DoS via deeply nested field names "GHSA-WC9G-MQFW-JRWM", // high — DoS via crafted multipart field names "GHSA-535W-7CP7-47Q4", // high — DoS via oversized array index // postcss@8.4.31 (apps/web, via next) — unblocked by the Next migration. "GHSA-6G55-P6WH-862Q", // high — arbitrary file read via sourceMappingURL "GHSA-R28C-9Q8G-F849" // high — path traversal in source map auto-loading ]); type Advisory = { id?: number; severity?: string; moduleName?: string; title?: string; references?: string; findings?: { version?: string; paths?: string[] }[]; }; /** Run `pnpm …`, returning stdout even on non-zero exit (audit exits 1 when * findings exist — that is the normal, expected case here). */ function pnpm(args: string[]): string { try { return execFileSync("pnpm", args, { cwd: REPO_ROOT, encoding: "utf8", timeout: 300_000, stdio: ["ignore", "pipe", "pipe"] }) as string; } catch (err) { return (err as { stdout?: string }).stdout ?? ""; } } /** GHSA id from an advisory's references, falling back to a stable key. */ function advisoryKey(a: Advisory): string { const m = (a.references ?? "").match(/GHSA-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+/i); return m ? m[0].toUpperCase() : `NPM-${a.id ?? a.title ?? "unknown"}`; } // --------------------------------------------------------------------------- // 1. Runtime audit gate // --------------------------------------------------------------------------- describe("deps / runtime audit gate (pnpm audit --prod, high+ vs exceptions)", () => { it("live high/critical advisories exactly match the documented exceptions", () => { const raw = pnpm(["audit", "--json", "--prod"]); const j = JSON.parse(raw) as { advisories?: Record }; const advisories = Object.values(j.advisories ?? {}); const hiCrit = advisories.filter((a) => a.severity === "high" || a.severity === "critical"); const live = new Map(); for (const a of hiCrit) live.set(advisoryKey(a), a); // NEW high/critical finding → build fails (DoD: "new high vuln ... fails // the build"). Exact set equality also closes stale exceptions. const unexpected = [...live.entries()] .filter(([key]) => !AUDIT_EXCEPTIONS.has(key)) .map(([key, a]) => `${key} (${a.severity}, ${a.moduleName ?? a.title ?? "?"})`); expect( unexpected, `NEW or un-documented high/critical finding(s) — add a dated exception to ` + `docs/security/license-allowlist.md §4.2 AND to AUDIT_EXCEPTIONS, or fix the dep:\n - ${unexpected.join("\n - ")}` ).toEqual([]); const stale = [...AUDIT_EXCEPTIONS].filter((key) => !live.has(key)); expect( stale, `Exception no longer matches the tree — the finding was resolved or the ` + `advisory was re-issued. Update docs/security/license-allowlist.md §4.2 ` + `and AUDIT_EXCEPTIONS:\n - ${stale.join("\n - ")}` ).toEqual([]); }, 360_000); }); // --------------------------------------------------------------------------- // 2. License allowlist // --------------------------------------------------------------------------- describe("deps / license allowlist (pnpm licenses ls --json)", () => { it("every distinct license in the tree is allowed or a documented exception", () => { const raw = pnpm(["licenses", "ls", "--json"]); const j = JSON.parse(raw) as Record; const byLicense = new Map(); for (const [license, entries] of Object.entries(j)) { const names = (entries ?? []).map((e) => e.name ?? "?").slice(0, 5); byLicense.set(license, names); } expect(byLicense.size, "the tree must report at least one license").toBeGreaterThan(0); const offenders: string[] = []; for (const [license, names] of [...byLicense.entries()].sort()) { const allowed = ALLOWED_LICENSES.has(license); const exceptioned = LICENSE_EXCEPTIONS.has(license); if (!allowed && !exceptioned) { offenders.push(`${license} — ${names.join(", ")}`); } } expect( offenders, `License(s) outside the allowlist (§1) and the documented exceptions ` + `(§4.1). Add a justification+date row to docs/security/license-allowlist.md ` + `AND to LICENSE_EXCEPTIONS, or swap the dependency:\n - ${offenders.join("\n - ")}` ).toEqual([]); }, 360_000); }); // --------------------------------------------------------------------------- // 3. New-high fixture — the gate must actually fail on a new high finding // --------------------------------------------------------------------------- describe("deps / new-high fixture (proof the audit machinery fails builds)", () => { it("a scratch workspace with a known-vulnerable tar@4.4.16 is flagged high/critical", () => { // tar@4.4.16 carries critical advisories (e.g. GHSA-8q69-5826-96kh // arbitrary file extraction). If npm/pnpm audit ever stopped flagging // high/critical findings, this fixture test fails — the audit gate would // otherwise be silently meaningless. const dir = mkdtempSync(path.join(tmpdir(), "factory-deps-fixture-")); writeFileSync( path.join(dir, "package.json"), JSON.stringify({ name: "factory-audit-fixture", version: "1.0.0", private: true }, null, 2) ); execFileSync("npm", ["install", "tar@4.4.16", "--no-audit", "--no-fund"], { cwd: dir, encoding: "utf8", timeout: 240_000, stdio: ["ignore", "pipe", "pipe"] }); let auditRc: number; let auditJson: string; try { execFileSync("npm", ["audit", "--audit-level=high", "--json"], { cwd: dir, encoding: "utf8", timeout: 240_000, stdio: ["ignore", "pipe", "pipe"] }); auditRc = 0; auditJson = ""; } catch (err) { auditRc = (err as { status?: number }).status ?? 1; auditJson = (err as { stdout?: string }).stdout ?? ""; } expect(auditRc, "npm audit --audit-level=high must FAIL on the fixture").not.toBe(0); // npm prints a `# npm audit report` comment line before the JSON when a // summary header is present — start parsing at the first `{`. const brace = auditJson.indexOf("{"); const j = JSON.parse(brace >= 0 ? auditJson.slice(brace) : "{}") as { metadata?: { vulnerabilities?: Record }; }; const m = j.metadata?.vulnerabilities ?? {}; const hiCrit = (m.high ?? 0) + (m.critical ?? 0); expect(hiCrit, "the fixture must produce at least one high/critical finding").toBeGreaterThan(0); }, 360_000); });