The release was two hours old
On October 21, attackers holding three LiteLLM developers' stolen GitHub CLI tokens published litellm 1.107.0 to PyPI. Every wheel carried a credential stealer that runs on any Python start and an hourly remote-code loader. PyPI pulled it 1 hour 43 minutes later, after an estimated 49,000 downloads. In Bromure Agentic Coding, the age gate that ships on by default hides any release younger than two days, so pip on a Bromure machine never saw 1.107.0.
The malicious release was on PyPI for 1 hour and 43 minutes. With Bromure's default settings, pip on an agent's machine would not have seen it until two days after it went up.
At 11:39 UTC on Tuesday, October 21, seven wheels for litellm==1.107.0
landed on PyPI. LiteLLM is the Python library and proxy that many teams use
to route model traffic, and it ships in a lot of AI tooling. The new
release looked like the project's own: the project's release workflow had
signed it, and it came with SLSA provenance. It also contained a credential
stealer and a backdoor.
PyPI removed it at 13:23 UTC. Infected Docker images tagged v1.107.0,
latest, main-latest and main-stable stayed on Docker Hub until 14:20.
Wiz
estimates about 49,000 downloads of the malicious version in that window.
LiteLLM's incident
report tells
anyone who installed it on a host with outbound internet access to "assume
the exfil succeeded."
This is the project's second compromise this year. The first, in March, came through a poisoned security scanner in its CI. We wrote about how responders checked the wrong window for that one.
How the attackers got the keys
The LiteLLM team has published a minute-by-minute timeline. The attackers
held GitHub CLI OAuth tokens for three developers. That Monday, release
permissions had been widened so two engineers could ship bugfix backports.
At 10:08 on Tuesday, branch protection rejected a change to the private
release workflow. Five seconds later, a token belonging to one of the
organization's two owners overrode the protection and pushed the change.
The modified workflow built from Berri-AI/litellm, a lookalike of the real
BerriAI/litellm, and a second developer's token passed the production
approval gate. At 11:53, a third developer's token deleted the workflow run
records.
LiteLLM traces the tokens to two things it found. One was an "unexpected, innocuously named user account" on its internal LiteLLM deployment, which was exposed to the internet. The other concerns coding agents. The report says LiteLLM has "observed specific situations in which Claude Code will embed a user's GitHub CLI OAuth token into a response body, and in situations where response bodies are logged, these tokens may appear in LiteLLM logs."
A gateway that logs model traffic keeps a copy of whatever the agent wrote. In this case the copy included the token that could publish the package.
What the wheels did
StepSecurity's
teardown
explains why the source on GitHub looked clean. The attackers added one
line to the build configuration, data = "cook" under [tool.maturin]. It
copied a cook/ directory into each wheel. The directory never appeared in
the source distribution, and the v1.107.0 tag on GitHub has no trace of
it.
Each wheel installs three files into site-packages:
cook.pth, 12 bytes:import cook. Python'ssitemodule runs import lines in.pthfiles at every interpreter start. Nothing ever has toimport litellm. Starting pip is enough.cook.py, one obfuscated line that decodes to about 61,000 bytes. It is a stealer that tags its output"tool": "privcollect"and runs 16 collection modules.sitecustomize.py, a loader that forks into a detached daemon. Every hour it readsdomain.txtfrom a GitHub repository the attacker controlled, fetcheshttp://<host>/codepyand runs the result withexec().
The stealer takes SSH and GPG keys, cloud credentials and instance metadata,
~/.kube/config, Docker config, GitHub CLI tokens, .git-credentials,
.netrc, registry tokens for npm, PyPI, Cargo and others, .env files two
levels deep, browser logins and cookies, shell and database history, and the
environment of every readable process from /proc/<pid>/environ. It also
lists auth and config files for Claude Code, Claude Desktop, Cursor,
Windsurf, Kiro, GitHub Copilot, Gemini CLI, Codex, Continue, Aider, Goose and
about a dozen other AI tools. On a LiteLLM proxy host, it reads the provider
API keys out of the proxy's database.
The stealer encrypts the bundle with AES-256, wraps the key with the
attacker's RSA-4096 public key, and posts the result over plain HTTP to
http://www.litellm.dev/healthcheck. The attacker registered the domain on October
13. Until the server answers 200, the stealer runs again at the next Python
start.
The loader's domain.txt was empty at release, so the second stage never
armed. The GitHub account behind it has been deleted, but infected hosts
are still polling that URL every hour. If anyone re-registers that username,
every one of those hosts runs their code.
Why coding agents were exposed
An agent working on a Python project runs pip install -U litellm because a
README or an error message suggests it. It runs pip install -r requirements.txt with an unpinned litellm line. It starts a fresh
virtualenv and installs whatever is newest, at any hour, and nobody reads
the version that came back.
On Tuesday, between 11:39 and 13:23 UTC, newest meant 1.107.0. On an agent
host, cook.pth then ran on every python, pytest and pip call the
agent made for the rest of the session. The developer's own credentials
were in the stealer's reach, and the agent's sign-ins were on its target
list.
Bromure never served 1.107.0
Bromure Agentic Coding runs Claude Code, Codex, Grok, Kimi and omp in Linux
VMs on your Mac. Every package fetch from the VM passes through a proxy on
the Mac, which applies the machine's supply-chain policy before the response
reaches pip. The VM can't change that policy. The pip.conf inside the VM
can only narrow it.
One layer of that policy is on for every new machine: the age gate,
with a minimum age of 2 days. When pip asks PyPI's simple index which
versions of litellm exist, the proxy drops every file younger than the
cutoff from the listing before pip reads it. At 11:45 UTC on Tuesday, a Bromure
machine running pip install -U litellm got a list that ended at 1.106.2,
the last clean release, and installed it. To the agent, 1.107.0 did not exist
yet, and by the time the gate would have opened, PyPI had deleted it.
If the agent pins the version, the artifact fetch is the second check. The proxy looks up the file's publish time on PyPI and refuses the download with HTTP 451:
$ pip install litellm==1.107.0
ERROR: HTTP error 451 while getting https://files.pythonhosted.org/.../litellm-1.107.0-...whl
Bromure Supply-Chain Security blocked this request: pypi package [email protected]
published 1 hour ago — policy requires 2 days minimum
pip prints the reason verbatim, so the agent reads it too, and it often pins an older version on its own.
The gate needed no advisory, no malware signature and no one to name
the package. Registries and researchers tend to find and pull a hijacked
release within hours, and a two-day wait costs you almost nothing in normal
development. If your own
team publishes a package that has to install the minute it ships, add it under
Exempt packages in the machine's Supply Chain pane, as pypi:yourpkg.
The rest of PyPI still waits two days.
The same pane adds more layers. The OSV check refuses any version with a published advisory at or above the severity you pick. The socket.dev filter blocks packages flagged as malware. If a reputation source you turned on can't give a verdict, the download pauses and Bromure asks you about that one package and version.
The token in the log was a stand-in
The release was only possible because the attackers held real GitHub tokens. LiteLLM believes they got them from a deployment that logged model traffic, where Claude Code had written a developer's GitHub CLI token into a response.
In a Bromure machine, that token is a stand-in. When the VM boots, Bromure
writes a fake ghp_ token into gh's configuration and
~/.git-credentials and exports it as GH_TOKEN. The fake has the right
prefix and length, so gh accepts it. The real token is kept on the Mac. When a
request leaves the VM for GitHub, the proxy swaps the fake for the real value
on the wire, for GitHub's hosts only.
An agent can still paste the token into a reply. It has only the stand-in to paste. The model provider and any gateway log in between record the stand-in too. Replayed from someone else's laptop, GitHub rejects it, because no Bromure proxy is there to swap it.
Release permissions get the same protection. In a machine's Guardrails
pane, every forge token has a write policy, and new machines start on
Prompt before write. A force-push to a release workflow, or an API call
that turns off branch protection, waits for a dialog on your Mac showing
METHOD /path, and you decide.
What privcollect would have found
Suppose 1.107.0 did reach a machine, because someone turned the gate off or added an exemption. The stealer's list is long. In a Bromure VM, most of what it can open holds stand-ins:
GH_TOKEN,ANTHROPIC_API_KEYand every other credential the machine was given, in the environment of every process the stealer reads through/proc, are fakes.~/.git-credentials,~/.docker/config.json,~/.kube/config,~/.aws/configand the MCP configs hold fakes. So do the agents' own sign-in files: the Claude, ChatGPT, Grok and Kimi subscriptions are signed in and refreshed on the Mac.- Model provider keys never enter the VM, so there is no proxy database full of upstream keys to read. Bromure's proxy adds them on the Mac, as we described in The provider runs where the keys are.
- SSH keys sign through an agent bridge to the Mac. The private key bytes
never enter the VM, so
~/.sshhas nothing to take.
A stand-in works only through that machine's proxy, toward the host it was minted for. The attacker can decrypt the tarball and replay every value in it, and GitHub, AWS and Anthropic will reject each one.
The POST had nowhere to go
The bundle goes out as a plain-HTTP POST to www.litellm.dev, a domain
the machine has never needed. With Unmatched traffic set to Deny in
the Guardrails pane, the VM reaches only the hosts you list:
allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny
The switch on the Mac refuses the connection, and the Security Timeline
shows a red deny row with the host and port. The loader's second stage
fails the same way: whatever host domain.txt names, http://<host>/codepy
isn't on the list, so the hourly exec() has nothing to run.
On a workspace running the OpenShell + Sentry sandbox engine, Show the
Whole Chain on that row opens the lineage of the blocked flow. It shows
the agent's pip install tool call and the reasoning the model gave for
it, then the pip process in which cook.pth started the stealer, then
the refused connection. You see the instruction that pulled the package in, and the
model's stated reason for running it.
"Did we install it?" takes one filter
Every responder had the same first question on Tuesday afternoon: which of
our machines ran 1.107.0? LiteLLM warns that finding no cook.pth proves
nothing: its absence doesn't mean a host is clean.
Bromure records every package fetch the proxy sees, whatever the policy
says and whether or not any check is on. Open Window → Security
Timeline…, pick Supply chain in the engine filter, and search for
litellm. Each row names the package and version, pypi · [email protected],
and what happened to it. The record stays on the Mac for 90 days, and
Export… saves it as CSV for the incident ticket. On a Mac enrolled with
bromure.io, each fetch is also a supply_chain.fetch event with the
ecosystem, package, version and outcome. An administrator can list every
machine in the organization that downloaded [email protected] without asking
anyone.
Cleaning up after a hit is quick. LiteLLM's guide says to kill the daemon
holding /tmp/remote_code_loader.lock before running pip, delete the
payload files from every site-packages, rebuild and rotate. In Bromure,
Reset disk re-clones the system disk from the base image, and Rewind
home… returns the home to a checkpoint from before the install. The keys
you would otherwise rotate in a hurry were stand-ins. The real ones never
left the Mac.
Fresh releases wait two days
The age gate is on for every new machine. Installs from PyPI, npm, Cargo, RubyGems and Packagist resolve to the newest version older than the cutoff. A pinned fresh version gets a 451 with the reason.
The token in the log is a fake
GitHub, cloud, registry and model credentials enter the VM as stand-ins. The proxy swaps in the real value on the Mac, for the credential's own host.
Exfiltration needs a route
With unmatched traffic set to deny, a POST to a domain registered last week dies at the switch and shows up as a red row.
Every install is on the record
The Security Timeline lists every package and version each machine fetched, for 90 days. Enrolled Macs send the same events to the organization.
Let the registry catch up first
The attackers needed minutes to publish 1.107.0. PyPI needed 1 hour 43 minutes to pull it, which is fast for a registry. Your agent installs packages all day, at any hour, and takes whatever is newest, so it lands inside windows like that one. On a Bromure machine it waits two days. By then PyPI, Wiz, StepSecurity and LiteLLM's own team have had their look at the release, and a bad one is gone.
For the stealer that went after coding-agent credentials earlier this month, read The stealer packed the stand-ins. For how tokens end up in transcripts and model logs in the first place, read The transcript kept the token.