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

“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.

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.
cook.pth, containing one line,import cookcook.py, which decodes a hidden program that names itself PRIVCOLLECT and starts collecting credentials in a background threadsitecustomize.py, a second loader on Linux and macOS that tries to download and run more code

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.

🔑 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.

🧰 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.
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.
- Outbound POST requests to
www.litellm[.]dev/healthcheck - Requests to
raw.githubusercontent.com/letters9813/pythonclient-test - A detached Python process holding
/tmp/remote_code_loader.lock - Temp folders starting with
.pc_e_ - A
GetCallerIdentitycall 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.

- Delete
cook.pth,cook.py,sitecustomize.pyand their.pycfiles from every affected site-packages, using the shell. - Kill the background Python process that holds
/tmp/remote_code_loader.lock. - Rebuild the venv or image from scratch with
litellm==1.106.2. Just deleting thelitellmfolder does not remove the extra files. - 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.
- 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.

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
- LiteLLM official incident post
- Microsoft Security Blog analysis of the startup hooks
- Datadog Security Labs write-up
- 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.