# How to block malicious PyPI packages with pip, uv and Poetry

> The pip, uv and Poetry settings that stop most malicious PyPI packages: wheels only, hashes, release-age cutoffs, index order, and where a firewall fits.

Source: https://chain305.com/learn/block-malicious-pypi-packages/

---

# 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.

By [Muhammad Zeeshan Sultan](https://chain305.com/about/#founder), founder of Chain305 · October 2026

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](https://chain305.com/teardowns/litellm-pypi-compromise/) 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-to` in 26.0 with a fixed date, and day durations such as `P2D` in 26.1. It only works on indexes that provide upload times.
-   **uv**’s `exclude-newer` takes a date or a duration such as `24 hours` or `1 week`, and records the cutoff in `uv.lock`. `exclude-newer-package` exempts a package you need straight away.
-   **Poetry** 2.4 added `solver.min-release-age`, in days, with `solver.min-release-age-exclude` for 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](https://docs.pypi.org/trusted-publishers/) 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](https://chain305.com/learn/maintainer-account-takeover/), 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.py` and `pyproject` install 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.](https://chain305.com/what-is-a-package-firewall/) · [How to block malicious npm packages.](https://chain305.com/learn/block-malicious-npm-packages/) · [How install script attacks work.](https://chain305.com/learn/install-script-attacks/)

## Sources

-   pip docs (26.2), [secure installs](https://pip.pypa.io/en/stable/topics/secure-installs/), [`pip install` options and the `--extra-index-url` warning](https://pip.pypa.io/en/stable/cli/pip_install/), [configuration](https://pip.pypa.io/en/stable/topics/configuration/), [build system](https://pip.pypa.io/en/stable/reference/build-system/) and [release notes](https://pip.pypa.io/en/stable/news/).
-   pip-tools, [`pip-compile --generate-hashes`](https://pip-tools.pypa.io/en/stable/).
-   uv docs, [settings: `exclude-newer`, `index-strategy`, `no-build`](https://docs.astral.sh/uv/reference/settings/), [resolution](https://docs.astral.sh/uv/concepts/resolution/), [indexes](https://docs.astral.sh/uv/concepts/indexes/), [locking, syncing and the malware check](https://docs.astral.sh/uv/concepts/projects/sync/) and [`uv pip compile`](https://docs.astral.sh/uv/pip/compile/).
-   Poetry docs, [configuration: `installer.only-binary`, `solver.min-release-age`](https://python-poetry.org/docs/configuration/), [repositories](https://python-poetry.org/docs/repositories/) and [commands](https://python-poetry.org/docs/cli/).
-   Python docs, [`site`: `.pth` files](https://docs.python.org/3/library/site.html).
-   PyPA, [pip-audit](https://github.com/pypa/pip-audit).
-   PyPI docs, [Trusted Publishers](https://docs.pypi.org/trusted-publishers/), [attestations](https://docs.pypi.org/attestations/) and [consuming attestations](https://docs.pypi.org/attestations/consuming-attestations/).
-   LiteLLM, [Security Update: Suspected Supply Chain Incident](https://docs.litellm.ai/blog/security-update-march-2026), 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.

[Start in monitor mode](https://chain305.com/chainsaw/signup) [Read the policy rules →](https://chain305.com/product/policy/)
