Files
cve-differential/lib/extract-sboms.js
T
Philipp 77357f0c3f
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
refactor(action): replace bash blocks with zero-dependency Node.js modules
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.
2026-09-15 00:39:13 +02:00

203 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Extracts or generates multi-platform SBOMs for head and target images.
*/
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');
const { parsePlatforms, discoverPlatforms, toArchTag } = require('./platforms.js');
const { formatSbomOverview } = require('./diagnostics.js');
function parseArgs(argv) {
const args = {
headImage: process.env.INPUT_HEAD_IMAGE || '',
targetImage: process.env.INPUT_TARGET_IMAGE || '',
architectures: process.env.INPUT_ARCHITECTURES || '',
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
};
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 === '--architectures' && i + 1 < argv.length) args.architectures = argv[++i];
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
}
return args;
}
function cleanupInvalidFile(filePath) {
if (!fs.existsSync(filePath)) return;
try {
const stat = fs.statSync(filePath);
if (stat.size === 0) {
fs.unlinkSync(filePath);
return;
}
const content = fs.readFileSync(filePath, 'utf8').trim();
if (content === 'null' || content === '') {
fs.unlinkSync(filePath);
}
} catch {
// Ignore cleanup errors
}
}
function runCommandSafe(cmd, args) {
try {
return cp.execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
} catch {
return null;
}
}
function extractOrGenerateSbom({ image, platform, archTag, outputDir, useInbuild }) {
const outputFile = path.join(outputDir, `sbom-${archTag}.spdx.json`);
const inbuildFile = path.join(outputDir, `inbuild-sbom-${archTag}.spdx.json`);
// 1. Attempt in-build SBOM extraction if requested
if (useInbuild) {
console.log(`Attempting extraction of built-in SBOM for image (${platform})...`);
const formatStr = `{{ json (index .SBOM "${platform}").SPDX }}`;
const rawSbom = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', image, '--format', formatStr]);
if (rawSbom) {
fs.writeFileSync(outputFile, rawSbom);
cleanupInvalidFile(outputFile);
if (fs.existsSync(outputFile)) {
fs.copyFileSync(outputFile, inbuildFile);
}
}
}
// 2. Fallback to direct Trivy image generation if SBOM not present
if (!fs.existsSync(outputFile)) {
console.log(`Generating SBOM for image (${platform}) with Trivy...`);
const platArgs = platform ? ['--platform', platform] : [];
try {
cp.execFileSync('trivy', [
'image',
'--skip-version-check',
...platArgs,
'--format', 'spdx-json',
'--output', outputFile,
image,
], { stdio: 'inherit' });
} catch (e) {
console.warn(`::warning::Trivy image scan failed for image (${platform}): ${e.message}`);
}
cleanupInvalidFile(outputFile);
}
}
function main() {
const args = parseArgs(process.argv);
const { headImage, targetImage, architectures, useInbuildSbom } = args;
const useInbuild = useInbuildSbom === 'true';
if (!headImage || !targetImage) {
console.error('::error::Both --head-image and --target-image must be provided.');
process.exit(1);
}
const headDir = path.join('sboms', 'head');
const targetDir = path.join('sboms', 'target');
fs.mkdirSync(headDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
// Determine platforms
let platforms = parsePlatforms(architectures);
if (platforms.length === 0) {
console.log('Auto-discovering architectures from images...');
const rawHead = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', headImage]);
const rawTarget = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', targetImage]);
platforms = discoverPlatforms(rawHead, rawTarget);
}
console.log(`Configured platforms: ${platforms.length > 0 ? platforms.join(', ') : '(single platform fallback)'}`);
if (useInbuild) {
console.log('ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed.');
console.log('ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans (\'trivy image\') due to binary vs. source package tracking.');
} else {
console.log('ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity.');
}
const targetPlatforms = platforms.length > 0 ? platforms : [''];
for (const platform of targetPlatforms) {
const archTag = toArchTag(platform);
// Head extraction
extractOrGenerateSbom({
image: headImage,
platform,
archTag,
outputDir: headDir,
useInbuild,
});
// Target extraction
extractOrGenerateSbom({
image: targetImage,
platform,
archTag,
outputDir: targetDir,
useInbuild,
});
}
// Collect generated files
const getSbomFiles = (dir) => {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
.map((f) => {
const fullPath = path.join(dir, f);
return { name: f, size: fs.statSync(fullPath).size };
});
};
const headFiles = getSbomFiles(headDir);
const targetFiles = getSbomFiles(targetDir);
let headError = null;
let targetError = null;
if (headFiles.length === 0) headError = 'No SBOM generated and head image could not be inspected';
if (targetFiles.length === 0) targetError = 'No SBOM generated and target baseline image could not be inspected';
const overview = formatSbomOverview({
headImage,
targetImage,
headFiles,
targetFiles,
headError,
targetError,
});
console.log(overview);
if (headError || targetError) {
console.error('::error::Both head and target images must have valid SBOMs to proceed with differential scanning.');
process.exit(1);
}
// Expose extracted head SBOMs to workspace root for workflow artifact uploads
for (const file of headFiles) {
try {
fs.copyFileSync(path.join(headDir, file.name), file.name);
} catch {
// Ignore copy error
}
}
}
if (require.main === module) {
main();
}
module.exports = {
parseArgs,
cleanupInvalidFile,
extractOrGenerateSbom,
main,
};