From 7fc2fa04d7d88a1cdf0d290ddd2de3815a7d52a5 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 14:56:35 +0200 Subject: build: audit the shipped Python dependencies with pip-audit No lock file pins the dependencies, so each build installs the newest releases and what ships is only known once it has. packaging/audit_deps.py runs pip-audit then: build-common.sh on the venv before stripping it, build-node-runtime.ps1 on the environment before PyInstaller freezes it. A known vulnerability stops the build, unless its id is listed in packaging/pip-audit-ignore.txt with why it does not apply. No network only skips the audit with a warning, as does a pip-audit that cannot be installed or writes no report. pip-audit runs from a venv of its own, never from the one that ships. Our packages are not audited (not on PyPI), nor pip, setuptools and wheel, which do not ship. Checked with the real build-common.sh (79 packages, none known), with PyJWT 2.14 (the build stops on its two advisories, and goes on once they are listed) and offline (a warning, the build goes on). The PowerShell step was not run here. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-node/tests/test_packaging_audit.py | 64 ++++++++++ packaging/README.md | 12 ++ packaging/audit_deps.py | 131 +++++++++++++++++++++ packaging/build/build-common.sh | 7 ++ packaging/pip-audit-ignore.txt | 6 + packaging/win/build-node-runtime.ps1 | 9 ++ 6 files changed, 229 insertions(+) create mode 100644 packages/meshbay-node/tests/test_packaging_audit.py create mode 100644 packaging/audit_deps.py create mode 100644 packaging/pip-audit-ignore.txt diff --git a/packages/meshbay-node/tests/test_packaging_audit.py b/packages/meshbay-node/tests/test_packaging_audit.py new file mode 100644 index 0000000..4409b01 --- /dev/null +++ b/packages/meshbay-node/tests/test_packaging_audit.py @@ -0,0 +1,64 @@ +""" +packaging/audit_deps.py, the pip-audit step of the builds. + +What can be checked without the network: a build with no network goes on +(the audit is skipped with a warning, never a failure), what is audited is +what ships, and the ignore list is read as ids. Whether pip-audit stops a +build on a real advisory needs the network and was checked by hand. +""" + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "packaging" / "audit_deps.py" + +pytestmark = pytest.mark.skipif(not SCRIPT.exists(), reason="packaging not present") + + +def _module(): + spec = importlib.util.spec_from_file_location("audit_deps", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_no_network_skips_the_audit_without_failing_the_build(tmp_path): + # A proxy nobody listens on: every request is refused at once. + dead = "http://127.0.0.1:9" + env = {**os.environ, "HTTPS_PROXY": dead, "https_proxy": dead, + "HTTP_PROXY": dead, "http_proxy": dead} + run = subprocess.run([sys.executable, str(SCRIPT), "--tool-dir", str(tmp_path / "t")], + capture_output=True, text=True, env=env, timeout=60) + assert run.returncode == 0, run.stderr + assert "pip-audit skipped: no network" in run.stderr + assert not (tmp_path / "t").exists(), "nothing is installed when it cannot run" + + +def test_what_is_audited_is_what_ships(): + names = {line.split("==")[0].lower() for line in _module().shipped()} + assert "pytest" in names, "the environment's packages are listed" + assert not {n for n in names if n.startswith("meshbay")}, "ours are not on PyPI" + assert not names & {"pip", "setuptools", "wheel"}, "build tools do not ship" + + +def test_the_ignore_list_is_read_as_ids(tmp_path, monkeypatch): + mod = _module() + f = tmp_path / "ignore.txt" + f.write_text("# a comment\n\nPYSEC-2026-1 # not reached: no JWK import\n" + "GHSA-xxxx-yyyy-zzzz\n", encoding="utf-8") + monkeypatch.setattr(mod, "IGNORE_FILE", f) + assert mod.ignored() == ["PYSEC-2026-1", "GHSA-xxxx-yyyy-zzzz"] + + +def test_the_shipped_ignore_list_lets_nothing_through_unexplained(): + """Every id in the real file carries its reason.""" + mod = _module() + for line in mod.IGNORE_FILE.read_text(encoding="utf-8").splitlines(): + if line.strip() and not line.lstrip().startswith("#"): + assert "#" in line, f"no reason given: {line!r}" diff --git a/packaging/README.md b/packaging/README.md index 55cc39a..8156ca0 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -99,6 +99,18 @@ Its architecture is the machine's, not `all`: argon2-cffi's bindings are compiled code. Its licence notices are in `/opt/meshbay-cli/THIRD-PARTY-NOTICES.txt`. +### Known vulnerabilities + +No lock file pins the Python dependencies: every build installs the newest +releases, so what ships is only known once a build has installed it. That is +where [`audit_deps.py`](audit_deps.py) runs pip-audit — `build-common.sh` on the +venv before stripping it, `build-node-runtime.ps1` on the environment before +PyInstaller freezes it. A known vulnerability stops the build, unless its id is +in [`pip-audit-ignore.txt`](pip-audit-ignore.txt) with why it does not apply. +No network only skips the audit, with a warning. pip-audit itself runs from a +venv of its own (`/pip-audit`, `packages/meshbay-client/build/_pip-audit` +on Windows), never from the one that ships. + The man page, `meshbay-cli(1)`, is generated at build time from the client's own argument parser (`python -m meshbay_cli.manpage`), so it lists the options the program has; `tests/test_manpage.py` fails when an action or option misses diff --git a/packaging/audit_deps.py b/packaging/audit_deps.py new file mode 100644 index 0000000..513b1dd --- /dev/null +++ b/packaging/audit_deps.py @@ -0,0 +1,131 @@ +""" +Audit the Python environment a build is about to ship against known +vulnerabilities, with pip-audit. + + python audit_deps.py [--tool-dir DIR] + +Run with the interpreter of the environment being shipped: what it lists is +what is installed beside it. The builds resolve the newest releases when they +run (there is no lock file), so this is the one moment the shipped set is known. + +- A known vulnerability stops the build (exit 1), unless its id is listed in + packaging/pip-audit-ignore.txt with the reason it does not apply. +- No network is not a reason to stop: the audit is skipped with a warning and + the build goes on (exit 0). So is a pip-audit that cannot be installed or + produces no report, which is what a network failing halfway looks like. + +pip-audit runs from a venv of its own (--tool-dir, kept between builds), never +from the shipped environment: nothing it pulls in may end up in a package. +Our own packages are left out — they are not on PyPI — and so are pip, +setuptools and wheel, which install the environment and do not ship in it: +build-common.sh strips them from the venv and PyInstaller does not freeze them. +""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import urllib.request +from importlib import metadata +from pathlib import Path + +PIP_AUDIT = "pip-audit==2.10.1" +# What pip-audit asks by default; reachable means the audit can run. +PROBE = "https://pypi.org/pypi/pip-audit/json" +IGNORE_FILE = Path(__file__).resolve().parent / "pip-audit-ignore.txt" +NOT_SHIPPED = {"pip", "setuptools", "wheel"} + + +def warn(msg: str) -> None: + print(f"!! pip-audit skipped: {msg}", file=sys.stderr) + + +def shipped() -> list[str]: + seen: dict[str, str] = {} + for dist in metadata.distributions(): + name = (dist.metadata["Name"] or "").strip() + if name and not name.lower().startswith("meshbay") and name.lower() not in NOT_SHIPPED: + seen.setdefault(name.lower(), f"{name}=={dist.version}") + return sorted(seen.values()) + + +def ignored() -> list[str]: + if not IGNORE_FILE.exists(): + return [] + ids = [] + for line in IGNORE_FILE.read_text(encoding="utf-8").splitlines(): + line = line.split("#", 1)[0].strip() + if line: + ids.append(line.split()[0]) + return ids + + +def tool(tool_dir: Path) -> Path | None: + exe = tool_dir / ("Scripts/pip-audit.exe" if os.name == "nt" else "bin/pip-audit") + py = tool_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + if not py.exists(): + if subprocess.run([sys.executable, "-m", "venv", "--clear", str(tool_dir)], + capture_output=True).returncode != 0: + return None + r = subprocess.run([str(py), "-m", "pip", "install", "--quiet", + "--disable-pip-version-check", PIP_AUDIT], + capture_output=True, text=True) + return exe if r.returncode == 0 and exe.exists() else None + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--tool-dir", type=Path, + default=Path(tempfile.gettempdir()) / "meshbay-pip-audit") + args = ap.parse_args() + + try: + urllib.request.urlopen(PROBE, timeout=10).close() + except Exception as e: + warn(f"no network ({e.__class__.__name__}: {e})") + return 0 + + exe = tool(args.tool_dir) + if exe is None: + warn(f"could not install {PIP_AUDIT}") + return 0 + + with tempfile.TemporaryDirectory() as tmp: + req = Path(tmp) / "shipped.txt" + req.write_text("\n".join(shipped()) + "\n", encoding="utf-8") + report = Path(tmp) / "report.json" + cmd = [str(exe), "-r", str(req), "--no-deps", "--disable-pip", + "--progress-spinner", "off", "-f", "json", "-o", str(report)] + for vid in ignored(): + cmd += ["--ignore-vuln", vid] + run = subprocess.run(cmd, capture_output=True, text=True) + try: + data = json.loads(report.read_text(encoding="utf-8")) + except (OSError, ValueError): + tail = (run.stderr or run.stdout).strip().splitlines()[-3:] + warn("no report — " + " | ".join(tail)) + return 0 + + # The same id can come back twice, from two advisory sources. + found: dict[tuple[str, str, str], str] = {} + for d in data.get("dependencies", []): + for v in d.get("vulns", []): + found.setdefault((d["name"], d["version"], v["id"]), + ", ".join(v.get("fix_versions") or []) or "none") + audited = sum(1 for d in data.get("dependencies", []) if "skip_reason" not in d) + if not found: + print(f" pip-audit: {audited} packages, no known vulnerability") + return 0 + print(f"!! pip-audit: {len(found)} known vulnerabilit{'y' if len(found) == 1 else 'ies'}:", + file=sys.stderr) + for (name, version, vid), fix in found.items(): + print(f"!! {name} {version} {vid} (fixed in: {fix})", file=sys.stderr) + print(f"!! Upgrade, or list the id in {IGNORE_FILE.name} with why it does not apply.", + file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/build/build-common.sh b/packaging/build/build-common.sh index 2010d1b..ca5bd1b 100755 --- a/packaging/build/build-common.sh +++ b/packaging/build/build-common.sh @@ -60,6 +60,13 @@ echo " writing THIRD-PARTY-NOTICES.txt" "$VENV_BUILD/bin/python" "$REPO/packaging/third_party_notices.py" \ -o "$ROOT/opt/meshbay-common/THIRD-PARTY-NOTICES.txt" meshbay-hub meshbay-node +# --- Known vulnerabilities in what ships ----------------------------------- +# One stops the build unless packaging/pip-audit-ignore.txt says why it does not +# apply; no network only skips the audit, with a warning. pip-audit runs from a +# venv of its own under the staging dir, never from the one shipped. +echo " auditing dependencies (pip-audit)" +"$VENV_BUILD/bin/python" "$REPO/packaging/audit_deps.py" --tool-dir "$STAGING/pip-audit" + # --- Strip build tools from the venv (not needed at runtime) --------------- echo " stripping build tools" "$VENV_BUILD/bin/pip" uninstall -y pip setuptools wheel 2>&1 | tail -1 diff --git a/packaging/pip-audit-ignore.txt b/packaging/pip-audit-ignore.txt new file mode 100644 index 0000000..8aec984 --- /dev/null +++ b/packaging/pip-audit-ignore.txt @@ -0,0 +1,6 @@ +# Vulnerabilities packaging/audit_deps.py lets through, one per line: +# +# # why it does not apply to MeshBay, and when to look again +# +# An id is what pip-audit prints (PYSEC-…, GHSA-…, CVE-…). Remove a line once +# the fixed version ships. diff --git a/packaging/win/build-node-runtime.ps1 b/packaging/win/build-node-runtime.ps1 index 3d26ec2..24baa0b 100644 --- a/packaging/win/build-node-runtime.ps1 +++ b/packaging/win/build-node-runtime.ps1 @@ -91,6 +91,15 @@ Step "installing meshbay-common, meshbay-node, meshbay-cli and PyInstaller" # 'Hidden import "tzdata" not found' when it is absent. if ($LASTEXITCODE -ne 0) { throw "pip install failed" } +# Known vulnerabilities in what is about to be frozen: one stops the build +# unless packaging\pip-audit-ignore.txt says why it does not apply; no network +# only skips the audit, with a warning. pip-audit runs from a venv of its own, +# never from the one PyInstaller freezes. +Step "auditing dependencies (pip-audit)" +& $Python (Join-Path $Repo "packaging\audit_deps.py") ` + --tool-dir (Join-Path $Client "build\_pip-audit") +if ($LASTEXITCODE -ne 0) { throw "pip-audit found known vulnerabilities" } + # --- 3. freeze -------------------------------------------------------- if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir } $pyiWork = Join-Path $Client "build\_pyinstaller" -- cgit v1.2.3