Repository navigation
Expand file tree
/
Copy pathcheck_pip_audit.py
More file actions
601 lines (530 loc) · 26.2 KB
/
Copy pathcheck_pip_audit.py
File metadata and controls
601 lines (530 loc) · 26.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
#!/usr/bin/env python3
"""Wave 4 / Faz 23 — `pip-audit` JSON output severity gate.
Reads the JSON report produced by ``pip-audit --format json`` and
applies ForgeLM's severity policy:
- ``HIGH`` / ``CRITICAL`` findings exit 1 (fail nightly).
- ``MEDIUM`` findings emit a ``::warning::`` GitHub annotation but do
not fail.
- ``LOW`` findings are silent.
- Any severity string the gate cannot positively classify — absent,
empty, whitespace-only, or an unrecognised tier such as ``SEVERE`` —
normalises to ``UNKNOWN`` and is handled as such. Bucketing is
total: no finding can match zero buckets and vanish from the gate.
- ``UNKNOWN`` findings (pip-audit's JSON omits OSV severity for
nearly every real advisory — see the empirical note near
``_HIGH_TIERS`` below) also exit 1, failing closed. The
``::error::`` annotation is split on
whether the advisory has a published fix: **fixable** findings name
the fix version so the operator's next action is "upgrade";
**no-fix** findings instead point at the documented suppression
workflow (``tools/pip_audit_ignores.yaml``). Both sub-buckets still
fail — this refines the message, not the severity policy — see
``_bucket_findings`` for the full rationale.
Used in ``.github/workflows/nightly.yml`` after the ``pip-audit`` step.
Optional opt-in ignore list via ``--ignores PATH``: every finding whose
``{id} ∪ aliases`` intersects an entry in the YAML file is suppressed
(emitting a ``::notice::`` annotation that names the id and the
``reason`` field) before severity bucketing. Deployers running this
script standalone WITHOUT ``--ignores`` inherit no suppressions, in
keeping with the deployer-side risk-acceptance policy documented in
``docs/reference/supply_chain_security.md``. ForgeLM's own nightly
points at ``tools/pip_audit_ignores.yaml``; see that file's header for
the schema.
Exit codes (per ``tools/`` contract — NOT the public 0/1/2/3/4 surface
that ``forgelm/`` honours):
- ``0`` — no high/critical CVEs and no UNKNOWN-severity findings
(medium/low may be present and warned).
- ``1`` — at least one high or critical CVE, OR at least one
UNKNOWN-severity finding (F-PR29-A7-11: pip-audit's JSON omits
severity, so UNKNOWN means we cannot prove a vulnerability is
low-impact; failing closed avoids silent drop — see the fix/no-fix
split below), OR the input file is missing / unparseable, OR the
ignore file (when supplied) is missing, unparseable, or
schema-invalid.
UNKNOWN-severity findings are further split by whether pip-audit
reports a non-empty ``fix_versions``. Both sub-buckets still fail the
gate — no finding may become a silent pass — this only changes which
``::error::`` message the operator sees:
- **Fixable** (``fix_versions`` non-empty) — an upgrade resolves it.
The annotation names the fix version(s).
- **No fix yet** (``fix_versions`` empty/absent) — no upstream release
resolves it yet. The annotation directs the operator to the
documented suppression workflow (``tools/pip_audit_ignores.yaml`` —
see that file's header for the required id/package/reason/
threat_model/verified_at/reevaluate_after schema), so an
accepted-risk advisory has a concrete next step instead of leaving
the gate red with no actionable path — a MEDIUM-tier local advisory
with no fix previously had no triage route other than a suppression
entry, which is what this split fixes.
Usage::
# Standalone (no project-side suppressions — recommended for deployers):
pip-audit --format json --output /tmp/pip-audit.json || true
python3 tools/check_pip_audit.py /tmp/pip-audit.json
# Project nightly (consumes the checked-in ignore file):
python3 tools/check_pip_audit.py /tmp/pip-audit.json \\
--ignores tools/pip_audit_ignores.yaml
Standards-side note: this helper exists to satisfy the ``|| true`` carve-out
in ``docs/standards/testing.md`` (CI bypass discipline). The bash
``pip-audit --format json > out.json || true`` step that calls into us is
sanctioned ONLY because this helper enforces a severity-tiered (CVE
HIGH / CRITICAL) gate on the captured output — without it, the ``|| true``
would silently swallow real findings. Removing this helper or replacing
it with ``pip-audit`` directly would break the contract; see the
``|| true`` discipline section of ``testing.md`` before touching either
side.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import date
from pathlib import Path
from typing import Any, Iterable, Optional
# pip-audit's JSON shape puts findings under ``dependencies[].vulns``,
# each vuln carrying ``id``, ``aliases``, ``description``, ``fix_versions``.
#
# Empirical note (verified against pip-audit 2.6.0–2.9.0 wheel sources
# during Wave 4 absorption round 2): pip-audit's ``_format/json.py``
# does NOT serialise OSV severity into the JSON output — ``aliases``
# is a flat list of CVE/GHSA identifier strings (per
# ``pip_audit/_service/interface.py``: ``aliases: set[str]``), no
# nested ``severity`` field appears at any nesting level. This means
# `_vuln_severity` returns ``"UNKNOWN"`` for every vuln in a real
# pip-audit JSON report, and the UNKNOWN summary annotation handles
# the operator-triage path. We retain the top-level string-severity
# branch to honour the documented CLAUDE.md / pyproject.toml schema
# (operators feeding hand-crafted JSON for non-pip-audit scanners can
# emit a top-level ``severity: "HIGH"`` and have the gate honour it).
_HIGH_TIERS: frozenset[str] = frozenset({"HIGH", "CRITICAL"})
_MED_TIERS: frozenset[str] = frozenset({"MEDIUM", "MODERATE"})
# ``LOW`` is the only tier this gate is willing to treat as silent, so it is
# spelled out rather than inferred from "not high and not medium". Any other
# vocabulary (``NEGLIGIBLE``, ``NONE``, ``SEVERE``, a typo) normalises to
# ``UNKNOWN`` and fails closed — see ``_normalise_severity``.
_LOW_TIERS: frozenset[str] = frozenset({"LOW"})
def _normalise_severity(raw: Optional[str]) -> str:
"""Upper-case + collapse synonyms; unknown/missing/blank → ``UNKNOWN``.
Every input this gate cannot positively classify must land on
``UNKNOWN``, because ``UNKNOWN`` is the tier that fails the run. Three
inputs reach that outcome:
- ``None`` and ``""`` — no severity was reported at all;
- whitespace-only (``" "``) — the strip happens *before* the
emptiness test, so a blank string cannot survive as ``""`` and match
no tier;
- any unrecognised tier string (``"SEVERE"``, ``"NEGLIGIBLE"``, a
typo) — an unmodelled severity vocabulary is a reason for manual
triage, never a reason to skip the finding.
The returned value is therefore always one of ``HIGH`` / ``CRITICAL`` /
``MEDIUM`` / ``LOW`` / ``UNKNOWN``, which is what lets
``_bucket_findings`` guarantee that no finding escapes every bucket.
"""
if raw is None:
return "UNKNOWN"
upper = raw.strip().upper()
if not upper:
return "UNKNOWN"
if upper in _MED_TIERS:
return "MEDIUM"
if upper in _HIGH_TIERS or upper in _LOW_TIERS:
return upper
return "UNKNOWN"
def _vuln_severity(vuln: dict[str, Any]) -> str:
"""Extract a single severity tier from a pip-audit vuln entry.
Honours only the top-level ``severity`` string — pip-audit's JSON
output never carries severity (verified against 2.6.0–2.9.0 wheel
sources). Hand-crafted JSON from non-pip-audit scanners can set
a top-level ``severity: "HIGH"`` and have the gate honour it.
Anything else falls through to ``"UNKNOWN"`` and surfaces via the
UNKNOWN summary annotation in ``main()``.
"""
direct = vuln.get("severity")
if isinstance(direct, str):
return _normalise_severity(direct)
return "UNKNOWN"
def _iter_findings(report: dict[str, Any]) -> Iterable[tuple[str, dict[str, Any]]]:
"""Yield (package_name, vuln_dict) pairs from a pip-audit report."""
deps = report.get("dependencies") or []
if not isinstance(deps, list):
return
for dep in deps:
if not isinstance(dep, dict):
continue
name = dep.get("name") or "<unknown-package>"
for vuln in dep.get("vulns") or []:
if isinstance(vuln, dict):
yield name, vuln
def _format_finding(name: str, vuln: dict[str, Any], severity: str) -> str:
vid = vuln.get("id") or "<no-id>"
fix_versions = vuln.get("fix_versions") or vuln.get("fix_version") or []
if isinstance(fix_versions, str):
fix_text = fix_versions
elif isinstance(fix_versions, list) and fix_versions:
fix_text = ", ".join(str(v) for v in fix_versions)
else:
fix_text = "(no fix available)"
return f"[{severity}] {name} {vid} — fix: {fix_text}"
def _load_report(report_path: Path) -> Optional[dict[str, Any]]:
"""Read + parse the pip-audit JSON; emit ``::error::`` annotations on
failure and return ``None``. Caller treats ``None`` as exit 1."""
try:
raw = report_path.read_text(encoding="utf-8")
except OSError as exc:
print(f"::error::pip-audit report not readable at {report_path}: {exc}", file=sys.stderr)
return None
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
print(f"::error::pip-audit report at {report_path} is not valid JSON: {exc}", file=sys.stderr)
return None
# Required keys per entry in the ignore file. Missing any of them is a
# policy violation (an undocumented suppression), so the gate fails closed
# rather than silently accepting CVEs with no recorded justification.
#
# All required fields must be non-empty strings EXCEPT ``verified_at``,
# which must additionally be an ISO ``YYYY-MM-DD`` date. ``reevaluate_after``
# is deliberately free text (e.g. "Each release cycle, or when …"), NOT a
# date — it captures the condition that retires the ignore, which is
# rarely a fixed calendar day.
_IGNORE_REQUIRED_STR_KEYS: tuple[str, ...] = (
"id",
"package",
"reason",
"threat_model",
"reevaluate_after",
)
_IGNORE_REQUIRED_KEYS: frozenset[str] = frozenset({*_IGNORE_REQUIRED_STR_KEYS, "verified_at"})
_VERIFIED_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# A CVE suppression is a security-policy exception; it must not silently
# outlive the advisory state it was justified against. When a ``verified_at``
# is older than this many days we emit a ``::warning::`` (not a hard fail, so an
# unattended nightly does not break over a weekend) so stale suppressions
# surface on the run summary for re-triage (F-P5-OPUS-14).
_VERIFIED_AT_STALE_DAYS = 90
def _verified_at_staleness_warning(entry: dict[str, Any], ignores_path: Path, today: date) -> Optional[str]:
"""Return a ``::warning::`` body when ``entry``'s ``verified_at`` is stale.
``verified_at`` is already shape-validated (``YYYY-MM-DD``) by the time
this runs, so the parse cannot fail; we still guard defensively and skip
silently on an unexpected value rather than crash the gate.
"""
raw = entry.get("verified_at")
if not isinstance(raw, str):
return None
try:
verified = date.fromisoformat(raw.strip())
except ValueError:
return None
age_days = (today - verified).days
if age_days > _VERIFIED_AT_STALE_DAYS:
return (
f"pip-audit ignore id {entry.get('id')!r} in {ignores_path} was last "
f"verified {age_days} days ago ({raw}) — older than the "
f"{_VERIFIED_AT_STALE_DAYS}-day re-evaluation window. Re-verify the "
f"advisory (reevaluate_after: {entry.get('reevaluate_after')!r}) and "
f"refresh verified_at, or drop the suppression."
)
return None
def _validate_ignore_entry(entry: dict[str, Any], index: int, ignores_path: Path) -> Optional[str]:
"""Return an ``::error::`` body if ``entry`` is malformed, else ``None``.
Caller guarantees every required key is present (so the missing-key
error can name all gaps at once); this validates value *shape* so a
well-meaning typo cannot weaken the gate:
- required string fields must be non-empty (an empty ``reason`` or
``threat_model`` is an undocumented suppression);
- ``verified_at`` must be a ``YYYY-MM-DD`` string (quote it in YAML
so it is not parsed as a ``datetime.date``);
- ``aliases``, when present, must be a list of strings — a bare
string would be unpacked character-by-character into the id index
and pollute matching.
"""
prefix = f"pip-audit ignore entry #{index} (id={entry.get('id')!r}) in {ignores_path}"
for field in _IGNORE_REQUIRED_STR_KEYS:
value = entry[field]
if not isinstance(value, str) or not value.strip():
return f"{prefix} field '{field}' must be a non-empty string."
verified_at = entry["verified_at"]
if not isinstance(verified_at, str) or not _VERIFIED_AT_RE.match(verified_at.strip()):
return (
f"{prefix} field 'verified_at' must be a 'YYYY-MM-DD' string "
f"(quote it in YAML so it is not parsed as a date)."
)
aliases = entry.get("aliases")
if aliases is not None and (not isinstance(aliases, list) or not all(isinstance(a, str) for a in aliases)):
return f"{prefix} field 'aliases' must be a list of strings."
return None
def _load_ignores(ignores_path: Path) -> Optional[dict[str, dict[str, Any]]]:
"""Read + validate ``--ignores`` YAML; return ``{id_or_alias: entry}``.
On any failure (file missing, YAML invalid, schema invalid) emits a
``::error::`` annotation and returns ``None`` so the caller fails
closed — an unreadable ignore file must not be silently treated as
"no ignores", or every CVE would suddenly fail an otherwise green
gate without anyone noticing the YAML drifted.
"""
try:
import yaml # type: ignore[import-untyped]
except ImportError as exc: # pragma: no cover - PyYAML is a runtime dep
print(
f"::error::--ignores requires PyYAML (`pip install pyyaml`): {exc}",
file=sys.stderr,
)
return None
try:
raw = ignores_path.read_text(encoding="utf-8")
except OSError as exc:
print(
f"::error::pip-audit ignore file not readable at {ignores_path}: {exc}",
file=sys.stderr,
)
return None
try:
loaded = yaml.safe_load(raw)
except yaml.YAMLError as exc:
print(
f"::error::pip-audit ignore file at {ignores_path} is not valid YAML: {exc}",
file=sys.stderr,
)
return None
if not isinstance(loaded, dict):
print(
f"::error::pip-audit ignore file at {ignores_path} must be a mapping with key 'ignores'.",
file=sys.stderr,
)
return None
entries = loaded.get("ignores")
if not isinstance(entries, list):
print(
f"::error::pip-audit ignore file at {ignores_path} must define a top-level 'ignores:' list.",
file=sys.stderr,
)
return None
by_id: dict[str, dict[str, Any]] = {}
today = date.today()
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
print(
f"::error::pip-audit ignore entry #{index} in {ignores_path} must be a mapping.",
file=sys.stderr,
)
return None
missing = _IGNORE_REQUIRED_KEYS - entry.keys()
if missing:
print(
f"::error::pip-audit ignore entry #{index} (id={entry.get('id')!r}) "
f"in {ignores_path} is missing required field(s): "
f"{', '.join(sorted(missing))}.",
file=sys.stderr,
)
return None
shape_error = _validate_ignore_entry(entry, index, ignores_path)
if shape_error:
print(f"::error::{shape_error}", file=sys.stderr)
return None
stale_warning = _verified_at_staleness_warning(entry, ignores_path, today)
if stale_warning:
# Advisory-only: a stale suppression surfaces for re-triage but does
# not fail the gate (F-P5-OPUS-14).
print(f"::warning::{stale_warning}")
primary_id = entry["id"]
# Index by every alias so cross-DB lookups (PYSEC ↔ CVE ↔ GHSA)
# match without the workflow having to know which form pip-audit
# emits this week. ``aliases`` is validated as a list of strings
# above; ``or []`` also tolerates an explicit ``aliases:`` null.
# Last write wins on duplicates, but we surface the dup so the
# file stays clean.
ids = {primary_id, *(entry.get("aliases") or [])}
for ident in ids:
if ident in by_id and by_id[ident] is not entry:
print(
f"::warning::pip-audit ignore id {ident!r} appears under "
f"both {by_id[ident].get('id')!r} and {primary_id!r} "
f"in {ignores_path}; later entry wins."
)
by_id[ident] = entry
return by_id
def _vuln_identifiers(vuln: dict[str, Any]) -> set[str]:
"""Return the union ``{id} ∪ aliases`` for ignore-match purposes."""
ids: set[str] = set()
primary = vuln.get("id")
if isinstance(primary, str):
ids.add(primary)
aliases = vuln.get("aliases")
if isinstance(aliases, list):
ids.update(a for a in aliases if isinstance(a, str))
return ids
def _has_fix_available(vuln: dict[str, Any]) -> bool:
"""Return ``True`` when the advisory names at least one fix version.
Mirrors ``_format_finding``'s ``fix_versions`` / ``fix_version``
fallback so the "is this actionable" check and the "what do we print"
check never disagree about what counts as a fix.
"""
fix_versions = vuln.get("fix_versions") or vuln.get("fix_version") or []
if isinstance(fix_versions, str):
return bool(fix_versions.strip())
if isinstance(fix_versions, list):
return len(fix_versions) > 0
return False
def _bucket_findings(
report: dict[str, Any],
ignores: Optional[dict[str, dict[str, Any]]] = None,
) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str]]:
"""Walk every (name, vuln) pair and return
``(high, medium, low, unknown_fixable, unknown_no_fix, suppressed)``
lists of pre-formatted lines.
**Bucketing is total**: every finding yielded by ``_iter_findings``
lands in exactly one returned list, so
``sum(map(len, _bucket_findings(report))) == <vuln count>`` always
holds. That invariant is the whole point of the terminal ``else``
below — a finding that matched no tier used to be dropped on the
floor (a blank ``severity: " "`` or an unrecognised tier such as
``"SEVERE"`` normalised to something outside every branch), which
turned a security gate into a silent pass. ``_normalise_severity``
now closes that at the source; the ``else`` keeps it closed even if a
future tier is added there without updating this function.
The UNKNOWN-severity bucket (pip-audit's JSON omits OSV severity for
almost every real vuln — see the module docstring) is split on
whether ``fix_versions`` is non-empty. Both sub-buckets still fail
the gate in ``main()`` — this split changes the ``::error::``
message an operator sees, not the pass/fail outcome:
- ``unknown_fixable`` — an upgrade resolves the finding; the
operator action is "bump the pin". The advisory's fix version(s)
are already embedded in the formatted line via ``_format_finding``.
- ``unknown_no_fix`` — no upstream fix exists yet; the operator
action is to document accepted risk via the
``tools/pip_audit_ignores.yaml`` suppression workflow (or keep
monitoring). A missing fix is not a reason to pass silently.
``suppressed`` carries findings whose ``{id} ∪ aliases`` intersected
the ignore set; the caller surfaces each one as a ``::notice::``
annotation so suppressions stay audit-visible. ``low`` is returned
but deliberately not annotated by ``main()`` — LOW tier stays silent
per policy, and the raw JSON remains in build artefacts for
post-mortem. It is returned rather than discarded so the
conservation invariant above is checkable by a test instead of being
a comment nobody can enforce.
"""
high: list[str] = []
medium: list[str] = []
low: list[str] = []
unknown_fixable: list[str] = []
unknown_no_fix: list[str] = []
suppressed: list[str] = []
# Materialise the ignore id set once rather than rebuilding the keys
# view on every finding.
ignore_ids: set[str] = set(ignores) if ignores else set()
for name, vuln in _iter_findings(report):
if ignore_ids:
matched = _vuln_identifiers(vuln) & ignore_ids
if matched:
# Pick the entry by any matching id — they all point at
# the same dict thanks to alias indexing.
entry = ignores[next(iter(matched))]
vid = vuln.get("id") or "<no-id>"
suppressed.append(f"{name} {vid} — reason: {entry.get('reason')}")
continue
severity = _vuln_severity(vuln)
if severity in _HIGH_TIERS:
high.append(_format_finding(name, vuln, severity))
elif severity in _MED_TIERS:
medium.append(_format_finding(name, vuln, severity))
elif severity in _LOW_TIERS:
low.append(_format_finding(name, vuln, severity))
else:
# UNKNOWN, plus anything _normalise_severity could not classify.
# This branch is deliberately the catch-all rather than an
# `elif severity == "UNKNOWN"`: a finding must fail closed when
# its tier is unrecognised, not disappear.
if _has_fix_available(vuln):
unknown_fixable.append(_format_finding(name, vuln, "UNKNOWN (fix available)"))
else:
unknown_no_fix.append(_format_finding(name, vuln, "UNKNOWN (no fix)"))
return high, medium, low, unknown_fixable, unknown_no_fix, suppressed
def _parse_argv(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog=argv[0],
description="Apply ForgeLM's severity gate to a pip-audit JSON report.",
)
parser.add_argument(
"report",
type=Path,
help="path to pip-audit JSON report (output of `pip-audit --format json`).",
)
parser.add_argument(
"--ignores",
type=Path,
default=None,
help=(
"optional YAML file listing CVE ids to suppress (each must "
"carry id/package/reason/threat_model/verified_at/"
"reevaluate_after). Without this flag no suppressions are "
"applied — deployers running this tool standalone get the "
"full unfiltered gate."
),
)
return parser.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = _parse_argv(argv)
report = _load_report(args.report)
if report is None:
return 1
ignores: Optional[dict[str, dict[str, Any]]] = None
if args.ignores is not None:
ignores = _load_ignores(args.ignores)
if ignores is None:
return 1
# ``low`` is intentionally unused: LOW tier is silent per policy. It is
# returned by _bucket_findings so bucketing stays provably total.
high, medium, _low, unknown_fixable, unknown_no_fix, suppressed = _bucket_findings(report, ignores)
for line in suppressed:
# ::notice:: keeps every suppression in the run log so a reviewer
# can spot-check that the ignore file hasn't accidentally hidden
# a freshly-rescored CVE.
print(f"::notice::pip-audit suppressed (project ignore list): {line}")
for line in medium:
# GitHub Actions annotation; surfaces in the run summary without
# failing the build.
print(f"::warning::pip-audit {line}")
if high:
for line in high:
print(f"::error::pip-audit {line}")
print(f"::error::pip-audit found {len(high)} high/critical-severity finding(s); failing the run.")
return 1
if unknown_fixable or unknown_no_fix:
# F-PR29-A7-11 (post-flip policy): UNKNOWN-severity findings still
# fail the gate. Rationale: pip-audit's JSON omits OSV severity
# for almost every real vuln, so a "warn only" branch would convert
# nearly all findings into a silent advisory — operators would
# never see the failures. Failing closed surfaces every vuln for
# explicit triage.
#
# Fix/no-fix split (this refines the *message*, not the *policy*
# — neither sub-bucket below is a pass): a fixable finding's next
# step is "upgrade"; a no-fix finding's next step is "document
# accepted risk", and printing the same generic message for both
# is what let a no-fix MEDIUM-tier advisory take the nightly red
# for days with no clear triage path beyond guessing at a
# suppression entry.
for line in unknown_fixable:
print(f"::error::pip-audit {line}")
for line in unknown_no_fix:
print(f"::error::pip-audit {line}")
if unknown_fixable:
print(
f"::error::pip-audit found {len(unknown_fixable)} finding(s) without parseable "
f"severity in {args.report} but WITH a published fix (see the fix version(s) "
f"named above); upgrade the affected package(s) to resolve."
)
if unknown_no_fix:
print(
f"::error::pip-audit found {len(unknown_no_fix)} finding(s) without parseable "
f"severity in {args.report} and no published fix; pip-audit's JSON does not "
f"serialise OSV severity, so each must be reviewed manually (failing closed). "
f"If the advisory is accepted risk, document it via the suppression workflow "
f"in tools/pip_audit_ignores.yaml (see that file's header for the required "
f"id/package/reason/threat_model/verified_at/reevaluate_after schema) rather "
f"than leaving the gate red with no actionable path."
)
return 1
if medium:
print(f"pip-audit: {len(medium)} medium-severity finding(s) (warning only).")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))