Public Access
generated from Philipp/template-git
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.
203 lines
6.5 KiB
JavaScript
203 lines
6.5 KiB
JavaScript
#!/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,
|
||
};
|