Public Access
generated from Philipp/template-git
CI Tests / JS Unit Tests (pull_request) Successful in 46s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m54s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Failing after 3m22s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Failing after 2m38s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Failing after 4m21s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Failing after 5m36s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Failing after 6m47s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Failing after 2m59s
Extract ~450 lines of complex bash from action.yaml into modular, unit-tested Node.js scripts in lib/. All logic is covered by 26 native node:test unit tests (node --test, no external dependencies). Modules added: - lib/platforms.js - platform parsing and OCI manifest auto-discovery - lib/distro.js - SPDX PURL distro detection (os_name/os_version) - lib/verifier.js - Trivy arch mapping and SHA256 verification - lib/diagnostics.js - CI terminal diagnostic formatters - lib/install-trivy.js - Trivy install/verify entrypoint (replaces bash step) - lib/extract-sboms.js - SBOM extraction entrypoint (replaces bash step) - lib/scan-sboms.js - SBOM scan + diff entrypoint (replaces bash step) Tests added (all pass, auto-discovered by ode --test): - test/platforms.test.js - test/distro.test.js - test/verifier.test.js - test/diagnostics.test.js - test/orchestration.test.js action.yaml shrinks from 554 to 107 lines. Inputs are bridged to Node scripts via INPUT_* env vars so modules are fully decoupled from the Actions runner and testable in isolation.
281 lines
9.3 KiB
JavaScript
281 lines
9.3 KiB
JavaScript
#!/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,
|
|
};
|