mandrel-platform 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,13 @@
11
11
  * whose expiry has passed are treated as un-suppressed and will cause
12
12
  * the script to exit non-zero.
13
13
  *
14
+ * Fail-closed contract:
15
+ * When `pnpm audit` exits non-zero AND the report it produced cannot be
16
+ * interpreted as a recognizable advisories document, the gate exits
17
+ * non-zero. A non-zero audit exit is a signal that something is wrong;
18
+ * an uninterpretable report means the gate cannot prove the graph is
19
+ * clean, so it must fail closed rather than wave the build through.
20
+ *
14
21
  * Usage:
15
22
  * node scripts/audit-check.mjs
16
23
  * node scripts/audit-check.mjs --allowlist path/to/allowlist.json
@@ -18,8 +25,9 @@
18
25
  * Exit codes:
19
26
  * 0 — no blocking vulnerabilities (all High/Critical suppressed with
20
27
  * valid, non-expired allowlist entries, or none found)
21
- * 1 — one or more unsuppressed High/Critical CVEs, or expired allowlist
22
- * entries were encountered
28
+ * 1 — one or more unsuppressed High/Critical CVEs, expired allowlist
29
+ * entries were encountered, or the audit report was uninterpretable
30
+ * while pnpm audit exited non-zero
23
31
  *
24
32
  * Allowlist format (JSON):
25
33
  * [
@@ -40,170 +48,92 @@ import { existsSync, readFileSync } from "node:fs";
40
48
  import { resolve } from "node:path";
41
49
 
42
50
  // ---------------------------------------------------------------------------
43
- // CLI arg parsing
51
+ // Pure core (unit-testable — no process.exit, no filesystem, no child process)
44
52
  // ---------------------------------------------------------------------------
45
53
 
46
- const args = process.argv.slice(2);
47
- let allowlistPath = null;
48
-
49
- for (let i = 0; i < args.length; i++) {
50
- if (args[i] === "--allowlist" && args[i + 1]) {
51
- allowlistPath = resolve(process.cwd(), args[i + 1]);
52
- i++;
53
- }
54
- }
55
-
56
- if (allowlistPath === null) {
57
- allowlistPath = resolve(process.cwd(), "audit-allowlist.json");
58
- }
59
-
60
- // ---------------------------------------------------------------------------
61
- // Allowlist loading and validation
62
- // ---------------------------------------------------------------------------
54
+ const BLOCKING_SEVERITIES = new Set(["high", "critical"]);
63
55
 
64
56
  /**
65
- * @typedef {{ id: string; reason: string; expires: string }} AllowlistEntry
57
+ * @typedef {{ id: string; reason?: string; expires: string }} AllowlistEntry
66
58
  */
67
59
 
68
- /** @type {AllowlistEntry[]} */
69
- let allowlist = [];
70
-
71
- if (existsSync(allowlistPath)) {
72
- try {
73
- const raw = readFileSync(allowlistPath, "utf8");
74
- const parsed = JSON.parse(raw);
75
-
76
- if (!Array.isArray(parsed)) {
77
- console.error(
78
- `[audit-check] ERROR: Allowlist at ${allowlistPath} must be a JSON array.`,
79
- );
80
- process.exit(1);
60
+ /**
61
+ * Partition allowlist entries into the active (non-expired) suppression set
62
+ * and the list of expired entries, relative to `today` (a `YYYY-MM-DD`
63
+ * string). Entries missing a required `id` or `expires` field are surfaced
64
+ * in `invalid` so the caller can fail closed on a malformed allowlist.
65
+ *
66
+ * @param {AllowlistEntry[]} allowlist
67
+ * @param {string} today `YYYY-MM-DD`
68
+ * @returns {{ suppressed: Set<string>; expired: AllowlistEntry[]; invalid: AllowlistEntry[] }}
69
+ */
70
+ export function partitionAllowlist(allowlist, today) {
71
+ /** @type {Set<string>} */
72
+ const suppressed = new Set();
73
+ /** @type {AllowlistEntry[]} */
74
+ const expired = [];
75
+ /** @type {AllowlistEntry[]} */
76
+ const invalid = [];
77
+
78
+ for (const entry of allowlist) {
79
+ if (!entry || !entry.id || !entry.expires) {
80
+ invalid.push(entry);
81
+ continue;
81
82
  }
82
83
 
83
- allowlist = parsed;
84
- } catch (err) {
85
- console.error(
86
- `[audit-check] ERROR: Failed to parse allowlist at ${allowlistPath}: ${err instanceof Error ? err.message : String(err)}`,
87
- );
88
- process.exit(1);
89
- }
90
- }
91
-
92
- const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
93
-
94
- /** @type {Set<string>} Active (non-expired) suppressed advisory IDs */
95
- const suppressed = new Set();
96
- /** @type {AllowlistEntry[]} */
97
- const expiredEntries = [];
98
-
99
- for (const entry of allowlist) {
100
- if (!entry.id || !entry.expires) {
101
- console.error(
102
- `[audit-check] ERROR: Allowlist entry missing required "id" or "expires" field: ${JSON.stringify(entry)}`,
103
- );
104
- process.exit(1);
105
- }
106
-
107
- if (entry.expires < today) {
108
- expiredEntries.push(entry);
109
- } else {
110
- suppressed.add(entry.id);
111
- }
112
- }
113
-
114
- if (expiredEntries.length > 0) {
115
- console.error("[audit-check] EXPIRED allowlist entries detected:");
116
- for (const entry of expiredEntries) {
117
- console.error(
118
- ` - ${entry.id} (expired ${entry.expires}): ${entry.reason ?? "no reason recorded"}`,
119
- );
84
+ if (entry.expires < today) {
85
+ expired.push(entry);
86
+ } else {
87
+ suppressed.add(entry.id);
88
+ }
120
89
  }
121
- console.error(
122
- "[audit-check] Renew or remove expired entries to proceed. Exit 1.",
123
- );
124
- process.exit(1);
125
- }
126
90
 
127
- // ---------------------------------------------------------------------------
128
- // Run pnpm audit (production graph only)
129
- // ---------------------------------------------------------------------------
130
-
131
- console.log("[audit-check] Running pnpm audit --prod --json ...");
132
-
133
- let auditOutput = "";
134
- let auditExitCode = 0;
135
-
136
- try {
137
- auditOutput = execSync("pnpm audit --prod --json 2>/dev/null", {
138
- encoding: "utf8",
139
- });
140
- } catch (err) {
141
- // pnpm audit exits non-zero when vulnerabilities are found.
142
- // We want the JSON regardless of the exit code.
143
- const execError = /** @type {{ stdout?: string; status?: number }} */ (err);
144
- auditOutput = execError.stdout ?? "";
145
- auditExitCode = execError.status ?? 1;
91
+ return { suppressed, expired, invalid };
146
92
  }
147
93
 
148
- // ---------------------------------------------------------------------------
149
- // Parse audit JSON
150
- // ---------------------------------------------------------------------------
151
-
152
- /** @type {unknown} */
153
- let report;
154
-
155
- try {
156
- report = JSON.parse(auditOutput);
157
- } catch {
158
- if (auditExitCode === 0) {
159
- // No JSON means nothing to audit — clean.
160
- console.log("[audit-check] No vulnerabilities found. Exit 0.");
161
- process.exit(0);
162
- }
163
- console.error(
164
- "[audit-check] ERROR: pnpm audit produced non-JSON output (exit code " +
165
- auditExitCode +
166
- ").",
94
+ /**
95
+ * True when `report` has the recognizable pnpm-audit shape: an object with
96
+ * an `advisories` object. This is the discriminator the fail-closed contract
97
+ * hangs on — a parsed-but-unrecognizable report (e.g. an error envelope) is
98
+ * NOT interpretable.
99
+ *
100
+ * @param {unknown} report
101
+ * @returns {boolean}
102
+ */
103
+ export function isInterpretableReport(report) {
104
+ return (
105
+ report !== null &&
106
+ typeof report === "object" &&
107
+ "advisories" in report &&
108
+ /** @type {Record<string, unknown>} */ (report).advisories !== null &&
109
+ typeof (/** @type {Record<string, unknown>} */ (report).advisories) ===
110
+ "object"
167
111
  );
168
- console.error(auditOutput.slice(0, 2000));
169
- process.exit(1);
170
112
  }
171
113
 
172
- // ---------------------------------------------------------------------------
173
- // Extract advisories
174
- // ---------------------------------------------------------------------------
175
-
176
114
  /**
177
- * pnpm audit --json shape:
178
- * {
179
- * "advisories": {
180
- * "<id>": {
181
- * "ghsa_id": "GHSA-xxxx",
182
- * "cve": ["CVE-xxxx"],
183
- * "severity": "high" | "critical" | "moderate" | "low" | "info",
184
- * "title": "...",
185
- * "url": "...",
186
- * ...
187
- * }
188
- * },
189
- * "metadata": { ... }
190
- * }
115
+ * Extract the blocking (unsuppressed High/Critical) advisories from an
116
+ * interpretable pnpm-audit report. An advisory is suppressed when any of its
117
+ * ids (GHSA id or CVE ids) is present in `suppressed`.
118
+ *
119
+ * Callers MUST gate this behind `isInterpretableReport` — an
120
+ * uninterpretable report yields an empty array here, which is exactly the
121
+ * fail-open trap the CLI guards against separately.
122
+ *
123
+ * @param {unknown} report
124
+ * @param {Set<string>} suppressed active (non-expired) suppressed ids
125
+ * @returns {Array<{ id: string; severity: string; title: string; url: string }>}
191
126
  */
127
+ export function extractBlockingAdvisories(report, suppressed) {
128
+ /** @type {Array<{ id: string; severity: string; title: string; url: string }>} */
129
+ const blocking = [];
192
130
 
193
- const BLOCKING_SEVERITIES = new Set(["high", "critical"]);
194
-
195
- /** @type {Array<{ id: string; severity: string; title: string; url: string }>} */
196
- const blocking = [];
131
+ if (!isInterpretableReport(report)) {
132
+ return blocking;
133
+ }
197
134
 
198
- if (
199
- report !== null &&
200
- typeof report === "object" &&
201
- "advisories" in report &&
202
- report.advisories !== null &&
203
- typeof report.advisories === "object"
204
- ) {
205
135
  const advisories = /** @type {Record<string, unknown>} */ (
206
- report.advisories
136
+ /** @type {Record<string, unknown>} */ (report).advisories
207
137
  );
208
138
 
209
139
  for (const [, advisory] of Object.entries(advisories)) {
@@ -240,48 +170,259 @@ if (
240
170
  });
241
171
  }
242
172
  }
173
+
174
+ return blocking;
175
+ }
176
+
177
+ /**
178
+ * Pure evaluation of a parsed audit report against the active suppression
179
+ * set and the pnpm-audit exit code. This is the fail-closed decision core,
180
+ * lifted out of the CLI so it is unit-testable without spawning pnpm.
181
+ *
182
+ * @param {unknown} report parsed audit JSON (or `null`)
183
+ * @param {number} auditExitCode pnpm audit exit code
184
+ * @param {Set<string>} suppressed active (non-expired) suppressed ids
185
+ * @returns {{ exitCode: number; reason: "clean" | "uninterpretable-failclosed" | "unsuppressed" | "clean-no-advisories"; blocking: Array<{ id: string; severity: string; title: string; url: string }> }}
186
+ */
187
+ export function evaluateReport(report, auditExitCode, suppressed) {
188
+ if (!isInterpretableReport(report)) {
189
+ if (auditExitCode !== 0) {
190
+ return {
191
+ exitCode: 1,
192
+ reason: "uninterpretable-failclosed",
193
+ blocking: [],
194
+ };
195
+ }
196
+ return { exitCode: 0, reason: "clean-no-advisories", blocking: [] };
197
+ }
198
+
199
+ const blocking = extractBlockingAdvisories(report, suppressed);
200
+ if (blocking.length === 0) {
201
+ return { exitCode: 0, reason: "clean", blocking };
202
+ }
203
+ return { exitCode: 1, reason: "unsuppressed", blocking };
243
204
  }
244
205
 
245
206
  // ---------------------------------------------------------------------------
246
- // Report and exit
207
+ // CLI
247
208
  // ---------------------------------------------------------------------------
248
209
 
249
- if (blocking.length === 0) {
250
- console.log(
251
- `[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph. Exit 0.`,
252
- );
253
- process.exit(0);
210
+ /**
211
+ * Parse the CLI argv (minus `node` and the script path) into options.
212
+ *
213
+ * @param {string[]} argv
214
+ * @param {string} [cwd]
215
+ * @returns {{ allowlistPath: string }}
216
+ */
217
+ export function parseArgs(argv, cwd = process.cwd()) {
218
+ let allowlistPath = null;
219
+
220
+ for (let i = 0; i < argv.length; i++) {
221
+ if (argv[i] === "--allowlist" && argv[i + 1]) {
222
+ allowlistPath = resolve(cwd, argv[i + 1]);
223
+ i++;
224
+ }
225
+ }
226
+
227
+ if (allowlistPath === null) {
228
+ allowlistPath = resolve(cwd, "audit-allowlist.json");
229
+ }
230
+
231
+ return { allowlistPath };
232
+ }
233
+
234
+ /**
235
+ * Load and JSON-parse the allowlist file. Returns `[]` when the file is
236
+ * absent. Throws with a descriptive message on parse failure or when the
237
+ * top-level value is not an array — the CLI turns these into exit 1.
238
+ *
239
+ * @param {string} allowlistPath
240
+ * @returns {AllowlistEntry[]}
241
+ */
242
+ export function loadAllowlist(allowlistPath) {
243
+ if (!existsSync(allowlistPath)) {
244
+ return [];
245
+ }
246
+
247
+ const raw = readFileSync(allowlistPath, "utf8");
248
+ const parsed = JSON.parse(raw);
249
+
250
+ if (!Array.isArray(parsed)) {
251
+ throw new Error(`Allowlist at ${allowlistPath} must be a JSON array.`);
252
+ }
253
+
254
+ return parsed;
255
+ }
256
+
257
+ /**
258
+ * Run `pnpm audit --prod --json`, returning the raw stdout and exit code.
259
+ * pnpm audit exits non-zero when vulnerabilities are found; we want the JSON
260
+ * regardless of the exit code.
261
+ *
262
+ * @returns {{ output: string; exitCode: number }}
263
+ */
264
+ function runPnpmAudit() {
265
+ try {
266
+ const output = execSync("pnpm audit --prod --json 2>/dev/null", {
267
+ encoding: "utf8",
268
+ });
269
+ return { output, exitCode: 0 };
270
+ } catch (err) {
271
+ const execError = /** @type {{ stdout?: string; status?: number }} */ (err);
272
+ return { output: execError.stdout ?? "", exitCode: execError.status ?? 1 };
273
+ }
254
274
  }
255
275
 
256
- console.error(
257
- `[audit-check] ${blocking.length} unsuppressed High/Critical CVE(s) found in prod dependency graph:`,
258
- );
276
+ /**
277
+ * CLI entrypoint. Returns the process exit code (0 clean, 1 blocking).
278
+ *
279
+ * @param {string[]} argv argv minus `node` and the script path
280
+ * @returns {number}
281
+ */
282
+ export function runCli(argv) {
283
+ const { allowlistPath } = parseArgs(argv);
284
+
285
+ // --- Load & validate the allowlist ---------------------------------------
286
+
287
+ /** @type {AllowlistEntry[]} */
288
+ let allowlist;
289
+ try {
290
+ allowlist = loadAllowlist(allowlistPath);
291
+ } catch (err) {
292
+ console.error(
293
+ `[audit-check] ERROR: ${err instanceof Error ? err.message : String(err)}`,
294
+ );
295
+ return 1;
296
+ }
297
+
298
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
299
+ const { suppressed, expired, invalid } = partitionAllowlist(allowlist, today);
300
+
301
+ if (invalid.length > 0) {
302
+ for (const entry of invalid) {
303
+ console.error(
304
+ `[audit-check] ERROR: Allowlist entry missing required "id" or "expires" field: ${JSON.stringify(entry)}`,
305
+ );
306
+ }
307
+ return 1;
308
+ }
309
+
310
+ if (expired.length > 0) {
311
+ console.error("[audit-check] EXPIRED allowlist entries detected:");
312
+ for (const entry of expired) {
313
+ console.error(
314
+ ` - ${entry.id} (expired ${entry.expires}): ${entry.reason ?? "no reason recorded"}`,
315
+ );
316
+ }
317
+ console.error(
318
+ "[audit-check] Renew or remove expired entries to proceed. Exit 1.",
319
+ );
320
+ return 1;
321
+ }
322
+
323
+ // --- Run pnpm audit (production graph only) ------------------------------
324
+
325
+ console.log("[audit-check] Running pnpm audit --prod --json ...");
326
+ const { output: auditOutput, exitCode: auditExitCode } = runPnpmAudit();
327
+
328
+ // --- Parse audit JSON ----------------------------------------------------
329
+
330
+ /** @type {unknown} */
331
+ let report;
332
+ try {
333
+ report = JSON.parse(auditOutput);
334
+ } catch {
335
+ if (auditExitCode === 0) {
336
+ // No JSON and a clean exit means nothing to audit — clean.
337
+ console.log("[audit-check] No vulnerabilities found. Exit 0.");
338
+ return 0;
339
+ }
340
+ console.error(
341
+ "[audit-check] ERROR: pnpm audit produced non-JSON output (exit code " +
342
+ auditExitCode +
343
+ ").",
344
+ );
345
+ console.error(auditOutput.slice(0, 2000));
346
+ return 1;
347
+ }
348
+
349
+ // --- Evaluate: fail closed on an uninterpretable report + non-zero exit --
350
+ //
351
+ // The report parsed as JSON. If it lacks a recognizable `advisories` shape
352
+ // (e.g. an error envelope) AND pnpm audit exited non-zero, we cannot prove
353
+ // the graph is clean — fail closed. A zero exit with no advisories key is
354
+ // the genuine "clean, nothing to report" case and passes.
355
+ const { exitCode, reason, blocking } = evaluateReport(
356
+ report,
357
+ auditExitCode,
358
+ suppressed,
359
+ );
360
+
361
+ if (reason === "uninterpretable-failclosed") {
362
+ console.error(
363
+ "[audit-check] ERROR: pnpm audit exited non-zero (" +
364
+ auditExitCode +
365
+ ") and produced a report without a recognizable `advisories` shape. Failing closed.",
366
+ );
367
+ console.error(auditOutput.slice(0, 2000));
368
+ return exitCode;
369
+ }
370
+
371
+ if (reason === "clean-no-advisories") {
372
+ console.log("[audit-check] No vulnerabilities found. Exit 0.");
373
+ return exitCode;
374
+ }
375
+
376
+ if (blocking.length === 0) {
377
+ console.log(
378
+ `[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph. Exit 0.`,
379
+ );
380
+ return exitCode;
381
+ }
382
+
383
+ console.error(
384
+ `[audit-check] ${blocking.length} unsuppressed High/Critical CVE(s) found in prod dependency graph:`,
385
+ );
259
386
 
260
- for (const vuln of blocking) {
261
- console.error(` [${vuln.severity.toUpperCase()}] ${vuln.id}: ${vuln.title}`);
262
- if (vuln.url) {
263
- console.error(` → ${vuln.url}`);
387
+ for (const vuln of blocking) {
388
+ console.error(
389
+ ` [${vuln.severity.toUpperCase()}] ${vuln.id}: ${vuln.title}`,
390
+ );
391
+ if (vuln.url) {
392
+ console.error(` → ${vuln.url}`);
393
+ }
264
394
  }
395
+
396
+ console.error(
397
+ "\n[audit-check] To suppress a known/accepted CVE, add a dated entry to audit-allowlist.json:",
398
+ );
399
+ console.error(
400
+ JSON.stringify(
401
+ [
402
+ {
403
+ id: blocking[0]?.id ?? "GHSA-xxxx-xxxx-xxxx",
404
+ reason: "Describe why this is accepted and any mitigations in place",
405
+ expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
406
+ .toISOString()
407
+ .slice(0, 10),
408
+ },
409
+ ],
410
+ null,
411
+ 2,
412
+ ),
413
+ );
414
+
415
+ console.error("\n[audit-check] Exit 1.");
416
+ return 1;
265
417
  }
266
418
 
267
- console.error(
268
- "\n[audit-check] To suppress a known/accepted CVE, add a dated entry to audit-allowlist.json:",
269
- );
270
- console.error(
271
- JSON.stringify(
272
- [
273
- {
274
- id: blocking[0]?.id ?? "GHSA-xxxx-xxxx-xxxx",
275
- reason: "Describe why this is accepted and any mitigations in place",
276
- expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
277
- .toISOString()
278
- .slice(0, 10),
279
- },
280
- ],
281
- null,
282
- 2,
283
- ),
284
- );
285
-
286
- console.error("\n[audit-check] Exit 1.");
287
- process.exit(1);
419
+ // ---------------------------------------------------------------------------
420
+ // Direct-invocation guard (skipped when imported by the test suite)
421
+ // ---------------------------------------------------------------------------
422
+
423
+ const invokedDirectly =
424
+ process.argv[1] && resolve(process.argv[1]).endsWith("audit-check.mjs");
425
+
426
+ if (invokedDirectly) {
427
+ process.exit(runCli(process.argv.slice(2)));
428
+ }