What Is a Python Supply Chain Attack?
A Python supply chain attack is a class of compromise where the attacker reaches your project not by breaking your code, but by tampering with something your project trusts. The trusted thing might be a package on PyPI, a maintainer account, a build pipeline, a transitive dependency, or the registry itself. By the time uv sync or pip install runs, the malicious code is already inside the dependency graph and the install treats it like any other package.
Real attacks cluster into seven mechanisms
Recent Python compromises follow patterns that recur. Most incidents combine more than one.
Direct PyPI upload after maintainer account takeover
The attacker steals a maintainer’s PyPI credentials or session and uploads a malicious version straight to PyPI, bypassing GitHub entirely. The release looks normal because PyPI signs it with the legitimate maintainer’s identity.
In March 2026, malicious versions 1.82.7 and 1.82.8 of litellm were uploaded directly to PyPI, with no corresponding GitHub release. Six weeks later, versions 2.6.2 and 2.6.3 of lightning shipped the same way: PyPI uploads with no matching GitHub tag. Both targeted high-traffic ML packages and both stole credentials within minutes of import or interpreter startup.
Typosquatting and LLM-generated copycats
The attacker publishes a package whose name resembles a popular one, hoping someone fat-fingers the install command or finds the copycat through PyPI search. The newer variant uses an LLM to fork a legitimate project, patch a couple of issues, and republish under a name that sounds like an upgrade.
In March 2024, over 500 typosquat packages targeting TensorFlow, requests, BeautifulSoup, and others were uploaded in two waves; PyPI temporarily suspended new account creation to stop the flood. The LLM-powered variant is harder to spot because the cloned code actually works.
Dependency confusion
Internal package names get resolved against PyPI instead of the private index that actually hosts them. If an attacker registers the internal name on PyPI, the resolver may prefer the public version because it has a higher version number or a more permissive index priority.
Alex Birsan’s 2021 research demonstrated the attack against Apple, Microsoft, PayPal, and dozens of others by uploading placeholder packages under their unclaimed internal names. The fix is to host internal packages on a private index configured so the resolver never falls back to PyPI for those names.
Maintainer-domain expiration takeover
The attacker re-registers an expired email domain that PyPI used for password recovery, triggers a reset, and uploads a malicious release through the legitimate account.
The 2022 ctx package compromise followed this script: the maintainer’s figlief.com domain expired, an attacker re-registered it, reset the PyPI password, and uploaded versions that exfiltrated environment variables. PyPI estimated 27,000 downloads of the malicious copies before the takedown. PyPI now blocks logins from expired-domain emails, which closes this specific vector but does not retire the broader category of credential-recovery hijacks.
Install-time and import-time code execution
The malicious release does not exploit a parser bug or a CVE. It just runs ordinary Python at install or import time, which is the historical default for the format. setup.py, .pth files, and __init__.py are all places a package can run code with the user’s permissions before any of the package’s exported APIs are called. Christopher Ariza reported at PyTexas 2026 that 454 of the top 1000 PyPI packages still ship a setup.py, so the legacy install-time execution path remains live across most of the ecosystem.
The litellm payload was a .pth file that ran on every interpreter startup. The lightning payload was a daemon thread spawned from __init__.py on import. The handbook explains why a Python install can run code across all four surfaces (install, import, two startup paths) and why turning that off everywhere is not realistic.
Build-pipeline compromise upstream of PyPI
The attacker compromises the project’s CI before the artifact reaches PyPI. The malicious wheel is built and signed by a legitimate workflow with legitimate credentials, so it passes every check downstream.
In December 2024, Ultralytics was hit through GitHub Actions script injection: an attacker’s PR branch name embedded shell metacharacters into a git pull step, which executed during a pull_request_target workflow and exfiltrated repository secrets. Cryptocurrency miners shipped in the next two PyPI releases. The Shai-Hulud worm followed the same upstream-compromise shape across multiple packages by harvesting GitHub tokens to commit poisoned releases back. Hash pinning and signature verification do not help here, because the bad artifact is the one the project genuinely produced.
Transitive compromise
The package you trust pulls one you do not. A two-line dependency on a popular framework drags in dozens or hundreds of packages, each of which can fall to any of the categories above. Reading the source of the package you import does not protect you from the package three hops down.
The litellm incident reached most of its targets this way: an MCP plugin inside Cursor depended on litellm transitively, and a fresh install pulled the bad version even though the user never named litellm directly.
Match each attack to its defense layer
Account takeover, whether through stolen credentials or expired-domain hijack, is handled at the publish path. Trusted publishing replaces long-lived PyPI tokens with short-lived OIDC credentials tied to a specific CI workflow, which removes the static secret an attacker would otherwise steal. The matching how-to walks through setting up trusted publishing. Digital attestations, recorded in pylock.toml, let the resolver detect a publisher change between two releases of the same package.
Direct upload of a fresh malicious version is handled at the resolver. A dependency cooldown (uv’s exclude-newer) refuses to install any package version published inside a rolling window. Most malicious uploads are caught and yanked within hours; a 7-day cooldown turns “the attacker has 18 minutes” into “the community has a week.”
Tampering at any layer is handled at install time. Hash pinning and a uv lockfile make the install bit-for-bit reproducible, which catches mirrors and CDNs that serve different bytes than PyPI did. They do not catch a fresh malicious upload (the recorded hash is the malicious one) but they catch every other tampering shape.
Already-disclosed vulnerabilities are handled with an audit step. uv audit and pip-audit check installed versions against the OSV database, so once a malicious release lands in the advisory feed, CI fails the next build that pulled it.
Install-time and import-time execution is the surface, not a defense. Why installing a Python package can run code names the four execution paths so the layered defenses make sense in context.
When a copycat or new dependency lands in your tree and none of the automated layers catches it, manual triage closes the gap. How to vet a Python package before installing it walks through that process.
Learn more
- Securing the Python Supply Chain by Bernát Gábor (PyPA maintainer of virtualenv, tox, platformdirs, filelock) is the most practical defense-in-depth guide available.
- PEP 740: Index support for digital attestations defines the cryptographic link between a PyPI release and the CI workflow that built it.
- PyPI security policy covers vulnerability reporting and the project lifecycle PyPI uses to handle compromised releases.