DepWall

Home / Blog

  • pypi
  • pip
  • bugs
  • supply chain

We were checking the wrong release on every pinned pip install

pip install evil==0.1.0 was judged on latest's age, latest's readme and latest's sdist. Four call sites had it, including the server, and we found it while measuring something else entirely.

This one is a bug report about our own gate, and it is the kind worth writing up because nothing failed loudly. Everything returned a verdict. The verdicts were just about a different release than the one being installed.

The bug#

Our pip argument parser threw the pinned version away. Every reference became latest, and the checker then fetched the latest metadata. So:

pip install evil==0.1.0

…was judged on latest's age, latest's readme, latest's sdist body and latest's maturity. If 0.1.0 was the malicious release and the maintainer had since shipped a clean 0.2.0, we looked at the clean one and said fine.

Our npm path has always parsed @version correctly. pip never did, and nothing said so — there is no error state for "answered a different question".

We found it by accident#

We were measuring whether to extend our exact-hash denylist to PyPI. The answer was no: of 310 hashed PyPI advisories, 246 packages were already removed, and only 3 were catchable.

But 61 were live with the malicious release not being latest — invisible to us, and not for any reason to do with hashes. That is what surfaced the parser bug. The feature we decided not to build paid for itself by being measured.

Four call sites, not one#

  • the command-line parser
  • requirements files — the one place a pin is the norm rather than the exception
  • uv, uvx and pipx, including uv's own [email protected] syntax, whose test was literally named "strips @version pins"
  • the shared verdict server, which computes per-version cache keys and then computed the verdict from latest — and a cloud ALLOW short-circuits the local check, so the server's wrong answer was final

The endpoint that looks right and is not#

The obvious fix is to fetch /pypi/<name>/<version>/json. We measured it against the live API before writing the code, which was lucky: that response carries no releases key at all. Package age comes back null, our maturity signal warns on null, and every pinned install would have turned into a prompt.

The ordinary packument already carries each release's files, so one request answers both questions — and package age stays the package's age. A fresh patch of a ten-year-old library is not an immature package, and measuring from the pinned release would have claimed it was.

What it looks like fixed#

Four real packages whose malicious release is not latest:

a3s-code==5.2.8       was scanning a3s_code-6.8.0.tar.gz    now 5.2.8
darkglitch==1.2.0     was scanning darkglitch-1.3.2.tar.gz  now 1.2.0
numpyp==0.7.7         was scanning numpyp-0.7.99.tar.gz     now 0.7.7
gcli-control==0.12.2  was scanning NOTHING                  now 0.12.2

The last one is the sharpest. Its latest release is wheel-only, so there was no sdist to read and the body scan never ran at all on a pinned source install.

Deliberately narrow#

== and === name exactly one release. Ranges, ~=, != and ==1.2.* keep the old behaviour, because resolving those ourselves would mean reimplementing pip's resolver and being confidently wrong about which release it picks — a verdict about the wrong version either way.

A pin PyPI never published now fails closed to BLOCK, the same as an unknown name.

The lesson we are keeping#

A security tool that answers the wrong question returns a confident, well-formatted, completely irrelevant verdict, and nothing about it looks broken. The only way we caught this was measuring a feature we then chose not to build.