scripts/audit-gate.mjs
Rama main · réplica de lectura del repositorio original.
#!/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)`);