Skip to content

How to Protect Your Python Projects Against PyPI Supply Chain Attacks

I woke up to alerts about a compromised LiteLLM package. Malicious versions were uploaded directly to PyPI, stealing SSH keys, cloud credentials, and environment variables from thousands of developers. And the worst part? No corresponding GitHub release existed for these versions.

My requirements.txt just had:

requirements.txt
litellm

One line. Zero protection. If I had run pip install -r requirements.txt at the wrong time, my AWS keys would be gone.

What Actually Happened

The LiteLLM attack in March 2026 was a textbook supply chain compromise:

  1. Attacker published malicious versions to PyPI
  2. No GitHub release matched these PyPI uploads
  3. .pth files triggered automatic execution on installation
  4. Thousands affected within hours
Attack flow
PyPI Upload --> pip install --> .pth executes --> Credentials stolen
^ |
|__________________________________________|
(No GitHub release to verify against)

The malware harvested:

  • SSH keys and configs
  • AWS/GCP/Azure credentials
  • Kubernetes secrets
  • Database passwords
  • Environment variables
  • Crypto wallets

I realized my projects were completely exposed.

First Attempt: Version Pinning

I started by pinning versions in requirements.txt:

Before and after
# requirements.txt - BEFORE (dangerous)
litellm
# requirements.txt - AFTER (better but not enough)
litellm==1.82.6

This helps, but it doesn’t prevent the attack. If version 1.82.8 is compromised, pip install litellm==1.82.8 still downloads and installs the malicious package.

I needed to verify the package was actually what I expected.

Second Attempt: Hash Pinning

I discovered that pip supports hash verification:

Generate hashes
pip install pip-tools
echo "litellm==1.82.6" > requirements.in
pip-compile requirements.in --generate-hashes

The output looked like:

requirements.txt
#
# This file is autogenerated by pip-compile
#
litellm==1.82.6 \
--hash=sha256:abc123def456... \
--hash=sha256:sha512hash...

Now when I install:

Terminal window
pip install -r requirements.txt --require-hashes

pip verifies the downloaded package matches the exact hash. If an attacker uploads a different package to PyPI with the same version number, installation fails.

But I hit a problem: maintaining hashes manually is tedious. Every dependency needs its hash updated when versions change.

Third Attempt: Using uv for Lock Files

I switched to uv which handles this automatically:

Using uv
# Initialize project with lock file
uv init
# Add dependency
uv add litellm
# The lock file is created automatically

The uv.lock file contains:

uv.lock
[[package]]
name = "litellm"
version = "1.82.6"
source = { registry = "https://pypi.org/simple" }
sdist = { hash = { sha256 = "abc123..." } }
wheels = [{ url = "...", hash = { sha256 = "def456..." } }]

To install in CI with frozen dependencies:

Terminal window
uv sync --frozen

This fails if the lock file doesn’t match exactly what’s downloaded.

Adding Vulnerability Scanning

Hash pinning prevents unauthorized changes, but what about known vulnerabilities?

I added pip-audit:

Install and run pip-audit
pip install pip-audit
pip-audit

Output:

Name Version Fix Versions Vulnerability
requests 2.28.0 2.31.0 PYSEC-2023-XXX
urllib3 1.26.0 1.26.18 PYSEC-2023-YYY

I also tried Safety:

Install and run safety
pip install safety
safety check --full-report

Both tools catch known vulnerabilities, but they work differently:

  • pip-audit uses the Python Packaging Advisory Database
  • safety uses pyup.io’s vulnerability database

I use both for defense in depth.

Verifying Package Sources

The LiteLLM attack used versions that never appeared on GitHub. I added a verification step:

Check GitHub release exists
gh release view --repo BerriAI/litellm 1.82.6

If this fails but PyPI has the version, something is wrong.

I created a quick check script:

verify_source.py
import subprocess
import requests
def verify_package_source(package, version, github_repo):
"""Verify PyPI version matches GitHub release."""
# Check PyPI
pypi_url = f"https://pypi.org/pypi/{package}/{version}/json"
pypi_resp = requests.get(pypi_url)
if pypi_resp.status_code != 200:
return {"status": "error", "message": "Version not on PyPI"}
# Check GitHub
result = subprocess.run(
["gh", "release", "view", "--repo", github_repo, version],
capture_output=True, text=True
)
if result.returncode != 0:
return {
"status": "warning",
"message": f"Version {version} on PyPI but not on GitHub!"
}
return {"status": "ok", "message": "Sources match"}
# Check LiteLLM
result = verify_package_source("litellm", "1.82.6", "BerriAI/litellm")
print(result)

This catches the pattern where attackers push to PyPI but not GitHub.

CI/CD Security Gate

I added automated checks to my GitHub Actions workflow:

.github/workflows/security.yml
name: Security Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies with hash verification
run: pip install -r requirements.txt --require-hashes
- name: Run pip-audit
run: |
pip install pip-audit
pip-audit --desc --aliases --skip-editable
- name: Run safety check
run: |
pip install safety
safety check --full-report
- name: Verify package sources
run: python scripts/verify_source.py

Now every PR gets checked for:

  1. Hash-verified dependencies
  2. Known vulnerabilities
  3. Suspicious package sources

The Defense in Depth Strategy

Putting it all together:

Defense layers
Layer 1: Version Pinning --> litellm==1.82.6
Layer 2: Hash Verification --> --hash=sha256:abc...
Layer 3: Lock Files --> uv.lock or requirements.lock
Layer 4: Vulnerability Scan --> pip-audit, safety
Layer 5: Source Verification --> GitHub release exists
Layer 6: CI/CD Gates --> Automated checks

Each layer catches different attack vectors:

Attack VectorLayer 1Layer 2Layer 3Layer 4Layer 5Layer 6
Version typoNoNoNoNoNoNo
Malicious updateYesYesYesMaybeYesYes
Compromised packageNoYesYesMaybeNoMaybe
Known vulnerabilityNoNoNoYesNoYes
PyPI-only uploadNoNoNoNoYesYes

Common Mistakes I Made

Mistake 1: Unpinned dependencies

Bad requirements.txt
requests
flask
litellm

Any version can be installed. An attacker just needs to upload a malicious version.

Mistake 2: Ignoring transitive dependencies

I only checked my direct dependencies. But litellm depends on tiktoken, requests, and others. Any of those could be compromised.

Mistake 3: Running pip install without verification

Terminal window
pip install -r requirements.txt # No hash checking

Mistake 4: Skipping dependency reviews in PRs

When a PR adds a new dependency, I wasn’t checking what else comes with it.

Quick Reference

Generate hashed requirements:

Hash generation
# Using pip-tools
pip install pip-tools
pip-compile requirements.in --generate-hashes
# Using uv
uv lock
uv sync --frozen

Verify installation:

Install with hash verification
pip install -r requirements.txt --require-hashes

Scan for vulnerabilities:

Vulnerability scanning
pip-audit
safety check --full-report

Check GitHub source:

Source verification
gh release view --repo owner/repo version

Summary

The LiteLLM compromise showed that relying on version numbers alone is not enough. Supply chain attacks are preventable with proper dependency management:

  1. Pin exact versions - No loose version specifiers
  2. Add hash verification - Verify package integrity
  3. Use lock files - Cryptographic checksums for all dependencies
  4. Scan for vulnerabilities - pip-audit and safety
  5. Verify package sources - Cross-check PyPI with GitHub
  6. Automate in CI/CD - Every PR gets checked

The cost of implementing these protections is far lower than dealing with a credential breach.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments