Public Access
generated from Philipp/template-git
refactor(action): replace bash blocks with zero-dependency Node.js modules
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m53s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m53s
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:
13 files changed
+1145
-347
No files matched your search
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user