Coverage for tests / unit / parsers / test_osv_scanner_parser.py: 100%
85 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Unit tests for OSV-Scanner parser."""
3from __future__ import annotations
5import json
7import pytest
8from assertpy import assert_that
10from lintro.parsers.osv_scanner.osv_scanner_parser import parse_osv_scanner_output
13@pytest.mark.parametrize(
14 ("output", "expected_count"),
15 [
16 pytest.param(None, 0, id="none_input"),
17 pytest.param("", 0, id="empty_string"),
18 pytest.param(" \n\n ", 0, id="whitespace_only"),
19 ],
20)
21def test_parse_empty_cases(output: str | None, expected_count: int) -> None:
22 """Parser returns empty list for empty/None input."""
23 result = parse_osv_scanner_output(output)
24 assert_that(result).is_length(expected_count)
27def test_parse_empty_results() -> None:
28 """Empty results list returns no issues."""
29 issues = parse_osv_scanner_output(json.dumps({"results": []}))
30 assert_that(issues).is_equal_to([])
33def test_parse_single_vulnerability() -> None:
34 """Parser extracts single vulnerability correctly."""
35 output = json.dumps(
36 {
37 "results": [
38 {
39 "source": {"path": "requirements.txt"},
40 "packages": [
41 {
42 "package": {
43 "name": "flask",
44 "version": "2.0.0",
45 "ecosystem": "PyPI",
46 },
47 "groups": [
48 {
49 "ids": ["GHSA-abcd-1234-efgh"],
50 "max_severity": "HIGH",
51 },
52 ],
53 "vulnerabilities": [
54 {
55 "id": "GHSA-abcd-1234-efgh",
56 "summary": "XSS in Flask",
57 },
58 ],
59 },
60 ],
61 },
62 ],
63 },
64 )
65 issues = parse_osv_scanner_output(output)
67 assert_that(issues).is_length(1)
68 assert_that(issues[0].file).is_equal_to("requirements.txt")
69 assert_that(issues[0].vuln_id).is_equal_to("GHSA-abcd-1234-efgh")
70 assert_that(issues[0].severity).is_equal_to("HIGH")
71 assert_that(issues[0].package_name).is_equal_to("flask")
72 assert_that(issues[0].package_version).is_equal_to("2.0.0")
73 assert_that(issues[0].package_ecosystem).is_equal_to("PyPI")
76def test_parse_multiple_vulnerabilities() -> None:
77 """Parser handles multiple vulnerabilities across packages."""
78 output = json.dumps(
79 {
80 "results": [
81 {
82 "source": {"path": "requirements.txt"},
83 "packages": [
84 {
85 "package": {
86 "name": "flask",
87 "version": "2.0.0",
88 "ecosystem": "PyPI",
89 },
90 "groups": [
91 {
92 "ids": ["GHSA-1111-aaaa-bbbb"],
93 "max_severity": "HIGH",
94 },
95 ],
96 "vulnerabilities": [
97 {
98 "id": "GHSA-1111-aaaa-bbbb",
99 "summary": "Issue 1",
100 },
101 ],
102 },
103 {
104 "package": {
105 "name": "django",
106 "version": "3.0.0",
107 "ecosystem": "PyPI",
108 },
109 "groups": [
110 {
111 "ids": ["GHSA-2222-cccc-dddd"],
112 "max_severity": "CRITICAL",
113 },
114 ],
115 "vulnerabilities": [
116 {
117 "id": "GHSA-2222-cccc-dddd",
118 "summary": "Issue 2",
119 },
120 ],
121 },
122 ],
123 },
124 ],
125 },
126 )
127 issues = parse_osv_scanner_output(output)
129 assert_that(issues).is_length(2)
130 assert_that(issues[0].package_name).is_equal_to("flask")
131 assert_that(issues[0].severity).is_equal_to("HIGH")
132 assert_that(issues[1].package_name).is_equal_to("django")
133 assert_that(issues[1].severity).is_equal_to("CRITICAL")
136def test_parse_vulnerability_with_multiple_ids() -> None:
137 """Parser uses first ID as primary when group has multiple IDs."""
138 output = json.dumps(
139 {
140 "results": [
141 {
142 "source": {"path": "package-lock.json"},
143 "packages": [
144 {
145 "package": {
146 "name": "lodash",
147 "version": "4.17.15",
148 "ecosystem": "npm",
149 },
150 "groups": [
151 {
152 "ids": [
153 "GHSA-xxxx-yyyy-zzzz",
154 "CVE-2021-23337",
155 ],
156 "max_severity": "CRITICAL",
157 },
158 ],
159 "vulnerabilities": [
160 {
161 "id": "GHSA-xxxx-yyyy-zzzz",
162 "summary": "Prototype pollution",
163 },
164 ],
165 },
166 ],
167 },
168 ],
169 },
170 )
171 issues = parse_osv_scanner_output(output)
173 assert_that(issues).is_length(1)
174 assert_that(issues[0].vuln_id).is_equal_to("GHSA-xxxx-yyyy-zzzz")
177def test_parse_vulnerability_with_fixed_version() -> None:
178 """Parser extracts fixed version from affected data."""
179 output = json.dumps(
180 {
181 "results": [
182 {
183 "source": {"path": "requirements.txt"},
184 "packages": [
185 {
186 "package": {
187 "name": "requests",
188 "version": "2.25.0",
189 "ecosystem": "PyPI",
190 },
191 "groups": [
192 {
193 "ids": ["GHSA-test-1234-abcd"],
194 "max_severity": "MEDIUM",
195 },
196 ],
197 "vulnerabilities": [
198 {
199 "id": "GHSA-test-1234-abcd",
200 "summary": "Test vuln",
201 "affected": [
202 {
203 "package": {
204 "name": "requests",
205 "ecosystem": "PyPI",
206 },
207 "ranges": [
208 {
209 "type": "ECOSYSTEM",
210 "events": [
211 {"introduced": "0"},
212 {"fixed": "2.32.0"},
213 ],
214 },
215 ],
216 },
217 ],
218 },
219 ],
220 },
221 ],
222 },
223 ],
224 },
225 )
226 issues = parse_osv_scanner_output(output)
228 assert_that(issues).is_length(1)
229 assert_that(issues[0].fixed_version).is_equal_to("2.32.0")
230 assert_that(issues[0].message).contains("fix: 2.32.0")
233def test_parse_invalid_json() -> None:
234 """Invalid JSON returns empty list without crashing."""
235 issues = parse_osv_scanner_output("not valid json")
236 assert_that(issues).is_equal_to([])
239def test_parse_non_object_json() -> None:
240 """Non-object JSON returns empty list."""
241 issues = parse_osv_scanner_output(json.dumps([1, 2, 3]))
242 assert_that(issues).is_equal_to([])
245def test_parse_non_list_results() -> None:
246 """Non-list results returns empty list."""
247 issues = parse_osv_scanner_output(json.dumps({"results": "not a list"}))
248 assert_that(issues).is_equal_to([])
251def test_parse_missing_results_key() -> None:
252 """Missing results key returns empty list."""
253 issues = parse_osv_scanner_output(json.dumps({}))
254 assert_that(issues).is_equal_to([])
257def test_parse_malformed_package_entry() -> None:
258 """Malformed package entries are skipped gracefully."""
259 output = json.dumps(
260 {
261 "results": [
262 {
263 "source": {"path": "requirements.txt"},
264 "packages": [
265 None,
266 42,
267 {"package": "not a dict"},
268 {"package": {"name": ""}},
269 ],
270 },
271 ],
272 },
273 )
274 issues = parse_osv_scanner_output(output)
275 assert_that(issues).is_equal_to([])
278def test_issue_display_row() -> None:
279 """OsvScannerIssue.to_display_row returns correct values."""
280 from lintro.parsers.osv_scanner.osv_scanner_issue import OsvScannerIssue
282 issue = OsvScannerIssue(
283 file="requirements.txt",
284 line=0,
285 column=0,
286 vuln_id="GHSA-test-1234",
287 severity="HIGH",
288 package_name="requests",
289 package_version="2.25.0",
290 package_ecosystem="PyPI",
291 )
292 row = issue.to_display_row()
293 assert_that(row["file"]).is_equal_to("requirements.txt")
294 assert_that(row["code"]).is_equal_to("GHSA-test-1234")
295 # "HIGH" is normalized to "ERROR" by SeverityLevel
296 assert_that(row["severity"]).is_equal_to("ERROR")
297 assert_that(row["message"]).contains("GHSA-test-1234")
298 assert_that(row["message"]).contains("requests@2.25.0")
301def test_issue_message_format() -> None:
302 """Issue message includes vuln ID, package info, and fix version."""
303 from lintro.parsers.osv_scanner.osv_scanner_issue import OsvScannerIssue
305 issue = OsvScannerIssue(
306 vuln_id="GHSA-abcd-1234",
307 package_name="flask",
308 package_version="2.0.0",
309 fixed_version="2.3.0",
310 )
311 assert_that(issue.message).is_equal_to(
312 "[GHSA-abcd-1234] flask@2.0.0 (fix: 2.3.0)",
313 )
316def test_issue_message_format_no_fix() -> None:
317 """Issue message omits fix when no fixed version available."""
318 from lintro.parsers.osv_scanner.osv_scanner_issue import OsvScannerIssue
320 issue = OsvScannerIssue(
321 vuln_id="GHSA-abcd-1234",
322 package_name="flask",
323 package_version="2.0.0",
324 )
325 assert_that(issue.message).is_equal_to("[GHSA-abcd-1234] flask@2.0.0")
328def test_parse_fallthrough_vuln_id_lookup() -> None:
329 """Parser finds fixed version even when primary ID is not in vulnerabilities.
331 When the first group ID (e.g. a CVE alias) doesn't have a matching entry
332 in the vulnerabilities array, the parser should fall through to subsequent
333 IDs to find the vulnerability details and fixed version.
334 """
335 output = json.dumps(
336 {
337 "results": [
338 {
339 "source": {"path": "requirements.txt"},
340 "packages": [
341 {
342 "package": {
343 "name": "requests",
344 "version": "2.25.0",
345 "ecosystem": "PyPI",
346 },
347 "groups": [
348 {
349 "ids": [
350 "CVE-2024-99999",
351 "GHSA-abcd-1234-efgh",
352 ],
353 "max_severity": "HIGH",
354 },
355 ],
356 "vulnerabilities": [
357 {
358 "id": "GHSA-abcd-1234-efgh",
359 "summary": "Session bypass",
360 "affected": [
361 {
362 "package": {
363 "name": "requests",
364 "ecosystem": "PyPI",
365 },
366 "ranges": [
367 {
368 "type": "ECOSYSTEM",
369 "events": [
370 {"introduced": "0"},
371 {"fixed": "2.32.0"},
372 ],
373 },
374 ],
375 },
376 ],
377 },
378 ],
379 },
380 ],
381 },
382 ],
383 },
384 )
385 issues = parse_osv_scanner_output(output)
387 assert_that(issues).is_length(1)
388 assert_that(issues[0].vuln_id).is_equal_to("CVE-2024-99999")
389 assert_that(issues[0].fixed_version).is_equal_to("2.32.0")
392def test_default_severity() -> None:
393 """Default severity is MEDIUM when not specified in groups."""
394 output = json.dumps(
395 {
396 "results": [
397 {
398 "source": {"path": "requirements.txt"},
399 "packages": [
400 {
401 "package": {
402 "name": "foo",
403 "version": "1.0.0",
404 "ecosystem": "PyPI",
405 },
406 "groups": [
407 {
408 "ids": ["GHSA-no-severity"],
409 },
410 ],
411 "vulnerabilities": [
412 {
413 "id": "GHSA-no-severity",
414 "summary": "No severity",
415 },
416 ],
417 },
418 ],
419 },
420 ],
421 },
422 )
423 issues = parse_osv_scanner_output(output)
425 assert_that(issues).is_length(1)
426 assert_that(issues[0].severity).is_equal_to("MEDIUM")