From 3b458f874984b16848f6edd5cc7c6929a7056ec7 Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sun, 13 Sep 2026 19:58:54 +0200 Subject: [PATCH 1/4] feat: add dual-side diagnostic overview and gating to ensure both scans succeed --- action.yaml | 205 +++++++++++++++++++++++++++++++++++++++++++++------- cve-diff.js | 31 ++++++-- 2 files changed, 204 insertions(+), 32 deletions(-) diff --git a/action.yaml b/action.yaml index 74e84a0..5404d7a 100644 --- a/action.yaml +++ b/action.yaml @@ -86,16 +86,22 @@ runs: shell: bash run: | export PATH="$HOME/.local/bin:$PATH" + export TRIVY_SKIP_VERSION_CHECK="true" mkdir -p sboms/head sboms/target cleanup_file() { local f="$1" - if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then - rm -f "$f" + if [ -f "$f" ]; then + if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then + rm -f "$f" + fi fi } - # Extract platform SBOMs for head image + head_sbom_err="" + target_sbom_err="" + + # --- Head Image SBOM Extraction --- 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 @@ -115,11 +121,17 @@ runs: # 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 + 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 fallback SBOM with Trivy for head image" + fi cleanup_file sboms/head/sbom-amd64.spdx.json fi - # Extract platform SBOMs for target image + if [ ! -f sboms/head/sbom-amd64.spdx.json ] && [ ! -f sboms/head/sbom-arm64.spdx.json ] && [ -z "$head_sbom_err" ]; then + head_sbom_err="No SBOM attestation found and head image could not be inspected" + fi + + # --- Target Image SBOM Extraction --- 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 @@ -138,10 +150,54 @@ runs: # 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 + 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 fallback 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 attestation found and target baseline image could not be inspected" + fi + + # Report on both sides + echo "" + echo "================================================================================" + echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW " + echo "================================================================================" + if [ -n "$head_sbom_err" ]; then + 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)" + 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)" + fi + echo "================================================================================" + echo "" + + has_sbom_err=false + if [ -n "$head_sbom_err" ]; then + echo "::error::Head SBOM extraction failed: $head_sbom_err" + has_sbom_err=true + fi + if [ -n "$target_sbom_err" ]; then + echo "::error::Target baseline SBOM extraction failed: $target_sbom_err" + has_sbom_err=true + fi + + if [ "$has_sbom_err" = "true" ]; then + echo "::error::Both head and target images must have valid SBOMs to proceed with differential scanning." + exit 1 + 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 @@ -150,31 +206,124 @@ runs: shell: bash run: | export PATH="$HOME/.local/bin:$PATH" + export TRIVY_SKIP_VERSION_CHECK="true" mkdir -p cve-reports/head cve-reports/target - # Scan each available head SBOM - for f in sboms/head/sbom-*.spdx.json; do - [ -f "$f" ] || continue - arch=$(basename "$f" .spdx.json | sed 's/sbom-//') - echo "Scanning head SBOM for architecture: $arch" - trivy sbom --format json --output "cve-reports/head/cve-${arch}.json" "$f" || true - done + shopt -s nullglob + head_sboms=(sboms/head/sbom-*.spdx.json) + target_sboms=(sboms/target/sbom-*.spdx.json) - # Scan each available target SBOM - for f in sboms/target/sbom-*.spdx.json; do - [ -f "$f" ] || continue - arch=$(basename "$f" .spdx.json | sed 's/sbom-//') - echo "Scanning target SBOM for architecture: $arch" - trivy sbom --format json --output "cve-reports/target/cve-${arch}.json" "$f" || true - done + head_ok=true + target_ok=true + head_report_count=0 + target_report_count=0 - # Run multi-arch set-based diff - node "${{ github.action_path }}/cve-diff.js" \ + head_summary=() + target_summary=() + + # 1. Scan Head SBOMs + if [ ${#head_sboms[@]} -eq 0 ]; then + head_ok=false + head_summary+=(" - ❌ No SBOM files found in sboms/head/") + else + for f in "${head_sboms[@]}"; do + arch=$(basename "$f" .spdx.json | sed 's/sbom-//') + out_json="cve-reports/head/cve-${arch}.json" + err_log="cve-reports/head/cve-${arch}.err" + + echo "Scanning head SBOM for architecture: $arch" + if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then + if [ -s "$out_json" ]; then + vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0) + head_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)") + head_report_count=$((head_report_count + 1)) + else + head_ok=false + head_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)") + fi + else + head_ok=false + err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g') + [ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status" + head_summary+=(" - ${arch}: ❌ FAILED (${err_msg})") + fi + done + fi + + # 2. Scan Target SBOMs + if [ ${#target_sboms[@]} -eq 0 ]; then + target_ok=false + target_summary+=(" - ❌ No SBOM files found in sboms/target/") + else + for f in "${target_sboms[@]}"; do + arch=$(basename "$f" .spdx.json | sed 's/sbom-//') + out_json="cve-reports/target/cve-${arch}.json" + err_log="cve-reports/target/cve-${arch}.err" + + echo "Scanning target SBOM for architecture: $arch" + if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then + if [ -s "$out_json" ]; then + vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0) + target_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)") + target_report_count=$((target_report_count + 1)) + else + target_ok=false + target_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)") + fi + else + target_ok=false + err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g') + [ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status" + target_summary+=(" - ${arch}: ❌ FAILED (${err_msg})") + fi + done + fi + + # 3. Print Comprehensive Diagnostic Overview + echo "" + echo "================================================================================" + echo " CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW " + echo "================================================================================" + echo "HEAD Image: ${{ inputs.head-image }}" + for line in "${head_summary[@]}"; do + echo "$line" + done + echo "" + echo "TARGET Baseline Image: ${{ inputs.target-image }} (branch: '${{ inputs.target-branch }}')" + for line in "${target_summary[@]}"; do + echo "$line" + done + echo "================================================================================" + echo "" + + # 4. Enforce Gating: Both must have succeeded with valid reports + has_error=false + if [ "$head_ok" != "true" ] || [ "$head_report_count" -eq 0 ]; then + echo "::error::Head image CVE analysis failed or produced no reports." + has_error=true + fi + + if [ "$target_ok" != "true" ] || [ "$target_report_count" -eq 0 ]; then + echo "::error::Target baseline CVE analysis failed or produced no reports." + has_error=true + fi + + if [ "$has_error" = "true" ]; then + echo "::error::Aborting differential analysis: both head and target scans must succeed before diffing." + exit 1 + fi + + # 5. Run multi-arch set-based diff (both are guaranteed present and valid) + 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 }}" || true + "${{ inputs.target-branch }}" > "${{ inputs.output-delta }}"; then + echo "::error::Failed to generate CVE differential report" + exit 1 + fi - # Generate full markdown summary report for primary platform + # 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" @@ -183,11 +332,15 @@ runs: fi if [ -n "$primary_sbom" ]; then - trivy sbom \ + if ! trivy sbom \ + --skip-version-check \ --format template \ --template "@${{ github.action_path }}/markdown.tpl" \ --output "${{ inputs.output-summary }}" \ - "$primary_sbom" || true + "$primary_sbom"; then + echo "::error::Trivy summary generation failed for primary SBOM: $primary_sbom" + exit 1 + fi fi - name: Publish Reports to Step Summary diff --git a/cve-diff.js b/cve-diff.js index 71628ef..00ae61a 100755 --- a/cve-diff.js +++ b/cve-diff.js @@ -41,14 +41,24 @@ function loadReportsForArg(arg) { } } + if (filePaths.length === 0) { + console.error(`::error::No CVE JSON report files found for path: ${arg}`); + process.exit(1); + } + + let loadedFiles = 0; for (const filePath of filePaths) { let stat; try { stat = fs.statSync(filePath); - } catch { - continue; + } catch (e) { + console.error(`::error::Failed to access report file ${filePath}: ${e.message}`); + process.exit(1); + } + if (stat.size === 0) { + console.error(`::error::CVE report file is empty (0 bytes): ${filePath}`); + process.exit(1); } - if (stat.size === 0) continue; const baseName = path.basename(filePath).toLowerCase(); let arch = 'all'; @@ -62,14 +72,18 @@ function loadReportsForArg(arg) { try { const content = fs.readFileSync(filePath, 'utf8'); data = JSON.parse(content); - } catch { - continue; + } catch (e) { + console.error(`::error::Invalid JSON in CVE report file ${filePath}: ${e.message}`); + process.exit(1); } if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) { - continue; + console.error(`::error::Malformed Trivy report structure (missing Results array) in: ${filePath}`); + process.exit(1); } + loadedFiles++; + for (const result of data.Results) { const target = result.Target || result.Type || 'App'; const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : []; @@ -88,6 +102,11 @@ function loadReportsForArg(arg) { } } + if (loadedFiles === 0) { + console.error(`::error::No valid CVE reports could be loaded for path: ${arg}`); + process.exit(1); + } + return cves; } -- 2.54.0 From 77f3cf68e4c2197cdad0963abad16c0a1f6839e2 Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sun, 13 Sep 2026 20:02:35 +0200 Subject: [PATCH 2/4] feat: warn when an image has 0 reported CVEs and display warning banner --- action.yaml | 14 ++++++++++++-- cve-diff.js | 47 ++++++++++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/action.yaml b/action.yaml index 5404d7a..5407c55 100644 --- a/action.yaml +++ b/action.yaml @@ -235,7 +235,12 @@ runs: if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then if [ -s "$out_json" ]; then vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0) - head_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)") + if [ "$vuln_cnt" -eq 0 ]; then + echo "::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized." + head_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)") + else + head_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)") + fi head_report_count=$((head_report_count + 1)) else head_ok=false @@ -264,7 +269,12 @@ runs: if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then if [ -s "$out_json" ]; then vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0) - target_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)") + if [ "$vuln_cnt" -eq 0 ]; then + echo "::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized." + target_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)") + else + target_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)") + fi target_report_count=$((target_report_count + 1)) else target_ok=false diff --git a/cve-diff.js b/cve-diff.js index 00ae61a..127157f 100755 --- a/cve-diff.js +++ b/cve-diff.js @@ -149,19 +149,17 @@ function formatVulnRow(v) { * @returns {string} */ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { - if (baseCves.size === 0) { - return [ - `### 🔄 Vulnerability Delta (vs \`${baseName}\`)`, - '', - '> [!NOTE]', - `> No baseline scan from \`${baseName}\` was available to compare against.`, - '', - ].join('\n'); - } - const baseKeys = new Set(baseCves.keys()); const branchKeys = new Set(branchCves.keys()); + if (branchCves.size === 0) { + console.error('::warning::Head image contains 0 reported vulnerabilities. Verify that packages and dependencies were recognized.'); + } + + if (baseCves.size === 0) { + console.error(`::warning::Baseline target (${baseName}) contains 0 reported vulnerabilities.`); + } + const newKeys = []; let unchangedCount = 0; for (const key of branchKeys) { @@ -196,15 +194,30 @@ function renderMarkdownDiff(baseCves, branchCves, baseName = 'main') { const lines = [ `### 🔄 Vulnerability Delta (vs \`${baseName}\`)`, '', - '| Metric | Count |', - '| :--- | :--- |', - `| 🚨 **New Vulnerabilities** | ${newVulns.length > 0 ? `🚨 **${newVulns.length}**` : '0'} |`, - `| ✅ **Resolved Vulnerabilities** | ${resolvedVulns.length > 0 ? `✅ **${resolvedVulns.length}**` : '0'} |`, - `| ⚪ **Unchanged Vulnerabilities** | ${unchangedCount} |`, - '', - `#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`, ]; + if (branchCves.size === 0 && baseCves.size === 0) { + lines.push('> [!WARNING]'); + lines.push('> Both the head image and baseline target have **0 reported vulnerabilities**.'); + lines.push(''); + } else if (branchCves.size === 0) { + lines.push('> [!WARNING]'); + lines.push('> The head image has **0 reported vulnerabilities**. Verify that OS packages and language dependencies were properly indexed in the SBOM.'); + lines.push(''); + } else if (baseCves.size === 0) { + lines.push('> [!WARNING]'); + lines.push(`> The baseline image (\`${baseName}\`) has **0 reported vulnerabilities**.`); + lines.push(''); + } + + lines.push('| Metric | Count |'); + lines.push('| :--- | :--- |'); + lines.push(`| 🚨 **New Vulnerabilities** | ${newVulns.length > 0 ? `🚨 **${newVulns.length}**` : '0'} |`); + lines.push(`| ✅ **Resolved Vulnerabilities** | ${resolvedVulns.length > 0 ? `✅ **${resolvedVulns.length}**` : '0'} |`); + lines.push(`| ⚪ **Unchanged Vulnerabilities** | ${unchangedCount} |`); + lines.push(''); + lines.push(`#### 🚨 New Vulnerabilities Introduced (${newVulns.length})`); + if (newVulns.length > 0) { lines.push('| Severity | CVE | Package | Arch | Installed | Fixed In | Title |'); lines.push('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |'); -- 2.54.0 From 86a00c54dc04df71656023e06010eb510fa66b97 Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sun, 13 Sep 2026 20:34:52 +0200 Subject: [PATCH 3/4] feat: add use-inbuild-sbom input defaulting to false --- action.yaml | 82 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/action.yaml b/action.yaml index 5407c55..ba6b630 100644 --- a/action.yaml +++ b/action.yaml @@ -24,6 +24,10 @@ inputs: description: Whether to append the generated reports to the job summary ($GITHUB_STEP_SUMMARY) required: false default: 'true' + use-inbuild-sbom: + description: Whether to inspect and use built-in image SBOM attestations. If false, generates SBOM directly from images with Trivy. + required: false + default: 'false' outputs: delta-report: @@ -58,14 +62,15 @@ runs: - name: Install and Verify Trivy shell: bash run: | - mkdir -p "$HOME/.cache/trivy-bin" "$HOME/.local/bin" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" + set -euo pipefail - ARCH=$(uname -m) - case "$ARCH" in + mkdir -p "$HOME/.local/bin" "$HOME/.cache/trivy-bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + ARCH="$(uname -m)" + case "${ARCH}" in x86_64) T_ARCH="64bit" ;; aarch64|arm64) T_ARCH="ARM64" ;; - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + *) echo "::error::Unsupported architecture: ${ARCH}"; exit 1 ;; esac checksums_file="${{ github.action_path }}/checksums.txt" @@ -100,64 +105,69 @@ runs: head_sbom_err="" target_sbom_err="" + use_inbuild="${{ inputs.use-inbuild-sbom }}" # --- Head Image SBOM Extraction --- - 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 - - 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 + 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 .SBOM.SPDX }}' > sboms/head/sbom-amd64.spdx.json 2>/dev/null || true + --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 + + 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 - # Fallback to generating head SBOM with Trivy if attestation missing + # 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 "Head image lacks SBOM attestation; generating with Trivy..." + 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 fallback SBOM with Trivy for head image" + head_sbom_err="Failed to generate SBOM with Trivy for head image" 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 - head_sbom_err="No SBOM attestation found and head image could not be inspected" + head_sbom_err="No SBOM generated and head image could not be inspected" fi # --- Target Image SBOM Extraction --- - 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 + 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 .SBOM.SPDX }}' > sboms/target/sbom-amd64.spdx.json 2>/dev/null || true + --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 - # Fallback to generating target SBOM with Trivy if attestation missing + # 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 "Target image lacks SBOM attestation; attempting to generate with Trivy..." + 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 fallback SBOM with Trivy for target image" + 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 attestation found and target baseline image could not be inspected" + target_sbom_err="No SBOM generated and target baseline image could not be inspected" fi # Report on both sides -- 2.54.0 From e97c5b1fc9f9c931e45adbe1ef117abbae3317cf Mon Sep 17 00:00:00 2001 From: Philipp Horstenkamp Date: Sun, 13 Sep 2026 20:40:38 +0200 Subject: [PATCH 4/4] docs: add comparison between built-in Syft SBOMs and direct Trivy scanning --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39ef788..e0eac07 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p | `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` | +| `use-inbuild-sbom` | Whether to inspect and use built-in image SBOM attestations. If false, generates SBOMs directly with Trivy | No | `false` | --- @@ -39,6 +40,31 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p --- +## 🔍 Built-in SBOM Attestations vs. Trivy Direct Scanning (Syft vs. Trivy) + +This action supports two modes for obtaining SBOMs: + +### 1. Direct Image Scanning with Trivy (`use-inbuild-sbom: false` — Default & Recommended) +By default, the action uses `trivy image --format spdx-json` to generate the SBOM directly from the container images: +- **Full Filesystem Fidelity**: Inspects the entire flattened root filesystem, reading `/etc/os-release` and native package databases (e.g. `/var/lib/dpkg/status`, RPM databases, apk). +- **Accurate OS & Language Package Detection**: Successfully catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.). +- **Symmetric, Apples-to-Apples Comparisons**: Both `head` and `target` images are analyzed using the identical Trivy engine and vulnerability database, preventing skewed diffs. + +### 2. Built-in Attestation Extraction (`use-inbuild-sbom: true`) +When enabled, the action extracts SBOM attestations attached to the container image using `docker buildx imagetools inspect`: +- **Speed**: Extracts the SBOM JSON metadata directly from the registry manifest index in seconds without pulling image layer blobs. +- **Underlying Generator (Syft)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`). +- **Known Limitations with Trivy**: + - **Testing/Rolling Distributions**: Embedded Syft versions may not recognize newer or unreleased distributions (such as Debian 13 "Trixie"), recording the OS as `family="none"` and skipping OS package vulnerability matching. + - **Multi-Stage Builds**: Analyzing layer diffs rather than the flattened container rootfs can cause package database paths to be omitted or detached from the OS manifest. + - **Schema/PURL Discrepancies**: Syft and Trivy express certain package qualifiers and namespaces differently in SPDX. When Trivy scans a Syft SPDX file that lacks explicit distro qualifiers, Trivy will silently skip OS vulnerability checks. + - **Differential Skew**: If a `head` image uses an incomplete Syft SBOM (detecting only language packages) while `target` is scanned directly with Trivy (detecting 400+ OS packages), the differential report will erroneously show hundreds of "resolved" CVEs. + +> [!TIP] +> If your builds run on stable base images where BuildKit's Syft scanner accurately captures all OS packages, you can set `use-inbuild-sbom: 'true'` to speed up CI runs. Otherwise, leave it as `false` to ensure consistent and comprehensive scans. + +--- + ## 🚀 Usage Example ```yaml @@ -52,7 +78,6 @@ jobs: with: context: . push: true - sbom: true tags: registry.example.com/my-app:git-${{ github.sha }} - name: Compare Vulnerabilities (Head vs Target) -- 2.54.0