The pipeline was real, the fork was not

LiteLLM's malicious 1.107.0 was built by LiteLLM's own release workflow, run against a fork whose name differs by one hyphen. Build provenance attests the pipeline that ran, not the source it read, so the control most teams adopted this year would have signed this artefact. Only a digest you already hold refuses bytes you have never reviewed.

By Agentic AI Wiki 12 min read

The malicious LiteLLM wheel was built by LiteLLM's own release workflow. Not a typosquat, not a hijacked maintainer account publishing by hand — the project's real, signed, logged pipeline, pointed at a fork called Berri-AI/litellm instead of BerriAI/litellm. That single hyphen is the whole lesson, because build provenance attests the workflow that ran and says nothing about the repository it read. Every team that spent 2026 adopting attestation as its supply-chain answer would have had that answer return "verified".

At a glance

All times UTC on 21 October 2026, per Datadog Security Labs' published timeline. The window from publication to PyPI quarantine was one hour and forty-one minutes; Docker images came down thirty-three minutes after that.

TimeEventWhat it means for you
11:39Malicious litellm 1.107.0 published to PyPIAny unpinned install from here on took it
~11:46Socket flags the malicious wheelsDetection worked; it does not uninstall anything
13:20PyPI quarantines 1.107.0New installs stop. Existing ones do not
13:53Affected container images removedTags v1.107.0, latest, main-latest, main-stable
How the malicious LiteLLM release was built Stolen GitHub CLI tokens from three engineer accounts dispatch LiteLLM's genuine release workflow. The workflow reads a lookalike fork named Berri-AI slash litellm instead of BerriAI slash litellm, then publishes signed artefacts to PyPI, GitHub Container Registry and Docker Hub under the project's own identity. stolen GitHub CLI tokens from 3 engineer accounts Berri-AI/litellm lookalike fork LiteLLM's own release workflow genuine, logged, signed PyPI litellm 1.107.0 GitHub Container Registry v1.107.0, latest Docker Hub main-latest, main-stable Only the lower-left box does not belong to the project. Every other box is its real infrastructure. A provenance attestation describes the centre box, which behaved correctly throughout.
Every box after the first is the project's real infrastructure. The substitution happened at the input, where nothing was checking.

LiteLLM is the open-source gateway that sits in front of model providers for a large share of the agent ecosystem — Datadog Security Labs puts it at roughly 23 million weekly downloads. The compromised release carried an infostealer plus a dormant second-stage loader. The last known-good version is 1.106.2. This is the project's second compromise of 2026; versions 1.82.7 and 1.82.8 were affected earlier in the year, and no link between the two has been established.

What was taken was the release path, not the repository

Per Datadog's account, the attackers held GitHub CLI OAuth tokens belonging to three engineers' accounts. They did not need to push a commit to the real repository, open a pull request, or get a review approved. They needed to start a workflow — and a developer CLI token is sufficient authority to do that.

The workflow then did its job correctly. It checked out the source it was told to check out, built it, and published the result to PyPI, GitHub Container Registry and Docker Hub under the project's own identity. Nothing malfunctioned. The one input nobody validated was which repository the word "source" resolved to, and Berri-AI versus BerriAI is a difference designed to survive a human glance at a log line.

This is the specific failure that makes the incident worth your attention rather than just your rotation runbook. A hijacked-maintainer-account attack produces an artefact with the wrong signer. A typosquat produces a package with the wrong name. This produced an artefact with the right signer, the right name, the right version sequence, and a build log showing it came from the official workflow.

The credential class that mattered here was a developer's own CLI token — the thing sitting in ~/.config/gh/hosts.yml on three laptops. If your release workflow can be dispatched by anyone holding a personal token, your release authority is the weakest laptop on the team, whatever your branch protection says.

Which control would have caught it, and which would have blessed it

What each supply-chain control verifies Three columns. A floating tag answers which artefact is newest and installed the malicious release. A build provenance attestation answers which pipeline built the artefact and verified successfully. A digest pin answers whether the bytes match the reviewed ones and refused the release. floating tag build provenance digest pin asks: what is the newest build? asks: whose pipeline built this? asks: are these the bytes I reviewed? installed 1.107.0 for 101 minutes verified, and passed the malicious wheel refused: no digest on record for it Two of the three ask about origin. Origin was never substituted, so only the third one moved.
Two of the three answer a question about origin. Only the third answers a question about bytes.

Sort your supply-chain controls by the question they answer and this incident separates them cleanly. Floating references — latest, main-stable, an unpinned pip install litellm — answer "what is newest?", and for one hour and forty-one minutes the honest answer was the malware. Provenance attestation answers "was this built by the project's pipeline?", and here the truthful answer was yes; a verifier checking it would have passed the artefact and logged a success. A digest pin answers "are these the exact bytes I reviewed?", and the answer was no, so nothing moved.

That ordering is uncomfortable because provenance is the control the industry has been building toward, and it is genuinely valuable against a large class of attacks. It just is not a content check. An attestation is a statement about a process, and a process can be aimed somewhere new without ceasing to be the same process. The site's note on pinning and verification makes the general version of this point: a name that resolves through somebody else's namespace is a preference, and a digest that is the content is a lock. Here the namespace being resolved was a GitHub organisation name, one character wide.

The corollary is the part worth arguing with your platform team about. Adding provenance verification to a pipeline that pulls floating tags does not make the pipeline safer against this attack; it makes the pipeline's logs more confident about an artefact it was going to install anyway. The safety came from refusing unreviewed bytes, and only the digest does that.

The blast radius was the interpreter, not the gateway

The payload dropped three files into site-packages: cook.pth, cook.py and sitecustomize.py. Python executes .pth files and sitecustomize.py at interpreter start, which means the stealer ran whenever anything in that environment ran Python. Not when an agent called the gateway. Not when litellm was imported. Whenever python or pip started.

Two consequences follow, and both of them break the intuitions people use when triaging a dependency incident.

Uninstalling the package does not remove the malware. The three files live beside the package, not inside it, so pip uninstall litellm leaves them in place — and the pip that runs the uninstall triggers them. Datadog's remediation starts with deleting the three files by hand for exactly this reason. A team that reasoned "we removed the bad version, we're clean" is still running it.

The reachable set is the environment's whole credential surface. Datadog's analysis lists cloud credentials including instance metadata for AWS, GCP and Azure; Kubernetes, Docker, Vault and Terraform secrets; SSH, GPG and TLS keys; Git, npm and PyPI tokens; .env files and shell history; browser-saved passwords and cookies; and provider API keys and sessions for Claude, Codex, Gemini, Cursor and Copilot. Exfiltration was encrypted with AES-256 and RSA-4096 and sent to www.litellm[.]dev/healthcheck — a lookalike domain and a path chosen to read as noise in an egress log.

That last group deserves its own sentence. If the LiteLLM proxy had a database configured, the upstream provider credentials stored in its tables were in scope. This is the structural hazard of the pattern, not a bug in this product: the advice to move provider keys out of the model's reach and into a broker at the egress boundary is correct, and it concentrates every provider credential you own into one process with one upgrade path. We wrote that up separately as when the credential broker is compromised, because the design response is a different piece of work from the incident response.

Two hours is longer than an install and shorter than a detection

One hour and forty-one minutes of PyPI availability sounds survivable until you count what fires automatically inside a window that size. Every CI job that resolves dependencies without a lockfile. Every container build that pulls latest. Every autoscaling event that starts a fresh node and installs from scratch. Every developer who ran an upgrade that morning. None of these involve a human deciding to adopt a new version; they are the machinery of a normal Wednesday.

Detection, meanwhile, worked impressively well and did not help the people who had already installed. Socket flagged the wheels roughly seven minutes after publication. That is close to the floor of what automated scanning can achieve, and it was still after the first installs. The asymmetry is the point: scanning shortens the window for the next victim, and does nothing for anyone inside it. Which is the fail-closed question wearing supply-chain clothes — a scanner that has not yet flagged a package is indistinguishable from a scanner that has cleared it.

Minutes of exposure, 21 October 2026 (UTC) A timeline from 11:30 to 14:00. The malicious release was installable from PyPI for 101 minutes, from 11:39 until the 13:20 quarantine. The container images stayed available for 134 minutes, until 13:53. Socket flagged the wheels about 7 minutes after publication. Minutes of exposure, 21 October 2026 (UTC) PyPI 101 min images 134 min Socket flag 7 min 11:30 12:00 13:00 14:00 Detection beat the quarantine by 94 minutes and the first installs by none of it.
Detection landed at seven minutes. The quarantine landed at 101. Everything installed in between stayed installed.

There is also a rollback trap worth naming. A team pinning main-stable has no record of which immutable artefact it was running last week, so "roll back to the known-good version" requires first reconstructing what the known-good version was. The teams who recovered fastest here were the ones whose lockfile already named 1.106.2 with a hash, which is to say the ones who were never exposed.

What to actually do

In rough order of how much protection each buys per hour of work:

  • Pin by digest, for images and for Python. Immutable image digests rather than tags, and hashes in your lockfile (pip install --require-hashes, or a resolver that writes them). This is the only control in this article that stopped the attack rather than describing it.
  • Lock release workflows to an allowlist of repository and branch. Datadog's recommendation, and the fix closest to the root cause: a workflow that can only build from one source cannot be aimed at a fork. Audit which identities can dispatch yours.
  • Treat developer CLI tokens as release credentials. They were the initial access here. Short lifetimes, no long-lived personal tokens with workflow scope, and a hardware-backed second factor on the accounts that can trigger a publish.
  • Write the "we installed a compromised package" runbook before you need it, and make its first step find files the package manager does not own. The .pth persistence trick is not novel and it defeats the obvious remediation.
  • Inventory what your gateway host can reach, not what your gateway is configured to do. The question is which of the four groups above live in that environment, and whether the answer needs to.
  • Rotate from a clean machine. Rotating credentials from the compromised host hands the new ones to the stealer. Obvious written down, routinely skipped under pressure.

And keep the scope of the argument honest. LiteLLM responded quickly, published what happened, and the detection ecosystem around it performed close to its theoretical best. Nothing here is a reason to prefer a closed gateway, where the same substitution would simply have been invisible. The reason to care is narrower and more durable: the control that was supposed to answer "can I trust this build?" answered yes, truthfully, about malware — and it will do so again, because that is what it was designed to assert.

FAQ

Which LiteLLM versions were affected?

Only litellm 1.107.0 on PyPI, plus the container images tagged v1.107.0, latest, main-latest and main-stable on GitHub Container Registry and Docker Hub. The last known-good release is 1.106.2.

Is uninstalling LiteLLM enough to clean a host?

No. The payload wrote cook.pth, cook.py and sitecustomize.py into site-packages alongside the package rather than inside it, so uninstalling leaves them — and Python runs them at every interpreter start, including the one that performs the uninstall. Delete the three files explicitly, then rebuild the environment.

Would a provenance attestation have caught this?

No, and that is the central point. The artefact was built by the project's genuine release workflow under the project's own identity; only the source repository was substituted. An attestation of build provenance would have verified successfully.

What credentials should be rotated?

Everything reachable from the affected environment, rotated from a machine that was never exposed: cloud and orchestration credentials, SSH, GPG and TLS keys, Git, npm and PyPI tokens, model provider API keys, and any upstream provider credentials stored in the LiteLLM proxy's own database. Then audit logs for use of those credentials during and after the window.

Was a second-stage payload ever delivered?

Not as far as has been reported. sitecustomize.py polled a file in a GitHub repository hourly for a domain to download and execute, but the file held only test values and the repository has since been removed. The loader was present and functional; the payload was not observed in the wild.

Does this mean self-hosting a gateway is the wrong call?

No. It means a self-hosted gateway is a credential concentrator whose upgrade path you own, so digest pinning and host-level credential hygiene are load-bearing rather than optional. A hosted gateway moves the same risk to a vendor whose release pipeline you cannot pin at all.

Further reading

On this wiki:

Sources: