1
27import { describe, expect, it } from "vitest";
28import { execFileSync } from "node:child_process";
29import { mkdtempSync, writeFileSync } from "node:fs";
30import { tmpdir } from "node:os";
31import path from "node:path";
32import { fileURLToPath } from "node:url";
33
34const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
35
36
37
38
39
40
41
42const ALLOWED_LICENSES = new Set([
43 "MIT",
44 "Apache-2.0",
45 "BSD-2-Clause",
46 "BSD-3-Clause",
47 "ISC",
48 "0BSD",
49 "BlueOak-1.0.0",
50 "Unlicense"
51]);
52
53
54const LICENSE_EXCEPTIONS = new Set([
55 "MIT-0",
56 "LGPL-3.0-or-later",
57 "Python-2.0",
58 "MPL-2.0",
59 "CC-BY-4.0",
60 "CC0-1.0",
61 "(MIT OR WTFPL)",
62 "(BSD-2-Clause OR MIT OR Apache-2.0)",
63 "MIT License"
64]);
65
66
68const AUDIT_EXCEPTIONS = new Set([
69
70
71 "GHSA-P293-QW3H-JR36",
72 "GHSA-G89C-P67H-R497",
73 "GHSA-83FC-FQCC-2HMG",
74 "GHSA-Q4GF-8MX6-V5V3",
75 "GHSA-RV78-F8RC-XRXH",
76 "GHSA-C4J6-FC7J-M34R",
77 "GHSA-36QX-FR4F-26G5",
78 "GHSA-M99W-X7HQ-7VFJ",
79 "GHSA-89XV-2M56-2M9X",
80 "GHSA-P9J2-GV94-2WF4",
81
82
83 "GHSA-XF7R-HGR6-V32P",
84 "GHSA-V52C-386H-88MC",
85 "GHSA-5528-5VMV-3XC2",
86 "GHSA-72GW-MP4G-V24J",
87 "GHSA-WC9G-MQFW-JRWM",
88 "GHSA-535W-7CP7-47Q4",
89
90 "GHSA-6G55-P6WH-862Q",
91 "GHSA-R28C-9Q8G-F849"
92]);
93
94type Advisory = {
95 id?: number;
96 severity?: string;
97 moduleName?: string;
98 title?: string;
99 references?: string;
100 findings?: { version?: string; paths?: string[] }[];
101};
102
103
105function pnpm(args: string[]): string {
106 try {
107 return execFileSync("pnpm", args, { cwd: REPO_ROOT, encoding: "utf8", timeout: 300_000, stdio: ["ignore", "pipe", "pipe"] }) as string;
108 } catch (err) {
109 return (err as { stdout?: string }).stdout ?? "";
110 }
111}
112
113
114function advisoryKey(a: Advisory): string {
115 const m = (a.references ?? "").match(/GHSA-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+/i);
116 return m ? m[0].toUpperCase() : `NPM-${a.id ?? a.title ?? "unknown"}`;
117}
118
119
120
121
122describe("deps / runtime audit gate (pnpm audit --prod, high+ vs exceptions)", () => {
123 it("live high/critical advisories exactly match the documented exceptions", () => {
124 const raw = pnpm(["audit", "--json", "--prod"]);
125 const j = JSON.parse(raw) as { advisories?: Record<string, Advisory> };
126 const advisories = Object.values(j.advisories ?? {});
127 const hiCrit = advisories.filter((a) => a.severity === "high" || a.severity === "critical");
128
129 const live = new Map<string, Advisory>();
130 for (const a of hiCrit) live.set(advisoryKey(a), a);
131
132
133
134 const unexpected = [...live.entries()]
135 .filter(([key]) => !AUDIT_EXCEPTIONS.has(key))
136 .map(([key, a]) => `${key} (${a.severity}, ${a.moduleName ?? a.title ?? "?"})`);
137 expect(
138 unexpected,
139 `NEW or un-documented high/critical finding(s) — add a dated exception to ` +
140 `docs/security/license-allowlist.md §4.2 AND to AUDIT_EXCEPTIONS, or fix the dep:\n - ${unexpected.join("\n - ")}`
141 ).toEqual([]);
142
143 const stale = [...AUDIT_EXCEPTIONS].filter((key) => !live.has(key));
144 expect(
145 stale,
146 `Exception no longer matches the tree — the finding was resolved or the ` +
147 `advisory was re-issued. Update docs/security/license-allowlist.md §4.2 ` +
148 `and AUDIT_EXCEPTIONS:\n - ${stale.join("\n - ")}`
149 ).toEqual([]);
150 }, 360_000);
151});
152
153
154
155
156describe("deps / license allowlist (pnpm licenses ls --json)", () => {
157 it("every distinct license in the tree is allowed or a documented exception", () => {
158 const raw = pnpm(["licenses", "ls", "--json"]);
159 const j = JSON.parse(raw) as Record<string, { name?: string }[]>;
160 const byLicense = new Map<string, string[]>();
161 for (const [license, entries] of Object.entries(j)) {
162 const names = (entries ?? []).map((e) => e.name ?? "?").slice(0, 5);
163 byLicense.set(license, names);
164 }
165 expect(byLicense.size, "the tree must report at least one license").toBeGreaterThan(0);
166
167 const offenders: string[] = [];
168 for (const [license, names] of [...byLicense.entries()].sort()) {
169 const allowed = ALLOWED_LICENSES.has(license);
170 const exceptioned = LICENSE_EXCEPTIONS.has(license);
171 if (!allowed && !exceptioned) {
172 offenders.push(`${license} — ${names.join(", ")}`);
173 }
174 }
175 expect(
176 offenders,
177 `License(s) outside the allowlist (§1) and the documented exceptions ` +
178 `(§4.1). Add a justification+date row to docs/security/license-allowlist.md ` +
179 `AND to LICENSE_EXCEPTIONS, or swap the dependency:\n - ${offenders.join("\n - ")}`
180 ).toEqual([]);
181 }, 360_000);
182});
183
184
185
186
187describe("deps / new-high fixture (proof the audit machinery fails builds)", () => {
188 it("a scratch workspace with a known-vulnerable tar@4.4.16 is flagged high/critical", () => {
189
190
191
192
193 const dir = mkdtempSync(path.join(tmpdir(), "factory-deps-fixture-"));
194 writeFileSync(
195 path.join(dir, "package.json"),
196 JSON.stringify({ name: "factory-audit-fixture", version: "1.0.0", private: true }, null, 2)
197 );
198 execFileSync("npm", ["install", "tar@4.4.16", "--no-audit", "--no-fund"], {
199 cwd: dir,
200 encoding: "utf8",
201 timeout: 240_000,
202 stdio: ["ignore", "pipe", "pipe"]
203 });
204
205 let auditRc: number;
206 let auditJson: string;
207 try {
208 execFileSync("npm", ["audit", "--audit-level=high", "--json"], {
209 cwd: dir,
210 encoding: "utf8",
211 timeout: 240_000,
212 stdio: ["ignore", "pipe", "pipe"]
213 });
214 auditRc = 0;
215 auditJson = "";
216 } catch (err) {
217 auditRc = (err as { status?: number }).status ?? 1;
218 auditJson = (err as { stdout?: string }).stdout ?? "";
219 }
220 expect(auditRc, "npm audit --audit-level=high must FAIL on the fixture").not.toBe(0);
221
222
223
224 const brace = auditJson.indexOf("{");
225 const j = JSON.parse(brace >= 0 ? auditJson.slice(brace) : "{}") as {
226 metadata?: { vulnerabilities?: Record<string, number> };
227 };
228 const m = j.metadata?.vulnerabilities ?? {};
229 const hiCrit = (m.high ?? 0) + (m.critical ?? 0);
230 expect(hiCrit, "the fixture must produce at least one high/critical finding").toBeGreaterThan(0);
231 }, 360_000);
232});
233