In the week of 19–23 October 2026, three separate incidents put developer secrets in attackers’ hands. OpenCode said a legacy part of its hosted Console exposed linked provider keys for 1.4% of Console users. Railway disclosed that an attacker reached the systems that store customer environment variables and decrypted a subset of them. And litellm 1.107.0, published to PyPI and Docker from a hijacked release pipeline, shipped a credential stealer that runs on every Python start.
This is the runbook I would follow today: find out whether you were exposed, rotate in blast-radius order without taking production down, deal with any machine that ran the bad LiteLLM build, and make the next leak cheaper. Every command is checked against vendor docs or the incident reports; where a claim rests on a single source, I say so.
1. The Problem: One Week, Three Leaks of Developer Secrets
The common thread is the target, not the technique. Railway says its attacker ‘was attempting to obtain AI provider API keys to perform distillation attacks’, and OpenCode also blamed a distillation attack: stolen OpenAI, Anthropic and Google keys used to pull model output at scale on someone else’s bill. The LiteLLM stealer takes those keys too, plus everything else on the host. No source links the LiteLLM attacker to the other two.
Timeline, October 2026 (UTC; vendor reports)
Are you in scope? The two-minute version
Railway: if your account predates 21 October and you keep any secret in Railway variables, Railway asks you to revoke and reapply it, email or no email. OpenCode: only Console users with external providers linked; OpenCode says no email means not affected. LiteLLM: only if something installed litellm 1.107.0 or pulled one of the four Docker tags inside the windows above, or still runs an environment or image created then. LiteLLM says its private enterprise release was not affected.
Two different kinds of exposure hide in that list. Railway and OpenCode leaked specific stored secrets: rotate those and you are done. A host that ran litellm 1.107.0 leaked everything it could read and may still be running a backdoor. The runbook treats them differently.
2. Four Concepts Before You Touch a Key
Short on purpose. These four ideas decide the order and the method of everything that follows.
Blast radius decides the order
What can this secret do?
Rotate first whatever lets an attacker create more access or reach your data: cloud IAM keys can mint new users and keys, database URLs open your data. AI keys burn budget; GitHub tokens reach your code and releases. If you publish packages or images, move GitHub tokens up: that is how LiteLLM’s release was hijacked.
Leaked secret vs compromised host
Railway and OpenCode vs LiteLLM
A leaked secret is fixed by rotating it. A compromised host is not: litellm 1.107.0 ran a stealer plus an hourly remote-code loader, so new secrets placed on the same machine can be stolen again. Rebuild first, then rotate from somewhere clean.
Overlap rotation
New, deploy, verify, revoke
Railway’s zero-downtime guide: issue the new credential, move services onto it, verify, and only then revoke the old one. Containers read variables only at startup, so nothing changes until you redeploy. The overlap should last minutes, not days.
Python startup hooks
.pth files and sitecustomize.py
Python runs .pth files and sitecustomize.py from site-packages every time the interpreter starts. That is why the payload fires without importing litellm, and why pip show, pip install or a downgrade on an infected environment runs it again.
3. Step 1 — Triage: Were You Exposed?
Start with inventory, not rotation. You can’t rotate what you haven’t listed, and you want evidence preserved before you change anything.
Railway: list every secret you keep there
Railway CLI 5.x (5.71.0 as I write; railway variables still works as an alias of railway variable). Print names, never values, for each service and environment. The CLI does not return sealed variables, so check those in the dashboard. Export GitHub and registry audit logs first: Railway’s own GitHub guide says to do it before you revoke anything.
# Names only, per service and environment: values never hit your terminal railway login railway link # pick project, environment and service railway variable list --service api --environment production --kv | cut -d= -f1 # Sealed variables are not returned by the CLI: list them from the dashboard # Before revoking anything, export: # GitHub org: Settings > Logs > Audit log (JSON or CSV) # Registries (Docker Hub, GHCR): access-token and push history
LiteLLM: find 1.107.0 without starting Python
Run it on laptops, servers, self-hosted CI runners and images. Blocks 1–3 only read files; block 4 starts Python, so keep it for hosts that already came back clean. File names, paths, the environment marker and the wheel hashes come from the GitHub issue, LiteLLM’s incident post, Aikido and Datadog.
# 1. Lockfiles and requirements
grep -rn 'litellm==1.107.0' --include='*.txt' --include='*.in' --include='*.toml' .
grep -rn -A2 'name = "litellm"' --include='uv.lock' --include='poetry.lock' . | grep '1\.107\.0'
# SHA-256 prefixes of the seven malicious wheels
grep -rnE '7598727f209d|0aaf7ba346d4|462d0c9f67ca|668169d35b87|1bbce8caa94f|c2e32d0ce254|12d4ccc5c6f8' .
# 2. Installed environments, filesystem only
find / -type d -name 'litellm-1.107.0.dist-info' 2>/dev/null
find / -path '*-packages*' \( -name cook.pth -o -name cook.py -o -name sitecustomize.py \) 2>/dev/null
ls -la /tmp/.pc_once_* /tmp/remote_code_loader.lock 2>/dev/null
sudo grep -l PRIVCOLLECT_ACTIVE /proc/*/environ 2>/dev/null # Linux: payload running now
# PowerShell: Get-ChildItem C:\ -Recurse -Filter cook.pth -ErrorAction SilentlyContinue
# 3. Images: the malicious index digest, then look inside without running
docker images --digests | grep -iE 'litellm|1bbcb1443bc9'
cid=$(docker create <IMAGE_ID>) # local image ID, not the tag: tags were repointed
docker export "$cid" | tar -tf - | grep -E 'cook\.pth|litellm-1\.107\.0\.dist-info'
docker rm "$cid"
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[*].imageID}{"\n"}{end}' | grep -i litellm
# 4. Only on hosts that passed 1-3 (these start Python)
pip show litellm | head -n 2
uv pip list | grep -i litellmHosted CI runners, caches and mirrors count too
A GitHub-hosted runner is thrown away after the job, but every secret that job could read left with the stealer. Search CI logs for litellm-1.107.0 or litellm==1.107.0 in runs between 11:39 and 14:20 UTC on 21 October. Then check pip and uv caches, internal PyPI mirrors and registry pull-through caches: they can keep serving a version PyPI already removed.
4. Step 2 — Rotate in Blast-Radius Order, Without Downtime
Do this from a machine you trust. If your laptop ran litellm 1.107.0, it is not that machine. For each secret below: create the new one, put it where it lives, redeploy, verify, then revoke the old one.
1. Cloud root and IAM keys
AWS access keys, GCP service-account keys, Azure secrets. Deactivate before you delete, so you can roll back for a few minutes. If a host with an instance or task role ran 1.107.0, revoke the role’s active sessions in IAM as well; LiteLLM suggests checking CloudTrail for GetCallerIdentity calls from that role around install time.
2. Database URLs
For a suspected leak on Railway Postgres, MySQL, Redis or MongoDB, use Railway’s Regenerate flow and redeploy every service that references the variables. Railway warns that editing POSTGRES_PASSWORD by hand or running ALTER USER yourself leaves the variables and the database out of sync. While you are there, turn off public networking on databases that don’t need it.
3. AI provider keys
Create the replacement, deploy it, verify, delete the old key, then review usage per key from 14 October onward. Railway notified providers about every key it stored and asked them to revoke, so a burst of 401s on 21–22 October may be the provider, not your code. Don’t forget keys stored inside a LiteLLM proxy database.
4. GitHub tokens
Classic and fine-grained PATs, OAuth and GitHub App authorizations, deploy keys, Actions secrets, and the GitHub CLI’s own OAuth token. gh auth logout only deletes the token locally; GitHub’s manual says to revoke the GitHub CLI app instead, which kills its tokens on every device. Railway also reset its GitHub App installations, so reconnect repos and recreate RAILWAY_TOKEN and RAILWAY_API_TOKEN in CI.
5. SSH keys
Generate a new key on a clean machine, register it, then remove the old public key from GitHub and from every server’s authorized_keys. The stealer reads ~/.ssh, so a key without a passphrase on an exposed host is simply gone.
6. Webhook signing secrets
Stripe, GitHub, Slack and similar. Use the provider’s roll feature when it has one; otherwise let your verifier accept the old and the new secret for a short window, then drop the old one. Railway’s Stripe guide says to choose immediate expiry when the leak is confirmed.
Railway: dual-key overlap from the CLI
This is Railway’s documented zero-downtime pattern. Give the service a healthcheck that actually uses the credential, so a bad value fails the new deploy while the old one keeps serving. Piping the key through stdin keeps it out of your shell history.
# 1. Create the new key at the provider. The old one still works.
# 2. Stage it without a restart (railway link chose the environment)
read -rs NEW_KEY
printf '%s' "$NEW_KEY" | railway variable set OPENAI_API_KEY --stdin \
--service api --skip-deploys
# 3. Repeat for every variable that changes, then ship them in one deploy
railway redeploy --service api
# 4. Check that the new deployment is healthy and calls succeed
railway logs --service api --since 10m
# 5. Only now revoke the old key at the provider
# Several services? Keep the key once as a shared variable and reference it:
# OPENAI_API_KEY=${{ shared.OPENAI_API_KEY }}Cloud, GitHub and SSH from the command line
Same order as the list above. Placeholders in capitals are yours to fill in.
# AWS: new key, deactivate the old one, delete it after a quiet day aws iam create-access-key --user-name APP_USER aws iam update-access-key --user-name APP_USER --access-key-id OLD_KEY_ID --status Inactive aws iam delete-access-key --user-name APP_USER --access-key-id OLD_KEY_ID aws cloudtrail lookup-events --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=OLD_KEY_ID \ --start-time 2026-10-14T00:00:00Z # per region: repeat with --region # GCP service-account keys gcloud iam service-accounts keys create new-key.json --iam-account=SA_EMAIL gcloud iam service-accounts keys delete OLD_KEY_ID --iam-account=SA_EMAIL # GitHub CLI: logout is local only gh auth status # accounts, hosts and token scopes here gh auth logout --hostname github.com --user YOUR_USER # then github.com/settings/applications > GitHub CLI > Revoke gh auth login # fresh token, from a clean machine # SSH: new key, register it, remove the old one everywhere ssh-keygen -t ed25519 -C "laptop-2026-10" -f ~/.ssh/id_ed25519_2026_10 gh ssh-key add ~/.ssh/id_ed25519_2026_10.pub --title "laptop 2026-10" gh ssh-key list gh ssh-key delete OLD_SSH_KEY_ID
When to skip the overlap
Overlap protects uptime, not secrecy. Railway’s own Postgres guide says that if you suspect a leak, regenerate first: cutting off the old credential matters more than a short outage. My rule: if usage dashboards or audit logs show the key being abused, revoke now and take the downtime; if the rotation is precautionary, keep the overlap to the minutes a redeploy takes.
5. Step 3 — If a Machine Ran litellm 1.107.0
Then it is a host incident, not a dependency bump. LiteLLM says to assume exfiltration if 1.107.0 ran on a host with outbound internet access, and that missing marker files don’t prove the host is clean.
🧯 Contain, rebuild, rotate
- 1. Isolate the host and keep evidence: snapshot the disk or volume and export the logs you will want later, before deleting anything.
- 2. Don’t start Python there and don’t ‘fix’ it with pip install litellm==1.106.2. Every interpreter start re-runs the payload, and Datadog notes that deleting the litellm package directory leaves cook.pth, cook.py and sitecustomize.py behind.
- 3. Rebuild from a clean base image or a fresh VM, venv and runner. LiteLLM also documents an in-place cleanup (delete the three files and their .pyc, kill the daemon holding /tmp/remote_code_loader.lock); I would use it only to stop the bleeding while the rebuild runs.
- 4. Rotate everything the host could read, from a clean machine: the instance or task role, every provider key plus the master and virtual keys in the LiteLLM DB, the DATABASE_URL password and any secret in the environment of processes on that host.
- 5. Pin the rebuild. The advisories name 1.106.2 as the clean version; since then LiteLLM has shipped 1.106.4 and backports such as 1.105.5 (25 October), signed with a new cosign key.
Egress and host indicators to search for
The exfiltration endpoint www.litellm.dev/healthcheck is named in the GitHub issue and by Aikido and Datadog. It is not LiteLLM’s domain (that is litellm.ai). The loader polled a dead-drop file on GitHub that has since been taken down; Datadog says it only held test values when observed. Search DNS, proxy and firewall logs from 21 October onward.
# DNS, proxy and firewall logs from 21 Oct onward
grep -rniE 'litellm\.dev|letters9813/pythonclient-test' /var/log 2>/dev/null
# Services on Railway: DNS query logs from the CLI
railway logs --service api --dns --since 1w | grep -i 'litellm\.dev'
# Outbound POSTs carried this header (Aikido, Datadog)
# X-Token: XWiA8gZNa7NcX2XRoAEb
# A sitecustomize.py can be legitimate: compare the hash before you panic
find / -path '*-packages*' -name sitecustomize.py -exec sha256sum {} + 2>/dev/null \
| grep 34bef04fe88ac9d06. Step 4 — Make the Next Leak Cheaper
None of this would have prevented Railway’s breach; that one sits with the platform. It does shrink what the next leak costs you, and two of these would have kept litellm 1.107.0 out of your builds entirely.
Pin with hashes, and know what they don’t do
Hashes guarantee that the file you install is the file you locked. They don’t stop you from locking a malicious new release: had you re-locked during those two hours, the hash would have been the stealer’s. That is what the cooldown is for.
# requirements.in: litellm[proxy]==1.106.4 uv pip compile requirements.in --generate-hashes -o requirements.txt pip install --require-hashes -r requirements.txt # uv projects: uv.lock already stores hashes; fail CI if it drifts uv sync --locked
Dependency cooldowns: a two-hour attack meets a seven-day wait
litellm 1.107.0 was on PyPI for under two hours; the March releases were up for between 40 minutes and three hours, depending on the source. A minimum release age of one day would have skipped both. Check your tool version first: uv 0.9.17+ for relative durations, pip 26.1+ for day durations (26.0 takes timestamps), pnpm 10.16+, and npm with min-release-age (11.10+, per release coverage). Keep an escape hatch for urgent security fixes.
# uv >= 0.9.17: pyproject.toml
[tool.uv]
exclude-newer = "7 days"
# pip >= 26.1: ISO 8601, days only
pip install --uploaded-prior-to P7D -r requirements.txt
# pnpm >= 10.16 (pnpm 11 defaults to 1440): pnpm-workspace.yaml, minutes
minimumReleaseAge: 10080
# npm: .npmrc, days
min-release-age=7
# Renovate: renovate.json
{ "minimumReleaseAge": "7 days" }
# Dependabot: .github/dependabot.yml, inside each updates entry
cooldown:
default-days: 7Docker: digest, not tag
Four tags, latest and main-stable included, pointed at the malicious image for up to two and a half hours. A digest can’t be repointed. Verify signatures with LiteLLM’s new key (commit e82a3c6, in use since 24 October); the old 0112e53 key fails on new releases. One caveat from Datadog: the malicious artifacts carried build provenance naming the lookalike fork as source, so if you check provenance, check which repository it names.
# Resolve the tag once, verify it, then pin the digest docker buildx imagetools inspect ghcr.io/berriai/litellm:v1.106.4 # prints Digest: sha256:... cosign verify \ --key https://raw.githubusercontent.com/BerriAI/litellm/e82a3c646908ba0a6ee0beddc4310d7a6cac4065/cosign.pub \ ghcr.io/berriai/litellm:v1.106.4 # Dockerfile or compose: tag for humans, digest for the machine FROM ghcr.io/berriai/litellm:v1.106.4@sha256:<DIGEST_YOU_VERIFIED>
📆 This month: fewer long-lived secrets
- Trusted Publishing for what you publish: PyPI swaps an OIDC token from CI for an upload token valid for 15 minutes, so there is no long-lived token to steal. It does not protect a release workflow an attacker can trigger: protect tags and branches with no admin bypass, and require a reviewer on the release environment.
- Treat gh tokens like production keys: LiteLLM’s root cause started with a master key committed to a public repo in May and never revoked; on 10 October an internal API returned an org owner’s GitHub CLI OAuth token for it. Keep owner accounts out of daily CLI use, review scopes with gh auth status and drop the ones you don’t use with gh auth refresh --remove-scopes.
- Short-lived credentials where they exist: Railway does not support OIDC federation (a staff reply on its community forum in February 2026; a feature request is open), so Railway services still hold static keys. Make them cheap to lose: one key per service, OpenAI project keys with an expiry, OpenRouter keys with a spend limit and expiration. Use OIDC wherever it does exist, such as GitHub Actions to your cloud or to OpenAI.
- Seal what you can: Railway sealed variables are never shown in the UI or returned by the API or CLI. That limits who can read them later; I would not assume it changes anything when the storage layer itself is breached.
Still running LiteLLM? My gateway guide still holds
On 14 October I recommended LiteLLM as a self-hosted gateway, pinned to v1.105.1 by exact tag. That version was never affected and the advice stands, with two updates: pin by digest instead of tag, and verify with the new cosign key, because the command in that post uses the old 0112e53 key, which fails from 1.105.4 on. Upgrading means 1.105.5 on the 1.105 line or 1.106.4 on 1.106.
Read the LiteLLM gateway guide →7. Common Mistakes I Would Avoid
Each of these shows up in the vendor guides or the incident threads.
❌ Downgrading litellm in place on an infected host.
✅ Rebuild. Any Python start re-runs the payload, and the three files outlive the package directory.
❌ Rotating from the laptop that ran the stealer.
✅ Rotate from a clean machine, or the new secrets leak the same way.
❌ Running gh auth logout and calling the token revoked.
✅ Revoke the GitHub CLI app on github.com; logout is local only.
❌ Revoking the old key before the new deploy is healthy.
✅ New, deploy, verify, revoke, unless you see active abuse.
❌ Rotating the Railway copy and forgetting the others.
✅ Find every copy: .env files, CI secrets, other platforms, the LiteLLM DB.
❌ Rotating LITELLM_SALT_KEY along with the master key.
✅ LiteLLM says not to rotate the salt key: change LITELLM_MASTER_KEY, restart, rotate provider keys at the provider.
8. Confirmed and Not Yet (Checked 26 October 2026)
‘Confirmed’ means the vendor’s own record plus at least one independent report. Everything else is worded ‘according to’.
LiteLLM artifacts and windows
PyPI 1.107.0 from 11:39:51 to 13:23 UTC; Docker Hub v1.107.0, latest, main-latest and main-stable from 11:46:56 to 14:20 UTC. LiteLLM’s incident post, GitHub issue #47826 and Datadog agree. Aikido gives 12:47 as the end of the PyPI window; I use LiteLLM’s later time.
Railway scope
At most 1.85% of workspaces had environment variables potentially compromised and a subset was decrypted, per Railway’s preliminary report; TechCrunch reports the same disclosure. Railway can’t say which variables in a project were exposed, because of how the keys were envelope-encrypted.
OpenCode figures
1.4% of Console users with external providers linked, according to OpenCode’s post on X and its email as quoted by BleepingComputer. I found no OpenCode blog post or root cause.
One campaign or three?
Railway and OpenCode both describe key theft for distillation. Nothing ties the LiteLLM attacker to them, and Datadog says no link to LiteLLM’s March incident has been established either.
Railway root cause
Only partly described: a privilege escalation in build infrastructure, then CI credentials. Railway says a follow-up will come as the investigation concludes; it was not out on 26 October.
Golden rule
Inventory before rotation, rebuild before rotation on any host that ran the bad build, and never revoke an old key until the new one is serving traffic, unless someone else is already using it.
Copy-Paste Checklist
Paste it into the incident ticket and tick it off.
[ ] Railway: list variable names per service and environment (sealed ones from the dashboard) [ ] Export GitHub org audit log and registry logs before revoking anything [ ] LiteLLM: grep lockfiles and wheel hashes; find dist-info, cook.pth, sitecustomize.py [ ] LiteLLM: images by digest, k8s imageIDs, CI logs 21 Oct 11:39-14:20 UTC, caches, mirrors [ ] Hit? Isolate, snapshot, rebuild from a clean base (no in-place downgrade) [ ] From a clean machine, rotate in order: cloud/IAM > DB > AI keys > GitHub (incl. gh) > SSH > webhooks [ ] Each one: new > railway variable set --skip-deploys > redeploy > verify > revoke old [ ] Abuse visible in usage or audit logs? Revoke first, accept the downtime [ ] Railway: recreate RAILWAY_TOKEN / RAILWAY_API_TOKEN, reconnect GitHub, redeploy latest commit [ ] Railway: turn off public networking on databases that don't need it [ ] LiteLLM proxy: new master key (not the salt key), regenerate virtual keys [ ] Review usage and cost per key from 14 Oct; GitHub security log; CloudTrail by key [ ] Pin: hashes in lockfiles, images by digest, cosign with key e82a3c6 [ ] Cooldown: uv exclude-newer / pip --uploaded-prior-to / pnpm / npm / Renovate / Dependabot [ ] Spend limits and expiry on every AI key; OIDC wherever the platform supports it
Conclusion
This week’s pattern is clear enough: AI provider keys are now worth stealing at scale, and attackers will take them from wherever they sit, whether that is a PaaS variable store, a hosted console or a developer’s site-packages. You can’t control the platform’s next breach. You can control how long a leaked key stays useful.
If you only do three things today: list every secret you keep on Railway and rotate it with the overlap pattern, prove that no machine or image of yours ran litellm 1.107.0, and add a cooldown to your dependency updates. The rest of the checklist can wait until tomorrow.
The runbook in one screen
Triage
- • Railway variable names
- • Lockfiles, images, CI logs
- • Files and egress IOCs
Rotate
- • Cloud, DB, AI keys first
- • Then GitHub, SSH, webhooks
- • New, deploy, verify, revoke
Harden
- • Hashes and digests
- • Release-age cooldowns
- • Short-lived, scoped keys
Sources
- Railway — Security Disclosure: Wednesday October 21st
- Railway — Preliminary Report: Oct 21st Security Incident
- Railway docs — Rotate credentials without downtime
- Railway docs — CLI: railway variable
- TechCrunch — Railway urges all users to revoke secret keys after cyberattack
- BleepingComputer — OpenCode hack exposes API keys amid AI distillation attacks
- GitHub — BerriAI/litellm issue #47826 (1.107.0 compromised)
- LiteLLM — Security incident, 21 October 2026
- LiteLLM — v1.107.0 attack chain (root-cause analysis)
- Datadog Security Labs — LiteLLM compromised on PyPI and Docker
- Aikido — LiteLLM compromised: infostealer and RAT
- GitHub — LiteLLM v1.106.4 release (new cosign key)
- GitHub CLI manual — gh auth logout
- uv docs — exclude-newer
- pnpm docs — minimumReleaseAge
- Renovate docs — minimum release age
- PyPI docs — Trusted Publishers
- Railway Station — OIDC federation not supported (staff reply)



