#!/usr/bin/env node /** * Compare two Trivy vulnerability JSON reports (supporting multi-arch) and produce a set-based Markdown diff. */ const fs = require('node:fs'); const path = require('node:path'); const SEVERITY_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']; const SEVERITY_BADGES = { CRITICAL: '🔴 CRITICAL', HIGH: '🟠 HIGH', MEDIUM: '🟡 MEDIUM', LOW: '🔵 LOW', UNKNOWN: '⚪ UNKNOWN', }; /** * Loads one or more JSON reports (path, comma-separated list, or directory) and merges findings with arch tracking. * @param {string} arg * @returns {Map} */ function loadReportsForArg(arg) { const cves = new Map(); const filePaths = []; for (const part of arg.split(',').map((s) => s.trim())) { if (!part || !fs.existsSync(part)) continue; const stat = fs.statSync(part); if (stat.isDirectory()) { const entries = fs.readdirSync(part).sort(); for (const entry of entries) { if (entry.endsWith('.json')) { filePaths.push(path.join(part, entry)); } } } else if (stat.isFile()) { filePaths.push(part); } } if (filePaths.length === 0) { console.error(`::error::No CVE JSON report files found for path: ${arg}`); process.exit(1); } let loadedFiles = 0; for (const filePath of filePaths) { let stat; try { stat = fs.statSync(filePath); } catch (e) { console.error(`::error::Failed to access report file ${filePath}: ${e.message}`); process.exit(1); } if (stat.size === 0) { console.error(`::error::CVE report file is empty (0 bytes): ${filePath}`); process.exit(1); } const baseName = path.basename(filePath).toLowerCase(); let arch = 'all'; if (baseName.includes('amd64')) { arch = 'amd64'; } else if (baseName.includes('arm64') || baseName.includes('aarch64')) { arch = 'arm64'; } let data; try { const content = fs.readFileSync(filePath, 'utf8'); data = JSON.parse(content); } catch (e) { console.error(`::error::Invalid JSON in CVE report file ${filePath}: ${e.message}`); process.exit(1); } if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) { console.error(`::error::Malformed Trivy report structure (missing Results array) in: ${filePath}`); process.exit(1); } loadedFiles++; for (const result of data.Results) { const target = result.Target || result.Type || 'App'; const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : []; for (const v of vulns) { const vid = v.VulnerabilityID || ''; const pkg = v.PkgName || ''; if (!vid || !pkg) continue; const key = `${vid}:::${pkg}`; if (!cves.has(key)) { cves.set(key, { ...v, _target: target, _archs: new Set([arch]) }); } else { cves.get(key)._archs.add(arch); } } } } if (loadedFiles === 0) { console.error(`::error::No valid CVE reports could be loaded for path: ${arg}`); process.exit(1); } return cves; } /** * Formats a vulnerability record into a Markdown table row. * @param {object} v * @returns {string} */ function formatVulnRow(v) { const sev = v.Severity || 'UNKNOWN'; const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`; const vid = v.VulnerabilityID || ''; const url = v.PrimaryURL || ''; const link = url ? `[${vid}](${url})` : vid; const pkg = v.PkgName || ''; const archs = v._archs || new Set(['all']); let archBadge; if ((archs.has('amd64') && archs.has('arm64')) || archs.has('all')) { archBadge = '`all`'; } else { archBadge = `\`${Array.from(archs).sort().join(', ')}\``; } const installed = v.InstalledVersion || '-'; const fixed = v.FixedVersion || '-'; let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim(); if (title.length > 80) { title = title.slice(0, 77) + '...'; } return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`; } /** * Compares base and branch vulnerabilities and formats a Markdown diff. * @param {Map} baseCves * @param {Map} branchCves * @param {string} baseName * @returns {string} */ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { const baseKeys = new Set(baseCves.keys()); const branchKeys = new Set(branchCves.keys()); if (branchCves.size === 0) { console.error('::warning::Head image contains 0 reported vulnerabilities. Verify that packages and dependencies were recognized.'); } if (baseCves.size === 0) { console.error(`::warning::Baseline target (${baseName}) contains 0 reported vulnerabilities.`); } const newKeys = []; let unchangedCount = 0; for (const key of branchKeys) { if (!baseKeys.has(key)) { newKeys.push(key); } else { unchangedCount++; } } const resolvedKeys = []; for (const key of baseKeys) { if (!branchKeys.has(key)) { resolvedKeys.push(key); } } function sortVulns(a, b) { const aSev = a.Severity || 'UNKNOWN'; const bSev = b.Severity || 'UNKNOWN'; const aIdx = SEVERITY_ORDER.indexOf(aSev) !== -1 ? SEVERITY_ORDER.indexOf(aSev) : 99; const bIdx = SEVERITY_ORDER.indexOf(bSev) !== -1 ? SEVERITY_ORDER.indexOf(bSev) : 99; if (aIdx !== bIdx) return aIdx - bIdx; const aPkg = a.PkgName || ''; const bPkg = b.PkgName || ''; return aPkg.localeCompare(bPkg); } const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns); const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns); const lines = [ `### 🔄 Vulnerability Delta (vs \`${baseName}\`)`, '', ]; if (branchCves.size === 0 && baseCves.size === 0) { lines.push('> [!WARNING]'); lines.push('> Both the head image and baseline target have **0 reported vulnerabilities**.'); lines.push(''); } else if (branchCves.size === 0) { lines.push('> [!WARNING]'); lines.push('> The head image has **0 reported vulnerabilities**. Verify that OS packages and language dependencies were properly indexed in the SBOM.'); lines.push(''); } else if (baseCves.size === 0) { lines.push('> [!WARNING]'); lines.push(`> The baseline image (\`${baseName}\`) has **0 reported vulnerabilities**.`); lines.push(''); } lines.push('| Metric | Count |'); lines.push('| :--- | :--- |'); lines.push(`| 🚨 **New Vulnerabilities** | ${newVulns.length > 0 ? `🚨 **${newVulns.length}**` : '0'} |`); lines.push(`| ✅ **Resolved Vulnerabilities** | ${resolvedVulns.length > 0 ? `✅ **${resolvedVulns.length}**` : '0'} |`); lines.push(`| ⚪ **Unchanged Vulnerabilities** | ${unchangedCount} |`); lines.push(''); lines.push(`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`); if (newVulns.length > 0) { lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |'); lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); for (const v of newVulns) { lines.push(formatVulnRow(v)); } } else { lines.push('✅ No new vulnerabilities introduced in this build.'); } lines.push(''); lines.push(`#### ✅ Resolved / Removed Vulnerabilities (${resolvedVulns.length})`); if (resolvedVulns.length > 0) { lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |'); lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); for (const v of resolvedVulns) { lines.push(formatVulnRow(v)); } } else { lines.push('⚪ No existing vulnerabilities were resolved.'); } lines.push(''); return lines.join('\n'); } function main() { const args = process.argv.slice(2); if (args.length < 2) { console.error('Usage: cve-diff.js [base_name]'); process.exit(1); } const baseArg = args[0]; const branchArg = args[1]; const baseName = args[2] || 'main'; const baseCves = loadReportsForArg(baseArg); const branchCves = loadReportsForArg(branchArg); const output = renderMarkdownDiff(baseCves, branchCves, baseName); console.log(output); } if (require.main === module) { main(); } module.exports = { loadReportsForArg, formatVulnRow, renderMarkdownDiff, };