refactor(action): replace bash blocks with zero-dependency Node.js modules
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.
This commit is contained in:
Philipp committed 2026-09-15 00:39:13 +02:00
1 parent 0ccfd5f512
commit 77357f0c3f
13 files changed
+1144 -468

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
#!/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,
};