We wanted to use that time to walk through what we know, what we've done so far, and how we're improving LiteLLM's release and security processes going forward. This post is a written version of that update. Slides available here
Status: Active investigation
Last updated: March 27, 2026
Update (March 30): A new clean version of LiteLLM is now available (v1.83.0). This was released by our new CI/CD v2 pipeline which added isolated environments, stronger security gates, and safer release separation for LiteLLM.
Update (March 27): Review Townhall updates, including explanation of the incident, what we've done, and what comes next. Learn more
Update (March 27): Added Verified safe versions section with SHA-256 checksums for all audited PyPI and Docker releases.
Update (March 25): Added community-contributed scripts for scanning GitHub Actions and GitLab CI pipelines for the compromised versions. See How to check if you are affected. s/o @Zach Fury for these scripts.
The compromised PyPI packages were litellm==1.82.7 and litellm==1.82.8. Those packages were live on March 24, 2026 from 10:39 UTC for about 40 minutes before being quarantined by PyPI.
We believe that the compromise originated from the Trivy dependency used in our CI/CD security scanning workflow.
Customers running the official LiteLLM Proxy Docker image were not impacted. That deployment path pins dependencies in requirements.txt and does not rely on the compromised PyPI packages.
We have paused all new LiteLLM releases until we complete a broader supply-chain review and confirm the release path is safe.Updated: We have now released a new safe version of LiteLLM (v1.83.0) by our new CI/CD v2 pipeline which added isolated environments, stronger security gates, and safer release separation for LiteLLM. We have also verified the codebase is safe and no malicious code was pushed to main.
LiteLLM AI Gateway is investigating a suspected supply chain attack involving unauthorized PyPI package publishes. Current evidence suggests a maintainer's PyPI account may have been compromised and used to distribute malicious code.
At this time, we believe this incident may be linked to the broader Trivy security compromise, in which stolen credentials were reportedly used to gain unauthorized access to the LiteLLM publishing pipeline.
This investigation is ongoing. Details below may change as we confirm additional findings.
You may be affected if any of the following are true:
You installed or upgraded LiteLLM via pip on March 24, 2026, between 10:39 UTC and 16:00 UTC
You ran pip install litellm without pinning a version and received v1.82.7 or v1.82.8
You built a Docker image during this window that included pip install litellm without a pinned version
A dependency in your project pulled in LiteLLM as a transitive, unpinned dependency
(for example through AI agent frameworks, MCP servers, or LLM orchestration tools)
You are not affected if any of the following are true:
LiteLLM AI Gateway/Proxy users: Customers running the official LiteLLM Proxy Docker image were not impacted. That deployment path pins dependencies in requirements.txt and does not rely on the compromised PyPI packages.
You are using LiteLLM Cloud
You are using the official LiteLLM AI Gateway Docker image: ghcr.io/berriai/litellm
You are on v1.82.6 or earlier and did not upgrade during the affected window
You installed LiteLLM from source via the GitHub repository, which was not compromised
Go to the proxy base url, and check the version of the installed LiteLLM.
Scans all repositories in a GitHub organization for workflow jobs that installed the compromised versions.
Requirements: Python 3 and requests (pip install requests).
Setup:
export GITHUB_TOKEN="your-github-pat"
Run:
python find_litellm_github.py
Set the ORG variable in the script to your GitHub organization name.
Both scripts default to scanning jobs from today. Adjust the WINDOW_START and WINDOW_END constants to cover March 24, 2026 (the incident date) if running on a different day.
View full script (find_litellm_github.py)
#!/usr/bin/env python3 """ Scan all GitHub Actions jobs in a GitHub org that ran between 0800-1244 UTC today and identify any that installed litellm 1.82.7 or 1.82.8. Adjust WINDOW_START / WINDOW_END to cover March 24, 2026 if running later. """ import io import os import re import sys import zipfile from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import requests GITHUB_URL ="https://api.github.com" ORG ="your-org"# <-- set to your GitHub organization TOKEN = os.environ.get("GITHUB_TOKEN","") TODAY = datetime.now(timezone.utc).date() WINDOW_START = datetime(TODAY.year, TODAY.month, TODAY.day,8,0,0, tzinfo=timezone.utc) WINDOW_END = datetime(TODAY.year, TODAY.month, TODAY.day,12,44,0, tzinfo=timezone.utc) TARGET_VERSIONS ={"1.82.7","1.82.8"} VERSION_PATTERN = re.compile(r"litellm[=\-](\d+\.\d+\.\d+)", re.IGNORECASE) SESSION = requests.Session() SESSION.headers.update({ "Authorization":f"Bearer {TOKEN}", "Accept":"application/vnd.github+json", "X-GitHub-Api-Version":"2022-11-28", }) defget_paginated(url, params=None): params =dict(params or{}) params.setdefault("per_page",100) page =1 whileTrue: params["page"]= page resp = SESSION.get(url, params=params, timeout=30) if resp.status_code ==404: return resp.raise_for_status() data = resp.json() ifisinstance(data,dict): items =next((v for v in data.values()ifisinstance(v,list)),[]) else: items = data ifnot items: break yieldfrom items iflen(items)< params["per_page"]: break page +=1 defparse_ts(ts_str): ifnot ts_str: returnNone return datetime.fromisoformat(ts_str.replace("Z","+00:00")) defget_repos(): repos =[] for r in get_paginated(f"{GITHUB_URL}/orgs/{ORG}/repos",{"type":"all"}): repos.append({"id": r["id"],"name": r["name"],"full_name": r["full_name"]}) return repos defget_runs_in_window(repo_full_name): created_filter =( f"{WINDOW_START.strftime('%Y-%m-%dT%H:%M:%SZ')}" f"..{WINDOW_END.strftime('%Y-%m-%dT%H:%M:%SZ')}" ) url =f"{GITHUB_URL}/repos/{repo_full_name}/actions/runs" runs =[] for run in get_paginated(url,{"created": created_filter,"per_page":100}): ts = parse_ts(run.get("run_started_at")or run.get("created_at")) if ts and WINDOW_START <= ts <= WINDOW_END: runs.append(run) return runs defget_jobs_for_run(repo_full_name, run_id): url =f"{GITHUB_URL}/repos/{repo_full_name}/actions/runs/{run_id}/jobs" jobs =[] for job in get_paginated(url,{"filter":"all"}): ts = parse_ts(job.get("started_at")) if ts and WINDOW_START <= ts <= WINDOW_END: jobs.append(job) return jobs deffetch_job_log(repo_full_name, job_id): url =f"{GITHUB_URL}/repos/{repo_full_name}/actions/jobs/{job_id}/logs" resp = SESSION.get(url, timeout=60, allow_redirects=True) if resp.status_code in(403,404,410): return"" resp.raise_for_status() content_type = resp.headers.get("Content-Type","") if"zip"in content_type or resp.content[:2]==b"PK": try: with zipfile.ZipFile(io.BytesIO(resp.content))as zf: parts =[] for name insorted(zf.namelist()): with zf.open(name)as f: parts.append(f.read().decode("utf-8", errors="replace")) return"\n".join(parts) except zipfile.BadZipFile: pass return resp.text defcheck_job(repo_full_name, job): job_id = job["id"] job_name = job["name"] run_id = job["run_id"] started = job.get("started_at","") log_text = fetch_job_log(repo_full_name, job_id) ifnot log_text: returnNone found_versions =set() context_lines =[] for line in log_text.splitlines(): m = VERSION_PATTERN.search(line) if m: ver = m.group(1) if ver in TARGET_VERSIONS: found_versions.add(ver) context_lines.append(line.strip()) ifnot found_versions: returnNone return{ "repo": repo_full_name, "run_id": run_id, "job_id": job_id, "job_name": job_name, "started_at": started, "versions":sorted(found_versions), "context": context_lines[:10], "job_url": job.get("html_url",f"https://github.com/{repo_full_name}/actions/runs/{run_id}"), } defmain(): ifnot TOKEN: print("ERROR: Set GITHUB_TOKEN environment variable.",file=sys.stderr) sys.exit(1) print(f"Time window : {WINDOW_START.isoformat()} -> {WINDOW_END.isoformat()}") print(f"Hunting for : litellm {', '.join(sorted(TARGET_VERSIONS))}") print() print(f"Fetching repositories for org '{ORG}'...") repos = get_repos() print(f" Found {len(repos)} repositories") print() jobs_to_check =[] print("Scanning workflow runs for time window...") for repo in repos: full_name = repo["full_name"] try: runs = get_runs_in_window(full_name) except requests.HTTPError as e: print(f" WARN: {full_name} - {e}",file=sys.stderr) continue ifnot runs: continue print(f" {full_name}: {len(runs)} run(s) in window") for run in runs: try: jobs = get_jobs_for_run(full_name, run["id"]) except requests.HTTPError as e: print(f" WARN: run {run['id']} - {e}",file=sys.stderr) continue for job in jobs: jobs_to_check.append((full_name, job)) total =len(jobs_to_check) print(f"\nFetching logs for {total} job(s)...") print() hits =[] with ThreadPoolExecutor(max_workers=8)as pool: futures ={ pool.submit(check_job, full_name, job):(full_name, job["id"]) for full_name, job in jobs_to_check } done =0 for future in as_completed(futures): done +=1 full_name, jid = futures[future] try: result = future.result() except Exception as e: print(f" ERROR {full_name} job {jid}: {e}",file=sys.stderr) continue if result: hits.append(result) print( f" [{done}/{total}] {full_name} job {jid}"+ (f" *** HIT: litellm {result['versions']} ***"if result else""), flush=True, ) print() print("="*72) print(f"RESULTS: {len(hits)} job(s) installed litellm {' or '.join(sorted(TARGET_VERSIONS))}") print("="*72) ifnot hits: print("No matches found.") return for h insorted(hits, key=lambda x: x["started_at"]): print() print(f" Repo : {h['repo']}") print(f" Job : {h['job_name']} (#{h['job_id']})") print(f" Run ID : {h['run_id']}") print(f" Started : {h['started_at']}") print(f" Versions : litellm {', '.join(h['versions'])}") print(f" URL : {h['job_url']}") print(f" Log lines :") for line in h["context"]: print(f" {line}") if __name__ =="__main__": main()
Scans all projects in a GitLab group (including subgroups) for CI/CD jobs that installed the compromised versions.
Requirements: Python 3 and requests (pip install requests).
Setup:
export GITLAB_TOKEN="your-gitlab-pat"
Run:
python find_litellm_jobs.py
Set the GROUP_NAME variable in the script to your GitLab group name.
Both scripts default to scanning jobs from today. Adjust the WINDOW_START and WINDOW_END constants to cover March 24, 2026 (the incident date) if running on a different day.
View full script (find_litellm_jobs.py)
#!/usr/bin/env python3 """ Scan all GitLab CI/CD jobs in a GitLab group that ran between 0800-1244 UTC today and identify any that installed litellm 1.82.7 or 1.82.8. Adjust WINDOW_START / WINDOW_END to cover March 24, 2026 if running later. """ import os import re import sys from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import requests GITLAB_URL ="https://gitlab.com" GROUP_NAME ="YourGroup"# <-- set to your GitLab group name TOKEN = os.environ.get("GITLAB_TOKEN","") TODAY = datetime.now(timezone.utc).date() WINDOW_START = datetime(TODAY.year, TODAY.month, TODAY.day,8,0,0, tzinfo=timezone.utc) WINDOW_END = datetime(TODAY.year, TODAY.month, TODAY.day,12,44,0, tzinfo=timezone.utc) TARGET_VERSIONS ={"1.82.7","1.82.8"} VERSION_PATTERN = re.compile(r"litellm[=\-](\d+\.\d+\.\d+)", re.IGNORECASE) HEADERS ={"PRIVATE-TOKEN": TOKEN} SESSION = requests.Session() SESSION.headers.update(HEADERS) defget_paginated(url, params=None): params =dict(params or{}) params.setdefault("per_page",100) page =1 whileTrue: params["page"]= page resp = SESSION.get(url, params=params, timeout=30) resp.raise_for_status() data = resp.json() ifnot data: break yieldfrom data iflen(data)< params["per_page"]: break page +=1 defget_group_id(group_name): resp = SESSION.get(f"{GITLAB_URL}/api/v4/groups/{group_name}", timeout=30) resp.raise_for_status() return resp.json()["id"] defget_all_projects(group_id): projects =[] for p in get_paginated( f"{GITLAB_URL}/api/v4/groups/{group_id}/projects", {"include_subgroups":"true","archived":"false"}, ): projects.append({"id": p["id"],"name": p["path_with_namespace"]}) return projects defparse_ts(ts_str): ifnot ts_str: returnNone ts_str = ts_str.replace("Z","+00:00") return datetime.fromisoformat(ts_str) defjobs_in_window(project_id): matching =[] url =f"{GITLAB_URL}/api/v4/projects/{project_id}/jobs" params ={"per_page":100,"scope[]":["success","failed","canceled","running"]} page =1 whileTrue: params["page"]= page resp = SESSION.get(url, params=params, timeout=30) if resp.status_code ==403: return matching resp.raise_for_status() jobs = resp.json() ifnot jobs: break stop_early =False for job in jobs: ts = parse_ts(job.get("started_at")or job.get("created_at")) if ts isNone: continue if ts > WINDOW_END: continue if ts < WINDOW_START: stop_early =True continue matching.append(job) if stop_early orlen(jobs)<100: break page +=1 return matching deffetch_trace(project_id, job_id): url =f"{GITLAB_URL}/api/v4/projects/{project_id}/jobs/{job_id}/trace" resp = SESSION.get(url, timeout=60) if resp.status_code in(403,404): return"" resp.raise_for_status() return resp.text defcheck_job(project_name, project_id, job): job_id = job["id"] job_name = job["name"] ref = job.get("ref","") started = job.get("started_at", job.get("created_at","")) trace = fetch_trace(project_id, job_id) ifnot trace: returnNone found_versions =set() formatchin VERSION_PATTERN.finditer(trace): ver =match.group(1) if ver in TARGET_VERSIONS: found_versions.add(ver) ifnot found_versions: returnNone context_lines =[] for line in trace.splitlines(): if VERSION_PATTERN.search(line): ver_match = VERSION_PATTERN.search(line) if ver_match and ver_match.group(1)in TARGET_VERSIONS: context_lines.append(line.strip()) return{ "project": project_name, "project_id": project_id, "job_id": job_id, "job_name": job_name, "ref": ref, "started_at": started, "versions":sorted(found_versions), "context": context_lines[:10], "job_url":f"{GITLAB_URL}/{project_name}/-/jobs/{job_id}", } defmain(): ifnot TOKEN: print("ERROR: Set GITLAB_TOKEN environment variable.",file=sys.stderr) sys.exit(1) print(f"Time window : {WINDOW_START.isoformat()} -> {WINDOW_END.isoformat()}") print(f"Hunting for : litellm {', '.join(sorted(TARGET_VERSIONS))}") print() print(f"Resolving group '{GROUP_NAME}'...") group_id = get_group_id(GROUP_NAME) print("Fetching projects...") projects = get_all_projects(group_id) print(f" Found {len(projects)} projects") print() all_jobs_to_check =[] print("Scanning job listings for time window...") for proj in projects: try: jobs = jobs_in_window(proj["id"]) except requests.HTTPError as e: print(f" WARN: {proj['name']} - {e}",file=sys.stderr) continue if jobs: print(f" {proj['name']}: {len(jobs)} job(s) in window") for j in jobs: all_jobs_to_check.append((proj["name"], proj["id"], j)) total =len(all_jobs_to_check) print(f"\nFetching traces for {total} job(s)...") print() hits =[] with ThreadPoolExecutor(max_workers=10)as pool: futures ={ pool.submit(check_job, pname, pid, job):(pname, job["id"]) for pname, pid, job in all_jobs_to_check } done =0 for future in as_completed(futures): done +=1 pname, jid = futures[future] try: result = future.result() except Exception as e: print(f" ERROR checking {pname} job {jid}: {e}",file=sys.stderr) continue if result: hits.append(result) print(f" [{done}/{total}] checked {pname} job {jid}"+ (f" *** HIT: litellm {result['versions']} ***"if result else""), flush=True) print() print("="*72) print(f"RESULTS: {len(hits)} job(s) installed litellm {' or '.join(sorted(TARGET_VERSIONS))}") print("="*72) ifnot hits: print("No matches found.") return for h insorted(hits, key=lambda x: x["started_at"]): print() print(f" Project : {h['project']}") print(f" Job : {h['job_name']} (#{h['job_id']})") print(f" Branch/tag: {h['ref']}") print(f" Started : {h['started_at']}") print(f" Versions : litellm {', '.join(h['versions'])}") print(f" URL : {h['job_url']}") print(f" Log lines :") for line in h["context"]: print(f" {line}") if __name__ =="__main__": main()
CI/CD scripts contributed by the community (original gist). Review before running.
Starting from v1.83.0-nightly, all LiteLLM Docker images published to GHCR are signed with cosign. Every release is signed with the same key introduced in commit 0112e53.
Verify using the pinned commit hash (recommended):
A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:
Replace <release-tag> with the version you are deploying (e.g. v1.83.0-stable).
Expected output:
The following checks were performed on each of these signatures: - The cosign claims were validated - The signatures were verified against the specified public key
When a custom guardrail returned the full LiteLLM request/data dictionary, the guardrail response logged by LiteLLM could include secret_fields.raw_headers, including plaintext Authorization headers containing API keys or other credentials.
This information could then propagate to logging and observability surfaces that consume guardrail metadata, including:
Spend logs in the LiteLLM UI: visible to admins with access to spend-log data
OpenTelemetry traces: visible to anyone with access to the relevant telemetry backend
LLM calls, proxy routing, and provider execution were not blocked by this bug. The impact was exposure of sensitive request headers in observability and logging paths.
A change to improve Redis connection pool cleanup introduced a regression that closed httpx clients that were still actively being used by the proxy. The LLMClientCache (an in-memory TTL cache) stores both Redis clients and httpx clients under the same eviction policy. When a cache entry expired or was evicted, the new cleanup code called aclose()/close() on the evicted value which worked correctly for Redis clients, but destroyed httpx clients that other parts of the system still held references to and were actively using for LLM API calls.
Impact: Any proxy instance that hit the cache TTL (default 10 minutes) or capacity limit (200 entries) would have its httpx clients closed out from under it, causing requests to LLM providers to fail with connection errors.
Date: Feb 24, 2026 Duration: Ongoing (until fix deployed) Severity: High (for users load balancing Responses API across different API keys) Status: Resolved
When load balancing OpenAI's Responses API across deployments with different API keys (e.g., different Azure regions or OpenAI organizations), follow-up requests containing encrypted content items (like rs_... reasoning items) would fail with:
{ "error":{ "message":"The encrypted content for item rs_0d09d6e56879e76500699d6feee41c8197bd268aae76141f87 could not be verified. Reason: Encrypted content organization_id did not match the target organization.", "type":"invalid_request_error", "code":"invalid_encrypted_content" } }
Encrypted content items are cryptographically tied to the API key's organization that created them. When the router load balanced a follow-up request to a deployment with a different API key, decryption failed.
Responses API calls with encrypted content: Complete failure when routed to wrong deployment
When a new Anthropic model (e.g. claude-sonnet-4-6) was added to the LiteLLM model cost map and a cost map reload was triggered, requests to the new model were rejected with:
key not allowed to access model. This key can only access models=['anthropic/*']. Tried to access claude-sonnet-4-6.
The reload updated litellm.model_cost correctly but never re-ran add_known_models(), so litellm.anthropic_models (the in-memory set used by the wildcard resolver) remained stale. The new model was invisible to the anthropic/* wildcard even though the cost map knew about it.
LLM calls: All requests to newly-added Anthropic models were blocked with a 401.
Existing models: Unaffected — only models missing from the stale provider set were impacted.
Other providers: Same bug class existed for any provider wildcard (e.g. openai/*, gemini/*).
A PR (#19467) accidentally removed the root_path=server_root_path parameter from the FastAPI app initialization in proxy_server.py. This caused the proxy to ignore the SERVER_ROOT_PATH environment variable when serving the UI. Users who deploy LiteLLM behind a reverse proxy with a path prefix (e.g., /api/v1 or /llmproxy) found that all UI pages returned 404 Not Found.
LLM API calls: No impact. API routing was unaffected.
UI pages: All UI pages returned 404 for deployments using SERVER_ROOT_PATH.
Swagger/OpenAPI docs: Broken when accessed through the configured root path.
A commit (dbcae4a) intended to fix OpenAI SDK behavior broke vLLM embeddings by explicitly passing encoding_format=None in API requests. vLLM rejects this with error: "unknown variant \`, expected float or base64"`.
vLLM embedding calls: Complete failure - all requests rejected
Other providers: No impact - OpenAI and other providers functioned normally
Other vLLM functionality: No impact - only embeddings were affected
Claude Code began sending unsupported Anthropic beta headers to non-Anthropic providers (Bedrock, Azure AI, Vertex AI), causing invalid beta flag errors. LiteLLM was forwarding all beta headers without provider-specific validation. Users experienced request failures when routing Claude Code requests through LiteLLM to these providers.
LLM calls to Anthropic: No impact.
LLM calls to Bedrock/Azure/Vertex: Failed with invalid beta flag errors when unsupported headers were present.
A malformed JSON entry in model_prices_and_context_window.json was merged to main (562f0a0). This caused LiteLLM to silently fall back to a stale local copy of the model cost map. Users on older package versions lost cost tracking for newer models only (e.g. azure/gpt-5.2). No LLM calls were blocked.
LLM calls and proxy routing: No impact.
Cost tracking: Impacted for newer models not present in the local backup. Older models were unaffected. The incident lasted ~20 minutes until the commit was reverted.