Visitar URL original
馃悰 fix(seed): verify downloaded wheel digests by gaborbernat 路 Pull Request #3251 路 pypa/virtualenv 路 GitHub
Skip to content

馃悰 fix(seed): verify downloaded wheel digests - #3251

Merged
gaborbernat merged 7 commits into
mainfrom
verify-wheel-digest
Sep 18, 2026
Merged

gaborbernat merged 7 commits into
mainfrom
verify-wheel-digest

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

download_wheel() handed pip's output straight to the seeder with no integrity check. A compromised index or MITM'd download could substitute a different wheel under the same name.

verify_wheel_digest() checks the downloaded sha256 against PyPI's public JSON record for that filename. Missing record: logged, not fatal (private indexes are legitimate). Mismatch: fatal, wheel is never seeded.

Skipped entirely when PIP_INDEX_URL, PIP_EXTRA_INDEX_URL, or PIP_INDEX is set - a private index can legitimately serve a different, intentionally rebuilt wheel under the same identity, and PyPI's digest isn't the right check for a wheel that never came from PyPI.

Tracked as GHSA-94p9-xgh2-xp45.

download_wheel() ran pip download and handed the result straight to
the seeder with no check on the bytes it got back. Everything else in
this path is verified: the embedded pip/setuptools wheels carry a
BUNDLE_SHA256 checked on every load, but a wheel fetched over the
network for the periodic-update or --download path had no equivalent.
A compromised index, a stale mirror, or a MITM'd download could
substitute a different file under the same name and have it seeded
into every virtual environment created afterward.

verify_wheel_digest() computes the downloaded file's sha256 and
compares it against the digest PyPI's public JSON API reports for
that exact distribution, version, and filename - an independent
source of truth regardless of which index actually served the file.
A missing or unreachable PyPI record only means the check cannot run,
since a private index PyPI has never heard of is a legitimate setup
(air-gapped installs, corporate mirrors), so that case is logged and
the wheel is let through unverified, matching the existing best-effort
posture of the periodic-update feature. An actual mismatch is fatal:
the wheel is never cached or seeded.

This intentionally does not touch how pip resolves its index
(PIP_INDEX_URL and friends stay untouched) - pointing the seed
download at an internal mirror is a legitimate, common setup, and
the digest check is the correct backstop regardless of which index a
wheel came from.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The digest is always taken from public PyPI, even when pip download was deliberately configured to use a private index or mirror. A private repository can legitimately publish a rebuilt/patched wheel under the same distribution, version, and filename as PyPI; if PyPI has that filename, this code rejects the private artifact even though it came from the configured source. Please bind verification to the index that supplied the wheel (for example via index metadata/pip reporting), or only make the PyPI digest authoritative when PyPI was actually the source, and add a private-index same-filename regression.

gaborbernat and others added 5 commits September 17, 2026 17:31
verify_wheel_digest() treated PyPI's published digest as
authoritative for any wheel matching a given distribution, version,
and filename, regardless of where pip actually fetched it from. A
private index can legitimately serve a rebuilt or repackaged wheel
under that same identity - an internal mirror re-signing a release
for policy reasons, for example - and the check would reject that
legitimate artifact just because it differs from the public PyPI
copy, even though it came from exactly the source it was configured
to come from.

download_wheel() now skips the PyPI cross-check whenever
PIP_INDEX_URL, PIP_EXTRA_INDEX_URL, or PIP_INDEX names a custom
index. This only catches the common, explicit way of pointing pip
elsewhere; a pip.conf file has the same effect and stays invisible
here, so this is a conservative signal against false rejections, not
a guarantee the check runs whenever it safely could.
download_wheel() suppressed three type errors around result, whose
inferred type stayed Optional despite the code already assuming it
was never None. An explicit None check narrows it for real and turns
what used to be a confusing AttributeError, if find_compatible_in_house
ever came up empty, into a clear RuntimeError. Also drops a debug log
on the custom-index skip path that added nothing a comment did not
already say.

verify_wheel_digest() and _pypi_release_entry_for_wheel() suppressed
two more by subscripting values PyPI's JSON response types as bare
object. isinstance checks narrow the digest and release-list lookups
properly, so ty can see they are safe, and replace the previous
try/except (KeyError, TypeError) with the same intent expressed
directly: malformed PyPI JSON is treated as unverifiable, not fatal.
Merged the version-missing-from-releases case into the existing
skips_when_unverifiable parametrize table by giving
_pypi_release_response an optional version argument, instead of a
separate near-duplicate test function.

Added a test for the RuntimeError download_wheel now raises when no
downloaded wheel can be identified at all - that branch had no
coverage.
@gaborbernat
gaborbernat merged commit a01ed3e into main Sep 18, 2026
64 checks passed
@gaborbernat
gaborbernat deleted the verify-wheel-digest branch September 18, 2026 01:37
@gaborbernat gaborbernat added the security Fixes a vulnerability or hardens the supply chain, CI or release label Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug security Fixes a vulnerability or hardens the supply chain, CI or release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants