Skip to content

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 attributes it to the legitimate maintainer’s account.

In March 2026, malicious versions 1.82.7 and 1.82.8 of litellm were uploaded directly to PyPI, with no corresponding GitHub release. PyPI’s incident report traces the publishing token to a compromised release of the Trivy GitHub Action, which also handed the attackers a token for the telnyx package. litellm’s bad versions stayed installable for 2 hours 32 minutes before quarantine and were downloaded more than 119,000 times. Five weeks later, versions 2.6.2 and 2.6.3 of lightning shipped the same way: PyPI uploads with no matching GitHub tag. All three targeted high-traffic ML packages and stole credentials within minutes of import or interpreter startup.

The Shai-Hulud worm, run by the group TeamPCP, automated this move across npm and PyPI from September 2025 into mid-2026. Each infected install harvests publishing tokens and uses them to push trojanized versions of whatever packages the victim can publish: mistralai 2.4.6 and guardrails-ai 0.10.1 in May 2026, then six bioinformatics packages on 8 June 2026, all uploaded with stolen PyPI API tokens.

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.

Between 26 and 28 March 2024, over 500 typosquat packages targeting requests, TensorFlow, BeautifulSoup, and others were uploaded in waves; PyPI suspended new user and project registration for about 10 hours to stop the flood. Typosquats and malware fall under the invalid-project rules of PEP 541 (Package Index Name Retention), which is the policy PyPI removes them under. 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 un-verifies any email address whose domain enters its redemption period and refuses to send password resets to unverified addresses (more than 1,800 addresses un-verified since June 2025), 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 four PyPI releases over three days (8.3.41, 8.3.42, 8.3.45, and 8.3.46), the last two pushed directly with a stolen API token after the workflow hole was patched. 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. An attestation covers who built a wheel, not what went into it; a byte-identical build lets a reviewer rebuild the wheel from the tagged source and compare digests.

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 pulled within hours; a 7-day cooldown turns “the attacker has two and a half hours” 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. uv can also run the same advisory check at install time: with UV_MALWARE_CHECK=1 set, a sync that would install a package matching a known-malware advisory stops before any of its code runs.

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.

Last updated on