feat: support dynamic multi-architecture discovery and iteration

This commit is contained in:
Philipp committed 2026-09-13 22:42:40 +02:00
1 parent abe45ffb02
commit e232a22547
3 files changed
+166 -66

No files matched your search

+5 -4
View File
@@ -6,9 +6,9 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
## ✨ Features ## ✨ Features
- **Multi-Platform Support**: Automatically detects and extracts SPDX SBOM attestations for multi arch images. - **Dynamic Multi-Platform Support**: Automatically discovers and scans all architectures present in container images, or iterates over an explicit list of target architectures.
- **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract SBOMs directly from registry manifest indices in seconds without downloading layer blobs. - **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract platform-specific SBOMs directly from registry manifest indices in seconds without downloading layer blobs.
- **Graceful Fallbacks**: If SBOM attestations or baseline images are missing, automatically falls back to generating SBOMs directly with Trivy. Using images that do contain an SBOM is highly recomended though! - **Graceful Fallbacks**: If SBOM attestations are missing for any platform, automatically falls back to generating SBOMs directly with Trivy per architecture.
- **Set-Based Differential Analysis**: Categorizes vulnerabilities into: - **Set-Based Differential Analysis**: Categorizes vulnerabilities into:
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit) - 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch) - 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
@@ -20,10 +20,11 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
## 📥 Inputs ## 📥 Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| :--- | :--- | :---: | :--- | | :--- | :--- | :--- | :--- |
| `head-image` | Head container image reference to scan (e.g. `registry.example.com/repo:git-sha`) | **Yes** | — | | `head-image` | Head container image reference to scan (e.g. `registry.example.com/repo:git-sha`) | **Yes** | — |
| `target-image` | Target baseline container image reference to compare against (e.g. `registry.example.com/repo:latest`) | **Yes** | — | | `target-image` | Target baseline container image reference to compare against (e.g. `registry.example.com/repo:latest`) | **Yes** | — |
| `target-branch` | Target branch name for report headings | No | `main` | | `target-branch` | Target branch name for report headings | No | `main` |
| `architectures` | Comma-separated architectures/platforms to scan (e.g. `amd64,arm64`). If omitted, scans all architectures present in the image. | No | _(all)_ |
| `output-delta` | Output path for the CVE delta Markdown report | No | `cve-delta.md` | | `output-delta` | Output path for the CVE delta Markdown report | No | `cve-delta.md` |
| `output-summary` | Output path for the full vulnerability Markdown summary | No | `cve-summary.md` | | `output-summary` | Output path for the full vulnerability Markdown summary | No | `cve-summary.md` |
| `write-summary` | Whether to append the generated reports to the job summary (`$GITHUB_STEP_SUMMARY`) | No | `true` | | `write-summary` | Whether to append the generated reports to the job summary (`$GITHUB_STEP_SUMMARY`) | No | `true` |
+137 -56
View File
@@ -12,6 +12,10 @@ inputs:
description: Target branch name for delta heading (e.g. main) description: Target branch name for delta heading (e.g. main)
required: false required: false
default: main default: main
architectures:
description: Comma-separated list of architectures or platforms to scan (e.g. 'amd64,arm64' or 'linux/amd64,linux/arm64'). If empty, scans all architectures found in the container image.
required: false
default: ''
output-delta: output-delta:
description: Output markdown file for CVE delta report description: Output markdown file for CVE delta report
required: false required: false
@@ -107,70 +111,136 @@ runs:
target_sbom_err="" target_sbom_err=""
use_inbuild="${{ inputs.use-inbuild-sbom }}" use_inbuild="${{ inputs.use-inbuild-sbom }}"
# --- Head Image SBOM Extraction --- # Determine target platforms / architectures
PLATFORMS=()
if [ -n "${{ inputs.architectures }}" ]; then
IFS=',' read -ra RAW_ARCHS <<< "${{ inputs.architectures }}"
for a in "${RAW_ARCHS[@]}"; do
clean_a=$(echo "$a" | xargs)
[ -z "$clean_a" ] && continue
if [[ "$clean_a" != *"/"* ]]; then
PLATFORMS+=("linux/${clean_a}")
else
PLATFORMS+=("$clean_a")
fi
done
else
echo "Auto-discovering architectures from images..."
raw_head=$(docker buildx imagetools inspect --raw "${{ inputs.head-image }}" 2>/dev/null || true)
raw_target=$(docker buildx imagetools inspect --raw "${{ inputs.target-image }}" 2>/dev/null || true)
discovered=$(node -e '
const raw1 = process.argv[1];
const raw2 = process.argv[2];
const set = new Set();
for (const raw of [raw1, raw2]) {
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 (e) {}
}
console.log(Array.from(set).sort().join("\n"));
' "$raw_head" "$raw_target")
if [ -n "$discovered" ]; then
while IFS= read -r line; do
[ -n "$line" ] && PLATFORMS+=("$line")
done <<< "$discovered"
fi
fi
echo "Configured platforms: ${PLATFORMS[*]:-(single platform fallback)}"
for platform in "${PLATFORMS[@]}"; do
arch_tag=$(echo "$platform" | sed -e 's#^[a-zA-Z0-9_-]*/##' -e 's#/#-#g')
# Head image extraction & fallback
if [ "$use_inbuild" = "true" ]; then if [ "$use_inbuild" = "true" ]; then
echo "Attempting extraction of built-in SBOM attestations for head image: ${{ inputs.head-image }}..." echo "Attempting extraction of built-in SBOM for head image ($platform)..."
docker buildx imagetools inspect "${{ inputs.head-image }}" \ docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true --format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/head/sbom-${arch_tag}.spdx.json" 2>/dev/null || true
cleanup_file sboms/head/sbom-amd64.spdx.json cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json"
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/head/sbom-arm64.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-arm64.spdx.json
# If single-arch, extract root .SPDX
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-amd64.spdx.json
fi
fi fi
# Generate head SBOM with Trivy if attestation not used or missing if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then echo "Generating SBOM for head image ($platform) with Trivy..."
echo "Generating SBOM for head image directly with Trivy: ${{ inputs.head-image }}..." if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/head/sbom-${arch_tag}.spdx.json" "${{ inputs.head-image }}"; then
if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-amd64.spdx.json "${{ inputs.head-image }}"; then echo "::warning::Trivy image scan failed for head image ($platform)"
fi
cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json"
fi
# Target image extraction & fallback
if [ "$use_inbuild" = "true" ]; then
echo "Attempting extraction of built-in SBOM for target image ($platform)..."
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/target/sbom-${arch_tag}.spdx.json" 2>/dev/null || true
cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json"
fi
if [ ! -f "sboms/target/sbom-${arch_tag}.spdx.json" ]; then
echo "Generating SBOM for target image ($platform) with Trivy..."
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/target/sbom-${arch_tag}.spdx.json" "${{ inputs.target-image }}"; then
echo "::warning::Trivy image scan failed for target image ($platform)"
fi
cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json"
fi
done
# Fallbacks if no platform-specific SBOMs were extracted (e.g. single-arch image without index)
if [ -z "$(find sboms/head -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
if [ "$use_inbuild" = "true" ]; then
docker buildx imagetools inspect "${{ inputs.head-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-default.spdx.json 2>/dev/null || true
cleanup_file sboms/head/sbom-default.spdx.json
fi
if [ ! -f sboms/head/sbom-default.spdx.json ]; then
echo "Generating default head SBOM directly with Trivy..."
if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-default.spdx.json "${{ inputs.head-image }}"; then
head_sbom_err="Failed to generate SBOM with Trivy for head image" head_sbom_err="Failed to generate SBOM with Trivy for head image"
fi fi
cleanup_file sboms/head/sbom-amd64.spdx.json cleanup_file sboms/head/sbom-default.spdx.json
fi
fi fi
if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ] && [ -z "$head_sbom_err" ]; then if [ -z "$(find sboms/target -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
if [ "$use_inbuild" = "true" ]; then
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-default.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-default.spdx.json
fi
if [ ! -f sboms/target/sbom-default.spdx.json ]; then
echo "Generating default target SBOM directly with Trivy..."
if ! trivy image --skip-version-check --format spdx-json --output sboms/target/sbom-default.spdx.json "${{ inputs.target-image }}"; then
target_sbom_err="Failed to generate SBOM with Trivy for target image"
fi
cleanup_file sboms/target/sbom-default.spdx.json
fi
fi
shopt -s nullglob
head_files=(sboms/head/sbom-*.spdx.json)
target_files=(sboms/target/sbom-*.spdx.json)
if [ ${#head_files[@]} -eq 0 ] && [ -z "$head_sbom_err" ]; then
head_sbom_err="No SBOM generated and head image could not be inspected" head_sbom_err="No SBOM generated and head image could not be inspected"
fi fi
# --- Target Image SBOM Extraction --- if [ ${#target_files[@]} -eq 0 ] && [ -z "$target_sbom_err" ]; then
if [ "$use_inbuild" = "true" ]; then
echo "Attempting extraction of built-in SBOM attestations for target image: ${{ inputs.target-image }}..."
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-amd64.spdx.json
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json (index .SBOM "linux/arm64").SPDX }}' > sboms/target/sbom-arm64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-arm64.spdx.json
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
docker buildx imagetools inspect "${{ inputs.target-image }}" \
--format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true
cleanup_file sboms/target/sbom-amd64.spdx.json
fi
fi
# Generate target SBOM with Trivy if attestation not used or missing
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then
echo "Generating SBOM for target image directly with Trivy: ${{ inputs.target-image }}..."
if ! trivy image --skip-version-check --format spdx-json --output sboms/target/sbom-amd64.spdx.json "${{ inputs.target-image }}"; then
target_sbom_err="Failed to generate SBOM with Trivy for target image"
fi
cleanup_file sboms/target/sbom-amd64.spdx.json
fi
if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ] && [ -z "$target_sbom_err" ]; then
target_sbom_err="No SBOM generated and target baseline image could not be inspected" target_sbom_err="No SBOM generated and target baseline image could not be inspected"
fi fi
# Report on both sides # Diagnostic overview
echo "" echo ""
echo "================================================================================" echo "================================================================================"
echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW " echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW "
@@ -179,16 +249,18 @@ runs:
echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err" echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err"
else else
echo "HEAD Image (${{ inputs.head-image }}): ✅ SUCCESS" echo "HEAD Image (${{ inputs.head-image }}): ✅ SUCCESS"
[ -f sboms/head/sbom-amd64.spdx.json ] && echo " - sbom-amd64.spdx.json ($(wc -c < sboms/head/sbom-amd64.spdx.json) bytes)" for f in "${head_files[@]}"; do
[ -f sboms/head/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/head/sbom-arm64.spdx.json) bytes)" echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
done
fi fi
if [ -n "$target_sbom_err" ]; then if [ -n "$target_sbom_err" ]; then
echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err" echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err"
else else
echo "TARGET Baseline Image (${{ inputs.target-image }}): ✅ SUCCESS" echo "TARGET Baseline Image (${{ inputs.target-image }}): ✅ SUCCESS"
[ -f sboms/target/sbom-amd64.spdx.json ] && echo " - sbom-amd64.spdx.json ($(wc -c < sboms/target/sbom-amd64.spdx.json) bytes)" for f in "${target_files[@]}"; do
[ -f sboms/target/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/target/sbom-arm64.spdx.json) bytes)" echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
done
fi fi
echo "================================================================================" echo "================================================================================"
echo "" echo ""
@@ -209,8 +281,10 @@ runs:
fi fi
# Expose extracted head SBOMs to workspace root for workflow artifact uploads # Expose extracted head SBOMs to workspace root for workflow artifact uploads
[ -f sboms/head/sbom-amd64.spdx.json ] && cp sboms/head/sbom-amd64.spdx.json sbom-amd64.spdx.json || true for f in sboms/head/sbom-*.spdx.json; do
[ -f sboms/head/sbom-arm64.spdx.json ] && cp sboms/head/sbom-arm64.spdx.json sbom-arm64.spdx.json || true [ -f "$f" ] || continue
cp "$f" "$(basename "$f")" || true
done
- name: Scan Multi-Platform SBOMs and Generate Delta - name: Scan Multi-Platform SBOMs and Generate Delta
shell: bash shell: bash
@@ -349,6 +423,13 @@ runs:
primary_sbom="sboms/head/sbom-amd64.spdx.json" primary_sbom="sboms/head/sbom-amd64.spdx.json"
elif [ -f sboms/head/sbom-arm64.spdx.json ]; then elif [ -f sboms/head/sbom-arm64.spdx.json ]; then
primary_sbom="sboms/head/sbom-arm64.spdx.json" primary_sbom="sboms/head/sbom-arm64.spdx.json"
else
for f in sboms/head/sbom-*.spdx.json; do
if [ -f "$f" ]; then
primary_sbom="$f"
break
fi
done
fi fi
if [ -n "$primary_sbom" ]; then if [ -n "$primary_sbom" ]; then
+24 -6
View File
@@ -24,6 +24,7 @@ const SEVERITY_BADGES = {
*/ */
function loadReportsForArg(arg) { function loadReportsForArg(arg) {
const cves = new Map(); const cves = new Map();
const scannedArchs = new Set();
const filePaths = []; const filePaths = [];
for (const part of arg.split(',').map((s) => s.trim())) { for (const part of arg.split(',').map((s) => s.trim())) {
@@ -60,13 +61,19 @@ function loadReportsForArg(arg) {
process.exit(1); process.exit(1);
} }
const baseName = path.basename(filePath).toLowerCase(); const baseName = path.basename(filePath, '.json').toLowerCase();
let arch = 'all'; let arch = 'all';
if (baseName.includes('amd64')) { const cveMatch = baseName.match(/^cve-([a-z0-9_.-]+)$/i);
if (cveMatch) {
arch = cveMatch[1];
} else if (baseName.includes('amd64')) {
arch = 'amd64'; arch = 'amd64';
} else if (baseName.includes('arm64') || baseName.includes('aarch64')) { } else if (baseName.includes('arm64') || baseName.includes('aarch64')) {
arch = 'arm64'; arch = 'arm64';
} }
if (arch !== 'all') {
scannedArchs.add(arch);
}
let data; let data;
try { try {
@@ -107,15 +114,17 @@ function loadReportsForArg(arg) {
process.exit(1); process.exit(1);
} }
cves.scannedArchs = scannedArchs;
return cves; return cves;
} }
/** /**
* Formats a vulnerability record into a Markdown table row. * Formats a vulnerability record into a Markdown table row.
* @param {object} v * @param {object} v
* @param {Set<string>} [allScannedArchs]
* @returns {string} * @returns {string}
*/ */
function formatVulnRow(v) { function formatVulnRow(v, allScannedArchs = new Set()) {
const sev = v.Severity || 'UNKNOWN'; const sev = v.Severity || 'UNKNOWN';
const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`; const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`;
const vid = v.VulnerabilityID || ''; const vid = v.VulnerabilityID || '';
@@ -125,7 +134,11 @@ function formatVulnRow(v) {
const archs = v._archs || new Set(['all']); const archs = v._archs || new Set(['all']);
let archBadge; let archBadge;
if ((archs.has('amd64') && archs.has('arm64')) || archs.has('all')) { if (
archs.has('all') ||
(allScannedArchs && allScannedArchs.size > 1 && archs.size === allScannedArchs.size) ||
(archs.has('amd64') && archs.has('arm64'))
) {
archBadge = '`all`'; archBadge = '`all`';
} else { } else {
archBadge = `\`${Array.from(archs).sort().join(', ')}\``; archBadge = `\`${Array.from(archs).sort().join(', ')}\``;
@@ -188,6 +201,11 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
return aPkg.localeCompare(bPkg); return aPkg.localeCompare(bPkg);
} }
const allScannedArchs = new Set([
...(baseCves.scannedArchs || []),
...(branchCves.scannedArchs || []),
]);
const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns); const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns);
const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns); const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns);
@@ -222,7 +240,7 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |'); lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |');
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
for (const v of newVulns) { for (const v of newVulns) {
lines.push(formatVulnRow(v)); lines.push(formatVulnRow(v, allScannedArchs));
} }
} else { } else {
lines.push('✅ No new vulnerabilities introduced in this build.'); lines.push('✅ No new vulnerabilities introduced in this build.');
@@ -234,7 +252,7 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') {
lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |'); lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |');
lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |');
for (const v of resolvedVulns) { for (const v of resolvedVulns) {
lines.push(formatVulnRow(v)); lines.push(formatVulnRow(v, allScannedArchs));
} }
} else { } else {
lines.push('⚪ No existing vulnerabilities were resolved.'); lines.push('⚪ No existing vulnerabilities were resolved.');