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