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.
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.
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.
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.
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.
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.
The golden rule is simple. Look at files, don’t run the suspect Python. These shell commands only read folder and file names.
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.
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 hashlibimport osimport sysimport sysconfig# File names and SHA-256 hashes published by LiteLLM, Microsoft and DatadogBAD_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_.lock"]# Common, harmless .pth files that also contain "import" linesUSUALLY_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/scantestChecking 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.
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 executesdocker create --name litellm-check <image-id-from-the-list-above>mkdir image-fs && docker export litellm-check | tar -x -C image-fsdocker rm litellm-checkpython3 -I -S scanner.py image-fs
If the container actually ran during the window, treat that host as exposed.
Microsoft and LiteLLM point to a few clues in your network and cloud logs.
Don’t panic, but move fast. And again, don’t start with pip install.
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.
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.
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.
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.
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.
How to Check Your Python Environment for the LiteLLM Credential Stealer (and Protect Your AI API… was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.