Python Supply Chain Security Has Caught Up. Here's What the Stack Looks Like.
Source: lobsters
In December 2024, the Ultralytics PyPI package was compromised through a GitHub Actions workflow injection. Versions 8.3.41 and 8.3.42 slipped a cryptominer onto machines that ran pip install ultralytics. The package had tens of millions of downloads. The attack was not sophisticated; it exploited a pull request that modified a workflow file with write access to secrets. The community caught it within hours, but hours was enough.
Python’s supply chain has a long history of incidents like this: typosquatting, dependency confusion, malicious maintainer takeovers, and build-time attacks. For years the response was mostly vibes and hope. That has changed. The tooling now exists to build a real layered defense, and a recent guide by Bernát Gábor is a good synthesis of what that stack looks like today. What it does not do is explain why each layer works the way it does, or how Python’s approach compares to ecosystems that got there first.
Why Other Ecosystems Had This Easier
Go and Rust built supply chain security in from the start. Go’s module system includes a checksum database at sum.golang.org that acts as a global certificate transparency log. Every module version that enters the ecosystem gets its hash recorded immutably. When you run go get, your client verifies the downloaded module against the database. A compromised package cannot retroactively alter what the log says it contained.
Rust’s Cargo does something simpler but equally effective: Cargo.lock records SHA-256 hashes of every source tarball and the contents of every Cargo.toml. The RustSec advisory database feeds cargo audit, which is functionally equivalent to pip-audit. Mozilla’s cargo vet goes further, letting teams annotate which packages have been audited by whom.
Python had none of this by default. pip install would happily fetch the latest matching version of a package, verify nothing, and execute arbitrary code in setup.py during install. The ecosystem is now closing this gap, but it is doing so by layering tools on top of a package manager that was not designed with security in mind.
Hash Pinning: The Foundation
The most impactful thing you can do is pin every dependency to a cryptographic hash. pip has supported this for years via --require-hashes, but generating and maintaining the hash file was painful enough that most teams skipped it.
uv, Astral’s Rust-written package manager, makes this straightforward:
# Generate a lockfile with hashes for all dependencies
uv lock
# Or generate a requirements.txt with hashes for pip compatibility
uv pip compile requirements.in --generate-hashes -o requirements.txt
The resulting requirements.txt looks like this:
requests==2.32.3 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 \
--hash=sha256:f2c3881dddb70d056c5bd7600a4fae312526282fe973b6d52f74b935a0ac3a83
When pip installs from this file with --require-hashes, it will refuse to install anything whose hash does not match. A compromised PyPI server cannot swap out the package contents; the hash is computed over the actual bytes. A compromised package in a future version cannot sneak in; you pinned an exact hash, not a version range.
The limitation Bernát’s guide correctly names: hash pinning does not help if you installed a malicious package on day one. You pinned the bad version faithfully. The hash check passes. The attack succeeds. Pinning stops tampering in transit and prevents silent upgrades to compromised versions; it cannot retroactively validate what you already trusted.
Scanning for Known Vulnerabilities
pip-audit, maintained by the Python Packaging Authority, queries the OSV database (Open Source Vulnerabilities), which aggregates advisories from GitHub Security Advisories, PyPI’s own vulnerability database, and several other sources. Running it in CI is straightforward:
pip-audit -r requirements.txt
Or against the current environment:
uv run pip-audit
The catch, again stated plainly by the guide: scanning finds known CVEs. A zero-day or a novel attack like Ultralytics will not appear in OSV until after the fact, by which point you need your SBOM to tell you whether you were exposed.
SBOMs: The Incident Response Tool
A Software Bill of Materials is not a security control in the active sense. It does not stop attacks. What it does is make incident response tractable. When the next supply chain compromise drops, the first question from management and from security is always the same: are we affected?
Without an SBOM, answering that question requires someone to audit every project, every deployment, every Docker image. With an SBOM, you run a query.
The CycloneDX format is well-supported in Python via the cyclonedx-py tool:
cyclonedx-py environment > sbom.json
# or for a requirements file
cyclonedx-py requirements requirements.txt > sbom.json
The output is a machine-readable JSON document containing each package’s name, version, pURL (package URL), hashes, and declared license. Feed this into your vulnerability scanner or your asset inventory. When a new advisory drops for cryptography==41.0.3, a SPDX or CycloneDX query tells you in seconds which services need patching.
The SPDX format is the other major option and is required for US federal software contracts under the White House executive order on cybersecurity. Both formats are supported by major SBOM tools; pick CycloneDX if you want richer vulnerability data fields, SPDX if you need regulatory compliance.
Trusted Publishing: Eliminating Long-Lived Secrets
Most PyPI package compromises that go through the publisher rather than the package contents happen because someone’s API token leaked. Long-lived tokens stored as CI secrets are a persistent risk.
Trusted Publishing solves this with OIDC. Instead of storing a secret, your GitHub Actions workflow requests a short-lived OIDC token from GitHub’s identity provider. PyPI verifies the token against a configured policy: only this repository, this workflow file, this environment can publish this package. The token is valid for minutes, scoped tightly, and never stored anywhere.
Setup requires no code changes, just a one-time configuration on PyPI’s side and this in your workflow:
jobs:
publish:
permissions:
id-token: write # Required for OIDC
steps:
- uses: pypa/gh-action-pypi-publish@release/v1
# No username, no password, no token
GitHub Actions, GitLab CI/CD, Google Cloud, and ActiveState are supported as identity providers. The mechanism is essentially the same as workload identity federation used in cloud IAM, applied to package publishing.
When you publish via Trusted Publishing, PyPI automatically generates Sigstore attestations for your package. Sigstore uses ephemeral keys tied to your OIDC identity; the attestation is recorded in the Rekor transparency log. Anyone can verify that a given package artifact was published by a specific workflow at a specific commit. This is the link Bernát’s guide describes: from package bytes back to source repository.
Linting for Security Anti-Patterns
Ruff implements a superset of Bandit’s rules under the S prefix. These catch common mistakes before they ship:
# pyproject.toml
[tool.ruff.lint]
select = ["S"]
Some rules with real bite: S301 flags pickle.loads() without a comment explaining why you trust the source; S506 catches yaml.load() without an explicit Loader (which can execute arbitrary Python); S602 catches subprocess calls with shell=True; S608 detects SQL queries constructed with string formatting. S113 flags HTTP requests without a timeout, which is not a supply chain issue but causes a different class of production incident.
None of these are substitutes for a code review. Ruff catches patterns mechanically; a clever attacker can work around them. The value is the same as any linter: eliminating the category of mistake that happens through inattention rather than malice.
Layering Is the Point
The framing that ties all of this together is the one the guide leads with, which is also the one that engineers sometimes resist: no single control is sufficient, and that is fine. Defense in depth is not a sign that each layer failed; it is the architecture.
Hash pinning stops tampering in transit. Scanning catches known vulnerabilities before deployment. SBOMs accelerate incident response after the fact. Trusted Publishing eliminates a class of credential-based attacks on the publishing side. Linting catches anti-patterns in your own code. Each control has failure modes that the others partially compensate for.
The sequencing the guide recommends is sensible: start with linting and pinning because they require the least infrastructure and have the most immediate payoff. Add scanning in CI next. Generate SBOMs and store them somewhere queryable. Move to Trusted Publishing when you have packages to publish. The mirror delay strategy with bandersnatch is real but requires internal infrastructure that most teams do not have; treat it as a later optimization rather than a starting point.
Python’s supply chain security story is no longer embarrassing. It took longer than Go or Rust to get here, and the path required retrofitting security onto a packaging ecosystem that predates modern threat models. The tooling now exists to do this properly. The remaining question is whether teams will use it.