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 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' 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.74.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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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: | mkdir -p "$HOME/.cache/trivy-bin" "$HOME/.local/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 ;; 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="" # --- 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 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 # 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..." 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 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 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 # 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..." 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 - 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=() # 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) 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" 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) 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 "================================================================================" 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 }}"; 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" 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: | 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