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

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:34:23 +02:00
1 parent 6643b7449e
commit 599b74ef49
13 files changed
+1145 -347

No files matched your search

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