Support reviewed npm audit baselines

This commit is contained in:
2026-09-01 20:06:16 +01:00
parent 8098495abf
commit 903e907225
5 changed files with 85 additions and 3 deletions
+2
View File
@@ -104,6 +104,7 @@ runner without placing them in the policy or report.
}, },
"allowDirectLifecycleScripts": {}, "allowDirectLifecycleScripts": {},
"archiveSizeExceptions": {}, "archiveSizeExceptions": {},
"auditExceptions": {},
"sourceEvidenceExceptions": {}, "sourceEvidenceExceptions": {},
"trustedRepositories": { "trustedRepositories": {
"example-package": "https://github.com/example/example-package" "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`. whose registry manifest declares `preinstall`, `install`, or `postinstall`.
- `archiveSizeExceptions` permits exact package versions to use reviewed archive - `archiveSizeExceptions` permits exact package versions to use reviewed archive
limits above the project defaults. 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 - `sourceEvidenceExceptions` permits only an exact package version to proceed when
source provenance or tag evidence is unavailable. Each entry requires a detailed source provenance or tag evidence is unavailable. Each entry requires a detailed
`reason` and an `expiresAt` ISO date, and produces a warning. `reason` and an `expiresAt` ISO date, and produces a warning.
+44 -3
View File
@@ -67,11 +67,18 @@ export async function checkProject(options, injected = {}) {
for (const args of [ for (const args of [
["ci", "--ignore-scripts"], ["ci", "--ignore-scripts"],
["audit", "signatures"], ["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); 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; if (command.result.code !== 0 && args[0] === "ci") break;
} }
} }
@@ -95,6 +102,40 @@ export async function checkProject(options, injected = {}) {
return report; 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) { async function execute(run, command, args, cwd, timeoutMs = COMMAND_TIMEOUT_MS, maxOutputBytes = 20_000) {
const result = await run(command, args, { cwd, timeoutMs, maxOutputBytes }); const result = await run(command, args, { cwd, timeoutMs, maxOutputBytes });
return { return {
+1
View File
@@ -2,6 +2,7 @@ export const DEFAULT_POLICY = Object.freeze({
allowInstallScripts: {}, allowInstallScripts: {},
allowDirectLifecycleScripts: {}, allowDirectLifecycleScripts: {},
archiveSizeExceptions: {}, archiveSizeExceptions: {},
auditExceptions: {},
sourceEvidenceExceptions: {}, sourceEvidenceExceptions: {},
trustedRepositories: {}, trustedRepositories: {},
trustedMaintainers: {}, trustedMaintainers: {},
+9
View File
@@ -22,6 +22,7 @@ export async function loadPolicy(file) {
validateVersionExceptions(policy.allowInstallScripts, "allowInstallScripts"); validateVersionExceptions(policy.allowInstallScripts, "allowInstallScripts");
validateVersionExceptions(policy.allowDirectLifecycleScripts, "allowDirectLifecycleScripts"); validateVersionExceptions(policy.allowDirectLifecycleScripts, "allowDirectLifecycleScripts");
validateArchiveExceptions(policy.archiveSizeExceptions); validateArchiveExceptions(policy.archiveSizeExceptions);
validateAuditExceptions(policy.auditExceptions);
validateSourceExceptions(policy.sourceEvidenceExceptions); validateSourceExceptions(policy.sourceEvidenceExceptions);
validateMap(policy, "trustedRepositories", false); validateMap(policy, "trustedRepositories", false);
validateMap(policy, "trustedMaintainers", true); 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) { function validateExceptionBase(name, exception, key, allowedFields) {
if (!name || !exception || Array.isArray(exception) || typeof exception !== "object") { if (!name || !exception || Array.isArray(exception) || typeof exception !== "object") {
throw new Error(`invalid ${key} entry for ${name || "<empty>"}`); throw new Error(`invalid ${key} entry for ${name || "<empty>"}`);
+29
View File
@@ -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.packages[0].tarballSizeBytes, 100);
assert.equal(report.commands[0].stdout.length, 20_000); 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")));
});