Waitlist open Our new book, AI Engineering For Production, is coming before the end of 2026. Get the release date first.New book: AI Engineering For Production, coming before the end of 2026. Join the waitlist
Name: Towards AI Legal Name: Towards AI, Inc. Description: Towards AI is the world's leading artificial intelligence (AI) and technology publication. Read by thought-leaders and decision-makers around the world. Phone Number: +1-650-246-9381 Email: pub@towardsai.net
228 Park Avenue South New York, NY 10003 United States
Website: Publisher: https://towardsai.net/#publisher Diversity Policy: https://towardsai.net/about Ethics Policy: https://towardsai.net/about Masthead: https://towardsai.net/about
Name: Towards AI Legal Name: Towards AI, Inc. Description: Towards AI is the world's leading artificial intelligence (AI) and technology publication. Founders: Roberto Iriondo, , Job Title: Co-founder and Advisor Works for: Towards AI, Inc. Follow Roberto: X, LinkedIn, GitHub, Google Scholar, Towards AI Profile, Medium, ML@CMU, FreeCodeCamp, Crunchbase, Bloomberg, Roberto Iriondo, Generative AI Lab, Generative AI Lab VeloxTrend Ultrarix Capital Partners Denis Piffaretti, Job Title: Co-founder Works for: Towards AI, Inc. Louie Peters, Job Title: Co-founder Works for: Towards AI, Inc. Louis-François Bouchard, Job Title: Co-founder Works for: Towards AI, Inc. Cover:
Towards AI Cover
Logo:
Towards AI Logo
Areas Served: Worldwide Alternate Name: Towards AI, Inc. Alternate Name: Towards AI Co. Alternate Name: towards ai Alternate Name: towardsai Alternate Name: towards.ai Alternate Name: tai Alternate Name: toward ai Alternate Name: toward.ai Alternate Name: Towards AI, Inc. Alternate Name: towardsai.net Alternate Name: pub.towardsai.net
5 stars – based on 497 reviews

Frequently Used, Contextual References

TODO: Remember to copy unique IDs whenever it needs used. i.e., URL: 304b2e42315e

Resources

How to Check Your Python Environment for the LiteLLM Credential Stealer (and Protect Your AI API Keys)
Latest   Machine Learning

How to Check Your Python Environment for the LiteLLM Credential Stealer (and Protect Your AI API Keys)

Last Updated on October 29, 2026 by Editorial Team

Author(s): Prajwal Waykos

Originally published on Towards AI.

How to Check Your Python Environment for the LiteLLM Credential Stealer (and Protect Your AI API Keys)

A beginner-friendly guide to finding the malicious litellm files, cleaning up safely, and keeping your API keys out of reach next time

Follow me on 🔗Linkedin, 🐙GitHub

How to Check Your Python Environment for the LiteLLM Credential Stealer (and Protect Your AI API Keys)
The three files you never want to see in site-packages (Image by author)

“I never even imported it.” Famous last words of a Python developer.

Hey there, fellow tech-enthusiast!

On October 21, 2026, I saw the words “LiteLLM” and “compromised” in the same sentence and did what any calm, rational engineer would do. I panicked a little. LiteLLM is the kind of library that quietly ends up in a lot of AI side projects, routing calls to OpenAI, Anthropic and friends.

So I spent my evening checking every virtualenv I own. Turns out I own a lot of them. One was called test2-final-REAL. I don't remember making it.

The good news is that the check is not hard once you know where to look. The not so good news is that the usual first instinct, typing pip show litellm, can actually make things worse. Let me show you why, and then let me give you a small scanner that does the job safely.

🕵️ What Actually Happened

A malicious release, litellm==1.107.0, was published to PyPI and to the official Docker images. According to the official LiteLLM incident post, all seven wheels were live on PyPI from 11:39 to 13:23 UTC, roughly two hours. The Docker Hub tags v1.107.0, latest, main-latest and main-stable pointed to a poisoned image from 11:46 to 14:20 UTC. The same tags on GHCR were affected until 13:51 UTC.

How did it get there? The attackers used stolen GitHub CLI tokens from three LiteLLM developers. LiteLLM believes the tokens leaked through logs of an internal deployment that was exposed to the internet. With those tokens, the attackers pushed past branch protection, ran the release pipeline from a lookalike repository called Berri-AI/litellm (note the extra dash), and then deleted workflow records to hide their tracks.

The official source code on GitHub was clean. Only the published packages were poisoned. LiteLLM says every earlier version is safe and recommends pinning to 1.106.2.

1How a few stolen tokens turned into a two hour supply chain attack (Image by author)

With about 23 million weekly downloads, two hours is plenty of time for a lot of CI pipelines to run pip install litellm without a version pin.

🐍 The Sneaky Part, Python Startup Hooks

Here’s the bit that surprised me most. You did not need to import LiteLLM for the malware to run.

Every time Python starts, a built-in module called site scans your site-packages folder. If it finds a file ending in .pth, it reads it line by line. Most lines are just folder paths. But any line that starts with import gets executed as Python code. Right there, before your script runs a single line.

After that, Python also imports a file called sitecustomize.py if it finds one. Again, automatically.

The bad wheel abused both. Microsoft’s analysis explains that it shipped three extra files which landed directly in site-packages, outside the litellm folder.

  1. cook.pth, containing one line, import cook
  2. cook.py, which decodes a hidden program that names itself PRIVCOLLECT and starts collecting credentials in a background thread
  3. sitecustomize.py, a second loader on Linux and macOS that tries to download and run more code
Python runs .pth import lines and sitecustomize.py before your own code even starts (Image by author)

Here’s a harmless experiment you can run yourself. It creates a fresh venv and drops in a .pth file that only prints a message.

# 1. make a throwaway virtual environment
python3 -m venv demo-venv
# 2. ask the venv where its site-packages folder lives
SITE=$(demo-venv/bin/python -c "import sysconfig; print(sysconfig.get_paths()['purelib'])")
# 3. drop in a harmless .pth file with an import line
echo 'import sys; print("hello from harmless_test.pth, I ran before your code!")' > "$SITE/harmless_test.pth"
# 4. run some code that has nothing to do with that file
demo-venv/bin/python -c 'print("my actual code")'

And the output on my machine.

hello from harmless_test.pth, I ran before your code!
hello from harmless_test.pth, I ran before your code!
my actual code

It even said hello twice. Very polite, very terrifying. Now swap that print for “zip up every API key on this laptop and mail it somewhere” and you get the idea.

This is also why pip show litellm is a bad first move. pip is a Python program, so starting it inside an infected environment fires the stealer again. Microsoft even saw one case where a routine pip install of an unrelated package triggered the collector.

The burglar who moves in during pip install (Image by author)

🔑 What It Was After

Microsoft and Datadog list cloud credentials for AWS, Azure and GCP, Kubernetes and Docker configs, SSH and GPG keys, Git and package manager tokens, .env files, shell history, browser data, and the configs of AI coding tools. On a LiteLLM proxy it also tried to read stored provider keys straight out of the proxy database.

The haul was encrypted and sent to www.litellm[.]dev/healthcheck. That domain looks official. It is not.

🔍 How to Check Your Machine Safely

The golden rule is simple. Look at files, don’t run the suspect Python. These shell commands only read folder and file names.

# every installed litellm version, read from folder names
find ~ -type d -name "litellm-*.dist-info" 2>/dev/null
# the three files the malicious wheel dropped into site-packages
find ~ -path "*-packages/*" \( -name "cook.pth" -o -name "cook.py" -o -name "sitecustomize.py" \) 2>/dev/null
# cached copies of the bad wheel (pip and uv caches)
find ~/.cache -name "litellm-1.107.0*" 2>/dev/null
# marker files left by the malware
ls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/null

A litellm-1.107.0.dist-info folder or a cook.pth file means trouble. A sitecustomize.py is not automatically evil, since some setups use one on purpose, but open it and read it. Also, a missing marker file proves nothing. Containers and tmp cleanup wipe them.

Four questions that tell you whether to worry (Image by author)

🧰 A Small Scanner You Can Run Today

Checking venvs by hand got old fast, so I wrote a tiny scanner. It only reads files. It never imports LiteLLM and never starts your venv’s Python. It looks for the known file names, the SHA-256 hashes published by Microsoft, any 1.107.0 leftovers, the marker files, and every .pth line that runs code.

Subscribe to the Medium newsletter

Run it with your system Python and two flags. -I ignores Python environment variables and your user site-packages. -S skips the site module, so no .pth file gets a chance to run.

"""
scanner.py: look for signs of the compromised litellm==1.107.0 release.

It only READS files. It never imports litellm and never runs your venv's Python.
Run it with a clean interpreter and startup hooks switched off:

python3 -I -S scanner.py ~/projects ~/.cache

-I ignores PYTHON* environment variables and your user site-packages
-S skips the "site" module, so no .pth file or sitecustomize.py gets to run
"""

import hashlib
import os
import sys
import sysconfig

# File names and SHA-256 hashes published by LiteLLM, Microsoft and Datadog
BAD_FILE_NAMES = {"cook.pth", "cook.py"}
BAD_HASHES = {
"7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb": "cook.pth",
"84dc8c82c0a4a4a62552ef826b49fcc86d1a5c4a5d0f2c06665723f93d129d7d": "cook.pth (variant)",
"9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25": "cook.py",
"03ac2a8b752605d1d7710979c17ff18b03dd978203ba23ba97fd8ce3d6f897ea": "cook.py (Windows)",
"34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa": "sitecustomize.py",
"b6122440d0d9cb1eb1ade9933f49214da8c31288de7710c076c2680dfa528465": "sitecustomize.py (Windows)",
}
# Marker files the malware leaves behind (missing does NOT mean you are safe)
MARKER_FILES = ["/tmp/.pc_once_72129118c59f5636c0bb", "/tmp/remote_code_loader.lock"]
# Common, harmless .pth files that also contain "import" lines
USUALLY_FINE = {"distutils-precedence.pth", "_virtualenv.pth"}
SKIP_DIRS = {".git", "node_modules", "proc", "sys", "dev"}

findings = [] # list of (level, message)


def report(level, message):
findings.append((level, message))
print(f"[{level}] {message}")


def sha256_of(path):
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()


def check_pth(path):
"""Python executes any .pth line that starts with 'import'. Show those lines."""
name = os.path.basename(path)
with open(path, encoding="utf-8", errors="replace") as f:
for number, line in enumerate(f, start=1):
if line.startswith(("import ", "import\t")):
level = "INFO" if name in USUALLY_FINE or name.startswith("__editable__") else "REVIEW"
report(level, f"{path} line {number} runs code at startup: {line.strip()[:80]}")


def check_site_packages(folder):
for name in os.listdir(folder):
path = os.path.join(folder, name)
if name.startswith("litellm-") and name.endswith(".dist-info"):
version = name[len("litellm-"):-len(".dist-info")]
level = "DANGER" if version == "1.107.0" else "INFO"
report(level, f"litellm {version} installed in {folder}")
if not os.path.isfile(path):
continue
if name in BAD_FILE_NAMES:
report("DANGER", f"known malicious file name: {path}")
if name == "sitecustomize.py":
report("REVIEW", f"sitecustomize.py runs on every start, check it: {path}")
if name.endswith((".pth", ".py")):
file_hash = sha256_of(path)
if file_hash in BAD_HASHES:
report("DANGER", f"hash matches {BAD_HASHES[file_hash]}: {path}")
if name.endswith(".pth"):
check_pth(path)


def find_site_packages(roots):
found = {sysconfig.get_paths()["purelib"]} # this interpreter's own folder
for root in roots:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for d in dirnames + filenames:
# e.g. a cached litellm-1.107.0-...whl (dist-info is checked later)
if "litellm-1.107.0" in d and not d.endswith(".dist-info"):
report("DANGER", f"1.107.0 artifact: {os.path.join(dirpath, d)}")
if os.path.basename(dirpath) in ("site-packages", "dist-packages"):
found.add(dirpath)
dirnames[:] = [] # no need to walk inside every package
return sorted(p for p in found if os.path.isdir(p))


def main():
roots = sys.argv[1:] or [os.path.expanduser("~")]
folders = find_site_packages(roots)
print(f"Checking {len(folders)} site-packages folder(s)...\n")
for folder in folders:
check_site_packages(folder)
for marker in MARKER_FILES:
if os.path.exists(marker):
report("DANGER", f"malware marker file exists: {marker}")

dangers = sum(1 for level, _ in findings if level == "DANGER")
reviews = sum(1 for level, _ in findings if level == "REVIEW")
print(f"\nDone. {dangers} danger, {reviews} to review.")
if dangers:
print("Treat this machine as compromised. Do not run pip or python in that env.")
sys.exit(2 if dangers else 1 if reviews else 0)


if __name__ == "__main__":
main()

I tested it on a venv with my harmless .pth file from earlier. It flagged exactly that file.

$ python3 -I -S scanner.py /workspace/scantest
Checking 2 site-packages folder(s)...

[REVIEW] /workspace/scantest/demo-venv/lib/python3.13/site-packages/harmless_test.pth line 1 runs code at startup: import sys; print("hello from harmless_test.pth, I ran before your code!")

Done. 0 danger, 1 to review.

A REVIEW line is not a verdict. Plenty of normal tools use .pth files, so just open the file and make sure you recognise it. A DANGER line means stop and go to the response section. The exit code is 0 for clean, 1 for review and 2 for danger, so you can drop it into CI too.

🐳 Checking Docker Images

If you ran LiteLLM in Docker on October 21, first list your images with their digests and compare against the bad index digest sha256:1bbcb1443bc9... from the LiteLLM post. Then you can export the image's files without ever starting the container and scan them.

docker images --digests | grep -i litellm
# create (not run!) a container from YOUR local image ID, so nothing inside it executes
docker create --name litellm-check <image-id-from-the-list-above>
mkdir image-fs && docker export litellm-check | tar -x -C image-fs
docker rm litellm-check
python3 -I -S scanner.py image-fs

If the container actually ran during the window, treat that host as exposed.

📡 Signs That Something Already Left

Microsoft and LiteLLM point to a few clues in your network and cloud logs.

  1. Outbound POST requests to www.litellm[.]dev/healthcheck
  2. Requests to raw.githubusercontent.com/letters9813/pythonclient-test
  3. A detached Python process holding /tmp/remote_code_loader.lock
  4. Temp folders starting with .pc_e_
  5. A GetCallerIdentity call in CloudTrail from the instance role around the time of the install

🚨 If You Find It

Don’t panic, but move fast. And again, don’t start with pip install.

The cleanup checklist, in the order that matters (Image by author)
  1. Delete cook.pth, cook.py, sitecustomize.py and their .pyc files from every affected site-packages, using the shell.
  2. Kill the background Python process that holds /tmp/remote_code_loader.lock.
  3. Rebuild the venv or image from scratch with litellm==1.106.2. Just deleting the litellm folder does not remove the extra files.
  4. Assume the data left if the machine had internet access, and rotate keys from a different, clean machine. That means your LLM provider keys, cloud keys and roles, LiteLLM master and virtual keys, the database password, GitHub tokens, and anything else sitting in that environment.
  5. Sign out of browsers and AI tools on that machine so stolen sessions die.

🛡️ How to Protect Your AI API Keys Next Time

No single trick fixes supply chain attacks, so stack a few cheap layers.

Seven cheap layers between a bad release and your API keys (Image by author)

Lock your versions and their hashes. With hashes, pip refuses any file that doesn’t match exactly what you locked.

# requirements.in has loose names, requirements.txt gets exact versions + hashes
uv pip compile requirements.in --generate-hashes -o requirements.txt
pip install --require-hashes -r requirements.txt

Wait a few days before trusting brand new releases. This one was gone in about two hours, and uv can simply ignore anything newer than a week. Then install from your lockfile only.

# pyproject.toml
[tool.uv]
exclude-newer = "7 days"
uv sync --locked

For Docker, pin by digest instead of latest, because a tag can be moved and a digest cannot.

Finally, make stolen keys less useful. Use short-lived, narrowly scoped keys with spending limits. Pull secrets from a secrets manager at runtime instead of leaving a fat .env file on your laptop. And if your servers don't need to talk to the whole internet, don't let them.

Final Thoughts 🤔

What scared me most was not the malware itself. It was realising that one line in a file I had never opened could run before my own code. Python has always worked this way. It’s just easy to forget until someone abuses it.

The fix is not to stop using open source. It’s to slow down a little, lock things down, and keep your keys short-lived. My test2-final-REAL venv has been deleted, by the way. Nobody will miss it.

📚 Resources

  1. LiteLLM official incident post
  2. Microsoft Security Blog analysis of the startup hooks
  3. Datadog Security Labs write-up
  4. Python docs for the site module

How many virtualenvs did you find on your machine? And did any of them have a name worse than mine? Let me know in the comments!

Happy coding! If you have any questions or suggestions, feel free to reach out or leave a comment.

Follow me on 🔗Linkedin, 🐙GitHub

Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming a sponsor.

Published via Towards AI


Towards AI Academy

We Build Enterprise-Grade AI. We'll Teach You to Master It Too.

15 engineers. 100,000+ students. Towards AI Academy teaches what actually survives production.

Start free — no commitment:

→ AI Engineering For Production — our new book, coming before the end of 2026. Join the waitlist.

→ Agents Architecture Cheatsheet — 3 years of architecture decisions in 6 pages

Our courses:

→ AI Engineering Certification — 90+ lessons from project selection to deployed product. The most comprehensive practical LLM course out there.

→ Agent Engineering Course — Hands on with production agent architectures, memory, routing, and eval frameworks — built from real enterprise engagements.

→ AI for Work — Understand, evaluate, and apply AI for complex work tasks.

Note: Article content contains the views of the contributing authors and not Towards AI.