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; }