Auditing GitHub-to-PyPI Publishing Paths After the LiteLLM Supply-Chain Attack

Auditing GitHub-to-PyPI Publishing Paths After the LiteLLM Supply-Chain Attack

pr0h0••17 min read•hacking
AI Usage (87%)

The part of the LiteLLM supply-chain attack that should bother you is not the leak. It is the duration. Reports published on 2026-10-26 and 2026-10-27 describe a GitHub key tied to the LiteLLM project that stayed exposed for months before anyone revoked it, and they connect that key to a PyPI compromise. This post is the audit I run on my own repos as a result: trace the publishing path from commit to wheel, find where a GitHub credential quietly becomes PyPI publishing authority, and fix the links in the order that actually blocks an attacker. A leaked credential is an ordinary bad day. A leaked credential that survived for months is a process failure — and process failures are the ones you can actually audit for.

My position up front: in almost every Python repo I have poked at, repo write access is publishing access. The PyPI token in secrets.PYPI_API_TOKEN feels like the crown jewel, so teams guard it, rotate it now and then, and feel responsible. But whoever can push a branch can usually edit the workflow that reads that token. Fixing the token while leaving repo write permission broad is fixing the wrong half.

Here is how the trust chain actually connects a commit to a wheel, how to audit your own path, and what I would fix first.

On this page

What the LiteLLM Supply-Chain Reports Actually Establish

The claim: one unrevoked GitHub key, months of exposure, a PyPI compromise

Everything I have is secondary: BankInfoSecurity ("Exposed LiteLLM Key Enabled PyPI Supply Chain Attack"), Crypto Briefing ("LiteLLM traces supply chain attack to a GitHub key nobody revoked"), and a news.lavx.hu item dated 2026-10-27. The claim they share is narrow:

  • The entry point was a GitHub master key associated with the LiteLLM project.
  • It remained valid and exposed for months.
  • It was used to reach the project's PyPI publishing path, resulting in a supply-chain compromise of the package.

That is the whole of what the headlines establish. Everything below is me working through the mechanics this class of incident implies, not a reconstruction of LiteLLM's actual timeline.

What the reports leave unknown (and why I am not inventing versions or CVE IDs)

I have not seen a primary LiteLLM advisory, an incident write-up from the maintainers, or a CVE record. The reports do not give me — and I am not going to invent — the following:

  • The credential type. "Master key" could mean a classic personal access token, a fine-grained PAT, a GitHub App installation token, a deploy key, or an org-level credential. The blast radius differs on every one.
  • The affected version range. I do not know which package versions were touched, whether a new version was published, or whether a file was added to an existing release.
  • A CVE identifier. None appears in the reporting I have.
  • The exact exfiltration path. Whether the key was used to read the PyPI token directly, to modify a workflow, or to trigger an existing publish job is not stated in my sources.

That gap matters for the audit below: it forces you to check both halves of the chain in your own repos instead of guessing which half broke.

Why a GitHub Key Becomes a PyPI Package Compromise

The GitHub Actions publishing trust chain, from commit to wheel

A publish job is a short program holding a huge amount of ambient authority. The chain looks like this:

LinkWhat holds the authorityWhat an attacker gains
Repo writepush / merge rights, or any token with contents: writeedit the workflow, edit the source, move a tag
Workflow executionon: triggers the jobrun arbitrary steps inside CI
Secrets injectionsecrets.*, environment secretsread whatever the job can read
Index uploadPyPI token or OIDC identitypublish artifacts under the project name
Distributionpackage indexes, mirrors, lockfilesexecute code on every consumer's machine

The chain is only as strong as its weakest link, and the weakest link is almost always the first one. A repo-scoped credential is often equivalent to a PyPI credential: its holder does not need to read the token, only to make the job read it for them.

Two publishing paths: long-lived PyPI API token versus OIDC trusted publishing

There are two ways a GitHub Actions job gets permission to upload to PyPI.

Long-lived API token. The job authenticates with username: __token__ and a token stored as a repo or environment secret. In most setups it does not expire on its own. Nobody gets a reminder. It works from any machine that holds it — laptop, container, attacker shell. This is the path that makes a stolen secret permanently dangerous.

OIDC trusted publishing. The job requests a short-lived OIDC token from GitHub, PyPI validates it against a trusted publisher configuration (owner, repo, workflow filename, optionally an environment), and returns a scoped, minutes-long upload credential. There is no long-lived secret in the repo to steal. PyPI documents the flow in Trusted Publishers, and the GitHub side requires permissions: id-token: write.

The caveat most "just use trusted publishing" posts skip: OIDC removes the stealable secret, not the repo-write-to-publish path. An attacker who can edit your workflow can add a step that publishes on the next run, and the OIDC exchange will still succeed, because the workflow genuinely is running in your repo. What actually blocks that is environment protection with required reviewers — covered below.

Where a repo-level key or secret sits in that chain

Put those two facts together and you get the failure mode worth internalizing:

  1. A repo-write credential can push a commit that changes .github/workflows/release.yml.
  2. On the next trigger — a tag push, a release, or a push to main — the modified workflow runs with whatever secrets the job can see.
  3. If the job holds a long-lived PyPI token, the token is now readable by the attacker's step.
  4. Revoking the repo key later does not undo step 3.

A leaked GitHub key that stays valid for months hands an attacker all four steps, repeatedly, plus the ability to scrub traces. That is why "months" is the load-bearing word in the LiteLLM reporting.

Auditing Your Own GitHub-to-PyPI Publishing Path

Five checks, in the order I run them. This takes about twenty minutes on a single-project repo.

Step 1: Inventory every workflow that can publish

rg -l 'gh-action-pypi-publish|twine upload|poetry publish|flit publish|uv publish' .github/workflows/

Anything it returns is a publishing workflow and needs every check below. Pay attention to the ones you forgot about: release-please bots, nightly snapshot publishers, that publish-docs job someone bolted a twine call onto.

Step 2: Grep for long-lived credentials and plaintext secrets

rg -n 'secrets\.|PYPI_API_TOKEN|TWINE_PASSWORD|password:' .github/workflows/
git log -p -- .github/workflows/ | rg -n '^\+.*(secrets\.|__token__|password)'

The second command is the one people skip. It catches the token that was hardcoded for a single commit in 2023 and "removed" afterward. Deleting the line does not delete the value from history, from forks, from clones, or from GitHub's cached views of a commit.

⚠️

Never test a suspected-live publishing credential by attempting an upload. Revoke it first, then confirm it is dead by making a deliberately failing call against the index with a throwaway project name.

Step 3: Check whether the project uses OIDC trusted publishing

rg -n 'id-token:\s*write' .github/workflows/
rg -n 'pypa/gh-action-pypi-publish' .github/workflows/

id-token: write present and no password: on the publish step means OIDC. password: ${{ secrets.… }} means a long-lived token, full stop. Also confirm the trusted publisher entry on PyPI names the right workflow filename — rename release.yml to publish.yml and the OIDC exchange fails until the PyPI config is updated, and the temptation is to "temporarily" paste a token back in.

Step 4: Read the effective GITHUB_TOKEN permissions, not the default

A workflow with no permissions: block inherits the repository default. Read it:

gh api repos/OWNER/REPO/actions/permissions/workflow

The endpoint returns two documented fields:

{"default_workflow_permissions":"write","can_approve_pull_request_reviews":false}

If default_workflow_permissions is write, every workflow without an explicit permissions: block runs with a write-capable token, publish job included. GitHub switched new repos to read-only by default, but repos created before that change carry the legacy setting, and org policy can override it. Set it explicitly at the top of each publish workflow:

permissions:
  contents: read

and grant id-token: write only on the publish job.

Step 5: Check environment protection and required reviewers

gh api repos/OWNER/REPO/environments \
  --jq '.environments[] | {name, rules: [.protection_rules[].type]}'

Trimmed output from a repo that is doing this properly:

{"name":"pypi","rules":["required_reviewers"]}
{"name":"staging","rules":[]}

This check actually closes the repo-write hole. Without a required reviewer on the pypi environment, a compromised writer pushes a branch, the job runs, and nothing interrupts it. With one, the publish job pauses and waits for a human. Two details worth getting right:

  • Store the publishing credential (if you still have one) as an environment secret, not a repository secret. A repository secret is readable by any job; an environment secret is only injected into jobs that declare that environment.
  • Required reviewers on a public repo can be bypassed by org admins in some configurations. Treat it as a speed bump for attackers and an audit trail for you, not a hard boundary.

A Lab Reproduction of the Weak Publishing Path

The deliberately weak workflow, trimmed to the risky lines

This is a fixture, not a real project. I built two workflows in a throwaway repo so the audit script had something with known flaws to chew on.

.github/workflows/release.yml (intentionally weak)
name: release
on:
push:
  branches: [main]
  tags: ["v*"]
jobs:
publish:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: "3.12"
    - run: python -m build
    - uses: pypa/gh-action-pypi-publish@release/v1
      with:
        password: ${{ secrets.PYPI_API_TOKEN }}

Five separate problems in twelve lines: no permissions: block, a long-lived token, no environment, publishing on every push to main as well as on tags, and actions/checkout leaving the GITHUB_TOKEN persisted in .git/config for every later step in the job.

Observed output: what the audit script and workflow logs actually show

publish-audit.py
#!/usr/bin/env python3
"""Flag long-lived PyPI credentials and unpinned publish jobs in workflows."""





LONG_LIVED = re.compile(r"(PYPI|TWINE)_?(API_?)?(TOKEN|PASSWORD)", re.I)
OIDC = re.compile(r"id-token:\s*write")
PUBLISH_ACTION = re.compile(r"pypa/gh-action-pypi-publish")
TRIGGERS = re.compile(r"^\s{0,4}(release|push|workflow_run|workflow_dispatch):", re.M)

for path in sorted(pathlib.Path(".github/workflows").glob("*.yml")):
  text = path.read_text()
  hits = sorted({m.group(0) for m in LONG_LIVED.finditer(text)})
  print(f"\n== {path}")
  if PUBLISH_ACTION.search(text):
      print("  publish-action: pypa/gh-action-pypi-publish")
  if hits and "secrets." in text:
      print(f"  long-lived-credential-refs: {', '.join(hits)}")
  print(f"  oidc-id-token-write: {bool(OIDC.search(text))}")
  print(f"  environment-pinned: {'environment:' in text}")
  for m in TRIGGERS.finditer(text):
      print(f"  trigger: {m.group(1)}")

Run against the fixture:

$ python3 publish-audit.py

== .github/workflows/release.yml
  publish-action: pypa/gh-action-pypi-publish
  long-lived-credential-refs: PYPI_API_TOKEN
  oidc-id-token-write: False
  environment-pinned: False
  trigger: push

== .github/workflows/publish-oidc.yml
  publish-action: pypa/gh-action-pypi-publish
  oidc-id-token-write: True
  environment-pinned: True
  trigger: release

The second file is the hardened version, included so the script has a clean case to report on:

.github/workflows/publish-oidc.yml (hardened)
name: publish
on:
release:
  types: [published]
permissions:
contents: read
jobs:
publish:
  runs-on: ubuntu-latest
  environment:
    name: pypi
    url: https://pypi.org/p/example-project
  permissions:
    id-token: write
  steps:
    - uses: actions/checkout@v4
      with:
        persist-credentials: false
    - uses: actions/setup-python@v5
      with:
        python-version: "3.12"
    - run: python -m build
    - uses: pypa/gh-action-pypi-publish@release/v1

Three changes there matter more than the rest: on: release instead of on: push (publishing is now a deliberate act, not a side effect of merging), persist-credentials: false (the GITHUB_TOKEN is not left lying in the checkout), and environment: pypi (which is what makes required reviewers possible).

Reading the evidence: what a misused publishing credential leaves behind

If you suspect a compromise, publish artifacts are more durable evidence than CI logs — attackers delete logs, but they cannot easily rewrite an index's file records. Start with the index's JSON API:

curl -s https://pypi.org/pypi/PROJECT/json \
  | jq '.releases | to_entries[] | {version: .key, files: [.value[] | {filename, upload_time_iso_8601, sha256: .digests.sha256}]}'

Then compare against the tag:

git log --diff-filter=A --format='%H %ad %s' --date=iso -- .github/workflows/
git log -p -- .github/workflows/ | rg -n '^\+.*(secrets\.|__token__)'

Look for an upload timestamp with no matching release commit, a wheel whose build metadata does not match the tagged tree, a workflow change that landed at 03:00 and added a step with network egress, or a second file added to a version that already had one. That last case is what defeats version pinning, which is why the pinning section below is about hashes.

Credential Revocation and Rotation as an Actual Process

Why a revoked key is not the same as a deleted commit

Revoking a GitHub token invalidates the token. It does not invalidate the commits pushed with it, the workflow runs triggered by it, the branches created by it, the actions taken while it was live, or any secret value it read. This is the specific mental error in "we removed the key from the repo." Removing a string from a file is not revocation, and revocation is not remediation. Remediation means assuming everything the credential could reach was reachable by someone else during the exposure window.

Secret scanning and push protection: what they catch and what they miss

Push protection blocks a push containing a recognized secret pattern. It is genuinely useful, and it is free on public repositories; private repos need GitHub Secret Protection (part of GHAS) for the same coverage. What it misses:

CaseCaught?
Live GitHub PAT pasted into a workflowYes, usually blocked at push
PyPI token in a README exampleSometimes, if the pattern is recognized
Secret in a commit from before push protection was enabledNo
Secret in a private repo without Secret ProtectionNo
Token split across two variables to dodge the patternTypically no
Secret that is an OIDC config mistake rather than a stringNo — different control entirely

Secret scanning detects a specific mistake class. It is not a control over publishing authority. If push protection is a repo's only defense, the attacker with write access never needs to push a secret at all — they push a workflow change, and no scanner flags it.

A rotation checklist that survives contact with a real repo

  1. Revoke first, investigate second. Delete the PyPI token in the index, revoke the GitHub PAT or deploy key, and remove the GitHub App installation if that is what "master key" turns out to mean. Speed beats completeness here.
  2. Enumerate everything the credential could reach. Repo contents, packages, org membership, other repos with the same collaborator, and any downstream CI system that trusted GitHub identity.
  3. Check every published artifact in the window, not just the newest version. Use the JSON API command above.
  4. Rotate the adjacent credentials, not just the one that leaked: any secret in a workflow the compromised credential could have edited is now suspect by association.
  5. Diff the workflows between the exposure window start and now. Look for additions, not just removals.
  6. Write down the revocation timestamp. You will need it to bound the artifact search, and "we think it was a few weeks" is not a bound.

Proving What You Shipped: PyPI Attestations and Hash Pinning

PyPI attestations and the coverage they do not provide

PyPI supports index-hosted attestations per PEP 740, documented at PyPI attestations. Current releases of pypa/gh-action-pypi-publish generate them automatically under trusted publishing, so a properly configured OIDC project gets provenance for free.

Here is what they do not do, and where I disagree with how they are usually sold: an attestation proves which workflow and commit produced a file. It does not prove the workflow was trustworthy at build time. If an attacker with repo write access modifies the workflow, the resulting attestation is a perfectly valid, cryptographically signed statement saying "this malicious wheel was built by your real workflow at your real commit." That is a detection control — it lets consumers compare the attested source against what they expect — not a prevention control. None of the attestation machinery would have stopped the LiteLLM attack if the attacker had repo write access.

📝

Attestation coverage is also far from universal. Older releases, packages published from a laptop, and projects still on token uploads generally have none. A missing attestation is not evidence of compromise, and a present attestation is not evidence of safety.

Pinning and hash locks for consumers, with the maintenance cost

For consumers, version pins are a weak integrity control because a release can gain a file after you pinned the version. Hash pinning is the strong version:

pip install --require-hashes -r requirements.txt

with a lockfile that records a hash per artifact. Hashes are the only consumer-side control that survives a compromised publisher, an added wheel, and a moved tag simultaneously. See pip's secure installs documentation for the constraints — every requirement needs a hash, and transitive dependencies need pinned versions.

The cost is real: hash locks break on every legitimate upstream release and force a regeneration step in your update workflow. I would still pay it for production services and anything running with credentials. For a throwaway notebook environment, version pins plus a recent install are a reasonable trade — just say that out loud instead of pretending the two are equivalent.

How I Would Prioritize These Fixes, in Order

If you have an hour, do these in this order, because the ordering reflects what actually blocks an attacker holding a stolen GitHub credential:

  1. Put an environment with required reviewers on every publish job. This is the only control in the list that interrupts repo-write-to-publish, and it takes ten minutes. Everything else is defense in depth behind it.
  2. Revoke every long-lived PyPI token, then migrate to trusted publishing. Not the other way around. Migrating first leaves the old token alive, and the old token is the thing that works from anywhere.
  3. Restrict tags and default-branch pushes, and require review on .github/workflows/ via CODEOWNERS. Workflow files are code with credential access; treat them like production config, not like docs.
  4. Set default_workflow_permissions to read at the org level and pin permissions: in every workflow. Cheap, mechanical, and it removes the accidental write token that turns a minor workflow injection into a repo takeover.
  5. Turn on push protection and secret scanning. It catches the specific mistake class in the incident reports, but it ranks fifth because it does nothing against an attacker who never needs to push a secret.
  6. Publish attestations and, separately, pin by hash on the consumer side. Valuable for verification and forensics; none of it would have prevented the incident.

The uncomfortable conclusion: the LiteLLM reports describe a stale credential. The durable lesson is about standing privilege, not key hygiene. A repo key that can edit a publish workflow is a publishing credential with extra steps, whether or not anyone calls it one — so audit the publishing path first, and treat the token rotation as the cleanup that follows.

What I Confirmed Versus What I Did Not Test

Confirmed by testing or documented behavior:

  • The audit script above flags password: ${{ secrets.PYPI_API_TOKEN }}, a missing id-token: write, a missing environment:, and a push trigger, and passes the hardened fixture. I ran it against my own fixture repo, not against LiteLLM.
  • GET /repos/{owner}/{repo}/actions/permissions/workflow returns default_workflow_permissions and can_approve_pull_request_reviews; environment protection rules are visible via GET /repos/{owner}/{repo}/environments.
  • PyPI's JSON API exposes per-file upload timestamps and digests for a release.
  • OIDC trusted publishing on PyPI requires id-token: write and matches against the configured workflow filename and, optionally, environment.

Not confirmed, or explicitly untested:

  • The exact credential type, scope, exposure start date, and exfiltration path in the LiteLLM incident. I only have secondary reporting from 2026-10-26 and 2026-10-27.
  • Which LiteLLM versions or artifacts were affected. I am not naming versions because I do not have a primary source for them.
  • Whether a malicious file was added to an existing release or published as a new version. I describe the added-file case because it is the one that defeats version pinning, not because I know it happened here.
  • Whether the hardened workflow above would have prevented this specific compromise. Without knowing the key's reach, I cannot say — and I would rather leave that unanswered than assert it.

Further Reading

Share this post

More posts

Loading comments…