How to block malicious PyPI packages
The pip, uv and Poetry settings that stop the common attacks, and what they miss.
What each setting does, what it misses, and where a package firewall fits once you have them on.
Questions and answers
How do malicious PyPI packages get onto a machine?
Mostly through code that runs during the install or at Python startup, before you import anything. Installing a source distribution (sdist) means building it, and pip hands that build to the package’s own build backend, so a setup.py runs on your machine. A wheel is not built, but it can drop a .pth file into site-packages, and Python runs the import lines in .pth files every time the interpreter starts. That is how litellm 1.82.8 ran its credential stealer in March 2026.
The malicious version usually arrives in one of four ways: a stolen publish token releases a new version of a popular package, a lookalike name catches a typo, a brand-new package is pulled in as a dependency, or a public package takes the name of one of your private ones.
Which pip, uv and Poetry settings help?
Six pip, uv and Poetry settings and habits help, and none needs a new tool. First, install wheels only (--only-binary :all: in pip, no-build in uv, installer.only-binary in Poetry), so no setup.py or build backend runs at install. Second, pin every version and check hashes with --require-hashes, so pip refuses any file whose hash is not in your requirements file. Third, wait before installing brand-new releases with pip’s --uploaded-prior-to, uv’s exclude-newer or Poetry’s solver.min-release-age. Fourth, keep private package names off the public index by dropping --extra-index-url. Fifth, check what you installed with pip-audit and uv’s malware check against OpenSSF’s malicious-packages data. Sixth, publish your own packages to PyPI with Trusted Publishing instead of a long-lived API token. Check your versions: some of these options are recent, for example pip 26.1 and Poetry 2.4.
Install wheels only
pip install --only-binary :all: -r requirements.txt
# pyproject.toml
[tool.uv]
no-build = true
poetry config installer.only-binary :all:
With --only-binary :all:, pip does not use source packages, so no setup.py or build backend runs at install. The pip docs list it as one of the two steps for secure installs. uv’s no-build stops uv from building source distributions (your own workspace packages are still built), and Poetry 2.0 added installer.only-binary. A package that publishes no wheel will fail to install; allow it by name rather than turning the setting off. This does not stop a wheel’s .pth file or code that runs on import.
Pin every version and check hashes
pip-compile --generate-hashes requirements.in
pip install --require-hashes -r requirements.txt
In hash-checking mode pip refuses any file whose hash is not in your requirements file, and every requirement and dependency must be pinned. A single --hash in the file switches the mode on for the whole install. pip-compile from pip-tools writes the pinned file with hashes; uv pip install takes --require-hashes too. A constraints file (-c constraints.txt) applies the same pins across several projects.
For projects with a lockfile, make CI fail when the lockfile is out of date: uv lock --check, or --locked on commands such as uv run, and poetry check --lock. poetry install installs the exact versions in poetry.lock.
Hashes catch a file that changed after you pinned it. A malicious version you pinned yourself verifies cleanly.
Wait before installing brand-new releases
pip install --uploaded-prior-to P2D -r requirements.txt
# pyproject.toml
[tool.uv]
exclude-newer = "2 days"
poetry config solver.min-release-age 2
All three ignore anything uploaded too recently:
- pip added
--uploaded-prior-toin 26.0 with a fixed date, and day durations such asP2Din 26.1. It only works on indexes that provide upload times. - uv’s
exclude-newertakes a date or a duration such as24 hoursor1 week, and records the cutoff inuv.lock.exclude-newer-packageexempts a package you need straight away. - Poetry 2.4 added
solver.min-release-age, in days, withsolver.min-release-age-excludefor exceptions.
Malicious releases are often removed within hours. LiteLLM’s own report of its March 2026 compromise gives two windows, about 40 minutes and about five hours, both shorter than a one-day wait.
Keep private package names off the public index
Do not use --extra-index-url to reach your private packages. pip’s own docs warn that an attacker can publish a package with the same name on the public index, and pip may pick it instead of yours. Use one --index-url that serves your internal packages and PyPI and never serves a PyPI package under one of your internal names, such as a repository manager or proxy with those names reserved.
uv’s default index-strategy is first-index: it stops at the first index that has the package, which uv documents as protection against dependency confusion. Keep that default rather than unsafe-best-match, and pin internal packages to their index:
# pyproject.toml
[[tool.uv.index]]
name = "internal"
url = "https://pypi.yourco.example/simple"
explicit = true
[tool.uv.sources]
yourco-auth = { index = "internal" }
In Poetry, pin each internal package to its source (poetry add --source internal yourco-auth). Poetry’s docs strongly suggest this for every package that should come from one source, and warn that a package on a supplemental source can be replaced by one published to a primary source.
Check what you installed
pip-audit -r requirements.txt checks your dependencies against known vulnerabilities, using PyPI’s advisory data or OSV. Its own docs say it is not a defence against malicious packages. For known malware, uv can check uv.lock against OSV’s malware advisories, which come from OpenSSF’s malicious-packages database, on every sync and stop the sync on a match. Turn it on with UV_MALWARE_CHECK=1 (uv 0.11.16 or later; or audit.malware-check = true); it is a preview feature.
Publish your own packages with Trusted Publishing
If you publish to PyPI, use Trusted Publishing instead of an API token. PyPI issues a token that expires 15 minutes after it is created, so there is no long-lived token in CI for an attacker to take, as happened with litellm. PyPI attestations bind each wheel and sdist to the identity that published it, and the PyPI docs describe checking them with the pypi-attestations tool.
What do these settings not cover?
They work per machine and per project, across pip.conf, pyproject.toml and CI scripts, and each one is easy to forget or switch off. They do not look at what a package does: a wheel whose .pth file or import-time code runs a payload passes wheels-only mode and hash checks, and a release-age cutoff only delays it. A lookalike name with no advisory yet, or a release from a hijacked maintainer account, passes too. And they cover Python only, while most teams also install from npm, Maven, Go and Docker.
Where does a package firewall fit?
A package firewall is a registry proxy every pip, uv and Poetry install pulls through, so the same checks apply once for the whole organisation, whichever client made the request. Chainsaw serves PyPI to pip, Poetry and uv on one index URL, and for PyPI its defaults:
- hold new versions for 48 hours (a setting,
release_min_age_hours), so version ranges resolve to the newest older version; - refuse packages first published less than a day ago;
- refuse
setup.pyandpyprojectinstall scripts which fetch remote URLs or shell out; - refuse code which runs a fetched or decoded payload on import, the rule which refused litellm 1.82.8 on first download in March 2026;
- refuse packages on the OpenSSF malicious-package feed, and match lookalike names against popular packages.
For dependency confusion you declare your reserved namespaces, and the proxy refuses any public package using one. Every rule can run in monitor mode before Chainsaw refuses anything.
Keep the pip, uv and Poetry settings. The settings still protect a machine that bypasses the firewall. What a package firewall is and how it works. · How to block malicious npm packages. · How install script attacks work.
Sources
- pip docs (26.2), secure installs,
pip installoptions and the--extra-index-urlwarning, configuration, build system and release notes. - pip-tools,
pip-compile --generate-hashes. - uv docs, settings:
exclude-newer,index-strategy,no-build, resolution, indexes, locking, syncing and the malware check anduv pip compile. - Poetry docs, configuration:
installer.only-binary,solver.min-release-age, repositories and commands. - Python docs,
site:.pthfiles. - PyPA, pip-audit.
- PyPI docs, Trusted Publishers, attestations and consuming attestations.
- LiteLLM, Security Update: Suspected Supply Chain Incident, March 2026.
Settings drift, machines differ
Apply the same checks to every install in your org
Start a free org in monitor mode and see which PyPI installs Chainsaw would refuse, then turn rules on one at a time.