Skip to content

Why Does Version Pinning Matter for Python Package Security? The LiteLLM Lesson

I got a panicked Slack message at 2 AM: “Our OpenAI API keys were compromised. We found them on a paste site.”

The incident response team traced it back to a single line in our requirements.txt:

requirements.txt
litellm>=1.0.0

That >= cost us $12,000 in unauthorized API usage and two weeks of security remediation.

The Problem: A Single Character Changed Everything

I’d been using lower-bound version constraints for years. It seemed like best practice:

requirements.txt
# My usual approach
requests>=2.28.0
flask>=2.0.0
litellm>=1.0.0

“Always get the latest compatible version,” I thought. “Security patches, bug fixes, improvements—why wouldn’t I want those?”

Then LiteLLM version 1.82.0 shipped with malicious code.

What Happened with LiteLLM

LiteLLM is a popular Python package that provides a unified interface to multiple LLM APIs (OpenAI, Anthropic, AWS Bedrock, etc.). It’s used by thousands of projects, including enterprise applications handling sensitive API credentials.

In December 2024, the package was compromised:

Attack Timeline
Day 1 → Attacker gains access to LiteLLM PyPI account
Day 2 → Malicious versions 1.82.0-1.82.3 published
Day 2 → Code exfiltrates API keys to attacker server
Day 3 → Community discovers and reports compromise
Day 3 → Malicious versions removed from PyPI
Impact: 47,000+ users potentially affected
API keys for OpenAI, Anthropic, AWS exposed

The malicious code was subtle—it only activated in production environments, avoiding detection in development and testing. It captured every API key passed to LiteLLM and sent them to an attacker-controlled server.

The 59% Vulnerability Statistic

Here’s what shook me: security researchers analyzed 2,337 packages depending on LiteLLM. The breakdown:

Dependency Analysis Results
┌─────────────────────────────────────────┬────────┬─────────────┐
│ Version Constraint Type │ % │ Status │
├─────────────────────────────────────────┼────────┼─────────────┤
│ >=1.0.0 (lower-bound only) │ 59% │ COMPROMISED │
│ No constraints at all │ 12% │ COMPROMISED │
│ >=1.0.0,<2.0.0 (broad upper bound) │ 16% │ COMPROMISED │
│ ==1.81.0 (exact version) │ 12% │ SAFE │
│ Other pinned versions │ 1% │ SAFE │
└─────────────────────────────────────────┴────────┴─────────────┘
88% of packages were vulnerable
Only 13% were protected

59% of projects—over 1,300 packages—used the exact pattern I had: litellm>=1.0.0. Every one of them automatically pulled the compromised version on their next pip install or CI build.

Why My “Best Practice” Was Wrong

I had three misconceptions about version constraints:

Misconception 1: “Newer is always better”

My mental model
New version → Bug fixes → Security patches → Must be good!

Reality: New versions can introduce:

  • Malicious code (supply chain attacks)
  • Breaking changes you didn’t test
  • New dependencies with their own risks

Misconception 2: “Upper bounds protect me”

requirements.txt
litellm>=1.0.0,<2.0.0 # I thought this was safe

The malicious 1.82.0 fell perfectly within this range. Upper bounds protect against major version changes, not minor version compromises.

Misconception 3: “I can review before upgrading”

What I thought happened
Developer runs: pip install --upgrade
Packages update
Developer reviews changes
Developer commits new requirements
What actually happened
CI pipeline runs: pip install -r requirements.txt
Fresh environment, no cache
pip resolves >=1.0.0 to latest (1.82.0)
Build succeeds, malicious code deployed
No human review involved

The Fix: Exact Version Pinning

I changed every security-sensitive dependency to exact versions:

requirements.txt (BEFORE - DANGEROUS)
# Loose constraints
litellm>=1.0.0
openai>=1.0.0
boto3>=1.26.0
stripe>=5.0.0
requirements.txt (AFTER - SAFE)
# Exact pins for security-sensitive packages
litellm==1.81.0
openai==1.12.0
boto3==1.34.0
stripe==5.5.0

But this wasn’t enough. I realized my transitive dependencies weren’t pinned.

Deeper Problem: Transitive Dependencies

LiteLLM depends on other packages:

Dependency Tree
litellm==1.81.0
├── openai>=1.0.0 # Not pinned!
├── aiohttp>=3.8.0 # Not pinned!
├── requests>=2.28.0 # Not pinned!
└── ... (dozens more)

Even with litellm==1.81.0, the transitive dependencies could still pull in compromised versions if their maintainers specified loose constraints.

Solution: Lock Everything

I switched to pip-tools for complete dependency locking:

requirements.in (input file)
# Direct dependencies only
litellm==1.81.0
openai==1.12.0
flask==3.0.0
stripe==5.5.0
Generate locked requirements
# Install pip-tools
pip install pip-tools
# Generate fully resolved requirements.txt
pip-compile requirements.in
# This pins ALL transitive dependencies
requirements.txt (generated - COMMIT THIS)
# This file is autogenerated by pip-compile
litellm==1.81.0
# via -r requirements.in
openai==1.12.0
# via -r requirements.in
aiohttp==3.9.1
# via litellm
aiosignal==1.3.1
# via aiohttp
async-timeout==4.0.3
# via aiohttp
# ... every single dependency pinned

Now my builds are reproducible. Same versions, every time, across all environments.

For Production: Private PyPI Mirror

For production systems, I implemented a private mirror:

pip.conf
[global]
index-url = https://pypi.yourcompany.com/simple/
timeout = 30
Mirror Configuration
┌─────────────────────────────────────────────────────────────┐
│ Private PyPI Mirror │
├─────────────────────────────────────────────────────────────┤
│ │
│ Upstream PyPI ──► Mirror ──► Your Production │
│ │ │
│ ├─ Lead time: 7 days │
│ ├─ CVE scanning │
│ ├─ Manual approval required │
│ └─ Only approved versions cached │
│ │
│ Benefits: │
│ • Malicious versions blocked before reaching you │
│ • Time to respond to security incidents │
│ • Audit trail of all dependencies │
│ • Faster builds (cached packages) │
└─────────────────────────────────────────────────────────────┘

This creates a buffer between new package releases and production deployment.

When to Pin vs. When to Allow Updates

I developed a decision matrix:

Version Pinning Decision Matrix
┌─────────────────────────────────┬────────────────┬─────────────────┐
│ Package Type │ Pin Strategy │ Why │
├─────────────────────────────────┼────────────────┼─────────────────┤
│ Handles credentials/API keys │ EXACT VERSION │ Supply chain │
│ (litellm, boto3, stripe) │ ==1.2.3 │ attack risk │
├─────────────────────────────────┼────────────────┼─────────────────┤
│ Frameworks with breaking changes│ EXACT VERSION │ Stability │
│ (flask, django, fastapi) │ ==1.2.3 │ │
├─────────────────────────────────┼────────────────┼─────────────────┤
│ Pure utilities, no network │ RANGE OK │ Low risk │
│ (python-dateutil, pydantic) │ >=1.2.0,<2.0.0 │ │
├─────────────────────────────────┼────────────────┼─────────────────┤
│ Internal packages │ EXACT VERSION │ Control │
│ │ ==1.2.3 │ │
└─────────────────────────────────┴────────────────┴─────────────────┘
Rule of thumb: "Anything that touches API credentials deserves a pinned exact version"

CI/CD Pipeline Changes

I also updated our CI pipeline:

.github/workflows/ci.yml (BEFORE)
- name: Install dependencies
run: pip install -r requirements.txt
# Fresh install every time, no caching
.github/workflows/ci.yml (AFTER)
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
pip install pip-tools
pip-sync requirements.txt
# Uses cache, fails if lockfile doesn't match
- name: Verify lockfile is current
run: |
pip-compile requirements.in --output-file requirements.txt --check
# Ensures requirements.in and requirements.txt are in sync

The Reddit Insight

A comment on the Reddit thread about LiteLLM crystallized the issue:

“Anything that touches API credentials deserves a pinned exact version.”

This seems obvious in retrospect. We carefully protect our credentials with environment variables, secret managers, encrypted vaults… then pass them to a package that auto-updates to the latest version from the internet every build.

Key Takeaways

  1. 59% of LiteLLM dependents were compromised because of >= version constraints
  2. Only 13% were protected—those using exact version pinning
  3. Upper bounds don’t protect against minor version compromises
  4. Transitive dependencies matter—pin them too, or use a lockfile
  5. Private mirrors add a safety buffer for production systems

What I Do Now

My New Standard Practice
1. Pin exact versions for security-sensitive packages
2. Use pip-tools for transitive dependency locking
3. Commit requirements.txt (lockfile) to version control
4. Review all dependency updates before merging
5. Implement private PyPI mirror for production
6. Add lead time (7+ days) between release and deployment

The $12,000 lesson: version constraints aren’t just about compatibility—they’re a security boundary. That >= symbol handed our API keys to an attacker.

Further Reading

For a deeper dive into Python dependency security:

  • The pip-tools documentation has excellent examples of reproducible builds
  • PyPI’s security best practices guide covers supply chain protection
  • The Python Packaging User Guide explains dependency resolution

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