From ec863c2a414675e9b5feef02a444be3ce48bf841 Mon Sep 17 00:00:00 2001 From: stonebig Date: Tue, 27 Oct 2026 09:50:16 +0100 Subject: [PATCH 1/4] wppm prod publish: tag wppm- after upload Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build_wppm_prod_publish.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/build_wppm_prod_publish.yml b/.github/workflows/build_wppm_prod_publish.yml index bae270ca..c4bc0a24 100644 --- a/.github/workflows/build_wppm_prod_publish.yml +++ b/.github/workflows/build_wppm_prod_publish.yml @@ -91,3 +91,23 @@ jobs: with: skip-existing: true verbose: true # Enable verbose output + + tag_release: + name: Tag the published commit + needs: [upload_pypi_test] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - name: tag wppm-, once + shell: bash + run: | + TAG="wppm-$(sed -n "s/^__version__ = '\(.*\)'/\1/p" wppm/__init__.py)" + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null; then + echo "$TAG already exists" + else + git tag "$TAG" "$GITHUB_SHA" + git push origin "$TAG" + fi From 40049183be0b540f1c75d93d9af30dafaf980d33 Mon Sep 17 00:00:00 2001 From: stonebig Date: Tue, 27 Oct 2026 13:56:51 +0100 Subject: [PATCH 2/4] wppm -sa: security advisories from osv.dev Co-Authored-By: Claude Opus 5.5 --- README_PYPI.md | 15 +++++++ tests/test_audit.py | 25 ++++++++++++ wppm/audit.py | 96 +++++++++++++++++++++++++++++++++++++++++++++ wppm/wppm.py | 20 ++++++++++ 4 files changed, 156 insertions(+) create mode 100644 tests/test_audit.py create mode 100644 wppm/audit.py diff --git a/README_PYPI.md b/README_PYPI.md index 483fc91c..94a46a56 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -200,6 +200,21 @@ one. No distribution is involved, so two downloaded files are enough. WinPython publishes one index per release and flavor, in [changelogs/](https://github.com/winpython/winpython/tree/master/changelogs). +## Security advisories + +`-sa` asks [osv.dev](https://osv.dev) about the installed packages, or about the pins of +a `pylock.toml` or `requirements.txt`. One line per affected package, with the version +that fixes all its known advisories: + +```console +$ wppm pylock.toml -sa +mako 1.3.12 -> 1.4.2 GHSA-5639-2j2p-m4mx +nbconvert 7.17.1 -> 7.17.2 GHSA-pjvx-prjx-h659 +``` + +`-v` adds each advisory's summary, `-j` gives JSON. The exit code is 1 when anything is +found, so it can gate a build. It needs network access. + ## Environments you have not installed anything into `-t` points `wppm` at *another* Python distribution, and `-ws` at a plain directory of diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 00000000..0fb11856 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +"""wppm -sa reads pins from files offline; only the osv.dev query needs network.""" +from wppm import audit + + +def test_pins_from_requirements(tmp_path): + req = tmp_path / "requirements.txt" + req.write_text("# comment\nMako==1.3.12\nnbconvert[webpdf] == 7.17.1 ; python_version>'3'\nnumpy>=2\n", encoding="utf-8") + assert audit.pins_from_file(req) == [("Mako", "1.3.12"), ("nbconvert", "7.17.1")] + + +def test_pins_from_pylock(tmp_path): + lock = tmp_path / "pylock.toml" + lock.write_text('lock-version = "1.0"\n[[packages]]\nname = "mako"\nversion = "1.3.12"\n' + '[[packages]]\nname = "local"\n', encoding="utf-8") + assert audit.pins_from_file(lock) == [("mako", "1.3.12")] + + +def test_fixed_in_picks_lowest_fix_above_installed(): + vuln = {"affected": [{"package": {"ecosystem": "PyPI", "name": "Pydantic_AI-slim"}, + "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0"}]}, + {"type": "ECOSYSTEM", "events": [{"introduced": "1.100"}, {"fixed": "1.107.4"}]}, + {"type": "GIT", "events": [{"fixed": "01e0558825b8"}]}]}]} + assert audit._fixed_in(vuln, "pydantic-ai-slim", "1.106.0") == "1.107.4" + assert audit._fixed_in(vuln, "other", "1.106.0") == "" diff --git a/wppm/audit.py b/wppm/audit.py new file mode 100644 index 00000000..96496100 --- /dev/null +++ b/wppm/audit.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""Known security advisories for a set of pinned packages, from osv.dev. + +One batch query covers the whole set; details are fetched only for the hits. +Needs network access, no key. +""" +import json +import re +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +try: + from packaging.version import Version +except ImportError: + from pip._vendor.packaging.version import Version + +OSV = "https://api.osv.dev/v1" +TIMEOUT = 30 +BATCH = 1000 # osv.dev limit per querybatch + + +def _call(url, payload=None): + data = json.dumps(payload).encode() if payload is not None else None + request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + return json.load(response) + + +def pins_from_file(path): + """(name, version) pairs of a pylock.toml or of the `name==version` lines of a requirements file.""" + path = Path(path) + text = path.read_text(encoding="utf-8") + if path.suffix == ".toml": + try: + import tomllib + except ImportError: + import tomli as tomllib + return [(p["name"], p["version"]) for p in tomllib.loads(text).get("packages", []) if p.get("version")] + pins = [] + for line in text.splitlines(): + match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([^\s;#\\]+)", line) + if match: + pins.append(match.groups()) + return pins + + +def _fixed_in(vuln, name, installed): + """Lowest version that fixes `vuln` above `installed`, '' when none is known.""" + normal = re.sub(r"[-_.]+", "-", name).lower() + fixes = [] + for affected in vuln.get("affected", []): + package = affected.get("package", {}) + if package.get("ecosystem") != "PyPI" or re.sub(r"[-_.]+", "-", package.get("name", "")).lower() != normal: + continue + for span in affected.get("ranges", []): + for event in span.get("events", []): + try: + if "fixed" in event and Version(event["fixed"]) > Version(installed): + fixes.append(Version(event["fixed"])) + except Exception: + pass + return str(min(fixes)) if fixes else "" + + +def audit(pins): + """Advisories per pinned package: [{package, version, id, aliases, fixed, summary}], sorted. + + The same flaw is often published twice (PYSEC and GHSA, aliases of each + other): it is reported once. A PYSEC record whose GHSA alias does not match + the same version is dropped: PYSEC often marks every version affected when + only a git commit is known as the fix, GHSA carries the curated range. + """ + pins = sorted(set(pins), key=lambda p: p[0].lower()) + hits = [] + for start in range(0, len(pins), BATCH): + chunk = pins[start:start + BATCH] + queries = [{"package": {"name": n, "ecosystem": "PyPI"}, "version": v} for n, v in chunk] + answers = _call(f"{OSV}/querybatch", {"queries": queries}).get("results", []) + for (name, version), answer in zip(chunk, answers): + hits += [(name, version, v["id"]) for v in answer.get("vulns") or []] + with ThreadPoolExecutor(8) as pool: + details = dict(zip((h[2] for h in hits), pool.map(lambda h: _call(f"{OSV}/vulns/{h[2]}"), hits))) + rows, seen = [], set() + for name, version, vid in sorted(hits, key=lambda h: (h[0].lower(), not h[2].startswith("GHSA"), h[2])): + vuln = details[vid] + aliases = [a for a in vuln.get("aliases", []) if a != vid] + if (name, vid) in seen: + continue + if vid.startswith("PYSEC") and any(a.startswith("GHSA") for a in aliases): + continue # its GHSA alias was not a hit, or it would be in seen + seen.update((name, a) for a in [vid] + aliases) + rows.append({"package": name, "version": version, "id": vid, "aliases": aliases, + "fixed": _fixed_in(vuln, name, version), + "summary": vuln.get("summary") or (vuln.get("details") or "").split("\n")[0]}) + return rows diff --git a/wppm/wppm.py b/wppm/wppm.py index 61b1e586..4a1aea05 100644 --- a/wppm/wppm.py +++ b/wppm/wppm.py @@ -351,6 +351,7 @@ def main(test=False): parser.add_argument("-r", dest="pipup", action="store_true", help=f"show Reverse (!= constraining) dependancies of the given package[option]: wppm -r pytest![test]") 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") parser.add_argument("-l", dest="levels", type=int, default=-1, help="show 'LEVELS' levels of dependencies (with -p, -r): wppm -p pandas -l1") + 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") 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") parser.add_argument("-t", dest="target", default=sys.prefix, help=f'path to target Python distribution (default: "{sys.prefix}")') 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,25 @@ def main(test=False): raise RuntimeError("Incompatible arguments: --install and --uninstall") if args.registerWinPython and args.unregisterWinPython: raise RuntimeError("Incompatible arguments: --install and --uninstall") + if args.audit: + from . import audit + source = next((Path(f) for f in args.fname if f and Path(f).is_file()), None) + pins = audit.pins_from_file(source) if source else piptree.PipData(targetpython, args.wheelsource).pip_list() + rows = audit.audit(pins) + if args.json: + print(json.dumps(rows, indent=4)) + else: + # one line per package, with the version fixing all its known advisories; -v lists them + for package in dict.fromkeys(r["package"] for r in rows): + own = [r for r in rows if r["package"] == package] + fixes = [r["fixed"] for r in own] + fix = f" -> {max(fixes, key=audit.Version)}" if all(fixes) else " (no fix known for all)" + print(f"{package} {own[0]['version']}{fix} {', '.join(r['id'] for r in own)}") + if args.verbose: + for r in own: + print(f" {r['id']} -> {r['fixed'] or '?'} {utils.sum_up(r['summary'], 90)}") + print(f"# {len(pins)} packages checked, {len(rows)} advisories in {len({r['package'] for r in rows})} packages", file=sys.stderr) + sys.exit(1 if rows else 0) if args.pipdown or args.pipup: pip = piptree.PipData(targetpython, args.wheelsource) # -tl is then a root filter, not a list of its own: "." asked for implicitly, From 1f18153f9d9460fa134c6c2419676215c1a7728a Mon Sep 17 00:00:00 2001 From: stonebig Date: Tue, 27 Oct 2026 14:11:49 +0100 Subject: [PATCH 3/4] wppm -sa: check the suggested version is clean Co-Authored-By: Claude Opus 5.5 --- tests/test_audit.py | 15 +++++++++++++++ wppm/audit.py | 25 +++++++++++++++++++++++++ wppm/wppm.py | 14 +++++++++----- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/tests/test_audit.py b/tests/test_audit.py index 0fb11856..4f071638 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -23,3 +23,18 @@ def test_fixed_in_picks_lowest_fix_above_installed(): {"type": "GIT", "events": [{"fixed": "01e0558825b8"}]}]}]} assert audit._fixed_in(vuln, "pydantic-ai-slim", "1.106.0") == "1.107.4" assert audit._fixed_in(vuln, "other", "1.106.0") == "" + + +def test_upgrades_flags_a_fix_with_advisories_of_its_own(monkeypatch): + rows = [{"package": "a", "version": "1.0", "fixed": "1.2"}, {"package": "a", "version": "1.0", "fixed": "1.10"}, + {"package": "b", "version": "2.0", "fixed": "2.1"}, {"package": "c", "version": "3.0", "fixed": ""}] + asked = [] + def fake_audit(pins): + asked.extend(pins) + return [{"package": "b"}] + monkeypatch.setattr(audit, "audit", fake_audit) + result = audit.upgrades(rows) + assert sorted(asked) == [("a", "1.10"), ("b", "2.1")] + assert result == {"a": {"version": "1.0", "to": "1.10", "clean": True}, + "b": {"version": "2.0", "to": "2.1", "clean": False}, + "c": {"version": "3.0", "to": "", "clean": None}} diff --git a/wppm/audit.py b/wppm/audit.py index 96496100..680653fc 100644 --- a/wppm/audit.py +++ b/wppm/audit.py @@ -94,3 +94,28 @@ def audit(pins): "fixed": _fixed_in(vuln, name, version), "summary": vuln.get("summary") or (vuln.get("details") or "").split("\n")[0]}) return rows + + +def upgrades(rows): + """Per affected package, the version fixing all its advisories, and whether it is clean. + + The highest `fixed` can carry an advisory of its own (pypdf needed eleven + releases to get past that), so the suggested versions are audited too: one + more batch query. Not clean means a later release is needed; it is not + searched for. {package: {"version", "to", "clean"}}, `to` '' and `clean` + None when some advisory has no known fix. + """ + result = {} + for row in rows: + entry = result.setdefault(row["package"], {"version": row["version"], "fixes": []}) + entry["fixes"].append(row["fixed"]) + for entry in result.values(): + fixes = entry.pop("fixes") + entry["to"] = max(fixes, key=Version) if all(fixes) else "" + entry["clean"] = None + proposed = [(name, entry["to"]) for name, entry in result.items() if entry["to"]] + still = {row["package"] for row in audit(proposed)} if proposed else set() + for name, entry in result.items(): + if entry["to"]: + entry["clean"] = name not in still + return result diff --git a/wppm/wppm.py b/wppm/wppm.py index 4a1aea05..2c7cfb1c 100644 --- a/wppm/wppm.py +++ b/wppm/wppm.py @@ -385,15 +385,19 @@ def main(test=False): source = next((Path(f) for f in args.fname if f and Path(f).is_file()), None) pins = audit.pins_from_file(source) if source else piptree.PipData(targetpython, args.wheelsource).pip_list() rows = audit.audit(pins) + upgrades = audit.upgrades(rows) if args.json: - print(json.dumps(rows, indent=4)) + print(json.dumps([dict(r, to=upgrades[r["package"]]["to"], clean=upgrades[r["package"]]["clean"]) for r in rows], indent=4)) else: # one line per package, with the version fixing all its known advisories; -v lists them - for package in dict.fromkeys(r["package"] for r in rows): + for package, up in upgrades.items(): own = [r for r in rows if r["package"] == package] - fixes = [r["fixed"] for r in own] - fix = f" -> {max(fixes, key=audit.Version)}" if all(fixes) else " (no fix known for all)" - print(f"{package} {own[0]['version']}{fix} {', '.join(r['id'] for r in own)}") + if not up["to"]: + fix = " (no fix known for all)" + else: + fix = f" -> {up['to']}" + ("" if up["clean"] else " (not clean yet: has advisories of its own)") + ids = ", ".join(r["id"] for r in own) if len(own) <= 3 else f"{len(own)} advisories" + print(f"{package} {up['version']}{fix} {ids}") if args.verbose: for r in own: print(f" {r['id']} -> {r['fixed'] or '?'} {utils.sum_up(r['summary'], 90)}") From 2ef027ce5875a554dd6de5e45dc6938c09748299 Mon Sep 17 00:00:00 2001 From: stonebig Date: Tue, 27 Oct 2026 14:21:16 +0100 Subject: [PATCH 4/4] wppm 17.14.20261027 Co-Authored-By: Claude Opus 5.5 --- wppm/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wppm/__init__.py b/wppm/__init__.py index 78978738..c97ce574 100644 --- a/wppm/__init__.py +++ b/wppm/__init__.py @@ -28,6 +28,6 @@ OTHER DEALINGS IN THE SOFTWARE. """ -__version__ = '17.13.20261027' +__version__ = '17.14.20261027' __license__ = __doc__ __project_url__ = 'http://winpython.github.io/'