name: Trivy Multi-Arch CVE Delta Comparison description: Downloads and verifies Trivy, extracts multi-platform SBOMs, and computes a consolidated set-based CVE delta report inputs: head-image: description: Head container image reference (e.g. host/server/gitea:git-12345678) required: true target-image: description: Target baseline container image reference (e.g. host/server/gitea:latest) required: true target-branch: 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 default: cve-delta.md output-summary: description: Output markdown file for full vulnerability summary required: false default: cve-summary.md write-summary: 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 (recommended default), generates SBOMs directly with Trivy for maximum vulnerability detection fidelity. If true, extracts attestations without pulling layers (faster, but findings may differ from direct scans due to binary vs source package tracking; falls back to Trivy image scan if 0 CVEs detected). required: false default: 'false' outputs: delta-report: description: Path to CVE delta markdown report value: ${{ inputs.output-delta }} summary-report: description: Path to full vulnerability markdown report value: ${{ inputs.output-summary }} runs: using: composite steps: - name: Prepare Cache Keys and Environment id: vars shell: bash env: TRIVY_VERSION: 0.76.0 # trivy-version run: | echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" echo "version=${TRIVY_VERSION}" >> "$GITHUB_OUTPUT" - name: Cache Trivy (Binary & Vulnerability DB) uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cache/trivy-bin ~/.cache/trivy key: trivy-${{ steps.vars.outputs.version }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.vars.outputs.date }} restore-keys: | trivy-${{ steps.vars.outputs.version }}-${{ runner.os }}-${{ runner.arch }}- - name: Install and Verify Trivy shell: bash run: | set -euo pipefail 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 "::error::Unsupported architecture: ${ARCH}"; exit 1 ;; esac checksums_file="${{ github.action_path }}/checksums.txt" tarball="trivy_${{ steps.vars.outputs.version }}_Linux-${T_ARCH}.tar.gz" if [ -f "$HOME/.cache/trivy-bin/${tarball}" ] && (cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -); then echo "Cached Trivy archive verified against lockfile." else echo "Archive missing or checksum mismatch; downloading..." curl -sfLO "https://github.com/aquasecurity/trivy/releases/download/v${{ steps.vars.outputs.version }}/${tarball}" --output-dir "$HOME/.cache/trivy-bin" (cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -) fi tar -xzf "$HOME/.cache/trivy-bin/${tarball}" -C "$HOME/.local/bin" trivy chmod +x "$HOME/.local/bin/trivy" - name: Extract Multi-Platform SBOMs 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 [ -f "$f" ]; then if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then rm -f "$f" fi fi } head_sbom_err="" target_sbom_err="" use_inbuild="${{ inputs.use-inbuild-sbom }}" # 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)}" if [ "$use_inbuild" = "true" ]; then echo "ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed." echo "ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans ('trivy image') due to binary vs. source package tracking." else echo "ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity." fi 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 (index .SBOM \"${platform}\").SPDX }}" > "sboms/head/sbom-${arch_tag}.spdx.json" 2>/dev/null || true cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json" [ -f "sboms/head/sbom-${arch_tag}.spdx.json" ] && cp "sboms/head/sbom-${arch_tag}.spdx.json" "sboms/head/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true 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" [ -f "sboms/target/sbom-${arch_tag}.spdx.json" ] && cp "sboms/target/sbom-${arch_tag}.spdx.json" "sboms/target/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true 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 [ -f sboms/head/sbom-default.spdx.json ] && cp sboms/head/sbom-default.spdx.json sboms/head/inbuild-sbom-default.spdx.json 2>/dev/null || true 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 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 [ -f sboms/target/sbom-default.spdx.json ] && cp sboms/target/sbom-default.spdx.json sboms/target/inbuild-sbom-default.spdx.json 2>/dev/null || true 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" fi 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 # Diagnostic overview 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" 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" for f in "${target_files[@]}"; do echo " - $(basename "$f") ($(wc -c < "$f") bytes)" done 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 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 run: | export PATH="$HOME/.local/bin:$PATH" export TRIVY_SKIP_VERSION_CHECK="true" mkdir -p cve-reports/head cve-reports/target shopt -s nullglob head_sboms=(sboms/head/sbom-*.spdx.json) target_sboms=(sboms/target/sbom-*.spdx.json) head_ok=true target_ok=true head_report_count=0 target_report_count=0 head_summary=() target_summary=() use_inbuild="${{ inputs.use-inbuild-sbom }}" detect_distro() { node -e ' const fs = require("fs"); try { const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); let name, ver; for (const pkg of (data.packages || [])) { for (const ref of (pkg.externalRefs || [])) { if (ref.referenceType === "purl" && ref.referenceLocator) { const mName = ref.referenceLocator.match(/[?&]os_name=([^&]+)/); const mVer = ref.referenceLocator.match(/[?&]os_version=([^&]+)/); if (mName && mVer) { name = mName[1]; ver = mVer[1]; break; } } } if (name && ver) break; } if (name && ver) console.log(name + "/" + ver); } catch (e) {} ' "$1" 2>/dev/null || true } # 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" distro_arg="" if [ "$use_inbuild" = "true" ]; then detected_distro=$(detect_distro "$f") if [ -n "$detected_distro" ]; then echo "Detected distribution '${detected_distro}' from in-build SBOM PURLs. Scanning with --distro." distro_arg="--distro ${detected_distro}" fi fi scan_success=false if trivy sbom $distro_arg --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then scan_success=true fi vuln_cnt=0 if [ -s "$out_json" ]; then cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) cnt=$(echo "$cnt" | tr -d '[:space:]') [ -n "$cnt" ] && vuln_cnt="$cnt" fi # Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then echo "::warning::In-build SBOM for head image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..." t_plat="linux/${arch}" [ "$arch" = "default" ] && t_plat="" plat_arg="" [ -n "$t_plat" ] && plat_arg="--platform $t_plat" if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.head-image }}"; then echo "Re-scanning head SBOM generated directly by Trivy..." if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then scan_success=true if [ -s "$out_json" ]; then cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) cnt=$(echo "$cnt" | tr -d '[:space:]') [ -n "$cnt" ] && vuln_cnt="$cnt" fi fi fi fi if [ "$scan_success" = "true" ]; then if [ -s "$out_json" ]; then 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 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" distro_arg="" if [ "$use_inbuild" = "true" ]; then detected_distro=$(detect_distro "$f") if [ -n "$detected_distro" ]; then echo "Detected distribution '${detected_distro}' from in-build SBOM PURLs. Scanning with --distro." distro_arg="--distro ${detected_distro}" fi fi scan_success=false if trivy sbom $distro_arg --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then scan_success=true fi vuln_cnt=0 if [ -s "$out_json" ]; then cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) cnt=$(echo "$cnt" | tr -d '[:space:]') [ -n "$cnt" ] && vuln_cnt="$cnt" fi # Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then echo "::warning::In-build SBOM for target image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..." t_plat="linux/${arch}" [ "$arch" = "default" ] && t_plat="" plat_arg="" [ -n "$t_plat" ] && plat_arg="--platform $t_plat" if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.target-image }}"; then echo "Re-scanning target SBOM generated directly by Trivy..." if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then scan_success=true if [ -s "$out_json" ]; then cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) cnt=$(echo "$cnt" | tr -d '[:space:]') [ -n "$cnt" ] && vuln_cnt="$cnt" fi fi fi fi if [ "$scan_success" = "true" ]; then if [ -s "$out_json" ]; then 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 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 "================================================================================" if [ "$use_inbuild" = "true" ]; then echo "Scan Strategy: Built-in OCI Attestation (Syft/Docker Scout) with distro detection" else echo "Scan Strategy: Direct Trivy Image Scan (Full rootfs & source package mapping)" fi 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 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-summary }}" > "${{ inputs.output-delta }}"; then echo "::error::Failed to generate CVE differential report" exit 1 fi - name: Publish Reports to Step Summary shell: bash run: | if [ "${{ inputs.write-summary }}" = "true" ] && [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then if [ -f "${{ inputs.output-delta }}" ]; then cat "${{ inputs.output-delta }}" >> "$GITHUB_STEP_SUMMARY" fi if [ -f "${{ inputs.output-summary }}" ]; then cat "${{ inputs.output-summary }}" >> "$GITHUB_STEP_SUMMARY" fi fi