1- name : Compare vulnerabilities (Syft SBOM -> Grype) between two branches
1+ name : Compare vulnerabilities (Syft SBOM -> Grype) between two branches (robust)
22
33on :
44 workflow_dispatch :
2020 steps :
2121 - name : Prepare workspace
2222 run : |
23+ set -euo pipefail
2324 mkdir -p "${REPORT_DIR}"
2425
2526 - name : Checkout branch A
@@ -37,15 +38,21 @@ jobs:
3738 fetch-depth : 0
3839
3940 - name : Install dependencies (jq, unzip)
40- run : sudo apt-get update && sudo apt-get install -y jq unzip
41+ run : |
42+ sudo apt-get update
43+ sudo apt-get install -y jq unzip
4144
42- - name : Install Syft (generate SBOMs)
45+ - name : Install Syft
4346 run : |
47+ set -euo pipefail
4448 curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sudo sh -s -- -b /usr/local/bin
49+ syft version
4550
46- - name : Install Grype (scan SBOMs)
51+ - name : Install Grype
4752 run : |
53+ set -euo pipefail
4854 curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sudo sh -s -- -b /usr/local/bin
55+ grype version
4956
5057 - name : Generate SBOM for branch A (CycloneDX JSON)
5158 run : |
@@ -60,14 +67,33 @@ jobs:
6067 - name : Scan SBOM with Grype (branch A)
6168 run : |
6269 set -euo pipefail
63- grype sbom:"${REPORT_DIR}/branchA-sbom.cdx.json" -o json > "${REPORT_DIR}/branchA-grype.json" || true
70+ grype sbom:"${REPORT_DIR}/branchA-sbom.cdx.json" -o json > "${REPORT_DIR}/branchA-grype.json"
6471
6572 - name : Scan SBOM with Grype (branch B)
6673 run : |
6774 set -euo pipefail
68- grype sbom:"${REPORT_DIR}/branchB-sbom.cdx.json" -o json > "${REPORT_DIR}/branchB-grype.json" || true
75+ grype sbom:"${REPORT_DIR}/branchB-sbom.cdx.json" -o json > "${REPORT_DIR}/branchB-grype.json"
76+
77+ - name :
78+ Debug : show match counts and sample (for troubleshooting)
79+ run : |
80+ set -euo pipefail
81+ echo "---- branchA summary ----"
82+ if [ -f "${REPORT_DIR}/branchA-grype.json" ]; then
83+ jq '.matches | length' "${REPORT_DIR}/branchA-grype.json" || true
84+ jq '.matches[0:5]' "${REPORT_DIR}/branchA-grype.json" || true
85+ else
86+ echo "branchA-grype.json missing"
87+ fi
88+ echo "---- branchB summary ----"
89+ if [ -f "${REPORT_DIR}/branchB-grype.json" ]; then
90+ jq '.matches | length' "${REPORT_DIR}/branchB-grype.json" || true
91+ jq '.matches[0:5]' "${REPORT_DIR}/branchB-grype.json" || true
92+ else
93+ echo "branchB-grype.json missing"
94+ fi
6995
70- - name : Generate comparison report (by VulnerabilityID and by package:version)
96+ - name : Generate robust comparison report (by VulnerabilityID and by package:version)
7197 run : |
7298 set -euo pipefail
7399 A_BRANCH="${{ github.event.inputs.branch_a }}"
@@ -77,40 +103,61 @@ jobs:
77103 OUT="${REPORT_DIR}/comparison-report.md"
78104 mkdir -p "$(dirname "$OUT")"
79105
106+ # --- basic presence checks ---
107+ if [ ! -s "$A_GRYPE" ]; then
108+ echo "ERROR: ${A_GRYPE} not found or empty" >&2
109+ exit 1
110+ fi
111+ if [ ! -s "$B_GRYPE" ]; then
112+ echo "ERROR: ${B_GRYPE} not found or empty" >&2
113+ exit 1
114+ fi
115+
80116 echo "# Vulnerability comparison: ${A_BRANCH} **vs** ${B_BRANCH}" > "${OUT}"
81117 echo "" >> "${OUT}"
118+
82119 # Totals (unique vulnerability IDs)
83120 totalA=$(jq -r '[ .matches[]?.vulnerability?.id ] | unique | length' "${A_GRYPE}" 2>/dev/null || echo 0)
84121 totalB=$(jq -r '[ .matches[]?.vulnerability?.id ] | unique | length' "${B_GRYPE}" 2>/dev/null || echo 0)
85122 echo "- **Total unique vulnerability IDs**: ${totalA} (${A_BRANCH}) | ${totalB} (${B_BRANCH})" >> "${OUT}"
86123 echo "" >> "${OUT}"
87124
88125 # Totals by package:version (unique vulnerable packages)
89- pkgsA=$(jq -r '[ .matches[]? | "\(.artifact.name // \"-\"):\(.artifact.version // \"-\")" ] | unique | length' "${A_GRYPE}" 2>/dev/null || echo 0)
90- pkgsB=$(jq -r '[ .matches[]? | "\(.artifact.name // \"-\"):\(.artifact.version // \"-\")" ] | unique | length' "${B_GRYPE}" 2>/dev/null || echo 0)
126+ # Use a robust expression: handle .artifact being object or string
127+ jq -r '[ .matches[]? |
128+ ( if (.artifact | type) == "object"
129+ then ((.artifact.name // .artifact.id // "-") + ":" + (.artifact.version // "-"))
130+ else ((.artifact // "-") + ":" + ("-"))
131+ end)
132+ ] | unique | .[]' "${A_GRYPE}" 2>/dev/null | sort > /tmp/a_pkgs.txt || true
133+ jq -r '[ .matches[]? |
134+ ( if (.artifact | type) == "object"
135+ then ((.artifact.name // .artifact.id // "-") + ":" + (.artifact.version // "-"))
136+ else ((.artifact // "-") + ":" + ("-"))
137+ end)
138+ ] | unique | .[]' "${B_GRYPE}" 2>/dev/null | sort > /tmp/b_pkgs.txt || true
139+
140+ pkgsA=$(wc -l < /tmp/a_pkgs.txt 2>/dev/null || echo 0)
141+ pkgsB=$(wc -l < /tmp/b_pkgs.txt 2>/dev/null || echo 0)
91142 echo "- **Total unique vulnerable package:version**: ${pkgsA} (${A_BRANCH}) | ${pkgsB} (${B_BRANCH})" >> "${OUT}"
92143 echo "" >> "${OUT}"
93144
94- # Severity table (counts by vulnerability ID)
145+ # Severity table (counts by unique vulnerability ID) — normalize severity uppercase
95146 echo "## Vulnerabilities by severity (counted by unique VulnerabilityID)" >> "${OUT}"
96147 echo "" >> "${OUT}"
97148 echo "| Severity | ${A_BRANCH} | ${B_BRANCH} |" >> "${OUT}"
98149 echo "|---:|---:|---:|" >> "${OUT}"
99150 for sev in CRITICAL HIGH MEDIUM LOW UNKNOWN; do
100- ca=$(jq --arg s "$sev" '[ .matches[]?.vulnerability? | select(.severity== $s) | .id ] | unique | length' "${A_GRYPE}" 2>/dev/null || echo 0)
101- cb=$(jq --arg s "$sev" '[ .matches[]?.vulnerability? | select(.severity== $s) | .id ] | unique | length' "${B_GRYPE}" 2>/dev/null || echo 0)
151+ ca=$(jq --arg s "$sev" '[ .matches[]?.vulnerability? | select(( .severity // "") | ascii_upcase == $s) | .id ] | unique | length' "${A_GRYPE}" 2>/dev/null || echo 0)
152+ cb=$(jq --arg s "$sev" '[ .matches[]?.vulnerability? | select(( .severity // "") | ascii_upcase == $s) | .id ] | unique | length' "${B_GRYPE}" 2>/dev/null || echo 0)
102153 echo "| $sev | $ca | $cb |" >> "${OUT}"
103154 done
104155 echo "" >> "${OUT}"
105156
106- # Create lists of unique vulnerability IDs
157+ # Prepare lists of unique vulnerability IDs
107158 jq -r '[ .matches[]?.vulnerability?.id ] | unique | .[]' "${A_GRYPE}" 2>/dev/null | sort > /tmp/a_ids.txt || true
108159 jq -r '[ .matches[]?.vulnerability?.id ] | unique | .[]' "${B_GRYPE}" 2>/dev/null | sort > /tmp/b_ids.txt || true
109160
110- # Create lists of unique package:version pairs
111- jq -r '[ .matches[]? | "\(.artifact.name // \"-\"):\(.artifact.version // \"-\")" ] | unique | .[]' "${A_GRYPE}" 2>/dev/null | sort > /tmp/a_pkgs.txt || true
112- jq -r '[ .matches[]? | "\(.artifact.name // \"-\"):\(.artifact.version // \"-\")" ] | unique | .[]' "${B_GRYPE}" 2>/dev/null | sort > /tmp/b_pkgs.txt || true
113-
114161 # New vulnerabilities in A not in B (by ID)
115162 echo "## VulnerabilityIDs present in ${A_BRANCH} but NOT in ${B_BRANCH}" >> "${OUT}"
116163 echo "" >> "${OUT}"
@@ -120,7 +167,11 @@ jobs:
120167 while read -r id; do
121168 jq --arg id "$id" -r '
122169 .matches[]? | select(.vulnerability?.id==$id) |
123- ("- " + (.vulnerability.id // "-") + " | " + (.vulnerability.severity // "-") + " | " + (.artifact.name // "-") + " | " + (.artifact.version // "-") + " | " + ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:250]))
170+ ("- " + (.vulnerability.id // "-") + " | " + ((.vulnerability.severity // "") | ascii_upcase // "-") + " | " +
171+ ( if (.artifact | type) == "object" then (.artifact.name // .artifact.id // "-") else (.artifact // "-") end ) + " | " +
172+ ( if (.artifact | type) == "object" then (.artifact.version // "-") else "-" end ) + " | " +
173+ ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:250] )
174+ )
124175 ' "${A_GRYPE}" | head -n 1 >> "${OUT}"
125176 done < /tmp/new_in_a_ids.txt
126177 else
@@ -140,7 +191,11 @@ jobs:
140191 while read -r id; do
141192 jq --arg id "$id" -r '
142193 .matches[]? | select(.vulnerability?.id==$id) |
143- ("- " + (.vulnerability.id // "-") + " | " + (.vulnerability.severity // "-") + " | " + (.artifact.name // "-") + " | " + (.artifact.version // "-") + " | " + ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:250]))
194+ ("- " + (.vulnerability.id // "-") + " | " + ((.vulnerability.severity // "") | ascii_upcase // "-") + " | " +
195+ ( if (.artifact | type) == "object" then (.artifact.name // .artifact.id // "-") else (.artifact // "-") end ) + " | " +
196+ ( if (.artifact | type) == "object" then (.artifact.version // "-") else "-" end ) + " | " +
197+ ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:250] )
198+ )
144199 ' "${B_GRYPE}" | head -n 1 >> "${OUT}"
145200 done < /tmp/new_in_b_ids.txt
146201 else
@@ -158,10 +213,13 @@ jobs:
158213 comm -23 /tmp/a_pkgs.txt /tmp/b_pkgs.txt > /tmp/new_in_a_pkgs.txt || true
159214 if [ -s /tmp/new_in_a_pkgs.txt ]; then
160215 while read -r pv; do
161- # show a sample vulnerability that affects this pkg:version
162216 jq -r --arg pv "$pv" '
163- .matches[]? | select((.artifact.name // "-") + ":" + (.artifact.version // "-") == $pv) |
164- ("- " + $pv + " | " + (.vulnerability.id // "-") + " | " + (.vulnerability.severity // "-") + " | " + ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:200]))
217+ .matches[]? | select(
218+ ( if (.artifact|type) == "object" then ((.artifact.name // .artifact.id // "-") + ":" + (.artifact.version // "-")) else ((.artifact // "-") + ":" + "-") end) == $pv
219+ ) |
220+ ("- " + $pv + " | " + (.vulnerability.id // "-") + " | " + ((.vulnerability.severity // "") | ascii_upcase // "-") + " | " +
221+ ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:200])
222+ )
165223 ' "${A_GRYPE}" | head -n 1 >> "${OUT}"
166224 done < /tmp/new_in_a_pkgs.txt
167225 else
@@ -180,8 +238,12 @@ jobs:
180238 if [ -s /tmp/new_in_b_pkgs.txt ]; then
181239 while read -r pv; do
182240 jq -r --arg pv "$pv" '
183- .matches[]? | select((.artifact.name // "-") + ":" + (.artifact.version // "-") == $pv) |
184- ("- " + $pv + " | " + (.vulnerability.id // "-") + " | " + (.vulnerability.severity // "-") + " | " + ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:200]))
241+ .matches[]? | select(
242+ ( if (.artifact|type) == "object" then ((.artifact.name // .artifact.id // "-") + ":" + (.artifact.version // "-")) else ((.artifact // "-") + ":" + "-") end) == $pv
243+ ) |
244+ ("- " + $pv + " | " + (.vulnerability.id // "-") + " | " + ((.vulnerability.severity // "") | ascii_upcase // "-") + " | " +
245+ ((.vulnerability.description // "") | gsub("\n"; " ") | .[0:200])
246+ )
185247 ' "${B_GRYPE}" | head -n 1 >> "${OUT}"
186248 done < /tmp/new_in_b_pkgs.txt
187249 else
@@ -202,11 +264,12 @@ jobs:
202264
203265 - name : Create ZIP of reports
204266 run : |
267+ set -euo pipefail
205268 cd "${REPORT_DIR}"
206269 zip -r comparison-artifacts.zip . || true
207270
208271 - name : Upload artifacts (reports)
209272 uses : actions/upload-artifact@v4
210273 with :
211- name : vuln-comparison-${{ github.run_id }}
274+ name : vuln-comparison-${{ github.run_id }}-${{ github.event.inputs.branch_a }}-vs-${{ github.event.inputs.branch_b }}-$(date +%s)
212275 path : ${{ env.REPORT_DIR }}
0 commit comments