@groupby/ai-dev 0.5.16 → 0.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/teams/firstspirit-caas/mcp/jira-tools.py +2122 -0
  3. package/teams/firstspirit-caas/mcp/test_bamboo_artifacts.py +315 -0
  4. package/teams/firstspirit-caas/mcp/test_jira_links.py +192 -0
  5. package/teams/firstspirit-caas/mcp/test_update_jira_ticket.py +161 -0
  6. package/teams/firstspirit-caas/resources/mcp-setup.md +57 -0
  7. package/teams/firstspirit-caas/skills/chronicle/SKILL.md +105 -0
  8. package/teams/firstspirit-caas/skills/create-jira-ticket/SKILL.md +183 -0
  9. package/teams/firstspirit-caas/skills/get-jira-ticket/SKILL.md +125 -0
  10. package/teams/firstspirit-caas/skills/handle-red-cve-plan/SKILL.md +316 -0
  11. package/teams/firstspirit-caas/skills/handle-red-cve-plan/references/suppression.md +79 -0
  12. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/gen_suppression.py +116 -0
  13. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_json_report.py +183 -0
  14. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_report.py +255 -0
  15. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_gen_suppression.py +171 -0
  16. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_json_report.py +244 -0
  17. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_report.py +464 -0
  18. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.html +39 -0
  19. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.json +76 -0
  20. package/teams/firstspirit-caas/skills/handle-weekly-cve-plans/SKILL.md +238 -0
  21. package/teams/firstspirit-caas/skills/retrospect/SKILL.md +94 -0
  22. package/teams/firstspirit-caas/skills/retrospect/extract_prompts.py +392 -0
  23. package/teams/firstspirit-caas/skills/review/SKILL.md +131 -0
  24. package/teams/firstspirit-caas/skills/review-finalize/SKILL.md +103 -0
  25. package/teams/firstspirit-caas/skills/specify/SKILL.md +239 -0
  26. package/teams/firstspirit-caas/skills/summarise-for-jira-comment/SKILL.md +162 -0
  27. package/teams/firstspirit-caas/skills/write-releasenotes/SKILL.md +177 -0
@@ -0,0 +1,464 @@
1
+ """Run: python3 plugins/planetexpress/skills/handle-red-cve-plan/scripts/test_parse_report.py"""
2
+ import contextlib
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import unittest
9
+
10
+ _here = os.path.dirname(os.path.abspath(__file__))
11
+ _spec = importlib.util.spec_from_file_location(
12
+ "parse_report", os.path.join(_here, "parse_report.py"))
13
+ _mod = importlib.util.module_from_spec(_spec)
14
+ _spec.loader.exec_module(_mod)
15
+
16
+ # Real Bamboo-published button markup (verified against a live report), attrs
17
+ # in a DIFFERENT order than testdata/sample-report.html's fixed
18
+ # display-name -> sha1 -> pkgurl -> type -> id-to-suppress order. This is the
19
+ # regression fixture for order-independent parsing.
20
+ _REORDERED_BUTTON_HTML = """
21
+ <div class="subsectioncontent">
22
+ <p><b><a href="?vulnId=CVE-2027-11111">CVE-2027-11111</a></b>&nbsp;
23
+ <button class="copybutton" data-id-to-suppress="CVE-2027-11111"
24
+ data-type-to-suppress="cve"
25
+ data-pkgurl="pkg:maven/com.example/foo@1.2.3"
26
+ data-sha1="deadbeef00112233445566778899aabbccddeeff"
27
+ title="Generate Suppression XML for this CVE for this file"
28
+ data-display-name="foo-1.2.3.jar">suppress</button></p>
29
+ CVSSv3: Base Score: CRITICAL (9.1) Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
30
+ </div>
31
+ """
32
+
33
+
34
+ class TestParse(unittest.TestCase):
35
+ def setUp(self):
36
+ with open(os.path.join(_here, "testdata", "sample-report.html"), encoding="utf-8") as fh:
37
+ self.html = fh.read()
38
+
39
+ def test_keeps_only_active_high(self):
40
+ findings = _mod.parse(self.html)["findings"]
41
+ ids = {f["cveId"] for f in findings}
42
+ self.assertEqual(ids, {"CVE-2026-54399"})
43
+
44
+ def test_finding_fields(self):
45
+ f = _mod.parse(self.html)["findings"][0]
46
+ self.assertEqual(f["cveId"], "CVE-2026-54399")
47
+ self.assertEqual(f["score"], 7.5)
48
+ self.assertEqual(f["severity"], "HIGH")
49
+ self.assertEqual(f["dependency"]["fileName"], "httpcore-4.4.16.jar")
50
+ self.assertEqual(f["dependency"]["packageUrl"],
51
+ "pkg:maven/org.apache.httpcomponents/httpcore@4.4.16")
52
+ self.assertEqual(f["dependency"]["sha1"],
53
+ "51cf043c87253c9f58b539c9f7e44c8894223850")
54
+
55
+ def test_drops_suppressed_section(self):
56
+ ids = {f["cveId"] for f in _mod.parse(self.html)["findings"]}
57
+ self.assertNotIn("CVE-2026-53914", ids)
58
+
59
+ # -- Report context: don't discard advisory data the report already has --
60
+
61
+ def test_captures_description_from_pre(self):
62
+ f = _mod.parse(self.html)["findings"][0]
63
+ self.assertIn("Uncontrolled Resource Consumption", f["description"])
64
+ self.assertIn("5.4.2 and earlier", f["description"])
65
+
66
+ def test_captures_reference_urls(self):
67
+ f = _mod.parse(self.html)["findings"][0]
68
+ self.assertIn("https://lists.apache.org/thread/abc123", f["references"])
69
+ self.assertIn("https://nvd.nist.gov/vuln/detail/CVE-2026-54399", f["references"])
70
+ # The "show all" toggle (href="#") must not be captured as a reference.
71
+ self.assertNotIn("#", f["references"])
72
+
73
+ def test_captures_affected_versions(self):
74
+ f = _mod.parse(self.html)["findings"][0]
75
+ self.assertTrue(any("versions up to (including) 5.4.2" in a
76
+ for a in f["affectedVersions"]))
77
+
78
+ def test_context_fields_absent_gracefully(self):
79
+ # A finding block with no <pre>/References/Vulnerable-Software still
80
+ # yields the keys with empty/None values, never a crash.
81
+ html = _REORDERED_BUTTON_HTML
82
+ f = _mod.parse(html)["findings"][0]
83
+ self.assertIsNone(f["description"])
84
+ self.assertEqual(f["references"], [])
85
+ self.assertEqual(f["affectedVersions"], [])
86
+
87
+ # -- Important-1: order-independent button attribute parsing --------
88
+
89
+ def test_button_attrs_in_different_order_still_parsed(self):
90
+ """A real report is not guaranteed to emit data-* attributes in any
91
+ particular order. If the parser silently drops a differently-ordered
92
+ button, a real HIGH/CRITICAL finding goes missing without warning."""
93
+ findings = _mod.parse(_REORDERED_BUTTON_HTML)["findings"]
94
+ ids = {f["cveId"] for f in findings}
95
+ self.assertIn("CVE-2027-11111", ids)
96
+ f = next(x for x in findings if x["cveId"] == "CVE-2027-11111")
97
+ self.assertEqual(f["dependency"]["fileName"], "foo-1.2.3.jar")
98
+ self.assertEqual(f["dependency"]["packageUrl"], "pkg:maven/com.example/foo@1.2.3")
99
+ self.assertEqual(f["dependency"]["sha1"], "deadbeef00112233445566778899aabbccddeeff")
100
+ self.assertEqual(f["score"], 9.1)
101
+
102
+ def test_reordered_button_does_not_bleed_into_neighbor(self):
103
+ """Two differently-ordered buttons back to back must each keep their
104
+ own attributes -- no cross-contamination between findings."""
105
+ html = _REORDERED_BUTTON_HTML + """
106
+ <div class="subsectioncontent">
107
+ <p><b><a href="?vulnId=CVE-2027-22222">CVE-2027-22222</a></b>&nbsp;
108
+ <button data-sha1="1111111111111111111111111111111111111111"
109
+ data-id-to-suppress="CVE-2027-22222"
110
+ data-display-name="bar-9.9.9.jar"
111
+ data-pkgurl="pkg:maven/com.example/bar@9.9.9"
112
+ class="copybutton">suppress</button></p>
113
+ CVSSv3: Base Score: HIGH (8.0)
114
+ </div>
115
+ """
116
+ findings = {f["cveId"]: f for f in _mod.parse(html)["findings"]}
117
+ self.assertEqual(findings["CVE-2027-11111"]["dependency"]["fileName"], "foo-1.2.3.jar")
118
+ self.assertEqual(findings["CVE-2027-22222"]["dependency"]["fileName"], "bar-9.9.9.jar")
119
+ self.assertEqual(findings["CVE-2027-11111"]["dependency"]["sha1"],
120
+ "deadbeef00112233445566778899aabbccddeeff")
121
+ self.assertEqual(findings["CVE-2027-22222"]["dependency"]["sha1"],
122
+ "1111111111111111111111111111111111111111")
123
+
124
+ # -- Important-2: bounded score lookup -------------------------------
125
+
126
+ def test_score_found_past_old_fixed_window(self):
127
+ """A long description must not push the Base Score line out of a
128
+ fixed-size lookahead window -- the whole finding block (up to the
129
+ next suppress button or EOF) must be searched."""
130
+ filler = "x" * 2000 # longer than the old 1500-char window
131
+ html = f"""
132
+ <div class="subsectioncontent">
133
+ <p><b><a href="?vulnId=CVE-2027-33333">CVE-2027-33333</a></b>&nbsp;
134
+ <button class="copybutton" data-display-name="baz-1.0.0.jar"
135
+ data-sha1="2222222222222222222222222222222222222222"
136
+ data-pkgurl="pkg:maven/com.example/baz@1.0.0"
137
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-33333">suppress</button></p>
138
+ {filler}
139
+ CVSSv3: Base Score: CRITICAL (9.8) Vector: CVSS:3.1/AV:N
140
+ </div>
141
+ """
142
+ ids = {f["cveId"] for f in _mod.parse(html)["findings"]}
143
+ self.assertIn("CVE-2027-33333", ids)
144
+
145
+ def test_score_lookup_bounded_by_next_button_not_full_document(self):
146
+ """A finding with no score of its own must not pick up the score
147
+ belonging to the NEXT finding's block."""
148
+ html = """
149
+ <div class="subsectioncontent">
150
+ <p><b><a href="?vulnId=CVE-2027-44444">CVE-2027-44444</a></b>&nbsp;
151
+ <button class="copybutton" data-display-name="qux-1.0.0.jar"
152
+ data-sha1="3333333333333333333333333333333333333333"
153
+ data-pkgurl="pkg:maven/com.example/qux@1.0.0"
154
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-44444">suppress</button></p>
155
+ No CVSS score present for this finding at all.
156
+ </div>
157
+ <div class="subsectioncontent">
158
+ <p><b><a href="?vulnId=CVE-2027-55555">CVE-2027-55555</a></b>&nbsp;
159
+ <button class="copybutton" data-display-name="quux-1.0.0.jar"
160
+ data-sha1="4444444444444444444444444444444444444444"
161
+ data-pkgurl="pkg:maven/com.example/quux@1.0.0"
162
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-55555">suppress</button></p>
163
+ CVSSv3: Base Score: HIGH (9.0)
164
+ </div>
165
+ """
166
+ ids = {f["cveId"] for f in _mod.parse(html)["findings"]}
167
+ self.assertNotIn("CVE-2027-44444", ids)
168
+ self.assertIn("CVE-2027-55555", ids)
169
+
170
+ # -- CVSS threshold boundary ------------------------------------------
171
+
172
+ def test_cvss_exactly_seven_is_kept(self):
173
+ """score == THRESHOLD (7.0) must be KEPT, not excluded."""
174
+ html = """
175
+ <div class="subsectioncontent">
176
+ <p><b><a href="?vulnId=CVE-2027-77777">CVE-2027-77777</a></b>&nbsp;
177
+ <button class="copybutton" data-display-name="boundary-1.0.0.jar"
178
+ data-sha1="5555555555555555555555555555555555555555"
179
+ data-pkgurl="pkg:maven/com.example/boundary@1.0.0"
180
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-77777">suppress</button></p>
181
+ CVSSv3: Base Score: HIGH (7.0) Vector: CVSS:3.1/AV:N
182
+ </div>
183
+ """
184
+ findings = _mod.parse(html)["findings"]
185
+ ids = {f["cveId"] for f in findings}
186
+ self.assertIn("CVE-2027-77777", ids)
187
+ f = next(x for x in findings if x["cveId"] == "CVE-2027-77777")
188
+ self.assertEqual(f["score"], 7.0)
189
+
190
+
191
+ # -- Fail-safe: unparseable-score active findings must never vanish -----
192
+
193
+ def test_cvssv2_style_score_line_without_base_score_prefix_is_a_finding(self):
194
+ """dependency-check renders CVSSv2 findings as `CVSSv2: Score: 7.5
195
+ (AV:N/...)` with no "Base Score:" prefix. This must still be treated
196
+ as a normal active finding (score/severity derived from the number),
197
+ not silently dropped and not misfiled as unparsed."""
198
+ html = """
199
+ <div class="subsectioncontent">
200
+ <p><b><a href="?vulnId=CVE-2027-66666">CVE-2027-66666</a></b>&nbsp;
201
+ <button class="copybutton" data-display-name="legacy-1.0.0.jar"
202
+ data-sha1="6666666666666666666666666666666666666666"
203
+ data-pkgurl="pkg:maven/com.example/legacy@1.0.0"
204
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-66666">suppress</button></p>
205
+ CVSSv2: Score: 7.5 (AV:N/AC:L/Au:N/C:P/I:P/A:P)
206
+ </div>
207
+ """
208
+ result = _mod.parse(html)
209
+ findings = {f["cveId"]: f for f in result["findings"]}
210
+ self.assertIn("CVE-2027-66666", findings)
211
+ f = findings["CVE-2027-66666"]
212
+ self.assertEqual(f["score"], 7.5)
213
+ self.assertEqual(f["severity"], "HIGH")
214
+ self.assertEqual(result["unparsed"], [])
215
+
216
+ def test_active_finding_with_no_recognizable_score_goes_to_unparsed_not_lost(self):
217
+ """An active suppress button whose block has no parseable score at
218
+ all is a fail-safe case: it must NEVER be silently discarded. It must
219
+ surface in the `unparsed` bucket (with enough detail to investigate)
220
+ and must NOT appear in `findings`."""
221
+ html = """
222
+ <div class="subsectioncontent">
223
+ <p><b><a href="?vulnId=CVE-2027-99999">CVE-2027-99999</a></b>&nbsp;
224
+ <button class="copybutton" data-display-name="mystery-1.0.0.jar"
225
+ data-sha1="9999999999999999999999999999999999999999"
226
+ data-pkgurl="pkg:maven/com.example/mystery@1.0.0"
227
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-99999">suppress</button></p>
228
+ No score information rendered for this finding at all.
229
+ </div>
230
+ """
231
+ result = _mod.parse(html)
232
+ ids = {f["cveId"] for f in result["findings"]}
233
+ self.assertNotIn("CVE-2027-99999", ids)
234
+ unparsed_ids = {u["cveId"] for u in result["unparsed"]}
235
+ self.assertIn("CVE-2027-99999", unparsed_ids)
236
+ u = next(x for x in result["unparsed"] if x["cveId"] == "CVE-2027-99999")
237
+ self.assertEqual(u["fileName"], "mystery-1.0.0.jar")
238
+ self.assertEqual(u["packageUrl"], "pkg:maven/com.example/mystery@1.0.0")
239
+ self.assertEqual(u["sha1"], "9999999999999999999999999999999999999999")
240
+
241
+ def test_sample_report_has_no_unparsed_entries(self):
242
+ """Sanity: the baseline fixture has no unparseable active findings."""
243
+ self.assertEqual(_mod.parse(self.html)["unparsed"], [])
244
+
245
+ # -- "Unscored: Severity: <band>" advisories (JS/NPM) ------------------
246
+ # dependency-check renders scoreless advisories as `Unscored: Severity:
247
+ # medium`. The band is authoritative -- below-HIGH must be excluded (not a
248
+ # false hard stop), at/above-HIGH handled by id type. Verified against the
249
+ # live haupia (SMASE-WDCS) weekly report, which is red on one real CVE but
250
+ # also carries 16 medium + 6 low unscored JS advisories.
251
+
252
+ def _unscored_button(self, cve, band, typ="vulnerabilityName", pkg="pkg:javascript/foo@1.0"):
253
+ return f"""
254
+ <div class="subsectioncontent">
255
+ <p><b>{cve}</b>&nbsp;
256
+ <button class="copybutton" data-display-name="foo.jar: foo.js"
257
+ data-sha1="abababababababababababababababababababab"
258
+ data-pkgurl="{pkg}"
259
+ data-type-to-suppress="{typ}" data-id-to-suppress="{cve}">suppress</button></p>
260
+ Some advisory prose here. Unscored: Severity: {band} References
261
+ </div>
262
+ """
263
+
264
+ def test_unscored_medium_cve_is_excluded_not_hard_stop(self):
265
+ """A scoreless medium CVE (e.g. DOMPurify XSS) is below HIGH -- it does
266
+ not redden the build and must NOT force a false hard stop via
267
+ `unparsed`."""
268
+ result = _mod.parse(self._unscored_button("CVE-2026-41238", "medium"))
269
+ self.assertEqual(result["findings"], [])
270
+ self.assertEqual(result["unparsed"], [])
271
+
272
+ def test_unscored_low_cve_is_excluded(self):
273
+ result = _mod.parse(self._unscored_button("CVE-2026-49978", "low"))
274
+ self.assertEqual(result["findings"], [])
275
+ self.assertEqual(result["unparsed"], [])
276
+
277
+ def test_unscored_high_cve_becomes_a_finding(self):
278
+ """A scoreless HIGH CVE is at/above the CVSS>=7.0 gate -- it must be a
279
+ finding (score None, severity carried from the band)."""
280
+ result = _mod.parse(self._unscored_button("CVE-2026-70001", "high"))
281
+ ids = {f["cveId"] for f in result["findings"]}
282
+ self.assertIn("CVE-2026-70001", ids)
283
+ f = next(x for x in result["findings"] if x["cveId"] == "CVE-2026-70001")
284
+ self.assertIsNone(f["score"])
285
+ self.assertEqual(f["severity"], "HIGH")
286
+ self.assertEqual(result["unparsed"], [])
287
+
288
+ def test_unscored_critical_cve_becomes_a_finding(self):
289
+ result = _mod.parse(self._unscored_button("CVE-2026-70002", "critical"))
290
+ self.assertIn("CVE-2026-70002", {f["cveId"] for f in result["findings"]})
291
+
292
+ def test_unscored_medium_non_cve_is_excluded(self):
293
+ """A scoreless medium GHSA is below HIGH -- excluded, no hard stop."""
294
+ result = _mod.parse(self._unscored_button("GHSA-aaaa-bbbb-cccc", "medium"))
295
+ self.assertEqual(result["findings"], [])
296
+ self.assertEqual(result["unparsed"], [])
297
+
298
+ def test_unscored_high_non_cve_goes_to_unparsed(self):
299
+ """A scoreless HIGH GHSA reddens the build but has no CVE to remediate
300
+ -- it must hard-stop via `unparsed`, not be silently excluded."""
301
+ result = _mod.parse(self._unscored_button("GHSA-dddd-eeee-ffff", "high"))
302
+ self.assertEqual(result["findings"], [])
303
+ self.assertIn("GHSA-dddd-eeee-ffff", {u["cveId"] for u in result["unparsed"]})
304
+
305
+ # -- unparsed is deduplicated by (id, file) ----------------------------
306
+
307
+ def test_unparsed_deduplicated_by_id_and_file(self):
308
+ """The same advisory on the same file rendered by two buttons (e.g. a
309
+ cve-typed and a vulnerabilityName-typed button) must yield ONE unparsed
310
+ entry, not a duplicate."""
311
+ one = self._unscored_button("GHSA-dddd-eeee-ffff", "high")
312
+ result = _mod.parse(one + one)
313
+ matches = [u for u in result["unparsed"] if u["cveId"] == "GHSA-dddd-eeee-ffff"]
314
+ self.assertEqual(len(matches), 1)
315
+
316
+ # -- Non-CVE advisory ids (GHSA-*) must never silently vanish ----------
317
+
318
+ # -- CPE identifier suppress buttons are benign, never a finding -------
319
+
320
+ def test_cpe_identifier_suppress_button_is_ignored(self):
321
+ """dependency-check renders a `data-type-to-suppress="cpe"` suppress
322
+ button on EVERY dependency's CPE identifier, whether or not it has a
323
+ vulnerability. These are ubiquitous and do not redden the build --
324
+ they must appear in neither `findings` nor `unparsed`."""
325
+ html = """
326
+ <div class="subsectioncontent">
327
+ <button class="copybutton" title="Generate Suppression XML for the identified vulnerability identifier"
328
+ data-display-name="netty-4.1.0.jar"
329
+ data-sha1="cccccccccccccccccccccccccccccccccccccccc"
330
+ data-pkgurl="pkg:maven/io.netty/netty@4.1.0"
331
+ data-type-to-suppress="cpe"
332
+ data-id-to-suppress="cpe:/a:netty:netty:4.1.0">suppress</button>
333
+ </div>
334
+ """
335
+ result = _mod.parse(html)
336
+ self.assertEqual(result["findings"], [])
337
+ self.assertEqual(result["unparsed"], [])
338
+
339
+ def test_cpe_button_ignored_even_without_type_attr(self):
340
+ """Belt-and-suspenders: a bare `cpe:` id with no type attribute is
341
+ still an identifier, never a vulnerability -- ignore it."""
342
+ html = """
343
+ <div class="subsectioncontent">
344
+ <button class="copybutton" data-display-name="netty-4.1.0.jar"
345
+ data-sha1="dddddddddddddddddddddddddddddddddddddddd"
346
+ data-id-to-suppress="cpe:/a:netty:netty:4.1.0">suppress</button>
347
+ </div>
348
+ """
349
+ result = _mod.parse(html)
350
+ self.assertEqual(result["findings"], [])
351
+ self.assertEqual(result["unparsed"], [])
352
+
353
+ def test_report_full_of_cpe_buttons_and_one_cve_yields_only_the_cve(self):
354
+ """A realistic slice: many benign CPE identifier buttons around a
355
+ single real CVE. Only the CVE is a finding; nothing lands in
356
+ `unparsed`."""
357
+ cpe_btns = "".join(f"""
358
+ <button class="copybutton" data-display-name="dep-{i}.jar"
359
+ data-sha1="{str(i)*40:.40}" data-pkgurl="pkg:maven/g/dep{i}@1.0"
360
+ data-type-to-suppress="cpe"
361
+ data-id-to-suppress="cpe:/a:g:dep{i}:1.0">suppress</button>""" for i in range(1, 6))
362
+ html = f"""
363
+ <div class="subsectioncontent">
364
+ {cpe_btns}
365
+ <p><b><a href="?vulnId=CVE-2027-12121">CVE-2027-12121</a></b>&nbsp;
366
+ <button class="copybutton" data-display-name="real-1.0.0.jar"
367
+ data-sha1="eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
368
+ data-pkgurl="pkg:maven/com.example/real@1.0.0"
369
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-12121">suppress</button></p>
370
+ CVSSv3: Base Score: HIGH (7.5) Vector: CVSS:3.1/AV:N
371
+ </div>
372
+ """
373
+ result = _mod.parse(html)
374
+ self.assertEqual({f["cveId"] for f in result["findings"]}, {"CVE-2027-12121"})
375
+ self.assertEqual(result["unparsed"], [])
376
+
377
+ # -- Non-CVE advisory ids (GHSA-*) must never silently vanish ----------
378
+
379
+ def test_ghsa_active_finding_goes_to_unparsed_not_lost(self):
380
+ """A GHSA-only active suppress button reddens the build but is not a
381
+ CVE. It must NOT be silently skipped (which would converge to "0
382
+ findings" on a red build); it must surface in `unparsed` so the caller
383
+ hard-stops, and must NOT appear in `findings`."""
384
+ html = """
385
+ <div class="subsectioncontent">
386
+ <p><b><a href="?vulnId=GHSA-abcd-1234-wxyz">GHSA-abcd-1234-wxyz</a></b>&nbsp;
387
+ <button class="copybutton" data-display-name="ghsaonly-1.0.0.jar"
388
+ data-sha1="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
389
+ data-pkgurl="pkg:maven/com.example/ghsaonly@1.0.0"
390
+ data-type-to-suppress="vulnerabilityName"
391
+ data-id-to-suppress="GHSA-abcd-1234-wxyz">suppress</button></p>
392
+ CVSSv3: Base Score: CRITICAL (9.8) Vector: CVSS:3.1/AV:N
393
+ </div>
394
+ """
395
+ result = _mod.parse(html)
396
+ self.assertNotIn("GHSA-abcd-1234-wxyz",
397
+ {f["cveId"] for f in result["findings"]})
398
+ u = next(x for x in result["unparsed"]
399
+ if x["cveId"] == "GHSA-abcd-1234-wxyz")
400
+ self.assertEqual(u["fileName"], "ghsaonly-1.0.0.jar")
401
+ self.assertEqual(u["packageUrl"], "pkg:maven/com.example/ghsaonly@1.0.0")
402
+ self.assertIn("non-CVE", u["reason"])
403
+
404
+ def test_ghsa_button_does_not_break_neighboring_cve_finding(self):
405
+ """A GHSA button sitting between a CVE button and its score must not
406
+ truncate the CVE block or drop the CVE finding."""
407
+ html = """
408
+ <div class="subsectioncontent">
409
+ <p><b><a href="?vulnId=CVE-2027-88888">CVE-2027-88888</a></b>&nbsp;
410
+ <button class="copybutton" data-display-name="real-1.0.0.jar"
411
+ data-sha1="7777777777777777777777777777777777777777"
412
+ data-pkgurl="pkg:maven/com.example/real@1.0.0"
413
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2027-88888">suppress</button></p>
414
+ CVSSv3: Base Score: HIGH (8.1) Vector: CVSS:3.1/AV:N
415
+ </div>
416
+ <div class="subsectioncontent">
417
+ <p><b><a href="?vulnId=GHSA-zzzz-yyyy-xxxx">GHSA-zzzz-yyyy-xxxx</a></b>&nbsp;
418
+ <button class="copybutton" data-display-name="ghsa-1.0.0.jar"
419
+ data-sha1="8888888888888888888888888888888888888888"
420
+ data-pkgurl="pkg:maven/com.example/ghsa@1.0.0"
421
+ data-type-to-suppress="vulnerabilityName"
422
+ data-id-to-suppress="GHSA-zzzz-yyyy-xxxx">suppress</button></p>
423
+ CVSSv3: Base Score: CRITICAL (9.0) Vector: CVSS:3.1/AV:N
424
+ </div>
425
+ """
426
+ result = _mod.parse(html)
427
+ findings = {f["cveId"]: f for f in result["findings"]}
428
+ self.assertEqual(findings["CVE-2027-88888"]["score"], 8.1)
429
+ self.assertIn("GHSA-zzzz-yyyy-xxxx",
430
+ {u["cveId"] for u in result["unparsed"]})
431
+
432
+
433
+ class TestMain(unittest.TestCase):
434
+ def setUp(self):
435
+ self.sample = os.path.join(_here, "testdata", "sample-report.html")
436
+
437
+ def test_out_writes_valid_json_and_exits_zero(self):
438
+ with tempfile.TemporaryDirectory() as tmpdir:
439
+ out_path = os.path.join(tmpdir, "out.json")
440
+ rc = _mod.main([self.sample, "--out", out_path])
441
+ self.assertEqual(rc, 0)
442
+ with open(out_path, encoding="utf-8") as fh:
443
+ data = json.load(fh)
444
+ ids = {f["cveId"] for f in data["findings"]}
445
+ self.assertEqual(ids, {"CVE-2026-54399"})
446
+
447
+ def test_missing_input_file_exits_nonzero_with_stderr_message(self):
448
+ stderr = io.StringIO()
449
+ with contextlib.redirect_stderr(stderr):
450
+ rc = _mod.main(["/nonexistent/path/does-not-exist.html"])
451
+ self.assertNotEqual(rc, 0)
452
+ self.assertIn("parse_report.py:", stderr.getvalue())
453
+
454
+ def test_unwritable_out_path_exits_nonzero_with_stderr_message(self):
455
+ stderr = io.StringIO()
456
+ with contextlib.redirect_stderr(stderr):
457
+ rc = _mod.main([self.sample, "--out", "/nonexistent-dir/out.json"])
458
+ self.assertNotEqual(rc, 0)
459
+ self.assertIn("parse_report.py:", stderr.getvalue())
460
+ self.assertIn("cannot write output", stderr.getvalue())
461
+
462
+
463
+ if __name__ == "__main__":
464
+ unittest.main()
@@ -0,0 +1,39 @@
1
+ <html><body>
2
+ <!-- ACTIVE finding, HIGH 7.5 -> KEEP -->
3
+ <div class="subsectioncontent">
4
+ <p><b><a href="?vulnId=CVE-2026-54399">CVE-2026-54399</a></b>&nbsp;
5
+ <button class="copybutton" data-display-name="httpcore-4.4.16.jar"
6
+ data-sha1="51cf043c87253c9f58b539c9f7e44c8894223850"
7
+ data-pkgurl="pkg:maven/org.apache.httpcomponents/httpcore@4.4.16"
8
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2026-54399">suppress</button></p>
9
+ <p><pre>Uncontrolled Resource Consumption in Apache HttpComponents Core (5.4.2 and earlier) allows a remote DoS.</pre></p>
10
+ CWE-400<br/><br/>
11
+ CVSSv3: Base Score: HIGH (7.5) Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H<br/><br/>
12
+ References:
13
+ <ul>
14
+ <li><a target="_blank" href="https://lists.apache.org/thread/abc123">MAILING_LIST,VENDOR_ADVISORY</a></li>
15
+ <li><a target="_blank" href="https://nvd.nist.gov/vuln/detail/CVE-2026-54399">NVD</a></li>
16
+ <li><a href="#" class="versionToggle">show all</a></li>
17
+ </ul>
18
+ Vulnerable Software &amp; Versions: (<a href="#" class="versionToggle">show all</a>)
19
+ <ul>
20
+ <li><a target="_blank" href="https://nvd.nist.gov/vuln/search#cpe">cpe:2.3:a:apache:httpcomponents_core:*:*:*:*:*:*:*:* versions up to (including) 5.4.2</a></li>
21
+ <li><a target="_blank" href="https://nvd.nist.gov/vuln/search#cpe">cpe:2.3:a:apache:httpcomponents_core:5.5:beta1:*:*:*:*:*:*</a></li>
22
+ </ul>
23
+ </div>
24
+ <!-- ACTIVE finding, MEDIUM 4.8 -> DROP (below threshold) -->
25
+ <div class="subsectioncontent">
26
+ <p><b><a href="?vulnId=CVE-2026-14683">CVE-2026-14683</a></b>&nbsp;
27
+ <button class="copybutton" data-display-name="HdrHistogram-2.2.2.jar"
28
+ data-sha1="aaaa1111bbbb2222cccc3333dddd4444eeee5555"
29
+ data-pkgurl="pkg:maven/org.hdrhistogram/HdrHistogram@2.2.2"
30
+ data-type-to-suppress="cve" data-id-to-suppress="CVE-2026-14683">suppress</button></p>
31
+ CVSSv3: Base Score: MEDIUM (4.8) Vector: CVSS:3.1/AV:N/AC:H
32
+ </div>
33
+ <!-- SUPPRESSED finding, CRITICAL 9.8 -> DROP (no suppress button) -->
34
+ <div class="sectionheader">Suppressed Vulnerabilities</div>
35
+ <div class="subsectioncontent">
36
+ <p><b>CVE-2026-53914</b> suppressed In JetBrains Kotlin ... </p>
37
+ CVSSv3: CRITICAL (9.8)
38
+ </div>
39
+ </body></html>
@@ -0,0 +1,76 @@
1
+ {
2
+ "reportSchema": "1.1",
3
+ "scanInfo": { "engineVersion": "12.2.2" },
4
+ "projectInfo": { "name": "sample" },
5
+ "dependencies": [
6
+ {
7
+ "fileName": "httpcore-4.4.16.jar",
8
+ "sha1": "51cf043c87253c9f58b539c9f7e44c8894223850",
9
+ "packages": [
10
+ { "id": "pkg:maven/org.apache.httpcomponents/httpcore@4.4.16" }
11
+ ],
12
+ "includedBy": [
13
+ { "reference": "pkg:maven/com.example/app@1.0.0" }
14
+ ],
15
+ "vulnerabilities": [
16
+ {
17
+ "source": "NVD",
18
+ "name": "CVE-2026-54399",
19
+ "severity": "HIGH",
20
+ "cvssv3": { "baseScore": 7.5, "baseSeverity": "HIGH", "version": "3.1" },
21
+ "description": "Uncontrolled Resource Consumption in Apache HttpComponents Core 5.4.2 and earlier.",
22
+ "references": [
23
+ { "source": "NVD", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54399", "name": "" },
24
+ { "source": "apache", "url": "https://lists.apache.org/thread/abc123", "name": "" }
25
+ ],
26
+ "vulnerableSoftware": [
27
+ {
28
+ "software": {
29
+ "id": "cpe:2.3:a:apache:httpcomponents_core:*:*:*:*:*:*:*:*",
30
+ "vulnerabilityIdMatched": "true",
31
+ "versionEndIncluding": "5.4.2"
32
+ }
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "suppressedVulnerabilities": [
38
+ {
39
+ "source": "NVD",
40
+ "name": "CVE-2026-53914",
41
+ "severity": "CRITICAL",
42
+ "cvssv3": { "baseScore": 9.8, "baseSeverity": "CRITICAL", "version": "3.1" },
43
+ "description": "Suppressed critical finding that must not surface."
44
+ }
45
+ ]
46
+ },
47
+ {
48
+ "fileName": "somelib-1.0.0.jar",
49
+ "sha1": "1111111111111111111111111111111111111111",
50
+ "packages": [ { "id": "pkg:maven/com.example/somelib@1.0.0" } ],
51
+ "vulnerabilities": [
52
+ {
53
+ "source": "NVD",
54
+ "name": "CVE-2026-11111",
55
+ "severity": "MEDIUM",
56
+ "cvssv3": { "baseScore": 5.3, "baseSeverity": "MEDIUM", "version": "3.1" }
57
+ }
58
+ ],
59
+ "suppressedVulnerabilities": []
60
+ },
61
+ {
62
+ "fileName": "ghsaonly-1.0.0.jar",
63
+ "sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
64
+ "packages": [ { "id": "pkg:maven/com.example/ghsaonly@1.0.0" } ],
65
+ "vulnerabilities": [
66
+ {
67
+ "source": "OSSINDEX",
68
+ "name": "GHSA-abcd-1234-wxyz",
69
+ "severity": "CRITICAL",
70
+ "cvssv3": { "baseScore": 9.8, "baseSeverity": "CRITICAL", "version": "3.1" }
71
+ }
72
+ ],
73
+ "suppressedVulnerabilities": []
74
+ }
75
+ ]
76
+ }