PyPI supply chain audit
TL;DR: PyPI’s risks:
setup.pyruns arbitrary Python at install time (analog of npmpostinstall),pip installresolves names without strict signature, dependency confusion targets private package names, and the namespace allows visually-similar typosquats. Auditing means: pin transitively, prefer wheels, scan forsetup.pyandpyproject.tomlbuild hooks, and use a private index with allowlist for production builds.
Attack surface
1. setup.py exec on install
pip install pkginvokessetup.pyif no wheel is available — arbitrary Python with full user privileges.- Even with wheels,
pyproject.tomlbuild backends (setuptools.build_meta,poetry-core,hatchling) run code during build / install of source distributions. entry_pointsregistered in metadata can execute on first import viapkg_resourcescache.
2. Typosquats
requestsvsrequets,urllib3vsurlib3,python-dateutilvspython-dateutils.- PyPI namespace policy is lax compared to npm; reports from Phylum/Sonatype list dozens per month.
3. Dependency confusion
- Private package
acme-internal-utilsreferenced inrequirements.txt. Attacker publishes same name on PyPI with higher version. Defaultpip install --index-urlfalls back to PyPI if not found in private → resolves to attacker’s. - Use
--index-urlpointing to your private index only, orpip config set global.index-urlto override default +--extra-index-urlfor fallback (still risky).
4. Wheel substitution
- Pre-built wheels (
*.whl) are zip files of compiled bytes — auditor cannot easily inspect for malicious code. Sdists (*.tar.gz) are auditable but slower to install. - Some packages ship native extensions (
.so/.dll) compiled by maintainer — opaque binary, attacker has multiple injection points (compiler, post-build).
5. Compromised maintainer / account
ctx,phpass,jellyfishincidents — maintainer accounts compromised, malicious versions pushed.- PyPI added 2FA mandatory for top maintainers in 2023; check status of your deps’ maintainers.
6. Compromised compile-time tools
pip install --no-binaryfor security-sensitive packages forces source build. But the source build’ssetup.pyis the attack surface — it can run anything during the build.
Audit workflow
Repo audit
1
2
3
4
5
6
7
8
9
10
11
# Dependencies (top-level + pinned)
cat requirements.txt requirements*.txt pyproject.toml
# Lockfile diff in PRs
git log --oneline -- poetry.lock uv.lock pdm.lock
# Find every setup.py that ships in installed packages
find .venv -name setup.py | head -20
# Find every build backend (pyproject.toml [build-system])
grep -l '\[build-system\]' $(find .venv -name pyproject.toml)
Suspicious indicators
- Recent first publish (<90 days) for a dep that claims maturity.
- No GitHub URL in metadata, or URL is to a different project.
- Single maintainer with no commit history elsewhere.
setup.pythat importsurllib,socket,subprocess,os.systemoutside of platform detection.
Tools
- pip-audit (PyPA) — CVE deps based on OSV.
- safety (Pyup) — same surface, commercial features.
- bandit — not supply chain, but catches some patterns in vendored copies.
- Socket.dev, Phylum — both cover PyPI now.
- OSV-Scanner — open-source.
- pip-licenses — license audit (compliance, not security, but related).
- pep-740 signed wheels (rolling out) — verify provenance via Sigstore signatures.
Hardening
Build pipeline
pip install --require-hashes -r requirements.txt— every dep + transitive pinned with--hash=sha256:....pip-compile --generate-hashesproduces it.pip install --no-depsthenpip install <dep>==<version>per dep — explicit only, no transitive surprise.- Build in container; ephemeral; no secrets in env beyond what install needs.
- Prefer
--only-binary :all:to force wheels (nosetup.pyexec for installed deps) — but wheel ≠ trusted, just less code-on-install.
Private index
--index-url https://pypi.internal.example.com/simple/— internal only.- No
--extra-index-urlto PyPI; mirror public deps explicitly into the internal index. - Allowlist what can be mirrored.
Lockfile + pinning
poetry.lock/uv.lock/pdm.lock(or pip-toolsrequirements.txtwith hashes).- Pin to specific versions, NOT ranges, for production.
- Renovate / Dependabot PRs reviewed by human.
Sigstore signed deps
- PEP 740 + PyPI Sigstore integration verifies wheel provenance.
pip install --require-virtual-env --require-hashes --no-deps+ verify Sigstore attestation in CI.
Pre-install screen
pip install --dry-runshows what would be installed; diff against expected.- Custom wrapper that rejects deps without allowlist match.
References
- PyPA security guide
- PEP 740 — Sigstore signing
- pip-audit
- [Phylum, Sonatype, ReversingLabs incident writeups]
- See also: python-code-auditing