Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/python_security_auditing/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -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})")
56 changes: 48 additions & 8 deletions src/python_security_auditing/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from pathlib import PurePath
from typing import Any

Expand All @@ -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]],
Expand Down Expand Up @@ -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)

Expand Down
20 changes: 20 additions & 0 deletions tests/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
80 changes: 80 additions & 0 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []},
Expand Down
Loading