diff --git a/CHANGELOG.md b/CHANGELOG.md index 845d78a1..d5098f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All versions prior to 0.0.9 are untracked. ## [Unreleased] +### Fixed + +* CLI: the `--format=markdown` and `--format=columns` output formats are no + longer broken by long vulnerability descriptions from the OSV and PyPI + vulnerability sources ([#323](https://github.com/trailofbits/pip-audit/pull/323)) + ## [2.4.1] ### Fixed diff --git a/pip_audit/_service/osv.py b/pip_audit/_service/osv.py index c9740401..5e5dfe14 100644 --- a/pip_audit/_service/osv.py +++ b/pip_audit/_service/osv.py @@ -98,6 +98,12 @@ def query(self, spec: Dependency) -> Tuple[Dependency, List[VulnerabilityResult] if description is None: description = "N/A" + # The "summary" field should be a single line, but "details" might + # be multiple (Markdown-formatted) lines. So, we normalize our + # description into a single line (and potentially break the Markdown + # formatting in the process). + description = description.replace("\n", " ") + aliases = set(vuln.get("aliases", [])) # OSV doesn't mandate this field either. There's very little we diff --git a/pip_audit/_service/pypi.py b/pip_audit/_service/pypi.py index 87edeb0b..b12fab20 100644 --- a/pip_audit/_service/pypi.py +++ b/pip_audit/_service/pypi.py @@ -117,8 +117,21 @@ def query(self, spec: Dependency) -> Tuple[Dependency, List[VulnerabilityResult] # The ranges aren't guaranteed to come in chronological order fix_versions.sort() + description = v.get("summary") + if description is None: + description = v.get("details") + + if description is None: + description = "N/A" + + # The "summary" field should be a single line, but "details" might + # be multiple (Markdown-formatted) lines. So, we normalize our + # description into a single line (and potentially break the Markdown + # formatting in the process). + description = description.replace("\n", " ") + results.append( - VulnerabilityResult(v["id"], v["details"], fix_versions, set(v["aliases"])) + VulnerabilityResult(v["id"], description, fix_versions, set(v["aliases"])) ) return spec, results diff --git a/test/service/test_osv.py b/test/service/test_osv.py index 752bddfe..bf8c03ba 100644 --- a/test/service/test_osv.py +++ b/test/service/test_osv.py @@ -171,7 +171,9 @@ def test_osv_unsupported_schema_version(monkeypatch, version): ["summary", "details", "description"], [ ("fakesummary", "fakedetails", "fakesummary"), + ("fakesummary\nanother line", "fakedetails", "fakesummary another line"), (None, "fakedetails", "fakedetails"), + (None, "fakedetails\nanother line", "fakedetails another line"), (None, None, "N/A"), ], ) diff --git a/test/service/test_pypi.py b/test/service/test_pypi.py index 4eca9c17..3b1f1e00 100644 --- a/test/service/test_pypi.py +++ b/test/service/test_pypi.py @@ -144,7 +144,7 @@ def json(self): { "aliases": ["foo", "bar"], "id": "VULN-0", - "details": "The first vulnerability", + "summary": "The first vulnerability", "fixed_in": ["1.1", "1.4"], } ] @@ -172,6 +172,57 @@ def json(self): ) +@pytest.mark.parametrize( + ["summary", "details", "description"], + [ + ("fakesummary", "fakedetails", "fakesummary"), + ("fakesummary\nanother line", "fakedetails", "fakesummary another line"), + (None, "fakedetails", "fakedetails"), + (None, "fakedetails\nanother line", "fakedetails another line"), + (None, None, "N/A"), + ], +) +def test_pypi_vuln_description_fallbacks(monkeypatch, cache_dir, summary, details, description): + def get_mock_response(): + class MockResponse: + def raise_for_status(self): + pass + + def json(self): + return { + "vulnerabilities": [ + { + "aliases": ["foo", "bar"], + "id": "VULN-0", + "summary": summary, + "details": details, + "fixed_in": ["1.1", "1.4"], + } + ] + } + + return MockResponse() + + monkeypatch.setattr( + service.pypi, "caching_session", lambda _: get_mock_session(get_mock_response) + ) + + pypi = service.PyPIService(cache_dir) + dep = service.ResolvedDependency("foo", Version("1.0")) + results: Dict[service.Dependency, List[service.VulnerabilityResult]] = dict( + pypi.query_all(iter([dep])) + ) + assert len(results) == 1 + assert dep in results + assert len(results[dep]) == 1 + assert results[dep][0] == service.VulnerabilityResult( + id="VULN-0", + description=description, + fix_versions=[Version("1.1"), Version("1.4")], + aliases={"foo", "bar"}, + ) + + def test_pypi_no_vuln_key(monkeypatch, cache_dir): def get_mock_response(): class MockResponse: @@ -209,7 +260,7 @@ def json(self): { "aliases": ["foo", "bar"], "id": "VULN-0", - "details": "The first vulnerability", + "summary": "The first vulnerability", "fixed_in": ["invalid_version"], } ] @@ -278,7 +329,7 @@ def json(self): "vulnerabilities": [ { "id": "VULN-0", - "details": "The first vulnerability", + "summary": "The first vulnerability", "fixed_in": ["1.1"], } ],