|
| 1 | +// CI dependency gate: `npm audit --omit=dev` with a documented allowlist. |
| 2 | +// Run with: node scripts/audit-check.mjs (or `npm run audit:ci`) |
| 3 | +// |
| 4 | +// Plain `npm audit --audit-level=high` has no way to accept a single advisory, |
| 5 | +// so one unpatchable transitive finding reddens every PR until upstream ships a |
| 6 | +// fix — which, for an unmaintained leaf package, may be never. This keeps the |
| 7 | +// gate blocking on high/critical, but lets ALLOWLIST carry the advisories that |
| 8 | +// have no fix to upgrade to *and* no reachable GeoLibre code path. |
| 9 | +// |
| 10 | +// Rules for adding an entry: there must be no patched version available, the |
| 11 | +// vulnerable code must not reach a GeoLibre runtime path, and the reason has to |
| 12 | +// say why on both counts. Anything upgradeable gets upgraded instead. |
| 13 | +import { spawnSync } from "node:child_process"; |
| 14 | + |
| 15 | +// Severities that fail the build. Moderate/low are left to Dependabot PRs. |
| 16 | +const BLOCKING = new Set(["high", "critical"]); |
| 17 | + |
| 18 | +const ALLOWLIST = new Map([ |
| 19 | + [ |
| 20 | + "GHSA-w3rx-r6r6-pgpr", |
| 21 | + "image-size DoS (ICNS parser infinite loop). No patched version exists — " + |
| 22 | + "the advisory covers <=2.0.2 and 2.0.2 is the latest release. It reaches " + |
| 23 | + "us only as a dependency of texture-compressor, which @loaders.gl/textures " + |
| 24 | + "spawns via `npx` from encodeImageURLToCompressedTextureURL (a Node-only " + |
| 25 | + "encoder). GeoLibre never calls that encoder and it cannot bundle into the " + |
| 26 | + "browser build, so no attacker-supplied image is ever parsed by it.", |
| 27 | + ], |
| 28 | + [ |
| 29 | + "GHSA-5p2g-fcmc-qvqq", |
| 30 | + "image-size DoS (JXL/HEIF parser infinite loops). Same package, same lack of " + |
| 31 | + "a patched version, and the same unreachable texture-compressor path as " + |
| 32 | + "GHSA-w3rx-r6r6-pgpr above.", |
| 33 | + ], |
| 34 | +]); |
| 35 | + |
| 36 | +const audit = spawnSync("npm", ["audit", "--omit=dev", "--json"], { |
| 37 | + encoding: "utf8", |
| 38 | + maxBuffer: 32 * 1024 * 1024, |
| 39 | + // npm is npm.cmd on Windows, which Node will not resolve without a shell. |
| 40 | + shell: process.platform === "win32", |
| 41 | +}); |
| 42 | + |
| 43 | +// The gate must fail closed: anything short of a report we can actually read is |
| 44 | +// an error, never an implicit "clean". npm's exit code can't carry that, since |
| 45 | +// it also goes non-zero merely because vulnerabilities exist — so the report |
| 46 | +// itself is the signal. |
| 47 | +function unusable(why, detail) { |
| 48 | + console.error(`npm audit did not return a usable report: ${why}`); |
| 49 | + if (detail) console.error(detail); |
| 50 | + process.exit(1); |
| 51 | +} |
| 52 | + |
| 53 | +if (audit.error) unusable("npm could not be run.", audit.error.message); |
| 54 | +if (audit.signal) unusable(`npm was killed by ${audit.signal}.`, audit.stderr); |
| 55 | + |
| 56 | +let report; |
| 57 | +try { |
| 58 | + report = JSON.parse(audit.stdout); |
| 59 | +} catch { |
| 60 | + unusable("stdout was not JSON.", audit.stdout || audit.stderr); |
| 61 | +} |
| 62 | + |
| 63 | +// A registry outage, an auth failure or an npm internal error still prints valid |
| 64 | +// JSON — but an `{error, message}` envelope with no `vulnerabilities` key rather |
| 65 | +// than a report. Left unchecked, `report.vulnerabilities ?? {}` would read that |
| 66 | +// as zero advisories and pass the gate exactly when the audit did not run. |
| 67 | +if (report === null || typeof report !== "object" || Array.isArray(report)) { |
| 68 | + unusable("stdout was JSON but not an object.", audit.stdout); |
| 69 | +} |
| 70 | +if (report.error) { |
| 71 | + // A registry outage fills the top-level `message` and leaves `error.summary` |
| 72 | + // and `error.detail` empty strings; other npm errors do the reverse. Try all |
| 73 | + // three so the failure output carries whichever one npm populated. |
| 74 | + unusable( |
| 75 | + "npm reported an error.", |
| 76 | + report.error.detail || report.error.summary || report.message || audit.stderr, |
| 77 | + ); |
| 78 | +} |
| 79 | +// Arrays are typeof "object" too, and an array would yield zero entries below |
| 80 | +// rather than an error — so a malformed report would read as clean. |
| 81 | +if ( |
| 82 | + typeof report.vulnerabilities !== "object" || |
| 83 | + report.vulnerabilities === null || |
| 84 | + Array.isArray(report.vulnerabilities) |
| 85 | +) { |
| 86 | + unusable("the report has no `vulnerabilities` section.", audit.stdout); |
| 87 | +} |
| 88 | + |
| 89 | +// Flatten the report to one entry per advisory. `via` holds advisory objects for |
| 90 | +// the package that actually carries the flaw, and plain package-name strings for |
| 91 | +// the dependents that only inherit it — so collecting the objects covers every |
| 92 | +// affected package without counting the same advisory once per dependent. |
| 93 | +const advisories = new Map(); |
| 94 | +for (const vuln of Object.values(report.vulnerabilities)) { |
| 95 | + for (const via of vuln.via ?? []) { |
| 96 | + if (typeof via !== "object") continue; |
| 97 | + // Fail closed on an advisory we cannot name: fall back to a key built from |
| 98 | + // whatever npm did give us. It can never match an ALLOWLIST entry (those are |
| 99 | + // GHSA ids), so a high/critical one still blocks instead of being dropped. |
| 100 | + const id = |
| 101 | + /(GHSA-[\w-]+)/.exec(via.url ?? "")?.[1] ?? |
| 102 | + `unidentified advisory (${via.url ?? via.source ?? via.name})`; |
| 103 | + const entry = advisories.get(id) ?? { |
| 104 | + title: via.title, |
| 105 | + severity: via.severity, |
| 106 | + url: via.url, |
| 107 | + packages: new Set(), |
| 108 | + }; |
| 109 | + entry.packages.add(via.name); |
| 110 | + advisories.set(id, entry); |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +const blocking = [...advisories].filter( |
| 115 | + ([id, a]) => BLOCKING.has(a.severity) && !ALLOWLIST.has(id), |
| 116 | +); |
| 117 | +const allowed = [...advisories].filter(([id]) => ALLOWLIST.has(id)); |
| 118 | + |
| 119 | +for (const [id, a] of allowed) { |
| 120 | + console.log(`allowed ${a.severity.padEnd(8)} ${id} ${[...a.packages].join(", ")}`); |
| 121 | + console.log(` ${ALLOWLIST.get(id)}`); |
| 122 | +} |
| 123 | + |
| 124 | +// Stale entries are a warning, not a failure: the advisory database is a live |
| 125 | +// service, so a transient omission must not redden an unrelated PR. The warning |
| 126 | +// is still worth acting on — delete the entry once upstream has a fix. |
| 127 | +for (const id of ALLOWLIST.keys()) { |
| 128 | + if (!advisories.has(id)) { |
| 129 | + console.warn(`warning: ${id} is allowlisted but no longer reported — drop it.`); |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +if (blocking.length === 0) { |
| 134 | + console.log(`\nNo unallowed high/critical advisories (${allowed.length} allowlisted).`); |
| 135 | + process.exit(0); |
| 136 | +} |
| 137 | + |
| 138 | +console.error( |
| 139 | + `\n${blocking.length} unallowed high/critical advisor${blocking.length === 1 ? "y" : "ies"}:`, |
| 140 | +); |
| 141 | +for (const [id, a] of blocking) { |
| 142 | + console.error(` ${a.severity.padEnd(8)} ${id} ${[...a.packages].join(", ")}`); |
| 143 | + console.error(` ${a.title}`); |
| 144 | + if (a.url) console.error(` ${a.url}`); |
| 145 | +} |
| 146 | +console.error("\nUpgrade the dependency, or add an entry to ALLOWLIST in scripts/audit-check.mjs."); |
| 147 | +process.exit(1); |
0 commit comments