The most dangerous line in a Python project is often the most boring one: pip install. That command reaches out to a public index, resolves a graph of dependencies you have never read, and runs their code on your build machine and in production. Attackers know this, and the Python packaging ecosystem has seen a steady stream of typosquats, dependency-confusion uploads, and compromised maintainer accounts aimed squarely at that moment of trust.
The three attacks that matter
Almost every PyPI supply-chain incident falls into one of three patterns, and defending against them is easier once you can name them.
Typosquatting registers a package whose name is a near-miss for a popular one, such as swapping a letter or a hyphen, and waits for a fat-fingered install. Dependency confusion exploits resolvers that check the public index alongside your private one: an attacker uploads a package to PyPI using your internal package's name with a higher version, and a misconfigured resolver prefers the public copy. Account and release compromise targets a legitimate, widely used package by stealing a maintainer's credentials or hijacking their release pipeline, then shipping a malicious version that everyone who upgrades pulls in.
Pin exactly, and pin with hashes
A floating version like requests>=2 means you install whatever the index serves today, including a version published five minutes ago by an attacker who took over an account. Pin exact versions and, crucially, pin hashes, so a package whose contents changed cannot silently install:
# Compile a fully pinned, hashed lockfile
pip-compile --generate-hashes --output-file requirements.txt requirements.in
# Enforce it: pip refuses anything whose hash does not match
pip install --require-hashes -r requirements.txt
With --require-hashes, every dependency, including transitive ones, must match a recorded digest. An attacker who republishes a tampered artifact under the same version cannot get it past the hash check.
Defeat dependency confusion explicitly
If you use a private index, never let pip treat it as interchangeable with PyPI. Point at your index and disable the fallback so a public package cannot impersonate an internal one:
# pip.conf
[global]
index-url = https://pypi.internal.example.com/simple/
# Do NOT add extra-index-url = https://pypi.org/simple/ for internal names
The extra-index-url setting is the classic footgun: it tells pip to consult multiple indexes and pick the best match by version, which is exactly the behavior dependency confusion abuses. Namespace your internal packages, host them on a resolver you control, and keep the resolution boundary sharp.
Verify what you are installing
Before a new dependency enters your tree, spend two minutes on due diligence: confirm the exact package name against the project's official docs, check that the project is actively maintained, and be wary of a brand-new package with an oddly familiar name. For the packages you publish, adopt PyPI's Trusted Publishing, which uses short-lived OpenID Connect tokens from your CI instead of a long-lived API token that can leak:
# GitHub Actions - no API token stored anywhere
permissions:
id-token: write
steps:
- uses: pypa/gh-action-pypi-publish@release/v1
Trusted Publishing removes the single most stolen secret in the packaging world: the PyPI upload token.
Install can run code before you import anything
A dangerous misconception is that a package only matters once you import it. Traditional source distributions execute a setup.py during installation, which means malicious code can run on your build machine at install time, before a single line of your application executes. This is why the install step deserves the same caution as running an unknown script.
Two practices help. Prefer wheels over source distributions where possible, since wheels do not run arbitrary build code to install. And run installs in an isolated, least-privilege environment, a container without production credentials mounted, so that even a hostile install script has nothing valuable to reach and nowhere persistent to land.
Know your tree with an SBOM
You cannot secure what you cannot see, and your direct dependencies are the small part of the graph. Generate a software bill of materials so every transitive package is inventoried and can be checked against advisories:
cyclonedx-py environment --output-format json --outfile sbom.json
Managing that inventory across many services is exactly what SBOM Studio is built for: it stores every generated SBOM, diffs them over time, and lets you answer "which of our services ship this exact package version" in one query when the next advisory lands.
Hardening checklist
- Exact, hash-pinned lockfiles enforced with
--require-hashes - Private index with no public fallback for internal names
- Internal package names namespaced and reserved
- Trusted Publishing (OIDC) instead of long-lived upload tokens
- New dependencies vetted for name, maintenance, and provenance
- SBOM generated in CI and retained per release
- Continuous vulnerability scanning of the full tree
How Safeguard helps
Pinning and index hygiene are things you configure; we will not pretend a scanner does that for you. What Safeguard adds is continuous eyes on the tree those controls produce. Our software composition analysis ingests your lockfile or SBOM, resolves every transitive package, and matches it against known vulnerabilities and malicious-package intelligence, so a compromised release or a newly disclosed CVE surfaces the same day rather than at your next manual audit. You can run the same check in your pipeline and on your laptop with the Safeguard CLI, failing a build before a bad package ships. Whether continuous scanning is worth it for your team is a real question, and our transparent pricing is built so you can start small and scale with your service count.
Get started
Lock and hash your dependencies today, then let Safeguard watch the index for you. Create a free project at app.safeguard.sh/register and follow the Python supply-chain guide at docs.safeguard.sh.