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, generates SBOM directly from images with Trivy. 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.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: | 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)}" 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 "================================================================================" 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