Support reviewed npm audit baselines
This commit is contained in:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const DEFAULT_POLICY = Object.freeze({
|
|||||||
allowInstallScripts: {},
|
allowInstallScripts: {},
|
||||||
allowDirectLifecycleScripts: {},
|
allowDirectLifecycleScripts: {},
|
||||||
archiveSizeExceptions: {},
|
archiveSizeExceptions: {},
|
||||||
|
auditExceptions: {},
|
||||||
sourceEvidenceExceptions: {},
|
sourceEvidenceExceptions: {},
|
||||||
trustedRepositories: {},
|
trustedRepositories: {},
|
||||||
trustedMaintainers: {},
|
trustedMaintainers: {},
|
||||||
|
|||||||
@@ -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>"}`);
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user