#!/usr/bin/env node // Blocking npm advisory gate with an explicit, documented allowlist. // // `npm audit --audit-level=high` alone can't express "this advisory is known, // unfixable without breaking changes, and tracked" — the job either fails or // gets demoted to report-only, which hides *new* advisories too. This gate // keeps the audit blocking: high/critical advisories fail the build unless // every affected installation matches a documented scope in // scripts/audit-allowlist.json. Allowlisted advisories are printed on every // run so they stay visible until they can be removed. // // npm's legacy quick-audit endpoint is being retired and now rejects valid // lockfiles after npm CLI's preferred bulk request fails. Read the lockfile and // call the supported bulk endpoint directly. If npm's advisory service is // unavailable, use OSV's exact-version batch API as an independent fail-closed // source instead of silently falling back to the obsolete endpoint. // // Usage: node ../scripts/audit-gate.mjs (cwd = the workspace to audit) import { execFileSync } from "node:child_process"; import { readFileSync, realpathSync } from "node:fs"; import { dirname, join, relative, posix } from "node:path"; import { fileURLToPath } from "node:url"; const allowlistPath = join(dirname(fileURLToPath(import.meta.url)), "audit-allowlist.json"); const allowlist = JSON.parse(readFileSync(allowlistPath, "utf8")); // Exceptions are checked against the actual audited directory, not a package // name or an environment variable supplied by the caller. // Scope fields are exact matches. `dev` is npm's lockfile classification; // the reason must separately assess exposure in the shipped image/bundle. const workspace = relative( realpathSync(join(dirname(allowlistPath), "..")), realpathSync("."), ).replaceAll("\\", "/") || "."; for (const entry of allowlist) { if (!entry.ghsa || !entry.reason?.trim() || !entry.scopes?.length || !entry.scopes.every((scope) => typeof scope.workspace === "string" && typeof scope.package === "string" && typeof scope.version === "string" && typeof scope.path === "string" && scope.dev === true && Array.isArray(scope.dependents) && scope.dependents.length > 0 && scope.dependents.every((parent) => typeof parent === "string"))) { throw new Error(`Invalid scoped audit exception: ${entry.ghsa ?? "unknown"}`); } } const allowed = new Map(allowlist.map((e) => [e.ghsa, e])); const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); const packages = {}; const installations = []; for (const [packagePath, entry] of Object.entries(lock.packages ?? {})) { if (!packagePath || !entry.version) continue; const normalizedPath = packagePath.replaceAll("\\", "/"); const markerIndex = normalizedPath.lastIndexOf("node_modules/"); if (markerIndex < 0) continue; const installedPath = normalizedPath.slice(markerIndex + "node_modules/".length); const pathParts = installedPath.split("/"); const packageName = entry.name ?? ( pathParts[0]?.startsWith("@") ? pathParts.slice(0, 2).join("/") : pathParts[0] ); if (!packageName) continue; installations.push({ name: packageName, version: entry.version, path: normalizedPath, dev: entry.dev === true }); packages[packageName] ??= []; if (!packages[packageName].includes(entry.version)) { packages[packageName].push(entry.version); } } const packageEntries = Object.entries(packages); // Resolve hoisted dependencies using the lockfile, without requiring an // installed node_modules tree. Check every incoming edge: a new consumer of // the same hoisted package must not inherit an unrelated tooling exception. function resolveDependency(parent, name) { let base = parent; for (;;) { const candidate = posix.join(base, "node_modules", name); if (Object.hasOwn(lock.packages, candidate)) return candidate; if (!base) return undefined; base = posix.dirname(base); if (base === ".") base = ""; } } const dependents = new Map(); for (const [parent, entry] of Object.entries(lock.packages ?? {})) { const names = new Set(Object.keys({ ...entry.dependencies, ...entry.optionalDependencies, ...entry.peerDependencies, ...(parent === "" ? entry.devDependencies : {}), })); for (const name of names) { const target = resolveDependency(parent, name); if (!target) continue; if (!dependents.has(target)) dependents.set(target, new Set()); dependents.get(target).add(parent); } } function isAllowed(exception, matches) { return Boolean(exception && matches.length > 0 && matches.every((match) => { const copies = installations.filter((copy) => copy.name === match.name && copy.version === match.version); return copies.length > 0 && copies.every((copy) => exception.scopes.some((scope) => { const parents = [...(dependents.get(copy.path) ?? [])]; return scope.workspace === workspace && scope.package === copy.name && scope.version === copy.version && scope.path === copy.path && copy.dev && parents.length > 0 && parents.every((parent) => scope.dependents.includes(parent)); })); })); } // FAIL CLOSED ON AN EMPTY LOCKFILE. A lockfile with no `packages` map (an // npm 6 lockfile, a truncated write, a wrong cwd) produces zero queries, so // every loop below is skipped, `advisories` is empty and the gate prints // "passed" having checked nothing at all — a gate that answers "clean" for a // tree it never looked at is worse than no gate. if (packageEntries.length === 0) { console.error( "package-lock.json declared no resolved packages — refusing to pass the gate.", ); console.error( " (npm 6 lockfiles have no `packages` map; run `npm install` with npm 7+ to regenerate it.)", ); process.exit(1); } const osvBaseUrl = process.env.OSV_API_BASE_URL ?? "https://api.osv.dev/v1"; /** * CANARY. `{}` is a perfectly valid npm bulk response — it is what the * registry sends for a clean tree — which means NO shape check can tell * "we looked and found nothing" apart from "this endpoint does not answer * this question". A proxy, a captive portal, an internal mirror that * implements the route as a stub, or the endpoint's eventual retirement all * produce 200 `{}`, and the gate happily printed "0 advisories, passed". * * So every batch carries a package+version we KNOW carries a high-severity * advisory. If the answer does not mention it, the endpoint is not answering * advisory questions and we fall through to OSV. The canary is stripped * before the report is evaluated, so it never appears as a finding. * * adm-zip 0.5.12 is a known vulnerable version. If the advisory service stops * reporting it, the gate falls back to OSV instead of trusting silence. */ const CANARY_PACKAGE = "adm-zip"; const CANARY_VERSION = "0.5.12"; /** * How many advisory batches were actually answered by a service. Checked * before the pass line: a run that never got an answer has not audited * anything, and must not be allowed to report success. */ let batchesAnswered = 0; async function fetchJson(url, init, { attempts = 3, timeoutMs = 30_000 } = {}) { let lastError; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) { throw new Error(`advisory service returned HTTP ${response.status}`); } return await response.json(); } catch (error) { lastError = error; if (attempt < attempts) { await new Promise((resolve) => setTimeout(resolve, attempt * 1_000)); } } } throw lastError; } async function loadNpmReport() { const registry = execFileSync("npm", ["config", "get", "registry"], { encoding: "utf8", }).trim().replace(/\/$/, ""); const endpoint = `${registry}/-/npm/v1/security/advisories/bulk`; async function requestBatch(batch, requireCanary) { const batchReport = await fetchJson(endpoint, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", "user-agent": "mike-audit-gate/1.0", }, body: JSON.stringify(batch), }, { attempts: 1, timeoutMs: 15_000 }); // VALIDATE, don't coerce. A registry (or a proxy, or a captive network) // that answers 200 with `{"error":"..."}` used to sail through here: the // payload is an object, its one value is not an array, the old code // coerced it to `[]`, and the gate reported "0 advisories, passed". An // advisory service that did not answer the question must send us to OSV, // and if OSV fails too the gate fails — never passes by default. if (!batchReport || Array.isArray(batchReport) || typeof batchReport !== "object") { throw new Error("npm returned an invalid advisory report"); } // An error envelope carries these keys as strings/objects. A real // package named "error", "message" or "code" (all exist on npm) answers // with an ARRAY of advisories, which is the shape a report entry has — // so only a non-array value under one of these keys is an envelope. for (const key of ["error", "message", "code"]) { if (Object.hasOwn(batchReport, key) && !Array.isArray(batchReport[key])) { throw new Error( `npm advisory service returned an error payload (${key}: ${JSON.stringify(batchReport[key])})`, ); } } for (const [packageName, packageAdvisories] of Object.entries(batchReport)) { if (!Array.isArray(packageAdvisories)) { throw new Error( `npm returned a non-array advisory list for ${packageName}`, ); } } // The canary must come back. An empty or missing entry means this // endpoint did not answer the question we asked, however well-formed its // 200 was — send the run to OSV rather than pass on silence. if (requireCanary && ( !Array.isArray(batchReport[CANARY_PACKAGE]) || batchReport[CANARY_PACKAGE].length === 0 )) { throw new Error( `npm returned no advisory for the canary ${CANARY_PACKAGE}@${CANARY_VERSION} — the endpoint is not answering advisory queries`, ); } return batchReport; } const report = {}; // Use one bounded attempt before the OSV fallback. Retrying a retired npm // fallback was the source of the previous five-minute CI failures. // One version per package in each request retains exact affected versions // without guessing at registry semver ranges. Different copies of a package // cannot be collapsed into a single advisory-wide exemption. const batches = []; const versionCount = Math.max(...packageEntries.map(([, versions]) => versions.length)); for (let index = 0; index < versionCount; index += 1) { const lane = packageEntries.filter(([, versions]) => versions[index] !== undefined) .map(([name, versions]) => [name, [versions[index]]]); for (let offset = 0; offset < lane.length; offset += 500) { batches.push(Object.fromEntries(lane.slice(offset, offset + 500))); } } for (const batch of batches) { const queried = structuredClone(batch); // npm keys its answer by package name, not version. Never combine an // injected canary with a different real adm-zip version: stripping the // canary would discard real findings, while keeping it would misattribute // canary findings to a potentially patched installation. const hasCanaryPackage = Object.hasOwn(batch, CANARY_PACKAGE); const canaryIsReal = batch[CANARY_PACKAGE]?.includes(CANARY_VERSION); if (hasCanaryPackage && !canaryIsReal) { await requestBatch({ [CANARY_PACKAGE]: [CANARY_VERSION] }, true); } if (!hasCanaryPackage) batch[CANARY_PACKAGE] = [CANARY_VERSION]; const batchReport = await requestBatch(batch, !hasCanaryPackage || canaryIsReal); if (!hasCanaryPackage) delete batchReport[CANARY_PACKAGE]; batchesAnswered += 1; for (const [packageName, packageAdvisories] of Object.entries(batchReport)) { report[packageName] = [ ...(report[packageName] ?? []), ...packageAdvisories.map((advisory) => ({ ...advisory, matches: (queried[packageName] ?? []).map((version) => ({ name: packageName, version })), })), ]; } } if (batchesAnswered === 0) { throw new Error("npm answered no advisory batches"); } return report; } async function loadOsvReport() { const queries = packageEntries.flatMap(([packageName, versions]) => versions.map((version) => ({ package: { ecosystem: "npm", name: packageName }, version, })), ); const vulnerabilityIds = new Set(); const vulnerabilityMatches = new Map(); for (let offset = 0; offset < queries.length; offset += 500) { const batchQueries = queries.slice(offset, offset + 500); const response = await fetchJson(`${osvBaseUrl}/querybatch`, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", "user-agent": "mike-audit-gate/1.0", }, body: JSON.stringify({ queries: batchQueries }), }); if (!Array.isArray(response?.results)) { throw new Error("OSV returned an invalid advisory report"); } // OSV answers positionally: one result per query, in order. A short array // means some queries went unanswered, and reading the rest as "clean" // would silently drop packages from the audit. if (response.results.length !== batchQueries.length) { throw new Error( `OSV answered ${response.results.length} of ${batchQueries.length} queries`, ); } batchesAnswered += 1; for (const [resultIndex, result] of response.results.entries()) { const query = batchQueries[resultIndex]; for (const vulnerability of result.vulns ?? []) { if (!vulnerability?.id || !query) continue; vulnerabilityIds.add(vulnerability.id); vulnerabilityMatches.set( vulnerability.id, [ ...(vulnerabilityMatches.get(vulnerability.id) ?? []), { name: query.package.name, version: query.version }, ], ); } } } const details = await Promise.all( [...vulnerabilityIds].map((id) => fetchJson(`${osvBaseUrl}/vulns/${encodeURIComponent(id)}`, { headers: { accept: "application/json", "user-agent": "mike-audit-gate/1.0" }, }), ), ); return { osv: details.flatMap((detail) => { // Some SheetJS advisories have an open-ended OSV range because patched // releases are distributed outside npm. GitHub records the upper bound // in last_known_affected_version_range; honor that bound so a patched // vendor release such as xlsx 0.20.3 is not treated as vulnerable. const matches = vulnerabilityMatches.get(detail.id) ?? []; const affectedMatches = matches.filter((match) => { const affected = detail.affected?.find( (entry) => entry.package?.ecosystem === "npm" && entry.package?.name === match.name, ); const lastKnownRange = affected?.database_specific?.last_known_affected_version_range; const rangeMatch = /^<\s*(=?)\s*(\d+(?:\.\d+){1,2})$/.exec(lastKnownRange ?? ""); if (!rangeMatch || !/^\d+(?:\.\d+){1,2}$/.test(match.version)) return true; const left = match.version.split(".").map((part) => Number.parseInt(part, 10)); const right = rangeMatch[2].split(".").map((part) => Number.parseInt(part, 10)); if ([...left, ...right].some(Number.isNaN)) return true; const comparison = [0, 1, 2] .map((index) => (left[index] ?? 0) - (right[index] ?? 0)) .find((difference) => difference !== 0) ?? 0; return rangeMatch[1] === "=" ? comparison <= 0 : comparison < 0; }); if (matches.length > 0 && affectedMatches.length === 0) return []; const severity = detail?.database_specific?.severity?.toLowerCase(); if (!severity) { throw new Error(`OSV advisory ${detail?.id ?? "unknown"} has no severity`); } const id = detail.id; return [{ url: id.startsWith("GHSA-") ? `https://github.com/advisories/${id}` : `https://osv.dev/vulnerability/${id}`, title: detail.summary ?? id, severity, matches: affectedMatches, }]; }), }; } let report; try { report = await loadNpmReport(); } catch (npmError) { // Each loader counts only its OWN answered batches; a partially answered // npm run must not lend its credit to the OSV attempt. batchesAnswered = 0; console.warn( `npm advisory service unavailable; checking OSV instead (${npmError instanceof Error ? npmError.message : String(npmError)})`, ); try { report = await loadOsvReport(); } catch (osvError) { console.error("Both advisory services failed — refusing to pass the gate:"); console.error(osvError instanceof Error ? osvError.message : String(osvError)); process.exit(1); } } const advisories = new Map(); // ghsa -> report metadata and every affected package/version for (const packageAdvisories of Object.values(report)) { if (!Array.isArray(packageAdvisories)) continue; for (const advisory of packageAdvisories) { if (!advisory?.url) continue; if (advisory.severity !== "high" && advisory.severity !== "critical") continue; const ghsa = advisory.url.split("/").pop(); advisories.set(ghsa, { severity: advisory.severity, title: advisory.title, url: advisory.url, matches: [...(advisories.get(ghsa)?.matches ?? []), ...(advisory.matches ?? [])], unmatched: advisories.get(ghsa)?.unmatched || !advisory.matches?.length, }); } } const blocking = []; for (const [ghsa, adv] of advisories) { if (!adv.unmatched && isAllowed(allowed.get(ghsa), adv.matches)) { console.log(`ALLOWLISTED ${adv.severity}: ${ghsa} — ${adv.title}`); console.log(` reason: ${allowed.get(ghsa).reason}`); } else { blocking.push(`${adv.severity}: ${ghsa} — ${adv.title} (${adv.url})${allowed.has(ghsa) ? " — outside the approved dependency scope" : ""}`); } } // The allowlist is shared across workspaces, so an entry unused here may // still be load-bearing in the other workspace — flag it, don't fail on it. const unused = allowlist.filter((e) => !advisories.has(e.ghsa)); for (const e of unused) { console.log(`note: allowlist entry ${e.ghsa} not reported in this workspace — remove it once no workspace reports it`); } if (blocking.length > 0) { console.error(`\n${blocking.length} high/critical advisories are not allowlisted:`); for (const line of blocking) console.error(` ${line}`); process.exit(1); } // Last line of defence: reaching here with nothing answered means the loops // above ran zero times, which is "no data", not "no advisories". if (batchesAnswered === 0) { console.error( "No advisory service answered — refusing to pass the gate.", ); process.exit(1); } console.log(`audit gate passed (${advisories.size} high/critical advisories, all allowlisted)`);