diff --git a/README.md b/README.md index 331c538..ff20e88 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ runner without placing them in the policy or report. }, "allowDirectLifecycleScripts": {}, "archiveSizeExceptions": {}, + "auditExceptions": {}, "sourceEvidenceExceptions": {}, "trustedRepositories": { "example-package": "https://github.com/example/example-package" @@ -123,6 +124,7 @@ runner without placing them in the policy or report. whose registry manifest declares `preinstall`, `install`, or `postinstall`. - `archiveSizeExceptions` permits exact package versions to use reviewed archive limits above the project defaults. +- `auditExceptions` permits only listed GHSA advisories until each entry's expiry. - `sourceEvidenceExceptions` permits only an exact package version to proceed when source provenance or tag evidence is unavailable. Each entry requires a detailed `reason` and an `expiresAt` ISO date, and produces a warning. diff --git a/lib/check.js b/lib/check.js index 56dfe70..448a354 100644 --- a/lib/check.js +++ b/lib/check.js @@ -67,11 +67,18 @@ export async function checkProject(options, injected = {}) { for (const args of [ ["ci", "--ignore-scripts"], ["audit", "signatures"], - ["audit", "--omit=dev"], + ["audit", "--omit=dev", "--json"], ]) { - const command = await execute(run, "npm", args, projectPath, options.commandTimeoutMs); + const isAdvisoryAudit = args.includes("--json"); + const command = await execute(run, "npm", args, projectPath, options.commandTimeoutMs, isAdvisoryAudit ? 5_000_000 : 20_000); report.commands.push(command.evidence); - if (command.result.code !== 0) report.failures.push(`npm ${args.join(" ")} failed${command.result.timedOut ? " (timed out)" : ""}`); + if (command.result.code !== 0 && isAdvisoryAudit && !command.result.timedOut) { + const assessment = assessAudit(command.result.stdout, options.policy, dependencies.now()); + report.failures.push(...assessment.failures); + report.warnings.push(...assessment.warnings); + } else if (command.result.code !== 0) { + report.failures.push(`npm ${args.join(" ")} failed${command.result.timedOut ? " (timed out)" : ""}`); + } if (command.result.code !== 0 && args[0] === "ci") break; } } @@ -95,6 +102,40 @@ export async function checkProject(options, injected = {}) { return report; } +function assessAudit(stdout, policy, now) { + let audit; + try { + audit = JSON.parse(stdout); + } catch { + return { failures: ["npm audit returned invalid JSON"], warnings: [] }; + } + if (!audit.vulnerabilities || typeof audit.vulnerabilities !== "object") { + return { failures: ["npm audit failed without vulnerability details"], warnings: [] }; + } + const advisories = new Set(); + let malformed = false; + for (const vulnerability of Object.values(audit.vulnerabilities)) { + for (const via of vulnerability.via ?? []) { + if (!via || typeof via !== "object") continue; + const id = String(via.url ?? "").match(/GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i)?.[0]?.toUpperCase(); + if (id) advisories.add(id); + else malformed = true; + } + } + const failures = []; + if (malformed || advisories.size === 0) failures.push("npm audit returned vulnerability data without recognizable GHSA identifiers"); + const accepted = []; + for (const id of advisories) { + const exception = policy.auditExceptions[id]; + if (exception && Date.parse(exception.expiresAt) > now) accepted.push(id); + else failures.push(`npm audit advisory ${id} is not covered by an unexpired policy exception`); + } + return { + failures, + warnings: accepted.length ? [`npm audit reports ${accepted.length} reviewed advisory exception(s): ${accepted.sort().join(", ")}`] : [], + }; +} + async function execute(run, command, args, cwd, timeoutMs = COMMAND_TIMEOUT_MS, maxOutputBytes = 20_000) { const result = await run(command, args, { cwd, timeoutMs, maxOutputBytes }); return { diff --git a/lib/constants.js b/lib/constants.js index 8a25d4f..530fac5 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -2,6 +2,7 @@ export const DEFAULT_POLICY = Object.freeze({ allowInstallScripts: {}, allowDirectLifecycleScripts: {}, archiveSizeExceptions: {}, + auditExceptions: {}, sourceEvidenceExceptions: {}, trustedRepositories: {}, trustedMaintainers: {}, diff --git a/lib/policy.js b/lib/policy.js index def5fca..1c49fe0 100644 --- a/lib/policy.js +++ b/lib/policy.js @@ -22,6 +22,7 @@ export async function loadPolicy(file) { validateVersionExceptions(policy.allowInstallScripts, "allowInstallScripts"); validateVersionExceptions(policy.allowDirectLifecycleScripts, "allowDirectLifecycleScripts"); validateArchiveExceptions(policy.archiveSizeExceptions); + validateAuditExceptions(policy.auditExceptions); validateSourceExceptions(policy.sourceEvidenceExceptions); validateMap(policy, "trustedRepositories", false); validateMap(policy, "trustedMaintainers", true); @@ -83,6 +84,14 @@ function validateArchiveExceptions(value) { } } +function validateAuditExceptions(value) { + if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("auditExceptions must be an object"); + for (const [id, exception] of Object.entries(value)) { + if (!/^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i.test(id)) throw new Error(`invalid auditExceptions advisory: ${id}`); + validateExceptionBase(id, exception, "auditExceptions", []); + } +} + function validateExceptionBase(name, exception, key, allowedFields) { if (!name || !exception || Array.isArray(exception) || typeof exception !== "object") { throw new Error(`invalid ${key} entry for ${name || ""}`); diff --git a/test/check.test.js b/test/check.test.js index 42ccfe3..cb6ad06 100644 --- a/test/check.test.js +++ b/test/check.test.js @@ -102,3 +102,32 @@ test("check parses full npm pack JSON while truncating report evidence", async ( assert.equal(report.packages[0].tarballSizeBytes, 100); assert.equal(report.commands[0].stdout.length, 20_000); }); + +test("check accepts only explicitly reviewed npm audit advisories", async () => { + const directory = await makeProject(); + const document = packageDocument(); + const gitHead = document.versions["1.2.3"].gitHead; + const policy = structuredClone(DEFAULT_POLICY); + policy.auditExceptions["GHSA-1234-5678-9ABC"] = { + reason: "Reviewed existing advisory pending a separately tracked upgrade.", + expiresAt: "2025-02-01T00:00:00Z", + }; + const run = async (_command, args) => { + if (args[0] === "pack") return { code: 0, stdout: JSON.stringify([{ size: 100, unpackedSize: 500 }]), stderr: "", timedOut: false }; + if (args.includes("--json")) return { + code: 1, + stdout: JSON.stringify({ vulnerabilities: { alpha: { via: [{ url: "https://github.com/advisories/GHSA-1234-5678-9abc" }] } } }), + stderr: "", + timedOut: false, + }; + return { code: 0, stdout: "ok", stderr: "", timedOut: false }; + }; + const report = await checkProject({ path: directory, report: join(directory, "report.json"), policy }, { + fetch: async () => jsonResponse(document), + remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }], + run, + now: () => Date.parse("2025-01-01T00:00:00Z"), + }); + assert.equal(report.status, "pass"); + assert.ok(report.warnings.some((warning) => warning.includes("GHSA-1234-5678-9ABC"))); +});