diff --git a/README.md b/README.md index 39ef788..ea5724d 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p ## ✨ Features -- **Multi-Platform Support**: Automatically detects and extracts SPDX SBOM attestations for multi arch images. -- **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract 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! +- **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 platform-specific SBOMs directly from registry manifest indices in seconds without downloading layer blobs. +- **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: - 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit) - 🟢 **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 | Input | Description | Required | Default | -| :--- | :--- | :---: | :--- | +| :--- | :--- | :--- | :--- | | `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-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-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` | diff --git a/action.yaml b/action.yaml index 74e84a0..b3d59d7 100644 --- a/action.yaml +++ b/action.yaml @@ -12,6 +12,10 @@ inputs: description: Target branch name for delta heading (e.g. main) required: false 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: description: Output markdown file for CVE delta report required: false @@ -95,56 +99,112 @@ runs: fi } - # Extract platform SBOMs for head image - echo "Extracting platform SBOMs for head image: ${{ 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 - cleanup_file sboms/head/sbom-amd64.spdx.json + # 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) - 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 + 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 single-arch, extract root .SPDX - if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then + 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 + echo "Extracting SBOM for head image ($platform)..." 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 + --format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/head/sbom-${arch_tag}.spdx.json" 2>/dev/null || true + cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json" - # Fallback to generating head SBOM with Trivy if attestation missing - if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then - echo "Head image lacks SBOM attestation; generating with Trivy..." - trivy image --format spdx-json --output sboms/head/sbom-amd64.spdx.json "${{ inputs.head-image }}" || true - cleanup_file sboms/head/sbom-amd64.spdx.json - fi + if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then + echo "Head image lacks SBOM attestation for $platform; generating with Trivy..." + trivy image --platform "$platform" --format spdx-json --output "sboms/head/sbom-${arch_tag}.spdx.json" "${{ inputs.head-image }}" || true + cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json" + fi - # Extract platform SBOMs for target image - echo "Extracting platform SBOMs 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 + # Target image extraction & fallback + echo "Extracting SBOM for target image ($platform)..." 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 + --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" + + if [ ! -f "sboms/target/sbom-${arch_tag}.spdx.json" ]; then + echo "Target image lacks SBOM attestation for $platform; generating with Trivy..." + trivy image --platform "$platform" --format spdx-json --output "sboms/target/sbom-${arch_tag}.spdx.json" "${{ inputs.target-image }}" || true + 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 + echo "Extracting root SBOM or scanning head image directly..." + 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 + + if [ ! -f sboms/head/sbom-default.spdx.json ]; then + trivy image --format spdx-json --output sboms/head/sbom-default.spdx.json "${{ inputs.head-image }}" || true + cleanup_file sboms/head/sbom-default.spdx.json + fi fi - # Fallback to generating target SBOM with Trivy if attestation missing - if [ ! -f sboms/target/sbom-amd64.spdx.json ] && [ ! -f sboms/target/sbom-arm64.spdx.json ]; then - echo "Target image lacks SBOM attestation; attempting to generate with Trivy..." - trivy image --format spdx-json --output sboms/target/sbom-amd64.spdx.json "${{ inputs.target-image }}" || true - cleanup_file sboms/target/sbom-amd64.spdx.json + if [ -z "$(find sboms/target -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then + echo "Extracting root SBOM or scanning target image directly..." + 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 + + if [ ! -f sboms/target/sbom-default.spdx.json ]; then + trivy image --format spdx-json --output sboms/target/sbom-default.spdx.json "${{ inputs.target-image }}" || true + cleanup_file sboms/target/sbom-default.spdx.json + fi fi # 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 - [ -f sboms/head/sbom-arm64.spdx.json ] && cp sboms/head/sbom-arm64.spdx.json sbom-arm64.spdx.json || true + for f in sboms/head/sbom-*.spdx.json; do + [ -f "$f" ] || continue + cp "$f" "$(basename "$f")" || true + done - name: Scan Multi-Platform SBOMs and Generate Delta shell: bash @@ -180,6 +240,13 @@ runs: primary_sbom="sboms/head/sbom-amd64.spdx.json" elif [ -f sboms/head/sbom-arm64.spdx.json ]; then 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 if [ -n "$primary_sbom" ]; then diff --git a/cve-diff.js b/cve-diff.js index 71628ef..f6efb2b 100755 --- a/cve-diff.js +++ b/cve-diff.js @@ -24,6 +24,7 @@ const SEVERITY_BADGES = { */ function loadReportsForArg(arg) { const cves = new Map(); + const scannedArchs = new Set(); const filePaths = []; for (const part of arg.split(',').map((s) => s.trim())) { @@ -50,13 +51,19 @@ function loadReportsForArg(arg) { } if (stat.size === 0) continue; - const baseName = path.basename(filePath).toLowerCase(); + const baseName = path.basename(filePath, '.json').toLowerCase(); 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'; } else if (baseName.includes('arm64') || baseName.includes('aarch64')) { arch = 'arm64'; } + if (arch !== 'all') { + scannedArchs.add(arch); + } let data; try { @@ -88,15 +95,17 @@ function loadReportsForArg(arg) { } } + cves.scannedArchs = scannedArchs; return cves; } /** * Formats a vulnerability record into a Markdown table row. * @param {object} v + * @param {Set} [allScannedArchs] * @returns {string} */ -function formatVulnRow(v) { +function formatVulnRow(v, allScannedArchs = new Set()) { const sev = v.Severity || 'UNKNOWN'; const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`; const vid = v.VulnerabilityID || ''; @@ -106,7 +115,11 @@ function formatVulnRow(v) { const archs = v._archs || new Set(['all']); 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`'; } else { archBadge = `\`${Array.from(archs).sort().join(', ')}\``; @@ -171,6 +184,11 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { return aPkg.localeCompare(bPkg); } + const allScannedArchs = new Set([ + ...(baseCves.scannedArchs || []), + ...(branchCves.scannedArchs || []), + ]); + const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns); const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns); @@ -190,7 +208,7 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |'); lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); for (const v of newVulns) { - lines.push(formatVulnRow(v)); + lines.push(formatVulnRow(v, allScannedArchs)); } } else { lines.push('✅ No new vulnerabilities introduced in this build.'); @@ -202,7 +220,7 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |'); lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); for (const v of resolvedVulns) { - lines.push(formatVulnRow(v)); + lines.push(formatVulnRow(v, allScannedArchs)); } } else { lines.push('⚪ No existing vulnerabilities were resolved.');