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:
litellmOne 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:
- Attacker published malicious versions to PyPI
- No GitHub release matched these PyPI uploads
.pthfiles triggered automatic execution on installation- Thousands affected within hours
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:
# requirements.txt - BEFORE (dangerous)litellm
# requirements.txt - AFTER (better but not enough)litellm==1.82.6This 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:
pip install pip-toolsecho "litellm==1.82.6" > requirements.inpip-compile requirements.in --generate-hashesThe output looked like:
## This file is autogenerated by pip-compile#litellm==1.82.6 \ --hash=sha256:abc123def456... \ --hash=sha256:sha512hash...Now when I install:
pip install -r requirements.txt --require-hashespip 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:
# Initialize project with lock fileuv init
# Add dependencyuv add litellm
# The lock file is created automaticallyThe uv.lock file contains:
[[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:
uv sync --frozenThis 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:
pip install pip-auditpip-auditOutput:
Name Version Fix Versions Vulnerabilityrequests 2.28.0 2.31.0 PYSEC-2023-XXXurllib3 1.26.0 1.26.18 PYSEC-2023-YYYI also tried Safety:
pip install safetysafety check --full-reportBoth tools catch known vulnerabilities, but they work differently:
pip-audituses the Python Packaging Advisory Databasesafetyuses 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:
gh release view --repo BerriAI/litellm 1.82.6If this fails but PyPI has the version, something is wrong.
I created a quick check script:
import subprocessimport 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 LiteLLMresult = 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:
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.pyNow every PR gets checked for:
- Hash-verified dependencies
- Known vulnerabilities
- Suspicious package sources
The Defense in Depth Strategy
Putting it all together:
Layer 1: Version Pinning --> litellm==1.82.6Layer 2: Hash Verification --> --hash=sha256:abc...Layer 3: Lock Files --> uv.lock or requirements.lockLayer 4: Vulnerability Scan --> pip-audit, safetyLayer 5: Source Verification --> GitHub release existsLayer 6: CI/CD Gates --> Automated checksEach layer catches different attack vectors:
| Attack Vector | Layer 1 | Layer 2 | Layer 3 | Layer 4 | Layer 5 | Layer 6 |
|---|---|---|---|---|---|---|
| Version typo | No | No | No | No | No | No |
| Malicious update | Yes | Yes | Yes | Maybe | Yes | Yes |
| Compromised package | No | Yes | Yes | Maybe | No | Maybe |
| Known vulnerability | No | No | No | Yes | No | Yes |
| PyPI-only upload | No | No | No | No | Yes | Yes |
Common Mistakes I Made
Mistake 1: Unpinned dependencies
requestsflasklitellmAny 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
pip install -r requirements.txt # No hash checkingMistake 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:
# Using pip-toolspip install pip-toolspip-compile requirements.in --generate-hashes
# Using uvuv lockuv sync --frozenVerify installation:
pip install -r requirements.txt --require-hashesScan for vulnerabilities:
pip-auditsafety check --full-reportCheck GitHub source:
gh release view --repo owner/repo versionSummary
The LiteLLM compromise showed that relying on version numbers alone is not enough. Supply chain attacks are preventable with proper dependency management:
- Pin exact versions - No loose version specifiers
- Add hash verification - Verify package integrity
- Use lock files - Cryptographic checksums for all dependencies
- Scan for vulnerabilities - pip-audit and safety
- Verify package sources - Cross-check PyPI with GitHub
- 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:
- 👨💻 pip-tools Documentation
- 👨💻 pip-audit on PyPI
- 👨💻 uv Documentation
- 👨💻 Safety CLI
- 👨💻 PEP 508 - Dependency specification
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments