Skip to content

Commit 43deabf

Browse files
authored
πŸ› fix(seed): verify sha256 of bundled wheels on load (#3119)
Security hardening. Bundled seed wheels were loaded straight off disk and handed to pip without any integrity check. A corrupted or tampered wheel sitting next to `embed/__init__.py` β€” whether from a botched upgrade, a filesystem error, or a supply-chain compromise β€” would have been silently installed into every new environment. πŸ”’ The fix records the SHA-256 of every bundled wheel alongside `BUNDLE_SUPPORT` in the generated `embed/__init__.py`, and verifies each wheel the first time it is requested. Hashes are cached per wheel name so the happy path keeps a single file read per interpreter run, and a mismatch aborts with a clear `RuntimeError`. When virtualenv runs from a zipapp the bytes are read straight from the archive entry, so the check applies to both on-disk and zipapp layouts. The hash table is produced by `tasks/upgrade_wheels.py` so future wheel bumps stay in sync without manual bookkeeping. A new `--regen` mode lets the generator rewrite the module from the wheels currently on disk without re-downloading anything, which is how this PR produced the initial table.
1 parent 4e412b0 commit 43deabf

4 files changed

Lines changed: 336 additions & 103 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Security hardening: verify the SHA-256 of every bundled seed wheel when it is loaded so a corrupted or tampered file on
2+
disk fails loud instead of being handed to pip. The hash table is generated alongside ``BUNDLE_SUPPORT`` by
3+
``tasks/upgrade_wheels.py``.

β€Žsrc/virtualenv/seed/wheels/embed/__init__.pyβ€Ž

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

3+
import hashlib
4+
import zipfile
35
from pathlib import Path
46

7+
from virtualenv.info import IS_ZIPAPP, ROOT
58
from virtualenv.seed.wheels.util import Wheel
69

710
BUNDLE_FOLDER = Path(__file__).absolute().parent
@@ -42,18 +45,74 @@
4245
}
4346
MAX = "3.8"
4447

48+
# SHA-256 of every bundled wheel. Verified on load so a corrupted or tampered wheel on disk fails loud instead of
49+
# being handed to pip. Generated together with ``BUNDLE_SUPPORT`` by ``tasks/upgrade_wheels.py``.
50+
BUNDLE_SHA256 = {
51+
"pip-25.0.1-py3-none-any.whl": "c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f",
52+
"pip-26.0.1-py3-none-any.whl": "bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b",
53+
"setuptools-75.3.4-py3-none-any.whl": "2dd50a7f42dddfa1d02a36f275dbe716f38ed250224f609d35fb60a09593d93e",
54+
"setuptools-82.0.1-py3-none-any.whl": "a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb",
55+
"wheel-0.45.1-py3-none-any.whl": "708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248",
56+
}
57+
58+
_VERIFIED_WHEELS: set[str] = set()
59+
4560

4661
def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None:
62+
"""Return the bundled wheel that ships with virtualenv for a given distribution and Python version.
63+
64+
:param distribution: project name of the seed package, for example ``pip`` or ``setuptools``.
65+
:param for_py_version: major.minor Python version string the environment will be created for.
66+
67+
:returns: a :class:`Wheel` pointing at the verified bundled file, or ``None`` when no wheel is bundled for the
68+
requested combination.
69+
70+
:raises RuntimeError: if the bundled wheel on disk fails SHA-256 verification.
71+
72+
"""
4773
mapping = BUNDLE_SUPPORT.get(for_py_version, {}) or BUNDLE_SUPPORT[MAX]
4874
wheel_file = mapping.get(distribution)
4975
if wheel_file is None:
5076
return None
5177
path = BUNDLE_FOLDER / wheel_file
78+
_verify_bundled_wheel(path)
5279
return Wheel.from_path(path)
5380

5481

82+
def _verify_bundled_wheel(path: Path) -> None:
83+
name = path.name
84+
if name in _VERIFIED_WHEELS:
85+
return
86+
expected = BUNDLE_SHA256.get(name)
87+
if expected is None:
88+
msg = f"bundled wheel {name} has no recorded sha256 in BUNDLE_SHA256"
89+
raise RuntimeError(msg)
90+
actual = _hash_bundled_wheel(path)
91+
if actual != expected:
92+
msg = f"bundled wheel {name} sha256 mismatch: expected {expected}, got {actual}"
93+
raise RuntimeError(msg)
94+
_VERIFIED_WHEELS.add(name)
95+
96+
97+
def _hash_bundled_wheel(path: Path) -> str:
98+
# ``path`` is under the package directory; when virtualenv runs from a zipapp the wheel lives inside the
99+
# archive and cannot be opened as a regular file, so read the bytes straight from the zipapp entry.
100+
digest = hashlib.sha256()
101+
if IS_ZIPAPP:
102+
entry = path.resolve().relative_to(Path(ROOT).resolve()).as_posix()
103+
with zipfile.ZipFile(ROOT, "r") as archive, archive.open(entry) as stream:
104+
for chunk in iter(lambda: stream.read(1 << 20), b""):
105+
digest.update(chunk)
106+
else:
107+
with path.open("rb") as stream:
108+
for chunk in iter(lambda: stream.read(1 << 20), b""):
109+
digest.update(chunk)
110+
return digest.hexdigest()
111+
112+
55113
__all__ = [
56114
"BUNDLE_FOLDER",
115+
"BUNDLE_SHA256",
57116
"BUNDLE_SUPPORT",
58117
"MAX",
59118
"get_embed_wheel",

0 commit comments

Comments
Β (0)