#!/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, };