Skip to content

Commit b91c42b

Browse files
committed
ci: Add pip-audit control logic
Signed-off-by: Cagri Yonca <cagri@ibm.com>
1 parent a33dbe9 commit b91c42b

2 files changed

Lines changed: 90 additions & 9 deletions

File tree

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ commands:
5050
name: Apply grace period to installed packages
5151
command: |
5252
. venv/bin/activate
53-
pip install --quiet requests packaging
53+
pip install --quiet requests packaging pip-audit
5454
python .circleci/pin_safe_versions.py <<parameters.requirements>>
5555
5656
run-tests-with-coverage-report:

.circleci/pin_safe_versions.py

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33

44
"""
55
Downgrades any installed packages that were released within the 5-day grace
6-
period to their latest safe version. Run after pip install so that CI tests
7-
only exercise versions that have cleared the supply-chain safety window.
6+
period to their latest safe version. "Safe" means both:
7+
8+
1. The version was released at least GRACE_PERIOD_DAYS ago, AND
9+
2. pip-audit reports no known vulnerabilities for that version.
10+
11+
Run after pip install so that CI tests only exercise versions that have
12+
cleared the supply-chain safety window.
813
914
Usage:
1015
python scripts/pin_safe_versions.py [requirements_file]
@@ -15,9 +20,12 @@
1520
from typing import Any, Union
1621

1722

23+
import json
24+
import os
1825
import re
1926
import subprocess
2027
import sys
28+
import tempfile
2129
from datetime import datetime, timedelta
2230

2331
import requests
@@ -64,14 +72,87 @@ def _get_pypi_releases(package_name: str) -> list[Any]:
6472
return result
6573

6674

67-
def _get_safe_version(releases: list[Any]) -> Union[tuple[Any, Any], tuple[None, None]]:
75+
def _run_pip_audit(package: str, version: str) -> bool:
76+
"""
77+
Run ``pip-audit`` against *package==version*.
78+
79+
Returns True if no vulnerabilities were found, False otherwise.
80+
Falls back to True (allow) if pip-audit is not installed or fails
81+
unexpectedly, so that a missing tool never blocks a release.
82+
"""
83+
try:
84+
with tempfile.TemporaryDirectory() as tmpdir:
85+
req_file = os.path.join(tmpdir, "req.txt")
86+
with open(req_file, "w") as f:
87+
f.write(f"{package}=={version}\n")
88+
89+
result = subprocess.run(
90+
[
91+
"pip-audit",
92+
"--requirement",
93+
req_file,
94+
"--no-deps",
95+
"--format",
96+
"json",
97+
"--progress-spinner",
98+
"off",
99+
],
100+
capture_output=True,
101+
text=True,
102+
)
103+
if result.returncode == 0:
104+
return True
105+
# Non-zero exit: parse JSON to distinguish real vulns from tool errors
106+
try:
107+
audit_output = json.loads(result.stdout)
108+
dependencies = audit_output.get("dependencies", [])
109+
for dep in dependencies:
110+
if dep.get("vulns"):
111+
print(
112+
f"[pip-audit] {package}=={version}: "
113+
f"{len(dep['vulns'])} vulnerability/ies found"
114+
)
115+
return False
116+
# Non-zero but no vulns listed — treat as pass
117+
return True
118+
except (json.JSONDecodeError, KeyError):
119+
print(
120+
f"[pip-audit] {package}=={version}: could not parse output, "
121+
f"assuming no vulnerabilities"
122+
)
123+
return True
124+
except FileNotFoundError:
125+
print(f"[pip-audit] pip-audit not found; skipping audit for {package}=={version}")
126+
return True
127+
except Exception as exc:
128+
print(f"[pip-audit] unexpected error for {package}=={version}: {exc}")
129+
return True
130+
131+
132+
def _get_safe_version(
133+
package: str, releases: list[Any]
134+
) -> Union[tuple[Any, Any], tuple[None, None]]:
135+
"""
136+
Return the newest version that:
137+
1. Was released at least GRACE_PERIOD_DAYS ago (grace period elapsed), AND
138+
2. Passed pip-audit (no known vulnerabilities).
139+
140+
Versions are evaluated **independently** — a newer release does NOT reset
141+
the grace period of an older one. This prevents the case where a package
142+
that ships a new release every day never produces a stable version.
143+
"""
68144
today = datetime.today().date()
69145
grace_cutoff = today - timedelta(days=GRACE_PERIOD_DAYS)
70-
for i, (ver, date) in enumerate(releases):
71-
grace_end = date + timedelta(days=GRACE_PERIOD_DAYS)
72-
superseded = any(nd < grace_end for _, nd in releases[:i])
73-
if not superseded and date <= grace_cutoff:
146+
147+
for ver, date in releases:
148+
if date > grace_cutoff:
149+
# Grace period not yet elapsed — skip
150+
continue
151+
print(f"[pip-audit] auditing {package}=={ver} (released {date})…")
152+
if _run_pip_audit(package, ver):
74153
return ver, date
154+
print(f"[pip-audit] {package}=={ver}: FAIL — skipping")
155+
75156
return None, None
76157

77158

@@ -132,7 +213,7 @@ def main() -> None:
132213
if installed_date is None or installed_date <= grace_cutoff:
133214
continue
134215

135-
safe_ver, safe_date = _get_safe_version(releases)
216+
safe_ver, safe_date = _get_safe_version(pkg, releases)
136217
if safe_ver is None:
137218
print(
138219
f"[grace-period] {pkg}=={installed_ver} (released {installed_date}) "

0 commit comments

Comments
 (0)