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,106 @@
|
||||
/**
|
||||
* Diagnostic formatting and reporting utilities.
|
||||
*/
|
||||
|
||||
function formatSbomOverview({
|
||||
headImage,
|
||||
targetImage,
|
||||
headFiles = [],
|
||||
targetFiles = [],
|
||||
headError = null,
|
||||
targetError = null,
|
||||
}) {
|
||||
const lines = [
|
||||
'',
|
||||
'================================================================================',
|
||||
' SBOM EXTRACTION DIAGNOSTIC OVERVIEW ',
|
||||
'================================================================================',
|
||||
];
|
||||
|
||||
if (headError) {
|
||||
lines.push(`HEAD Image (${headImage}): ❌ FAILED - ${headError}`);
|
||||
} else {
|
||||
lines.push(`HEAD Image (${headImage}): ✅ SUCCESS`);
|
||||
for (const f of headFiles) {
|
||||
lines.push(` - ${f.name} (${f.size} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetError) {
|
||||
lines.push(`TARGET Baseline Image (${targetImage}): ❌ FAILED - ${targetError}`);
|
||||
} else {
|
||||
lines.push(`TARGET Baseline Image (${targetImage}): ✅ SUCCESS`);
|
||||
for (const f of targetFiles) {
|
||||
lines.push(` - ${f.name} (${f.size} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('================================================================================');
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatScanOverview({
|
||||
headImage,
|
||||
targetImage,
|
||||
targetBranch = 'main',
|
||||
strategy = null,
|
||||
headSummary = [],
|
||||
targetSummary = [],
|
||||
}) {
|
||||
const lines = [
|
||||
'',
|
||||
'================================================================================',
|
||||
' CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW ',
|
||||
'================================================================================',
|
||||
];
|
||||
|
||||
if (strategy) {
|
||||
lines.push(`Scan Strategy: ${strategy}`);
|
||||
lines.push('--------------------------------------------------------------------------------');
|
||||
}
|
||||
|
||||
lines.push(`HEAD Image: ${headImage}`);
|
||||
for (const line of headSummary) {
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`TARGET Baseline Image: ${targetImage} (branch: '${targetBranch}')`);
|
||||
for (const line of targetSummary) {
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
lines.push('================================================================================');
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function countVulnerabilities(report) {
|
||||
if (!report) return 0;
|
||||
let data = report;
|
||||
if (typeof report === 'string' || Buffer.isBuffer(report)) {
|
||||
try {
|
||||
data = JSON.parse(report.toString('utf8'));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (!data || typeof data !== 'object') return 0;
|
||||
|
||||
let count = 0;
|
||||
if (Array.isArray(data.Results)) {
|
||||
for (const res of data.Results) {
|
||||
if (res && Array.isArray(res.Vulnerabilities)) {
|
||||
count += res.Vulnerabilities.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatSbomOverview,
|
||||
formatScanOverview,
|
||||
countVulnerabilities,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Distribution detection from SPDX SBOM package PURLs.
|
||||
*/
|
||||
|
||||
function detectDistro(spdxInput) {
|
||||
if (!spdxInput) return null;
|
||||
|
||||
let data = spdxInput;
|
||||
if (typeof spdxInput === 'string' || Buffer.isBuffer(spdxInput)) {
|
||||
try {
|
||||
data = JSON.parse(spdxInput.toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object' || !Array.isArray(data.packages)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const pkg of data.packages) {
|
||||
if (!pkg || !Array.isArray(pkg.externalRefs)) continue;
|
||||
for (const ref of pkg.externalRefs) {
|
||||
if (ref && ref.referenceType === 'purl' && typeof ref.referenceLocator === 'string') {
|
||||
const purl = ref.referenceLocator;
|
||||
const nameMatch = purl.match(/[?&]os_name=([^&]+)/);
|
||||
const verMatch = purl.match(/[?&]os_version=([^&]+)/);
|
||||
if (nameMatch && verMatch) {
|
||||
const osName = decodeURIComponent(nameMatch[1]);
|
||||
const osVer = decodeURIComponent(verMatch[1]);
|
||||
return `${osName}/${osVer}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectDistro,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Installs and verifies Trivy binary against checksums lockfile.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const cp = require('node:child_process');
|
||||
const { mapArch, parseChecksums, verifyFileSha256 } = require('./verifier.js');
|
||||
|
||||
function main() {
|
||||
const version = process.env.TRIVY_VERSION || '0.74.0';
|
||||
const homeDir = os.homedir();
|
||||
const binDir = path.join(homeDir, '.local', 'bin');
|
||||
const cacheDir = path.join(homeDir, '.cache', 'trivy-bin');
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
// Expose bin directory to GITHUB_PATH if running in Actions runner
|
||||
if (process.env.GITHUB_PATH) {
|
||||
fs.appendFileSync(process.env.GITHUB_PATH, `${binDir}\n`);
|
||||
}
|
||||
|
||||
const unameArch = os.arch() === 'x64' ? 'x86_64' : (os.arch() === 'arm64' ? 'ARM64' : os.arch());
|
||||
const tArch = mapArch(unameArch);
|
||||
const tarball = `trivy_${version}_Linux-${tArch}.tar.gz`;
|
||||
const tarballPath = path.join(cacheDir, tarball);
|
||||
|
||||
const checksumsFile = path.resolve(__dirname, '..', 'checksums.txt');
|
||||
if (!fs.existsSync(checksumsFile)) {
|
||||
console.error(`::error::Checksums lockfile not found at ${checksumsFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const checksums = parseChecksums(fs.readFileSync(checksumsFile, 'utf8'));
|
||||
const expectedHash = checksums.get(tarball);
|
||||
if (!expectedHash) {
|
||||
console.error(`::error::Tarball ${tarball} not found in checksums.txt`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fs.existsSync(tarballPath) && verifyFileSha256(tarballPath, expectedHash)) {
|
||||
console.log('Cached Trivy archive verified against lockfile.');
|
||||
} else {
|
||||
console.log('Archive missing or checksum mismatch; downloading...');
|
||||
const downloadUrl = `https://github.com/aquasecurity/trivy/releases/download/v${version}/${tarball}`;
|
||||
cp.execFileSync('curl', ['-sfLO', downloadUrl, '--output-dir', cacheDir], { stdio: 'inherit' });
|
||||
|
||||
if (!verifyFileSha256(tarballPath, expectedHash)) {
|
||||
console.error(`::error::Downloaded archive ${tarball} failed SHA256 checksum verification!`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Downloaded Trivy archive verified successfully.');
|
||||
}
|
||||
|
||||
// Extract binary
|
||||
cp.execFileSync('tar', ['-xzf', tarballPath, '-C', binDir, 'trivy'], { stdio: 'inherit' });
|
||||
const trivyBin = path.join(binDir, 'trivy');
|
||||
if (fs.existsSync(trivyBin)) {
|
||||
fs.chmodSync(trivyBin, 0o755);
|
||||
console.log(`Trivy ${version} installed and verified successfully at ${trivyBin}`);
|
||||
} else {
|
||||
console.error('::error::Failed to extract trivy binary from tarball.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Platform and architecture resolution utilities.
|
||||
*/
|
||||
|
||||
function parsePlatforms(input) {
|
||||
if (!input || typeof input !== 'string') return [];
|
||||
const parts = input.split(',');
|
||||
const platforms = [];
|
||||
for (const part of parts) {
|
||||
const clean = part.trim();
|
||||
if (!clean) continue;
|
||||
if (!clean.includes('/')) {
|
||||
platforms.push(`linux/${clean}`);
|
||||
} else {
|
||||
platforms.push(clean);
|
||||
}
|
||||
}
|
||||
return platforms;
|
||||
}
|
||||
|
||||
function discoverPlatforms(headRawJson, targetRawJson) {
|
||||
const set = new Set();
|
||||
for (const raw of [headRawJson, targetRawJson]) {
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
const manifests = data.manifests || [];
|
||||
for (const m of manifests) {
|
||||
if (m.platform && m.platform.architecture && m.platform.architecture !== 'unknown') {
|
||||
const os = m.platform.os || 'linux';
|
||||
const variant = m.platform.variant ? `/${m.platform.variant}` : '';
|
||||
set.add(`${os}/${m.platform.architecture}${variant}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors from invalid manifests
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
function toArchTag(platform) {
|
||||
if (!platform) return 'default';
|
||||
// Strip optional leading os/ (e.g. linux/)
|
||||
const withoutOs = platform.replace(/^[a-zA-Z0-9_-]+\//, '');
|
||||
// Replace slashes with dashes (e.g. arm/v7 -> arm-v7)
|
||||
return withoutOs.replace(/\//g, '-');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parsePlatforms,
|
||||
discoverPlatforms,
|
||||
toArchTag,
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Scans multi-platform SBOMs with Trivy, handles distro detection and fallbacks,
|
||||
* prints diagnostic overview, and invokes diff engine.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
const { detectDistro } = require('./distro.js');
|
||||
const { formatScanOverview, countVulnerabilities } = require('./diagnostics.js');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
headImage: process.env.INPUT_HEAD_IMAGE || '',
|
||||
targetImage: process.env.INPUT_TARGET_IMAGE || '',
|
||||
targetBranch: process.env.INPUT_TARGET_BRANCH || 'main',
|
||||
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
|
||||
outputDelta: process.env.INPUT_OUTPUT_DELTA || 'cve-delta.md',
|
||||
outputSummary: process.env.INPUT_OUTPUT_SUMMARY || 'cve-summary.md',
|
||||
};
|
||||
|
||||
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 === '--target-branch' && i + 1 < argv.length) args.targetBranch = argv[++i];
|
||||
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
|
||||
else if (arg === '--output-delta' && i + 1 < argv.length) args.outputDelta = argv[++i];
|
||||
else if (arg === '--output-summary' && i + 1 < argv.length) args.outputSummary = argv[++i];
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function scanSbomFile({ sbomPath, image, outJson, errLog, useInbuild }) {
|
||||
const archMatch = path.basename(sbomPath, '.spdx.json').match(/^sbom-(.+)$/);
|
||||
const arch = archMatch ? archMatch[1] : 'default';
|
||||
|
||||
console.log(`Scanning SBOM for architecture: ${arch}`);
|
||||
let distroArg = [];
|
||||
if (useInbuild) {
|
||||
try {
|
||||
const content = fs.readFileSync(sbomPath, 'utf8');
|
||||
const detected = detectDistro(content);
|
||||
if (detected) {
|
||||
console.log(`Detected distribution '${detected}' from in-build SBOM PURLs. Scanning with --distro.`);
|
||||
distroArg = ['--distro', detected];
|
||||
}
|
||||
} catch {
|
||||
// Ignore read error
|
||||
}
|
||||
}
|
||||
|
||||
const runTrivySbom = () => {
|
||||
try {
|
||||
const errFd = fs.openSync(errLog, 'w');
|
||||
try {
|
||||
cp.execFileSync('trivy', [
|
||||
'sbom',
|
||||
...distroArg,
|
||||
'--skip-version-check',
|
||||
'--format', 'json',
|
||||
'--output', outJson,
|
||||
sbomPath,
|
||||
], { stdio: ['ignore', 'ignore', errFd] });
|
||||
return true;
|
||||
} finally {
|
||||
fs.closeSync(errFd);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let scanSuccess = runTrivySbom();
|
||||
let vulnCount = 0;
|
||||
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
||||
}
|
||||
|
||||
// Fallback if in-build scan produced 0 findings or failed
|
||||
if (useInbuild && (!scanSuccess || vulnCount === 0)) {
|
||||
console.warn(`::warning::In-build SBOM for image (${arch}) yielded ${vulnCount} CVE findings. Falling back to direct Trivy image scan...`);
|
||||
const platArgs = arch !== 'default' ? ['--platform', `linux/${arch}`] : [];
|
||||
try {
|
||||
cp.execFileSync('trivy', [
|
||||
'image',
|
||||
'--skip-version-check',
|
||||
...platArgs,
|
||||
'--format', 'spdx-json',
|
||||
'--output', sbomPath,
|
||||
image,
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
console.log('Re-scanning SBOM generated directly by Trivy...');
|
||||
// Re-scan without external distroArg (Trivy's generated SBOM has OS declarations)
|
||||
distroArg = [];
|
||||
scanSuccess = runTrivySbom();
|
||||
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`::warning::Direct Trivy scan fallback failed for ${arch}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { arch, scanSuccess, vulnCount, outJson, errLog };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const {
|
||||
headImage,
|
||||
targetImage,
|
||||
targetBranch,
|
||||
useInbuildSbom,
|
||||
outputDelta,
|
||||
outputSummary,
|
||||
} = args;
|
||||
const useInbuild = useInbuildSbom === 'true';
|
||||
|
||||
fs.mkdirSync(path.join('cve-reports', 'head'), { recursive: true });
|
||||
fs.mkdirSync(path.join('cve-reports', 'target'), { recursive: true });
|
||||
|
||||
const getSboms = (dir) => {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
|
||||
.sort()
|
||||
.map((f) => path.join(dir, f));
|
||||
};
|
||||
|
||||
const headSboms = getSboms(path.join('sboms', 'head'));
|
||||
const targetSboms = getSboms(path.join('sboms', 'target'));
|
||||
|
||||
let headOk = true;
|
||||
let targetOk = true;
|
||||
let headReportCount = 0;
|
||||
let targetReportCount = 0;
|
||||
const headSummary = [];
|
||||
const targetSummary = [];
|
||||
|
||||
// 1. Scan Head SBOMs
|
||||
if (headSboms.length === 0) {
|
||||
headOk = false;
|
||||
headSummary.push(' - ❌ No SBOM files found in sboms/head/');
|
||||
} else {
|
||||
for (const f of headSboms) {
|
||||
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
||||
const outJson = path.join('cve-reports', 'head', `cve-${arch}.json`);
|
||||
const errLog = path.join('cve-reports', 'head', `cve-${arch}.err`);
|
||||
|
||||
const res = scanSbomFile({
|
||||
sbomPath: f,
|
||||
image: headImage,
|
||||
outJson,
|
||||
errLog,
|
||||
useInbuild,
|
||||
});
|
||||
|
||||
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||
if (res.vulnCount === 0) {
|
||||
console.warn(`::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
||||
headSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
||||
} else {
|
||||
headSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
||||
}
|
||||
headReportCount++;
|
||||
} else {
|
||||
headOk = false;
|
||||
let errMsg = 'Trivy scan exited with non-zero status';
|
||||
if (fs.existsSync(errLog)) {
|
||||
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
||||
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
||||
}
|
||||
headSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan Target SBOMs
|
||||
if (targetSboms.length === 0) {
|
||||
targetOk = false;
|
||||
targetSummary.push(' - ❌ No SBOM files found in sboms/target/');
|
||||
} else {
|
||||
for (const f of targetSboms) {
|
||||
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
|
||||
const outJson = path.join('cve-reports', 'target', `cve-${arch}.json`);
|
||||
const errLog = path.join('cve-reports', 'target', `cve-${arch}.err`);
|
||||
|
||||
const res = scanSbomFile({
|
||||
sbomPath: f,
|
||||
image: targetImage,
|
||||
outJson,
|
||||
errLog,
|
||||
useInbuild,
|
||||
});
|
||||
|
||||
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
|
||||
if (res.vulnCount === 0) {
|
||||
console.warn(`::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
|
||||
targetSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
|
||||
} else {
|
||||
targetSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
|
||||
}
|
||||
targetReportCount++;
|
||||
} else {
|
||||
targetOk = false;
|
||||
let errMsg = 'Trivy scan exited with non-zero status';
|
||||
if (fs.existsSync(errLog)) {
|
||||
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
|
||||
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
|
||||
}
|
||||
targetSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Print Comprehensive Diagnostic Overview
|
||||
const strategy = useInbuild
|
||||
? 'Built-in OCI Attestation (Syft/Docker Scout) with distro detection'
|
||||
: 'Direct Trivy Image Scan (Full rootfs & source package mapping)';
|
||||
|
||||
const overview = formatScanOverview({
|
||||
headImage,
|
||||
targetImage,
|
||||
targetBranch,
|
||||
strategy,
|
||||
headSummary,
|
||||
targetSummary,
|
||||
});
|
||||
console.log(overview);
|
||||
|
||||
// 4. Enforce Gating
|
||||
let hasError = false;
|
||||
if (!headOk || headReportCount === 0) {
|
||||
console.error('::error::Head image CVE analysis failed or produced no reports.');
|
||||
hasError = true;
|
||||
}
|
||||
if (!targetOk || targetReportCount === 0) {
|
||||
console.error('::error::Target baseline CVE analysis failed or produced no reports.');
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
console.error('::error::Aborting differential analysis: both head and target scans must succeed before diffing.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 5. Run multi-arch set-based diff and generate reports
|
||||
console.log('Both head and target scans succeeded. Computing set-based vulnerability delta...');
|
||||
const cveDiffScript = path.resolve(__dirname, '..', 'cve-diff.js');
|
||||
try {
|
||||
const deltaOutput = cp.execFileSync(process.execPath, [
|
||||
cveDiffScript,
|
||||
path.join('cve-reports', 'target'),
|
||||
path.join('cve-reports', 'head'),
|
||||
targetBranch,
|
||||
outputSummary,
|
||||
], { encoding: 'utf8' });
|
||||
|
||||
fs.writeFileSync(outputDelta, deltaOutput, 'utf8');
|
||||
console.log(`CVE delta written to ${outputDelta} and full summary written to ${outputSummary}`);
|
||||
} catch (e) {
|
||||
console.error(`::error::Failed to generate CVE differential report: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseArgs,
|
||||
scanSbomFile,
|
||||
main,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Architecture mapping and cryptographic checksum verification.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
function mapArch(unameArch) {
|
||||
const normalized = (unameArch || '').trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'x86_64':
|
||||
case 'amd64':
|
||||
return '64bit';
|
||||
case 'aarch64':
|
||||
case 'arm64':
|
||||
return 'ARM64';
|
||||
default:
|
||||
throw new Error(`Unsupported architecture: ${unameArch}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseChecksums(checksumsContent) {
|
||||
const map = new Map();
|
||||
if (!checksumsContent) return map;
|
||||
|
||||
const lines = checksumsContent.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
// Format: <hash> <filename>
|
||||
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+(.+)$/);
|
||||
if (match) {
|
||||
const hash = match[1].toLowerCase();
|
||||
const filename = match[2].trim();
|
||||
map.set(filename, hash);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function verifyFileSha256(filePath, expectedHash) {
|
||||
if (!fs.existsSync(filePath) || !expectedHash) return false;
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
return actualHash.toLowerCase() === expectedHash.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mapArch,
|
||||
parseChecksums,
|
||||
verifyFileSha256,
|
||||
};
|
||||
Reference in new issue
Block a user