Supply-Chain Security

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

Every pip install and poetry install runs or installs code written by strangers. This page collects the controls that pip and Poetry offer against the common attacks: a malicious package that wins a name collision, a tampered file, a compromised new release, and a known vulnerability nobody noticed. It was written against pip 26.2.2 and Poetry 2.5.1, and the examples were run on CPython 3.14.6 against PyPI on 2026-10-22. The threat model behind each control is described on PyPI’s common attacks page.

Dependency confusion

An organisation publishes birdtally-internal on its private index. A developer installs it with

python -m pip install --extra-index-url https://pypi.internal.example/simple birdtally-internal

pip now searches both PyPI and the private index and picks the best version it finds anywhere. If an attacker uploads birdtally-internal 99.0 to PyPI, that version wins. pip’s documentation carries an explicit warning on exactly this example: using --extra-index-url to find packages that are not on the main index "is unsafe", "a class of security issue known as dependency confusion". There is no index priority in pip: --index-url and --extra-index-url are equal sources.

PEP 708, which would have let an index declare "this name is only valid from me" (tracks and alternate locations), was Rejected, so the fix has to come from configuration:

  • With pip, point --index-url (PIP_INDEX_URL) at a single index that serves both your private packages and a proxy of PyPI (devpi, Nexus, Artifactory, CodeArtifact, Azure Artifacts and the GitLab registry can all do this), and do not use --extra-index-url. See pip: Package Indexes & Finding Packages.

  • Reserve your internal names on PyPI if your policy allows it, so that nobody else can register them.

  • Use hash-checking mode (below): a file from the wrong index has a different hash.

Poetry: source priorities and source constraints

Poetry, unlike pip, has priorities. Every [[tool.poetry.source]] is primary (the default), supplemental or explicit:

  • primary sources are all searched for every dependency without a source constraint, and configuring any primary source disables the implicit PyPI source;

  • supplemental sources are searched only if no higher-priority source has a compatible distribution, which brings back the dependency confusion problem: the Poetry documentation warns that someone could publish a matching package to a primary source;

  • explicit sources are searched only for packages that name them.

A source constraint pins a package to one source, whatever its priority:

poetry source add --priority=explicit internal https://pypi.internal.example/simple/
poetry add --source internal birdtally-internal
[tool.poetry.dependencies]
birdtally-internal = { source = "internal" }

[[tool.poetry.source]]
name = "internal"
url = "https://pypi.internal.example/simple/"
priority = "explicit"

Source constraints are not inherited: if birdtally-internal depends on another internal package, constrain that one too (poetry add --source internal other-internal@*, possibly in a dedicated group). The Poetry documentation "strongly suggests" source constraints for every package that only one source provides. The full priority model is on Poetry: Repositories & Package Sources.

Hashes and binary-only installs

Hash-checking mode makes pip refuse any downloaded file whose sha256 is not listed for that requirement. It turns on as soon as one requirement in the file carries a --hash, or with --require-hashes (PIP_REQUIRE_HASHES); since 26.2, --no-require-hashes turns the automatic switch off. It is all-or-nothing: every requirement must be pinned with == and hashed, including indirect ones, so that an unhashed dependency cannot sneak in. poetry install checks the hashes recorded in poetry.lock the same way, and pylock.toml always carries hashes.

--only-binary :all: (PIP_ONLY_BINARY) adds a second layer: it forbids sdists, so no setup.py or build backend of a dependency runs on your machine at install time. Combine both in CI and container builds:

python -m pip install --require-hashes --only-binary :all: --no-deps -r requirements.lock

The tampered-hash error and the details are on pip: Repeatable & Secure Installs.

Cooldowns: ignore releases that are too new

Malicious releases (a stolen maintainer token, a compromised CI) are usually found and removed within hours or days. A cooldown refuses to consider any release younger than a chosen age, so that you never install one in that window. Both tools read the upload-time that PyPI publishes in its JSON simple API.

pip: --uploaded-prior-to

--uploaded-prior-to (PIP_UPLOADED_PRIOR_TO) was added in pip 26.0 with an ISO 8601 date or time; pip 26.1 added durations in PnD form (P3D = uploaded at least three days ago, a day being 24 hours). P0D on the command line cancels a duration set in configuration. pip 26.2.2 was released on 2026-10-20, two days before these examples ran:

python -m pip install --dry-run --ignore-installed --no-deps --uploaded-prior-to=P3D pip
Would install pip-26.2.1

Asking for the new release explicitly shows that the filter hides it:

python -m pip install --dry-run --ignore-installed --no-deps --uploaded-prior-to=P3D "pip==26.2.2"
ERROR: Ignored the following yanked versions: 20.0, 20.3.2, 21.2
ERROR: Could not find a version that satisfies the requirement pip==26.2.2 (from versions: 0.2, 0.2.1, … 26.1.2, 26.2, 26.2.1)
ERROR: No matching distribution found for pip==26.2.2

A fixed date instead freezes the index at a point in time, which is useful for reproducing an old build:

python -m pip install --dry-run --no-deps --uploaded-prior-to=2026-04-01T00:00:00Z rich
Would install rich-14.3.3

The filter applies only to remote indexes: local files, --find-links directories and VCS URLs are always allowed. An index that does not publish upload times makes pip fail immediately rather than silently skip the filter. Since 26.2 the option is also respected by pip list --outdated and works with -r pylock.toml.

Poetry: solver.min-release-age

Poetry 2.4.0 added three settings: solver.min-release-age (an integer number of days, POETRY_SOLVER_MIN_RELEASE_AGE), solver.min-release-age-exclude (package names that skip the filter) and solver.min-release-age-exclude-source (source names or URLs that skip it). A version is considered only if all its files are older than the limit. In a throw-away project that depends on pip:

POETRY_SOLVER_MIN_RELEASE_AGE=3 poetry lock
grep -A1 'name = "pip"' poetry.lock
POETRY_SOLVER_MIN_RELEASE_AGE=3 POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE=pip poetry lock --regenerate
grep -A1 'name = "pip"' poetry.lock
Writing lock file
name = "pip"
version = "26.2.1"
Writing lock file
name = "pip"
version = "26.2.2"

Unlike pip, Poetry does not filter a source that publishes no upload times; it simply cannot enforce the age there. Use -exclude-source for your own private index, where releases are trusted and often needed immediately.

A cooldown delays security fixes as much as attacks. pip’s documentation recommends pairing it with a vulnerability scanner so that you are told about a vulnerable pinned version independently of the update schedule.

Auditing known vulnerabilities

pip and Poetry do not check vulnerability databases themselves. pip-audit (third party, from the PyPA) checks an environment, a requirements file or a project against the PyPI JSON API’s advisory data or OSV:

python -m pip install pip-audit      # in a tools environment, not the project
printf 'pygments==2.15.0\n' > old.txt
pip-audit -r old.txt
Found 3 known vulnerabilities in 1 package
Name     Version ID              Fix Versions
-------- ------- --------------- ------------
pygments 2.15.0  PYSEC-2023-117  2.15.1
pygments 2.15.0  PYSEC-2026-2987 2.20.0
pygments 2.15.0  PYSEC-2026-2987 2.20.0

The exit code is 1 when anything is found, which makes it a CI gate. On the birdtally lock from the previous page it printed No known vulnerabilities found. For a Poetry project, export first (poetry export -f requirements.txt) and audit the export.

SBOMs

A software bill of materials lists the components inside a distribution. PEP 770 (Final, 2025) reserved .dist-info/sboms/ in wheels and installed projects for SBOM files in a standard format (CycloneDX or SPDX), mainly for wheels that vendor or statically link other software, which a scanner would otherwise not see. Build backends add them on request; installers copy them. pip itself is an example of a project that vendors its dependencies: since 26.2 it ships a CycloneDX SBOM of them at pip/_vendor/bom.cdx.json.

Verifying attestations

Files uploaded through Trusted Publishing usually carry PEP 740 attestations: Sigstore signatures that bind the file to the CI workflow and repository that built it. PyPI exposes them through its Integrity API and the provenance key of the JSON simple API. Neither pip nor Poetry verifies them at install time yet; the pypi-attestations tool does, for one file at a time:

python -m pip install pypi-attestations==0.0.30     # in a tools environment
export WHEEL_DIRECT_URL=https://files.pythonhosted.org/packages/d7/73/c16e5f3f0d37c60947e70865c255a58dc408780a6474de0523afd0ec553a/sampleproject-4.0.0-py3-none-any.whl
pypi-attestations verify pypi --repository https://github.com/pypa/sampleproject $WHEEL_DIRECT_URL
pypi-attestations verify pypi --repository https://github.com/example/not-sampleproject $WHEEL_DIRECT_URL
OK: sampleproject-4.0.0-py3-none-any.whl
Verification failed: provenance was signed by repository "pypa/sampleproject", expected "example/not-sampleproject"

The second call exits with code 1. How attestations are produced is on Trusted Publishing & Attestations.

Keep the tools themselves patched

Your installer runs with your permissions and parses untrusted archives, so its own security releases matter. The ones relevant to the baseline releases of this section:

Tool Fixed in Issue

pip

26.2

CVE-2026-13346: the URL path was decoded twice when determining a link’s file name. 26.2 also rejects tar archives that write outside the target directory through symlinks on Pythons without PEP 706, and rejects path packages in a remote pylock.toml that point outside the lock file’s location.

Poetry

2.3.3 (2026-03-29)

A path traversal in the wheel installer that let a malicious wheel write outside the installation directory.

Poetry

2.3.4 (2026-04-12)

A path traversal in sdist extraction on Python 3.10.0–3.10.12 and 3.11.0–3.11.4 that let a malicious tarball write outside the target directory.

Poetry

2.4.2 (2026-08-29)

A path traversal when downloading files from a compromised URL or package source, a further sdist-extraction path traversal, and installing an artifact not listed in the lock file when the source provides no hash for it.

pypa/gh-action-pypi-publish

see advisories

GHSA-vxmw-7h4f-hqxh and GHSA-5xjx-3w67-w9x9; use the current release/v1 (v1.14.3).

cibuildwheel

4.3.2–4.3.4

Certificate-authority handling fixes in the bundled tooling; use 4.3.4.

Update pip with python -m pip install -U pip in each venv, and Poetry with poetry self update (or pipx upgrade poetry). Only the latest pip is supported.

pip and Poetry side by side

Control pip Poetry

Only one source for a package

Single --index-url that proxies PyPI

Source constraint source = "…", explicit priority

Verify files

--require-hashes, hashed requirements, pylock.toml

Always, from poetry.lock

No build code at install

--only-binary :all:

installer.no-binary / installer.only-binary settings

Cooldown

--uploaded-prior-to P7D

solver.min-release-age = 7

Audit

pip-audit

pip-audit on poetry export output