Key Takeaway
LiteLLM 1.82.7 and 1.82.8 on PyPI carried credential-stealing malware and were quarantined 2 hours 32 minutes after upload, after over 119,000 downloads. A dependency cooldown such as uv exclude-newer set to P3D keeps fresh resolutions outside a window that short. Hash locks, Trusted Publishers and workflow audits each close different gaps.
The number that stayed with me from PyPI's incident report is not the 119,000 downloads. It is 2h 32m: the time between the malicious LiteLLM files being uploaded and PyPI quarantining them. Any install that ran inside that window was exposed. Any install that waited a day was not.
This post is a read-through of the primary sources on the March 2026 LiteLLM compromise: PyPI's incident report, LiteLLM's own security update, the PYSEC-2026-2 advisory and FutureSearch's discovery write-up, plus the uv, pip and PyPI docs behind each control. I have not reproduced the malware and this is not an exploit guide. It is for the person who owns a requirements file or a CI runner and wants to know which settings would have mattered. Where the sources disagree, I say so.
What was published, and what was never at risk
Two PyPI releases of litellm were malicious: 1.82.7 and 1.82.8. LiteLLM's notice says 1.82.7 carried a payload in the proxy's proxy_server.py, and 1.82.8 carried the same plus a file named litellm_init.pth. PYSEC-2026-2 records the affected range as introduced 1.82.7, last affected 1.82.8. The same notice is careful about what stayed clean, and that list is where most readers should start.
| Artifact | Status | Why, per LiteLLM and PYSEC-2026-2 |
|---|---|---|
| litellm 1.82.6 and earlier | Not affected | Published before the incident. Applies to users who did not upgrade during the window. |
| litellm 1.82.7 | Malicious | Payload in proxy/proxy_server.py. |
| litellm 1.82.8 | Malicious | litellm_init.pth in site-packages, plus the payload in proxy_server.py. |
| Official proxy Docker image (ghcr.io/berriai/litellm) | Not affected | LiteLLM says it pins dependencies in requirements.txt and does not rely on the compromised PyPI packages. |
| Install from the GitHub source repository | Not affected | LiteLLM states the repository was not compromised and that no malicious code was pushed to main. |
| LiteLLM Cloud | Not affected | Stated by LiteLLM in its security update. |
The pattern in that table is the point. Everything that resolved dependencies fresh from PyPI during the window was exposed. Everything that installed from a pinned list, a signed image or a source checkout was not. LiteLLM's notice adds one caveat that is easy to miss: Docker images built during the window with an unpinned pip install litellm, and projects that pulled LiteLLM in as an unpinned transitive dependency, count as affected. FutureSearch found it exactly that way, as a transitive dependency of an MCP plugin running inside Cursor.
Why a .pth file is a better hook than a bad function
A malicious function only runs if something calls it. A .pth file runs when Python starts. The Python documentation for the site module says that lines in a .pth file starting with import are executed with exec(), and that such executable lines always run at interpreter startup unless -S disables site.py, regardless of whether the module is ever going to be used. FutureSearch says litellm_init.pth executed on every Python process startup once the package was installed.
That is why the blast radius was larger than people who run LiteLLM would guess. Installing the package in a virtualenv meant every Python process using that environment, including pip, linters and test runners, started the hook. FutureSearch also reports that the launcher spawned a child Python process which re-triggered the same file, creating an exponential fork bomb that crashed the machine. The authors describe that as a flaw in the malware rather than a feature, and a machine that crashes is hard to miss. Quieter malware would not have drawn the same attention.
# The rule from the Python docs (site module): lines in a .pth file that
# start with "import" are exec()'d, and they run at every interpreter
# start unless python -S is used, whether or not the module is ever imported.
#
# Audit: list every .pth line in this environment that would execute code.
# Expect a few legitimate hits (editable installs, some build tools), so
# diff the output against a known-good baseline instead of panicking.
python3 -I - <<'EOF'
import pathlib
import site
dirs = site.getsitepackages() + [site.getusersitepackages()]
for d in dirs:
for pth in sorted(pathlib.Path(d).glob("*.pth")):
text = pth.read_text(errors="replace")
for n, line in enumerate(text.splitlines(), 1):
if line.startswith(("import ", "import\t")):
print(pth, n, line[:120])
EOFPython 3.15 deprecates import lines in .pth files and a later release will ignore them, per the same documentation, but that is a long way from your production interpreter today. Until then, auditing the lines is a cheap control, and the script above does it with the standard library only.
How the publish happened, and what is still unproven
PyPI's incident report attributes the cause to an API token exposure from an exploited Trivy dependency. PYSEC-2026-2 repeats that framing: after a token was exposed through an exploited Trivy dependency, two new litellm releases were uploaded. The report does not explain how that token led to publish rights, and I will not guess at it.
LiteLLM's own notice is more cautious. It says current evidence suggests a maintainer's PyPI account may have been compromised, and also that the team believes the compromise originated from the Trivy dependency used in its CI/CD security scanning workflow. It adds that initial evidence suggests the attacker bypassed the official CI/CD workflows and uploaded packages directly to PyPI. FutureSearch's observation fits that: it found no matching tag or release on the litellm GitHub repository for the malicious version.
The sources disagree on timing. LiteLLM says the releases were live for about 40 minutes from 10:39 UTC on 24 March. PyPI measured 2h 32m from upload to quarantine. FutureSearch lists 1.82.8 as published at 10:52 UTC. I use PyPI's durations in this post because PyPI ran the quarantine, but treat any single timestamp as approximate.
What LiteLLM changed afterwards is the useful part for anyone running a release pipeline. It paused releases, rotated maintainer credentials, brought in Google's Mandiant for forensics, and audited its PyPI and Docker artifacts from v1.78.0 through v1.82.6 by checking each for indicators, comparing it with its Git commit and recording a SHA-256 digest. It shipped v1.83.0 through a new CI/CD v2 pipeline and began signing Docker images with cosign.
The exposure arithmetic
PyPI published its own measurements for both packages hit in this campaign, litellm and telnyx. They are worth reading as a pair, because they show detection was working and still took hours.
| Metric | LiteLLM | Telnyx |
|---|---|---|
| Upload to first report | 1h 19m | 1h 45m |
| First report to quarantine | 1h 12m | 1h 57m |
| Upload to quarantine (total exposure) | 2h 32m | 3h 42m |
| Downloads and reports | Over 119k downloads of the exploited versions; 13 inbound malware reports | No download count given; response was automated from weighted reports by trusted reporters |
Two numbers explain why those 119,000 downloads happened. PyPI says LiteLLM is installed around 15 to 20 million times a week, which it rounds to about 1,700 installs per minute, and it estimates 40 to 50 percent of all installs were unpinned and fetched the latest version. So roughly half of everyone installing were, in effect, volunteering to run whatever was newest.
A cooldown of three days is 72 hours. Against a 2h 32m window, that is about 28 times longer than the exposure, and against Telnyx's 3h 42m it is about 19 times. That arithmetic is mine, not PyPI's, and it assumes the same detection speed next time. It is the reason cooldowns are the control PyPI leads with.
Four controls compared, and a fifth that LiteLLM already had
PyPI's report recommends several controls and does not rank them. The table below is my own assessment of each against this specific incident. Read the last column twice: every control has a case it does not cover.
| Control | What it stops | Against this incident | Blind spot |
|---|---|---|---|
| Dependency cooldown (uv exclude-newer, pip uploaded-prior-to) | Resolving a release uploaded in the last N days | Yes for any fresh resolution: the window was hours, a P3D cooldown is 72 hours | Relies on someone else detecting the release first; delays legitimate fixes |
| Hash-pinned lock file | A different file arriving under a version you already locked | Only if you locked before 1.82.7 existed | A hash proves identity, not safety: locking after the upload would pin the malicious file |
| Trusted Publishers | Theft of a long-lived PyPI upload token | Removes that token class; PyPI says the exchanged token expires after 15 minutes | A publisher-side control: your consumers get nothing from it, and it does not help if an attacker can run the trusted workflow |
| Workflow audit (zizmor, SHA pins, environment approval) | CI misconfigurations that leak secrets to attackers | Targets the stated root cause, a token exposed through CI | Static analysis of workflow files; it cannot tell you a dependency you pinned was itself compromised |
| Pinned requirements or the pinned official image | Anything that resolves at build time | LiteLLM says the official image was unaffected because it pins | You now own the bump process, and a stale pin is its own risk |
Read as a set, the controls split by who holds them. Cooldowns, hashes and pins protect the consumer. Trusted Publishers and workflow audits protect the maintainer. An incident like this one needs both sides, but only the consumer side is in your hands this week.
Config you can paste
Every setting below comes from the uv docs, the pip docs, or PyPI's incident report. The uv cutoff accepts RFC 3339 timestamps, local dates, and durations such as 1 week or the ISO form P7D. Calendar months and years are rejected, and a day counts as 24 hours. The pip option is documented as taking an ISO 8601 datetime or a duration in days, and as only effective on indexes that provide upload-time metadata.
# --- uv: ~/.config/uv/uv.toml (user-wide) or pyproject.toml (per project) ---
# Cooldown: ignore any file uploaded in the last 3 days. The cutoff compares
# each FILE's upload time, and the resolved timestamp is stored in uv.lock.
# pyproject.toml form:
[tool.uv]
exclude-newer = "P3D"
# Need one brand-new release on purpose (say, a fix you have read the diff of)?
# Opt that package out instead of lowering the global window.
[tool.uv]
exclude-newer = "P3D"
exclude-newer-package = { somepackage = false }
# --- pip: ~/.config/pip/pip.conf ---
[install]
uploaded-prior-to = P3D
# pip on the command line, absolute cutoff (GNU date shown):
python -m pip install --uploaded-prior-to=$(date -d '-3days' -Idate) somepackage
# --- hash-pinned requirements: identity, not safety ---
pip-compile --generate-hashes requirements.in -o requirements.txt
python -m pip install --require-hashes -r requirements.txt
# What requirements.txt then looks like (every dependency pinned and hashed):
# somepackage==1.2.3 \
# --hash=sha256:<64 hex chars from the index> \
# --hash=sha256:<one per wheel or sdist you may install>On the publishing side, the PyPA guide splits build from publish, requires id-token: write on the publish job, and says you must require manual approval on each run for the pypi environment. It also says building distributions inside the publishing job is unsupported. PyPI's incident report separately advises pinning actions to commit SHAs, passing inputs through environment variables and avoiding triggers an attacker can influence, such as pull_request_target.
# Publish job shaped like the PyPA guide: separate from build, no stored
# token, and a protected environment that needs a human click on each run.
publish-to-pypi:
if: startsWith(github.ref, 'refs/tags/')
needs:
- build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/your-package-name
permissions:
id-token: write # mandatory: lets the job mint an OIDC token
steps:
- uses: actions/download-artifact@v6
with:
name: python-package-distributions
path: dist/
# PyPA's guide uses the moving ref release/v1. PyPI's own incident
# guidance is to pin third-party actions to a full commit SHA instead.
- uses: pypa/gh-action-pypi-publish@release/v1
# Audit the workflows you already have. Exit 11-14 = findings by severity.
zizmor .github/workflows/
zizmor --format=sarif .github/workflows/ > zizmor.sarifOne version detail is worth checking before you rely on a pip config file. PyPI's report written in April said pip 26.0 and later accepted an absolute date on the command line and that the config-file form needed 26.1, which it described as expected that month. Run pip install --help on your own version and look for uploaded-prior-to before assuming it exists.
Put the cooldown in the user-wide file, not in each repository. A project that forgets to set it still inherits it, and the repos you never touch get the same protection as the ones you do.
What a cooldown costs, and where it breaks
A cooldown is a bet that other people look first. It does nothing against a package that is malicious for a week before anyone notices, and it works here only because detection landed in hours. It also delays honest releases: a bug fix you need today will not resolve until the cutoff passes unless you opt that package out. In uv the opt-out is exclude-newer-package set to false for that name, which overrides the global value.
Security updates are the sharp edge. PyPI notes that Dependabot and Renovate bypass cooldowns for security updates by default, which is sensible for a real CVE fix. It also means a release presented as a security fix is the kind a cooldown will let through by design, so a human should still read the diff of any bypassed update. That second point is my inference, not a claim from the report.
The uv documentation lists three gaps. The index must publish upload-time data (PEP 700) or uv treats a distribution as unavailable. The cutoff covers registry packages only, not Git dependencies. And error messages for an unsatisfiable resolution do not mention that the cutoff excluded anything, so a new transitive requirement that fails to resolve can look like a bug elsewhere. Remember it before you file one.
A hash lock does not detect malware. If you run pip-compile --generate-hashes the day after a bad release, you faithfully pin the bad file with a valid hash. Hashes protect a lock you made while the world was still clean, and a cooldown is what makes sure that was the case.
What to do on Monday
In order, cheapest first. Steps one and two tell you whether you were exposed. Steps three to six make the next incident boring.
- Search every environment you own, including CI runners, build hosts and package caches, for litellm 1.82.7 and 1.82.8, and for litellm_init.pth, using the commands below.
- If you find either, follow LiteLLM's remediation: remove it, investigate the host for further compromise, and rotate every secret that host could reach, including API keys, cloud keys, SSH keys, Kubernetes tokens and database passwords. Purge uv and pip caches so the bad wheel cannot come back.
- Set a user-wide cooldown today: exclude-newer of P3D in uv.toml, or uploaded-prior-to in pip.conf once your pip supports it. Choose the window deliberately and write down who may bypass it.
- For anything deployed, install from a hash-pinned lock with --require-hashes, so a wheel that changes under a version number fails loudly instead of installing.
- If you publish packages, move to Trusted Publishers, split build from publish, put the pypi environment behind manual approval, and run zizmor over .github/workflows in CI.
- Add the two domains named in the write-ups, models.litellm[.]cloud and checkmarx[.]zone, to your egress alerting, and review how many of your images build with an unpinned pip install.
# Detection only. Run on dev machines, CI runners and build hosts.
# Indicators come from LiteLLM's notice, PyPI advisory PYSEC-2026-2 and
# FutureSearch's write-up; none of these commands touch the network.
# 1. Which version is installed, and was it ever (1.82.7 or 1.82.8)?
python -m pip show litellm | grep -i '^version'
# 2. The .pth file both write-ups name. Check venvs AND package caches.
find / -name "litellm_init.pth" -not -path "/proc/*" 2>/dev/null
find ~/.cache/uv -name "litellm_init.pth" 2>/dev/null
# 3. Persistence paths FutureSearch lists, and the systemd unit name
# PYSEC-2026-2 says was disguised as "System Telemetry Service".
ls -l ~/.config/sysmon/sysmon.py ~/.config/systemd/user/sysmon.service 2>/dev/null
systemctl --user list-units --all | grep -i telemetry
# 4. Kubernetes: FutureSearch says to audit kube-system for node-setup-* pods.
kubectl -n kube-system get pods | grep '^node-setup-'
# 5. Egress: search DNS, proxy and firewall logs (not this host) for the two
# domains the write-ups name: models.litellm[.]cloud and checkmarx[.]zone.
# Neither belongs to LiteLLM.None of these commands send data anywhere, and they only look for the indicators the cited sources publish. If a source later amends its indicator list, as LiteLLM did when it added a second domain on 26 March, update the script rather than trusting this snapshot.
The rule I take from this incident is that installing the newest release the moment it exists is a decision, and it should be one you make on purpose. A cooldown turns it into a default of three days, a hash lock turns every later install into a comparison, and a pinned image or lock file turns an unreviewed upgrade into a pull request. None of them would have stopped the attacker. All of them would have kept most of those 119,000 installs out of the window.
Sources and further reading
- PyPI blog: incident report on the LiteLLM and Telnyx supply chain attack (2 April 2026)
- LiteLLM: security update, March 2026
- PyPA advisory database: PYSEC-2026-2 for litellm
- FutureSearch: how the litellm PyPI compromise was found
- uv docs: resolution, exclude-newer and dependency cooldowns
- pip docs: pip install options, including uploaded-prior-to
- pip docs: secure installs and hash-checking mode
- PyPI docs: Trusted Publishers
- Python Packaging User Guide: publishing with GitHub Actions and trusted publishing
- zizmor docs: usage, output formats and exit codes
- Python docs: the site module and .pth files

