#!/usr/bin/env node /** * Scans multi-platform SBOMs with Trivy, handles distro detection and fallbacks, * prints diagnostic overview, and invokes diff engine. */ const fs = require('node:fs'); const path = require('node:path'); const cp = require('node:child_process'); const { detectDistro } = require('./distro.js'); const { formatScanOverview, countVulnerabilities } = require('./diagnostics.js'); function parseArgs(argv) { const args = { headImage: process.env.INPUT_HEAD_IMAGE || '', targetImage: process.env.INPUT_TARGET_IMAGE || '', targetBranch: process.env.INPUT_TARGET_BRANCH || 'main', useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false', outputDelta: process.env.INPUT_OUTPUT_DELTA || 'cve-delta.md', outputSummary: process.env.INPUT_OUTPUT_SUMMARY || 'cve-summary.md', }; for (let i = 2; i < argv.length; i++) { const arg = argv[i]; if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i]; else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i]; else if (arg === '--target-branch' && i + 1 < argv.length) args.targetBranch = argv[++i]; else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i]; else if (arg === '--output-delta' && i + 1 < argv.length) args.outputDelta = argv[++i]; else if (arg === '--output-summary' && i + 1 < argv.length) args.outputSummary = argv[++i]; } return args; } function scanSbomFile({ sbomPath, image, outJson, errLog, useInbuild }) { const archMatch = path.basename(sbomPath, '.spdx.json').match(/^sbom-(.+)$/); const arch = archMatch ? archMatch[1] : 'default'; console.log(`Scanning SBOM for architecture: ${arch}`); let distroArg = []; if (useInbuild) { try { const content = fs.readFileSync(sbomPath, 'utf8'); const detected = detectDistro(content); if (detected) { console.log(`Detected distribution '${detected}' from in-build SBOM PURLs. Scanning with --distro.`); distroArg = ['--distro', detected]; } } catch { // Ignore read error } } const runTrivySbom = () => { try { const errFd = fs.openSync(errLog, 'w'); try { cp.execFileSync('trivy', [ 'sbom', ...distroArg, '--skip-version-check', '--format', 'json', '--output', outJson, sbomPath, ], { stdio: ['ignore', 'ignore', errFd] }); return true; } finally { fs.closeSync(errFd); } } catch { return false; } }; let scanSuccess = runTrivySbom(); let vulnCount = 0; if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) { vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8')); } // Fallback if in-build scan produced 0 findings or failed if (useInbuild && (!scanSuccess || vulnCount === 0)) { console.warn(`::warning::In-build SBOM for image (${arch}) yielded ${vulnCount} CVE findings. Falling back to direct Trivy image scan...`); const platArgs = arch !== 'default' ? ['--platform', `linux/${arch}`] : []; try { cp.execFileSync('trivy', [ 'image', '--skip-version-check', ...platArgs, '--format', 'spdx-json', '--output', sbomPath, image, ], { stdio: 'inherit' }); console.log('Re-scanning SBOM generated directly by Trivy...'); // Re-scan without external distroArg (Trivy's generated SBOM has OS declarations) distroArg = []; scanSuccess = runTrivySbom(); if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) { vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8')); } } catch (e) { console.warn(`::warning::Direct Trivy scan fallback failed for ${arch}: ${e.message}`); } } return { arch, scanSuccess, vulnCount, outJson, errLog }; } function main() { const args = parseArgs(process.argv); const { headImage, targetImage, targetBranch, useInbuildSbom, outputDelta, outputSummary, } = args; const useInbuild = useInbuildSbom === 'true'; fs.mkdirSync(path.join('cve-reports', 'head'), { recursive: true }); fs.mkdirSync(path.join('cve-reports', 'target'), { recursive: true }); const getSboms = (dir) => { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir) .filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json')) .sort() .map((f) => path.join(dir, f)); }; const headSboms = getSboms(path.join('sboms', 'head')); const targetSboms = getSboms(path.join('sboms', 'target')); let headOk = true; let targetOk = true; let headReportCount = 0; let targetReportCount = 0; const headSummary = []; const targetSummary = []; // 1. Scan Head SBOMs if (headSboms.length === 0) { headOk = false; headSummary.push(' - ❌ No SBOM files found in sboms/head/'); } else { for (const f of headSboms) { const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, ''); const outJson = path.join('cve-reports', 'head', `cve-${arch}.json`); const errLog = path.join('cve-reports', 'head', `cve-${arch}.err`); const res = scanSbomFile({ sbomPath: f, image: headImage, outJson, errLog, useInbuild, }); if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) { if (res.vulnCount === 0) { console.warn(`::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`); headSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`); } else { headSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`); } headReportCount++; } else { headOk = false; let errMsg = 'Trivy scan exited with non-zero status'; if (fs.existsSync(errLog)) { const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n'); errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg; } headSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`); } } } // 2. Scan Target SBOMs if (targetSboms.length === 0) { targetOk = false; targetSummary.push(' - ❌ No SBOM files found in sboms/target/'); } else { for (const f of targetSboms) { const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, ''); const outJson = path.join('cve-reports', 'target', `cve-${arch}.json`); const errLog = path.join('cve-reports', 'target', `cve-${arch}.err`); const res = scanSbomFile({ sbomPath: f, image: targetImage, outJson, errLog, useInbuild, }); if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) { if (res.vulnCount === 0) { console.warn(`::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`); targetSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`); } else { targetSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`); } targetReportCount++; } else { targetOk = false; let errMsg = 'Trivy scan exited with non-zero status'; if (fs.existsSync(errLog)) { const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n'); errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg; } targetSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`); } } } // 3. Print Comprehensive Diagnostic Overview const strategy = useInbuild ? 'Built-in OCI Attestation (Syft/Docker Scout) with distro detection' : 'Direct Trivy Image Scan (Full rootfs & source package mapping)'; const overview = formatScanOverview({ headImage, targetImage, targetBranch, strategy, headSummary, targetSummary, }); console.log(overview); // 4. Enforce Gating let hasError = false; if (!headOk || headReportCount === 0) { console.error('::error::Head image CVE analysis failed or produced no reports.'); hasError = true; } if (!targetOk || targetReportCount === 0) { console.error('::error::Target baseline CVE analysis failed or produced no reports.'); hasError = true; } if (hasError) { console.error('::error::Aborting differential analysis: both head and target scans must succeed before diffing.'); process.exit(1); } // 5. Run multi-arch set-based diff and generate reports console.log('Both head and target scans succeeded. Computing set-based vulnerability delta...'); const cveDiffScript = path.resolve(__dirname, '..', 'cve-diff.js'); try { const deltaOutput = cp.execFileSync(process.execPath, [ cveDiffScript, path.join('cve-reports', 'target'), path.join('cve-reports', 'head'), targetBranch, outputSummary, ], { encoding: 'utf8' }); fs.writeFileSync(outputDelta, deltaOutput, 'utf8'); console.log(`CVE delta written to ${outputDelta} and full summary written to ${outputSummary}`); } catch (e) { console.error(`::error::Failed to generate CVE differential report: ${e.message}`); process.exit(1); } } if (require.main === module) { main(); } module.exports = { parseArgs, scanSbomFile, main, };