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,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,
|
||||
};
|
||||
Reference in new issue
Block a user