diff --git a/src/python_security_auditing/annotations.py b/src/python_security_auditing/annotations.py index 7a09ecd..c17b09e 100644 --- a/src/python_security_auditing/annotations.py +++ b/src/python_security_auditing/annotations.py @@ -4,7 +4,7 @@ from typing import Any -from .report import bandit_skipped_message +from .report import bandit_skipped_message, unique_vulns from .settings import Settings _SEVERITY_TO_LEVEL: dict[str, str] = { @@ -71,7 +71,7 @@ def _sort_key(r: dict[str, Any]) -> int: continue name = pkg.get("name", "") version = pkg.get("version", "") - for vuln in pkg["vulns"]: + for vuln in unique_vulns(pkg["vulns"]): vid = vuln.get("id", "") fix = ", ".join(vuln.get("fix_versions", [])) or "no fix available" print(f"::warning::pip-audit: {name}@{version} — {vid} (fix: {fix})") diff --git a/src/python_security_auditing/report.py b/src/python_security_auditing/report.py index 61fd5ff..3913d52 100644 --- a/src/python_security_auditing/report.py +++ b/src/python_security_auditing/report.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import PurePath from typing import Any @@ -28,6 +29,42 @@ def bandit_skipped_message(skipped: list[dict[str, Any]]) -> str: ) +def unique_vulns(vulns: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Merge advisories pip-audit lists more than once (same ID or alias), keeping all fixes.""" + unique: list[dict[str, Any]] = [] + seen: dict[str, dict[str, Any]] = {} + for vuln in vulns: + ids = [i for i in (vuln.get("id", ""), *vuln.get("aliases", [])) if i] + kept = next((seen[i] for i in ids if i in seen), None) + if kept is None: + kept = {**vuln, "fix_versions": list(vuln.get("fix_versions", []))} + unique.append(kept) + else: + fixes = kept["fix_versions"] + fixes += [v for v in vuln.get("fix_versions", []) if v not in fixes] + seen.update(dict.fromkeys(ids, kept)) + return unique + + +def _summary(description: str) -> str: + """First sentence of an advisory, cut at a word, safe for a table cell. + + pip-audit replaces newlines with spaces, so paragraphs end in two spaces and a GHSA-style + heading is either a paragraph of its own ("### Summary") or, when one newline followed it, + glued to its text ("### Summary The parser..."): drop the first, strip the second. + """ + paragraphs = [p.strip() for p in description.split(" ")] + texts = [ + re.sub(r"^#+ *\S+ *", "", p) for p in paragraphs if not (p.startswith("#") and "." not in p) + ] + text = next((t for t in texts if t), description.strip()) + sentence, end, _ = text.partition(". ") + text = sentence + end.strip() + if len(text) > 120: + text = text[:120].rsplit(" ", 1)[0] + "…" + return text.replace("|", "\\|") + + def build_markdown( bandit_report: dict[str, Any], pip_audit_report: list[dict[str, Any]], @@ -166,20 +203,23 @@ def _pip_audit_section(report: list[dict[str, Any]], settings: Settings, error: return "\n".join(lines) lines.append("| Package | Version | ID | Fix Versions | Description |\n|---|---|---|---|---|") - for pkg in vulnerable: + merged = [(pkg, unique_vulns(pkg["vulns"])) for pkg in vulnerable] + for pkg, vulns in merged: name = pkg.get("name", "") version = pkg.get("version", "") - for vuln in pkg.get("vulns", []): + for vuln in vulns: vid = vuln.get("id", "") - fix_versions = ", ".join(vuln.get("fix_versions", [])) or "none" - desc = (vuln.get("description", "") or "")[:120].replace("|", "\\|") + fix_versions = ", ".join(vuln["fix_versions"]) or "none" + desc = _summary(vuln.get("description", "") or "") lines.append(f"| {name} | {version} | {vid} | {fix_versions} | {desc} |") - total_vulns = sum(len(pkg.get("vulns", [])) for pkg in vulnerable) - fixable = sum(1 for pkg in vulnerable for v in pkg.get("vulns", []) if v.get("fix_versions")) + reported = sum(len(pkg["vulns"]) for pkg in vulnerable) + unique = sum(len(vulns) for _, vulns in merged) + fixable = sum(1 for _, vulns in merged for v in vulns if v["fix_versions"]) + dupes = f"; pip-audit reported {reported}, duplicates merged" if reported > unique else "" lines.append( - f"\n_{total_vulns} vulnerability/vulnerabilities found " - f"({fixable} fixable) across {len(vulnerable)} package(s)._\n" + f"\n_{unique} vulnerability/vulnerabilities found " + f"({fixable} fixable) across {len(vulnerable)} package(s){dupes}._\n" ) return "\n".join(lines) diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 6de5e14..a3b775e 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -84,6 +84,26 @@ def test_pip_audit_fixable_emits_warning( assert "GHSA-" in out +def test_pip_audit_duplicate_advisory_emits_one_warning( + bandit_clean: dict[str, Any], capsys: pytest.CaptureFixture[str] +) -> None: + vuln = {"id": "PYSEC-2026-4320", "aliases": ["GHSA-6q48-6c7w-9gw2"]} + report = [ + { + "name": "certifi", + "version": "2026.7.22", + "vulns": [ + {**vuln, "fix_versions": ["2026.10.18"]}, + {**vuln, "fix_versions": ["2026.10.19"]}, + ], + } + ] + emit_annotations(bandit_clean, report, Settings()) + out = capsys.readouterr().out + assert out.count("::warning::pip-audit:") == 1 + assert "PYSEC-2026-4320 (fix: 2026.10.18, 2026.10.19)" in out + + def test_pip_audit_no_file_line_in_annotation( bandit_clean: dict[str, Any], pip_fixable: list[Any], capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_report.py b/tests/test_report.py index 863ba65..efffa45 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -194,6 +194,86 @@ def test_markdown_pip_table(bandit_clean: dict[str, Any], pip_fixable: list[Any] assert "GHSA-j8r2-6x86-q33q" in md +def test_markdown_pip_merges_duplicate_advisories(bandit_clean: dict[str, Any]) -> None: + """pip-audit can list one advisory twice, by ID or by alias: one row, every fix version.""" + vuln = {"id": "PYSEC-2026-4320", "aliases": ["GHSA-6q48-6c7w-9gw2"], "description": "Bad CA."} + report = [ + { + "name": "certifi", + "version": "2026.7.22", + "vulns": [ + {**vuln, "fix_versions": ["2026.10.18"]}, + {**vuln, "fix_versions": ["2026.10.19"]}, + {"id": "GHSA-6q48-6c7w-9gw2", "fix_versions": ["2026.10.18"], "aliases": []}, + ], + } + ] + md = build_markdown(bandit_clean, report, Settings()) + assert md.count("| certifi |") == 1 + assert "| PYSEC-2026-4320 | 2026.10.18, 2026.10.19 | Bad CA. |" in md + assert ( + "_1 vulnerability/vulnerabilities found (1 fixable) across 1 package(s); " + "pip-audit reported 3, duplicates merged._" + ) in md + + +def test_markdown_pip_alias_chain_merges_in_id_order(bandit_clean: dict[str, Any]) -> None: + """An advisory aliasing two earlier ones joins the first match, whatever the hash seed.""" + report = [ + { + "name": "pkg", + "version": "1.0", + "vulns": [ + {"id": "PYSEC-A", "aliases": ["CVE-X"], "fix_versions": ["1.1"]}, + {"id": "PYSEC-B", "aliases": ["CVE-Y"], "fix_versions": ["1.2"]}, + {"id": "PYSEC-C", "aliases": ["CVE-X", "CVE-Y"], "fix_versions": ["1.3"]}, + ], + } + ] + md = build_markdown(bandit_clean, report, Settings()) + assert "| PYSEC-A | 1.1, 1.3 |" in md + assert "| PYSEC-B | 1.2 |" in md + + +@pytest.mark.parametrize( + "description", + [ + # heading on its own line, then a blank line + "### Summary The keepalive timeout was not used. It applies to the first request. " + "### Impact An attacker could wait.", + # heading followed by a single newline + "### Summary The keepalive timeout was not used. It applies. ### Impact Waits.", + # multi-word heading on its own line + "## Vulnerability Summary The keepalive timeout was not used. It applies.", + ], +) +def test_markdown_pip_description_skips_markdown_headings( + bandit_clean: dict[str, Any], description: str +) -> None: + """pip-audit flattens newlines to spaces, leaving GHSA "### Summary" headings inline.""" + report = [ + { + "name": "aiohttp", + "version": "3.14.3", + "vulns": [{"id": "CVE-1", "fix_versions": ["3.14.4"], "description": description}], + } + ] + md = build_markdown(bandit_clean, report, Settings()) + assert "| CVE-1 | 3.14.4 | The keepalive timeout was not used. |" in md + + +def test_markdown_pip_description_cut_at_a_word(bandit_clean: dict[str, Any]) -> None: + report = [ + { + "name": "pkg", + "version": "1.0", + "vulns": [{"id": "CVE-1", "fix_versions": [], "description": "abcdefghi " * 20}], + } + ] + md = build_markdown(bandit_clean, report, Settings()) + assert f"| none | {'abcdefghi ' * 11}abcdefghi… |" in md + + def test_markdown_pip_counts_audited_and_skipped(bandit_clean: dict[str, Any]) -> None: report: list[dict[str, Any]] = [ {"name": "requests", "version": "2.32.0", "vulns": []},