From e232a225474d3d8c58add2e72ae502d53054cc30 Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sun, 13 Sep 2026 21:51:43 +0200 Subject: [PATCH 1/2] feat: support dynamic multi-architecture discovery and iteration --- README.md | 9 +-- action.yaml | 191 +++++++++++++++++++++++++++++++++++++--------------- cve-diff.js | 30 +++++++-- 3 files changed, 165 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index e0eac07..eb6ca08 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 ba6b630..f576ef2 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 @@ -107,70 +111,136 @@ runs: target_sbom_err="" use_inbuild="${{ inputs.use-inbuild-sbom }}" - # --- Head Image SBOM Extraction --- - if [ "$use_inbuild" = "true" ]; then - echo "Attempting extraction of built-in SBOM attestations 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 + if [ "$use_inbuild" = "true" ]; then + echo "Attempting extraction of built-in 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 + --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" + fi + + if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then + echo "Generating SBOM for head image ($platform) with Trivy..." + if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/head/sbom-${arch_tag}.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" + fi + cleanup_file sboms/head/sbom-default.spdx.json fi fi - # Generate head SBOM with Trivy if attestation not used or missing - if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ]; then - echo "Generating SBOM for head image directly with Trivy: ${{ inputs.head-image }}..." - if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-amd64.spdx.json "${{ inputs.head-image }}"; then - head_sbom_err="Failed to generate SBOM with Trivy for head image" + 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 - cleanup_file sboms/head/sbom-amd64.spdx.json fi - if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ] && [ -z "$head_sbom_err" ]; then + 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" fi - # --- Target Image SBOM Extraction --- - 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 + if [ ${#target_files[@]} -eq 0 ] && [ -z "$target_sbom_err" ]; then target_sbom_err="No SBOM generated and target baseline image could not be inspected" fi - # Report on both sides + # Diagnostic overview echo "" echo "================================================================================" echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW " @@ -179,16 +249,18 @@ runs: echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err" else 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)" - [ -f sboms/head/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/head/sbom-arm64.spdx.json) bytes)" + for f in "${head_files[@]}"; do + echo " - $(basename "$f") ($(wc -c < "$f") bytes)" + done fi if [ -n "$target_sbom_err" ]; then echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err" else 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)" - [ -f sboms/target/sbom-arm64.spdx.json ] && echo " - sbom-arm64.spdx.json ($(wc -c < sboms/target/sbom-arm64.spdx.json) bytes)" + for f in "${target_files[@]}"; do + echo " - $(basename "$f") ($(wc -c < "$f") bytes)" + done fi echo "================================================================================" echo "" @@ -209,8 +281,10 @@ runs: 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 @@ -349,6 +423,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 127157f..1f28134 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())) { @@ -60,13 +61,19 @@ function loadReportsForArg(arg) { process.exit(1); } - 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 { @@ -107,15 +114,17 @@ function loadReportsForArg(arg) { process.exit(1); } + 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 || ''; @@ -125,7 +134,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(', ')}\``; @@ -188,6 +201,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); @@ -222,7 +240,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.'); @@ -234,7 +252,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.'); From e394966a967603c22493e8f3cc2d9810a0b1146d Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sun, 13 Sep 2026 22:01:33 +0200 Subject: [PATCH 2/2] feat: show Arch column only when multiple architectures are scanned --- action.yaml | 32 ++------------ cve-diff.js | 118 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 100 insertions(+), 50 deletions(-) diff --git a/action.yaml b/action.yaml index f576ef2..2257cd4 100644 --- a/action.yaml +++ b/action.yaml @@ -407,43 +407,17 @@ runs: exit 1 fi - # 5. Run multi-arch set-based diff (both are guaranteed present and valid) + # 5. Run multi-arch set-based diff and generate consolidated full summary report echo "Both head and target scans succeeded. Computing set-based vulnerability delta..." if ! node "${{ github.action_path }}/cve-diff.js" \ "cve-reports/target" \ "cve-reports/head" \ - "${{ inputs.target-branch }}" > "${{ inputs.output-delta }}"; then + "${{ inputs.target-branch }}" \ + "${{ inputs.output-summary }}" > "${{ inputs.output-delta }}"; then echo "::error::Failed to generate CVE differential report" exit 1 fi - # 6. Generate full markdown summary report for primary platform - primary_sbom="" - if [ -f sboms/head/sbom-amd64.spdx.json ]; then - 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 - if ! trivy sbom \ - --skip-version-check \ - --format template \ - --template "@${{ github.action_path }}/markdown.tpl" \ - --output "${{ inputs.output-summary }}" \ - "$primary_sbom"; then - echo "::error::Trivy summary generation failed for primary SBOM: $primary_sbom" - exit 1 - fi - fi - - name: Publish Reports to Step Summary shell: bash run: | diff --git a/cve-diff.js b/cve-diff.js index 1f28134..f0de854 100755 --- a/cve-diff.js +++ b/cve-diff.js @@ -122,9 +122,10 @@ function loadReportsForArg(arg) { * Formats a vulnerability record into a Markdown table row. * @param {object} v * @param {Set} [allScannedArchs] + * @param {boolean} [showArch] * @returns {string} */ -function formatVulnRow(v, allScannedArchs = new Set()) { +function formatVulnRow(v, allScannedArchs = new Set(), showArch = true) { const sev = v.Severity || 'UNKNOWN'; const badge = SEVERITY_BADGES[sev] || `⚪ ${sev}`; const vid = v.VulnerabilityID || ''; @@ -132,18 +133,6 @@ function formatVulnRow(v, allScannedArchs = new Set()) { const link = url ? `[${vid}](${url})` : vid; const pkg = v.PkgName || ''; - const archs = v._archs || new Set(['all']); - let archBadge; - 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(', ')}\``; - } - const installed = v.InstalledVersion || '-'; const fixed = v.FixedVersion || '-'; let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim(); @@ -151,7 +140,22 @@ function formatVulnRow(v, allScannedArchs = new Set()) { title = title.slice(0, 77) + '...'; } - return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`; + if (showArch) { + const archs = v._archs || new Set(['all']); + let archBadge; + 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(', ')}\``; + } + return `| ${badge} | ${link} | \`${pkg}\` | ${archBadge} | ${installed} | ${fixed} | ${title} |`; + } + + return `| ${badge} | ${link} | \`${pkg}\` | ${installed} | ${fixed} | ${title} |`; } /** @@ -205,6 +209,7 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { ...(baseCves.scannedArchs || []), ...(branchCves.scannedArchs || []), ]); + const showArch = allScannedArchs.size > 1; const newVulns = newKeys.map((k) => branchCves.get(k)).sort(sortVulns); const resolvedVulns = resolvedKeys.map((k) => baseCves.get(k)).sort(sortVulns); @@ -237,10 +242,15 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { lines.push(`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`); if (newVulns.length > 0) { - lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |'); - lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); + if (showArch) { + lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |'); + lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); + } else { + lines.push('| Severity | CVE | Package | Installed | Fixed In | Title |'); + lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |'); + } for (const v of newVulns) { - lines.push(formatVulnRow(v, allScannedArchs)); + lines.push(formatVulnRow(v, allScannedArchs, showArch)); } } else { lines.push('✅ No new vulnerabilities introduced in this build.'); @@ -249,10 +259,15 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { lines.push(`#### ✅ Resolved / Removed Vulnerabilities (${resolvedVulns.length})`); if (resolvedVulns.length > 0) { - lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |'); - lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); + if (showArch) { + lines.push('| Severity | CVE | Package | Arch | Previously Installed | Fixed In | Title |'); + lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); + } else { + lines.push('| Severity | CVE | Package | Previously Installed | Fixed In | Title |'); + lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |'); + } for (const v of resolvedVulns) { - lines.push(formatVulnRow(v, allScannedArchs)); + lines.push(formatVulnRow(v, allScannedArchs, showArch)); } } else { lines.push('⚪ No existing vulnerabilities were resolved.'); @@ -262,22 +277,82 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { return lines.join('\n'); } +/** + * Renders a full markdown summary report of all vulnerabilities in a scan. + * @param {Map} cves + * @returns {string} + */ +function renderFullSummary(cves) { + const allScannedArchs = cves.scannedArchs || new Set(); + const showArch = allScannedArchs.size > 1; + + const lines = [ + '### 🛡️ Security Scan Report', + '', + ]; + + if (cves.size === 0) { + lines.push('✅ No vulnerabilities found.'); + lines.push(''); + return lines.join('\n'); + } + + function sortVulns(a, b) { + const aSev = a.Severity || 'UNKNOWN'; + const bSev = b.Severity || 'UNKNOWN'; + const aIdx = SEVERITY_ORDER.indexOf(aSev) !== -1 ? SEVERITY_ORDER.indexOf(aSev) : 99; + const bIdx = SEVERITY_ORDER.indexOf(bSev) !== -1 ? SEVERITY_ORDER.indexOf(bSev) : 99; + if (aIdx !== bIdx) return aIdx - bIdx; + const aPkg = a.PkgName || ''; + const bPkg = b.PkgName || ''; + return aPkg.localeCompare(bPkg); + } + + const sortedVulns = Array.from(cves.values()).sort(sortVulns); + + lines.push(`| Metric | Count |`); + lines.push(`| :--- | :--- |`); + lines.push(`| Total Vulnerabilities | **${sortedVulns.length}** |`); + lines.push(''); + + if (showArch) { + lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |'); + lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); + } else { + lines.push('| Severity | CVE | Package | Installed | Fixed In | Title |'); + lines.push('| :--- | :--- | :--- | :--- | :--- | :--- |'); + } + + for (const v of sortedVulns) { + lines.push(formatVulnRow(v, allScannedArchs, showArch)); + } + lines.push(''); + + return lines.join('\n'); +} + function main() { const args = process.argv.slice(2); if (args.length < 2) { - console.error('Usage: cve-diff.js [base_name]'); + console.error('Usage: cve-diff.js [base_name] [summary_output_path]'); process.exit(1); } const baseArg = args[0]; const branchArg = args[1]; const baseName = args[2] || 'main'; + const summaryOutputPath = args[3]; const baseCves = loadReportsForArg(baseArg); const branchCves = loadReportsForArg(branchArg); const output = renderMarkdownDiff(baseCves, branchCves, baseName); console.log(output); + + if (summaryOutputPath) { + const summaryOutput = renderFullSummary(branchCves); + fs.writeFileSync(summaryOutputPath, summaryOutput, 'utf8'); + } } if (require.main === module) { @@ -288,4 +363,5 @@ module.exports = { loadReportsForArg, formatVulnRow, renderMarkdownDiff, + renderFullSummary, };