199 lines
5.6 KiB
JavaScript
199 lines
5.6 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');
|