Files
xread/scripts/security-audit-report.cjs
T

219 lines
5.7 KiB
JavaScript

const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const projectRoot = path.resolve(__dirname, "..");
const baselinePath = path.join(
projectRoot,
"security",
"npm-audit-baseline.json",
);
const reportDirectory = path.join(projectRoot, "security-reports");
const summaryJsonPath = path.join(reportDirectory, "npm-audit-summary.json");
const summaryMarkdownPath = path.join(reportDirectory, "npm-audit-summary.md");
const severityRank = {
info: 0,
low: 1,
moderate: 2,
high: 3,
critical: 4,
};
function npmCommand() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function readBaseline() {
const raw = fs.readFileSync(baselinePath, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed.entries) ? parsed.entries : [];
}
function runAudit(extraArgs) {
const result = spawnSync(npmCommand(), ["audit", "--json", ...extraArgs], {
cwd: projectRoot,
encoding: "utf8",
env: process.env,
});
if (![0, 1].includes(result.status ?? 0)) {
process.stderr.write(
result.stderr || result.stdout || "npm audit failed.\n",
);
process.exit(result.status ?? 1);
}
const combinedOutput =
`${result.stdout || ""}\n${result.stderr || ""}`.trim();
const jsonStart = combinedOutput.indexOf("{");
if (jsonStart === -1) {
if ((result.status ?? 0) === 0) {
return {
auditReportVersion: 2,
vulnerabilities: {},
metadata: {
vulnerabilities: {
info: 0,
low: 0,
moderate: 0,
high: 0,
critical: 0,
total: 0,
},
},
};
}
throw new Error("npm audit returned no JSON payload.");
}
return JSON.parse(combinedOutput.slice(jsonStart));
}
function toFindings(report, scope) {
return Object.entries(report.vulnerabilities || {}).map(([pkg, entry]) => {
const advisories = (entry.via || [])
.filter((item) => item && typeof item === "object" && item.title)
.map((item) => ({
source: item.source,
title: item.title,
severity: item.severity,
url: item.url,
}));
return {
package: pkg,
scope,
severity: entry.severity,
isDirect: Boolean(entry.isDirect),
nodes: entry.nodes || [],
effects: entry.effects || [],
advisories,
};
});
}
function matchBaseline(finding, baselineEntries) {
return baselineEntries.find(
(entry) =>
entry.package === finding.package &&
entry.scope === finding.scope &&
entry.severity === finding.severity,
);
}
function formatFinding(finding, baselineEntry) {
const headline = `${finding.package} (${finding.scope}, ${finding.severity})`;
const notes = [];
if (finding.advisories.length) {
notes.push(
`advisories: ${finding.advisories.map((item) => item.title).join(" | ")}`,
);
}
if (finding.effects.length) {
notes.push(`effects: ${finding.effects.join(", ")}`);
}
if (finding.nodes.length) {
notes.push(`nodes: ${finding.nodes.join(", ")}`);
}
if (baselineEntry) {
notes.push(
`baseline: ${baselineEntry.reason} (review by ${baselineEntry.reviewBy})`,
);
}
return `- ${headline}\n ${notes.join("\n ")}`;
}
function writeReports(payload) {
fs.mkdirSync(reportDirectory, { recursive: true });
fs.writeFileSync(summaryJsonPath, JSON.stringify(payload, null, 2) + "\n");
const sections = [
"# npm audit summary",
"",
`- generatedAt: ${payload.generatedAt}`,
`- blockingFindings: ${payload.blockingFindings.length}`,
`- baselinedDevelopmentFindings: ${payload.baselinedDevelopmentFindings.length}`,
`- productionVulnerabilities: ${JSON.stringify(payload.production.metadata.vulnerabilities)}`,
`- fullAuditVulnerabilities: ${JSON.stringify(payload.full.metadata.vulnerabilities)}`,
"",
"## Blocking findings",
...(payload.blockingFindings.length
? payload.blockingFindings.map((item) => formatFinding(item, null))
: ["- none"]),
"",
"## Baselined development findings",
...(payload.baselinedDevelopmentFindings.length
? payload.baselinedDevelopmentFindings.map((item) =>
formatFinding(item.finding, item.baseline),
)
: ["- none"]),
];
fs.writeFileSync(summaryMarkdownPath, sections.join("\n") + "\n");
}
const baselineEntries = readBaseline();
const productionAudit = runAudit(["--omit=dev"]);
const fullAudit = runAudit([]);
const productionFindings = toFindings(productionAudit, "production");
const fullFindings = toFindings(fullAudit, "development");
const baselinedDevelopmentFindings = [];
const blockingFindings = [];
for (const finding of productionFindings) {
if (severityRank[finding.severity] >= severityRank.high) {
blockingFindings.push(finding);
}
}
for (const finding of fullFindings) {
if (
productionFindings.some(
(prodFinding) => prodFinding.package === finding.package,
)
) {
continue;
}
const baselineEntry = matchBaseline(finding, baselineEntries);
if (baselineEntry) {
baselinedDevelopmentFindings.push({ finding, baseline: baselineEntry });
continue;
}
if (severityRank[finding.severity] >= severityRank.high) {
blockingFindings.push(finding);
}
}
const payload = {
generatedAt: new Date().toISOString(),
production: {
metadata: productionAudit.metadata,
findings: productionFindings,
},
full: {
metadata: fullAudit.metadata,
findings: fullFindings,
},
blockingFindings,
baselinedDevelopmentFindings,
};
writeReports(payload);
if (blockingFindings.length) {
process.stderr.write(
`Security audit failed with ${blockingFindings.length} blocking finding(s).\n`,
);
process.exit(1);
}
process.stdout.write("Security audit policy passed.\n");