Coverage for .github / scripts / check_pip_audit_ignore.py: 100.00%
62 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Validate the .pip-audit-ignore suppression file.
3Each entry must:
4 * have a non-comment, non-blank line whose first whitespace-delimited
5 token is the vulnerability ID (e.g. ``PYSEC-2025-183``,
6 ``CVE-2025-45768``, ``GHSA-xxxx-xxxx-xxxx``); and
7 * include an ``exp:YYYY-MM-DD`` marker somewhere on the same line.
9The check fails the workflow when:
10 * any entry's ``exp:`` date is on-or-before the reference date
11 (today by default; configurable via ``--today`` for tests); or
12 * any entry is missing the ``exp:`` marker entirely or has a
13 malformed date.
15Inclusive expiration is intentional — once the listed date arrives the
16suppression is considered expired, no bonus day. That mirrors how
17``.trivyignore`` is treated by the rest of the project.
19Usage::
21 python3 .github/scripts/check_pip_audit_ignore.py .pip-audit-ignore
22 python3 .github/scripts/check_pip_audit_ignore.py .pip-audit-ignore --today 2026-08-19
24Exit codes::
26 0 OK (or file does not exist — an absent ignore file is not an error)
27 1 one or more entries failed validation
28 2 unexpected I/O / argument error
30The module is importable from the test suite — call ``check_file()``
31directly to exercise the logic against fixtures.
32"""
34from __future__ import annotations
36import argparse
37import datetime
38import re
39import sys
40from pathlib import Path
42EXP_RE = re.compile(r"\bexp:(\d{4})-(\d{2})-(\d{2})\b")
45def check_file(
46 path: Path, today: datetime.date | None = None
47) -> tuple[list[tuple[int, str]], list[tuple[int, str, datetime.date]]]:
48 """Return (missing, expired) entry lists for the given ignore file.
50 ``missing`` lists ``(line_number, vuln_id)`` for entries without a
51 valid ``exp:YYYY-MM-DD`` marker. ``expired`` lists
52 ``(line_number, vuln_id, exp_date)`` for entries whose date is
53 on-or-before ``today``.
55 ``today`` defaults to ``datetime.date.today()`` and exists as a
56 parameter so the test suite can pin a deterministic reference date.
58 A non-existent file returns two empty lists — the absence of an
59 ignore file is not an error.
60 """
61 today = today or datetime.date.today()
62 missing: list[tuple[int, str]] = []
63 expired: list[tuple[int, str, datetime.date]] = []
65 if not path.exists():
66 return missing, expired
68 for lineno, raw in enumerate(path.read_text().splitlines(), start=1):
69 stripped = raw.strip()
70 # Blank lines and full-line comments are skipped.
71 if not stripped or stripped.startswith("#"):
72 continue
73 # First whitespace token is the vuln ID; rest is rationale + exp:.
74 tokens = stripped.split(None, 1)
75 vuln_id = tokens[0]
76 rest = tokens[1] if len(tokens) > 1 else ""
77 match = EXP_RE.search(rest)
78 if not match:
79 missing.append((lineno, vuln_id))
80 continue
81 year, month, day = (int(part) for part in match.groups())
82 try:
83 exp_date = datetime.date(year, month, day)
84 except ValueError:
85 # Catches e.g. exp:2026-13-40 — invalid date components.
86 missing.append((lineno, vuln_id))
87 continue
88 if exp_date <= today:
89 expired.append((lineno, vuln_id, exp_date))
91 return missing, expired
94def _format_report(
95 today: datetime.date,
96 missing: list[tuple[int, str]],
97 expired: list[tuple[int, str, datetime.date]],
98) -> str:
99 """Render the human-facing error report. Empty string when both clean."""
100 lines: list[str] = []
101 if missing:
102 lines.append("ERROR: .pip-audit-ignore entries missing a valid exp:YYYY-MM-DD marker:")
103 for lineno, vuln_id in missing:
104 lines.append(f" line {lineno}: {vuln_id}")
105 if expired:
106 lines.append(
107 f"ERROR: .pip-audit-ignore entries past their expiration date "
108 f"(today is {today.isoformat()}):"
109 )
110 for lineno, vuln_id, exp_date in expired:
111 lines.append(f" line {lineno}: {vuln_id} expired on {exp_date.isoformat()}")
112 lines.append(
113 "Re-evaluate each entry: remove it if the CVE is fixed, or extend "
114 "the date with fresh rationale."
115 )
116 return "\n".join(lines)
119def _parse_today(value: str) -> datetime.date:
120 try:
121 return datetime.date.fromisoformat(value)
122 except ValueError as exc:
123 raise argparse.ArgumentTypeError(f"--today must be YYYY-MM-DD, got {value!r}") from exc
126def main(argv: list[str] | None = None) -> int:
127 parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
128 parser.add_argument(
129 "path",
130 type=Path,
131 help="Path to the .pip-audit-ignore file to validate.",
132 )
133 parser.add_argument(
134 "--today",
135 type=_parse_today,
136 default=None,
137 help=(
138 "Reference date in YYYY-MM-DD format. Defaults to today's "
139 "UTC-naive date. Provided for deterministic testing."
140 ),
141 )
142 args = parser.parse_args(argv)
144 today = args.today or datetime.date.today()
145 missing, expired = check_file(args.path, today=today)
146 report = _format_report(today, missing, expired)
147 if report:
148 print(report)
149 return 1
150 return 0
153if __name__ == "__main__":
154 sys.exit(main())