Skip to content

Commit ee74a6a

Browse files
authored
Merge pull request #2122 from stonebig/master
wppm -sa: integrated security audit of packages
2 parents e0de222 + 2ef027c commit ee74a6a

6 files changed

Lines changed: 221 additions & 1 deletion

File tree

‎.github/workflows/build_wppm_prod_publish.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,23 @@ jobs:
9191
with:
9292
skip-existing: true
9393
verbose: true # Enable verbose output
94+
95+
tag_release:
96+
name: Tag the published commit
97+
needs: [upload_pypi_test]
98+
runs-on: ubuntu-24.04
99+
permissions:
100+
contents: write
101+
steps:
102+
- uses: actions/checkout@v6
103+
104+
- name: tag wppm-<version>, once
105+
shell: bash
106+
run: |
107+
TAG="wppm-$(sed -n "s/^__version__ = '\(.*\)'/\1/p" wppm/__init__.py)"
108+
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null; then
109+
echo "$TAG already exists"
110+
else
111+
git tag "$TAG" "$GITHUB_SHA"
112+
git push origin "$TAG"
113+
fi

‎README_PYPI.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,21 @@ one. No distribution is involved, so two downloaded files are enough. WinPython
200200
publishes one index per release and flavor, in
201201
[changelogs/](https://github.com/winpython/winpython/tree/master/changelogs).
202202

203+
## Security advisories
204+
205+
`-sa` asks [osv.dev](https://osv.dev) about the installed packages, or about the pins of
206+
a `pylock.toml` or `requirements.txt`. One line per affected package, with the version
207+
that fixes all its known advisories:
208+
209+
```console
210+
$ wppm pylock.toml -sa
211+
mako 1.3.12 -> 1.4.2 GHSA-5639-2j2p-m4mx
212+
nbconvert 7.17.1 -> 7.17.2 GHSA-pjvx-prjx-h659
213+
```
214+
215+
`-v` adds each advisory's summary, `-j` gives JSON. The exit code is 1 when anything is
216+
found, so it can gate a build. It needs network access.
217+
203218
## Environments you have not installed anything into
204219

205220
`-t` points `wppm` at *another* Python distribution, and `-ws` at a plain directory of

‎tests/test_audit.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# -*- coding: utf-8 -*-
2+
"""wppm -sa reads pins from files offline; only the osv.dev query needs network."""
3+
from wppm import audit
4+
5+
6+
def test_pins_from_requirements(tmp_path):
7+
req = tmp_path / "requirements.txt"
8+
req.write_text("# comment\nMako==1.3.12\nnbconvert[webpdf] == 7.17.1 ; python_version>'3'\nnumpy>=2\n", encoding="utf-8")
9+
assert audit.pins_from_file(req) == [("Mako", "1.3.12"), ("nbconvert", "7.17.1")]
10+
11+
12+
def test_pins_from_pylock(tmp_path):
13+
lock = tmp_path / "pylock.toml"
14+
lock.write_text('lock-version = "1.0"\n[[packages]]\nname = "mako"\nversion = "1.3.12"\n'
15+
'[[packages]]\nname = "local"\n', encoding="utf-8")
16+
assert audit.pins_from_file(lock) == [("mako", "1.3.12")]
17+
18+
19+
def test_fixed_in_picks_lowest_fix_above_installed():
20+
vuln = {"affected": [{"package": {"ecosystem": "PyPI", "name": "Pydantic_AI-slim"},
21+
"ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0"}]},
22+
{"type": "ECOSYSTEM", "events": [{"introduced": "1.100"}, {"fixed": "1.107.4"}]},
23+
{"type": "GIT", "events": [{"fixed": "01e0558825b8"}]}]}]}
24+
assert audit._fixed_in(vuln, "pydantic-ai-slim", "1.106.0") == "1.107.4"
25+
assert audit._fixed_in(vuln, "other", "1.106.0") == ""
26+
27+
28+
def test_upgrades_flags_a_fix_with_advisories_of_its_own(monkeypatch):
29+
rows = [{"package": "a", "version": "1.0", "fixed": "1.2"}, {"package": "a", "version": "1.0", "fixed": "1.10"},
30+
{"package": "b", "version": "2.0", "fixed": "2.1"}, {"package": "c", "version": "3.0", "fixed": ""}]
31+
asked = []
32+
def fake_audit(pins):
33+
asked.extend(pins)
34+
return [{"package": "b"}]
35+
monkeypatch.setattr(audit, "audit", fake_audit)
36+
result = audit.upgrades(rows)
37+
assert sorted(asked) == [("a", "1.10"), ("b", "2.1")]
38+
assert result == {"a": {"version": "1.0", "to": "1.10", "clean": True},
39+
"b": {"version": "2.0", "to": "2.1", "clean": False},
40+
"c": {"version": "3.0", "to": "", "clean": None}}

‎wppm/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@
2828
OTHER DEALINGS IN THE SOFTWARE.
2929
"""
3030

31-
__version__ = '17.13.20261027'
31+
__version__ = '17.14.20261027'
3232
__license__ = __doc__
3333
__project_url__ = 'http://winpython.github.io/'

‎wppm/audit.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# -*- coding: utf-8 -*-
2+
"""Known security advisories for a set of pinned packages, from osv.dev.
3+
4+
One batch query covers the whole set; details are fetched only for the hits.
5+
Needs network access, no key.
6+
"""
7+
import json
8+
import re
9+
import urllib.request
10+
from concurrent.futures import ThreadPoolExecutor
11+
from pathlib import Path
12+
13+
try:
14+
from packaging.version import Version
15+
except ImportError:
16+
from pip._vendor.packaging.version import Version
17+
18+
OSV = "https://api.osv.dev/v1"
19+
TIMEOUT = 30
20+
BATCH = 1000 # osv.dev limit per querybatch
21+
22+
23+
def _call(url, payload=None):
24+
data = json.dumps(payload).encode() if payload is not None else None
25+
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
26+
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
27+
return json.load(response)
28+
29+
30+
def pins_from_file(path):
31+
"""(name, version) pairs of a pylock.toml or of the `name==version` lines of a requirements file."""
32+
path = Path(path)
33+
text = path.read_text(encoding="utf-8")
34+
if path.suffix == ".toml":
35+
try:
36+
import tomllib
37+
except ImportError:
38+
import tomli as tomllib
39+
return [(p["name"], p["version"]) for p in tomllib.loads(text).get("packages", []) if p.get("version")]
40+
pins = []
41+
for line in text.splitlines():
42+
match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([^\s;#\\]+)", line)
43+
if match:
44+
pins.append(match.groups())
45+
return pins
46+
47+
48+
def _fixed_in(vuln, name, installed):
49+
"""Lowest version that fixes `vuln` above `installed`, '' when none is known."""
50+
normal = re.sub(r"[-_.]+", "-", name).lower()
51+
fixes = []
52+
for affected in vuln.get("affected", []):
53+
package = affected.get("package", {})
54+
if package.get("ecosystem") != "PyPI" or re.sub(r"[-_.]+", "-", package.get("name", "")).lower() != normal:
55+
continue
56+
for span in affected.get("ranges", []):
57+
for event in span.get("events", []):
58+
try:
59+
if "fixed" in event and Version(event["fixed"]) > Version(installed):
60+
fixes.append(Version(event["fixed"]))
61+
except Exception:
62+
pass
63+
return str(min(fixes)) if fixes else ""
64+
65+
66+
def audit(pins):
67+
"""Advisories per pinned package: [{package, version, id, aliases, fixed, summary}], sorted.
68+
69+
The same flaw is often published twice (PYSEC and GHSA, aliases of each
70+
other): it is reported once. A PYSEC record whose GHSA alias does not match
71+
the same version is dropped: PYSEC often marks every version affected when
72+
only a git commit is known as the fix, GHSA carries the curated range.
73+
"""
74+
pins = sorted(set(pins), key=lambda p: p[0].lower())
75+
hits = []
76+
for start in range(0, len(pins), BATCH):
77+
chunk = pins[start:start + BATCH]
78+
queries = [{"package": {"name": n, "ecosystem": "PyPI"}, "version": v} for n, v in chunk]
79+
answers = _call(f"{OSV}/querybatch", {"queries": queries}).get("results", [])
80+
for (name, version), answer in zip(chunk, answers):
81+
hits += [(name, version, v["id"]) for v in answer.get("vulns") or []]
82+
with ThreadPoolExecutor(8) as pool:
83+
details = dict(zip((h[2] for h in hits), pool.map(lambda h: _call(f"{OSV}/vulns/{h[2]}"), hits)))
84+
rows, seen = [], set()
85+
for name, version, vid in sorted(hits, key=lambda h: (h[0].lower(), not h[2].startswith("GHSA"), h[2])):
86+
vuln = details[vid]
87+
aliases = [a for a in vuln.get("aliases", []) if a != vid]
88+
if (name, vid) in seen:
89+
continue
90+
if vid.startswith("PYSEC") and any(a.startswith("GHSA") for a in aliases):
91+
continue # its GHSA alias was not a hit, or it would be in seen
92+
seen.update((name, a) for a in [vid] + aliases)
93+
rows.append({"package": name, "version": version, "id": vid, "aliases": aliases,
94+
"fixed": _fixed_in(vuln, name, version),
95+
"summary": vuln.get("summary") or (vuln.get("details") or "").split("\n")[0]})
96+
return rows
97+
98+
99+
def upgrades(rows):
100+
"""Per affected package, the version fixing all its advisories, and whether it is clean.
101+
102+
The highest `fixed` can carry an advisory of its own (pypdf needed eleven
103+
releases to get past that), so the suggested versions are audited too: one
104+
more batch query. Not clean means a later release is needed; it is not
105+
searched for. {package: {"version", "to", "clean"}}, `to` '' and `clean`
106+
None when some advisory has no known fix.
107+
"""
108+
result = {}
109+
for row in rows:
110+
entry = result.setdefault(row["package"], {"version": row["version"], "fixes": []})
111+
entry["fixes"].append(row["fixed"])
112+
for entry in result.values():
113+
fixes = entry.pop("fixes")
114+
entry["to"] = max(fixes, key=Version) if all(fixes) else ""
115+
entry["clean"] = None
116+
proposed = [(name, entry["to"]) for name, entry in result.items() if entry["to"]]
117+
still = {row["package"] for row in audit(proposed)} if proposed else set()
118+
for name, entry in result.items():
119+
if entry["to"]:
120+
entry["clean"] = name not in still
121+
return result

‎wppm/wppm.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ def main(test=False):
351351
parser.add_argument("-r", dest="pipup", action="store_true", help=f"show Reverse (!= constraining) dependancies of the given package[option]: wppm -r pytest![test]")
352352
parser.add_argument("-tl", "--top-level", action="store_true", help="keep only the entries no other entry pulls in, sorted: wppm -tl, wppm requirements.txt -tl -v\nwith -p or -r, start the tree from them instead of every package: wppm -tl -p")
353353
parser.add_argument("-l", dest="levels", type=int, default=-1, help="show 'LEVELS' levels of dependencies (with -p, -r): wppm -p pandas -l1")
354+
parser.add_argument("-sa", "--audit", action="store_true", help="show known security advisories (osv.dev) of installed packages, or of a pylock.toml / requirements.txt: wppm -sa, wppm pylock.toml -sa")
354355
parser.add_argument("-j", "--json", dest="json", action="store_true", help="machine-readable JSON output (with -p, -r, -ls, -md, -tl): wppm -p pandas[.] -j")
355356
parser.add_argument("-t", dest="target", default=sys.prefix, help=f'path to target Python distribution (default: "{sys.prefix}")')
356357
parser.add_argument("-i", "--install", action="store_true", help="install a given package wheel or pylock file (use pip for more features)")
@@ -379,6 +380,29 @@ def main(test=False):
379380
raise RuntimeError("Incompatible arguments: --install and --uninstall")
380381
if args.registerWinPython and args.unregisterWinPython:
381382
raise RuntimeError("Incompatible arguments: --install and --uninstall")
383+
if args.audit:
384+
from . import audit
385+
source = next((Path(f) for f in args.fname if f and Path(f).is_file()), None)
386+
pins = audit.pins_from_file(source) if source else piptree.PipData(targetpython, args.wheelsource).pip_list()
387+
rows = audit.audit(pins)
388+
upgrades = audit.upgrades(rows)
389+
if args.json:
390+
print(json.dumps([dict(r, to=upgrades[r["package"]]["to"], clean=upgrades[r["package"]]["clean"]) for r in rows], indent=4))
391+
else:
392+
# one line per package, with the version fixing all its known advisories; -v lists them
393+
for package, up in upgrades.items():
394+
own = [r for r in rows if r["package"] == package]
395+
if not up["to"]:
396+
fix = " (no fix known for all)"
397+
else:
398+
fix = f" -> {up['to']}" + ("" if up["clean"] else " (not clean yet: has advisories of its own)")
399+
ids = ", ".join(r["id"] for r in own) if len(own) <= 3 else f"{len(own)} advisories"
400+
print(f"{package} {up['version']}{fix} {ids}")
401+
if args.verbose:
402+
for r in own:
403+
print(f" {r['id']} -> {r['fixed'] or '?'} {utils.sum_up(r['summary'], 90)}")
404+
print(f"# {len(pins)} packages checked, {len(rows)} advisories in {len({r['package'] for r in rows})} packages", file=sys.stderr)
405+
sys.exit(1 if rows else 0)
382406
if args.pipdown or args.pipup:
383407
pip = piptree.PipData(targetpython, args.wheelsource)
384408
# -tl is then a root filter, not a list of its own: "." asked for implicitly,

0 commit comments

Comments
 (0)