SecuritySupply chainIncident mapping

When the Security Scanner Is the Attacker: The LiteLLM / TeamPCP Compromise, Mapped to DaloyJS

On March 24, 2026 the litellm Python package was backdoored after a poisoned Trivy GitHub Action stole the maintainer's PyPI token. The same attack pattern — compromised scanner action → exfiltrated publish token → malicious release with a startup-time payload — would have to clear nine of DaloyJS's existing CI gates before it could ship. Here's the stage-by-stage mapping.

Devlin DuldulaoFullstack cloud engineer9 min read

Different reader this time. The link was Snyk's write-up of the LiteLLM / TeamPCP compromise. The question was the same: are we doing anything about this? Yes. Most of it was already shipped. The post is the receipt.

For the unfamiliar — the short version: on March 24, 2026, a threat actor known as TeamPCP published two backdoored versions of the litellmPython package (1.82.7 and 1.82.8) to PyPI. They didn't hack PyPI. They didn't guess the maintainer's password. They stole the PYPI_PUBLISHtoken straight out of LiteLLM's GitHub Actions runner environment via a previously compromised Trivy GitHub Action — a security scanner that LiteLLM's CI ran during the build. Same trick was used to backdoor Checkmarx KICS a day earlier.

The payload was a Python .pth file that fires every time the interpreter starts — including during pip install itself — harvested SSH keys, cloud credentials, kubeconfigs, and crypto wallets, encrypted them with a hardcoded RSA key, and POSTed the bundle to models.litellm.cloud(registered the day before). Then it installed a systemd persistence service called "System Telemetry Service" and, if it found a Kubernetes service-account token, deployed privileged pods named node-setup-* to every node in kube-system.

DaloyJS is Node/TypeScript, not Python, so the .pth hook is not literally applicable. But the attack chain— poisoned scanner action → exfiltrated publish token → malicious release that runs at install time — is platform-agnostic, and is exactly what every JS framework that publishes to npm has to defend against. Here is how each stage maps to what's already in this repo, in the order the attack ran.

Stage 0 — The Trivy tag rewrite (March 19)

Initial compromise
What happened
Attackers rewrote the v0.69.4 git tag on the aquasecurity/trivy-action repository to point at a malicious commit. Anyone using 'uses: aquasecurity/trivy-action@v0.69.4' pulled the malicious version on their next CI run.
DaloyJS posture
Every GitHub Action in this repo is pinned to a 40-character commit SHA, not a tag. A tag rewrite is a no-op. 'verify:actions-pinned' is a release-blocking gate and runs on every PR — landing 'uses: foo/bar@v2' without a resolved SHA fails CI before merge.
yaml
# .github/workflows/release.yml — every action pinned to a 40-char SHA.
# A maintainer rewriting the v0.69.4 tag on trivy-action does nothing to
# a SHA-pinned reference. 'verify:actions-pinned' fails CI if anyone tries
# to land 'uses: foo/bar@v2' without the resolved commit SHA.
- name: Harden runner
  uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2
  with:
    egress-policy: audit

- name: Checkout
  uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
  with:
    persist-credentials: false   # the worker can't push back to origin
    show-progress: false

- name: Set up pnpm
  uses: pnpm/action-setup@ac6db6d3c1f721f886538a378a2d73e85697340a # v6
  with:
    version: 11.1.3
    run_install: false

- name: Set up Node.js
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
  with:
    node-version: 24

- name: Install dependencies (no scripts)
  run: pnpm install --frozen-lockfile --ignore-scripts
  env:
    npm_config_ignore_scripts: "true"

This is the single most important defense for this entire attack class, and it costs nothing. The Snyk post calls out "LiteLLM's CI/CD pipeline ran Trivy as part of its build process, pulling it from apt without a pinned version" . That sentence describes a class of mistake DaloyJS's CI cannot make — there are zero apt installcalls in any workflow, and the only binaries downloaded at runtime (opengrep) are cosign-verified against the publisher's OIDC identity before they are executed.

Stage 1 — Token exfiltration from the runner

Credential theft
What happened
The compromised Trivy action ran in the same job as the publish step, so it could read PYPI_PUBLISH from the process environment, base64-encode it, and POST it to the attacker's C2 domain.
DaloyJS posture
Three separate defenses. (1) Scanners and publish are in different workflows; the publish workflow has zero scanner steps. (2) The publish job runs in a GitHub Environment ('npm-publish') gated on manual maintainer approval. (3) Publishing uses npm Trusted Publishing via OIDC — there is no long-lived NPM_TOKEN sitting in repo secrets to steal.
bash
# .github/workflows — scanners and publish are in SEPARATE workflows,
# in separate jobs, with separate permissions. A scanner job has
# 'contents: read' and never sees a publish credential.
#
#   ci.yml             — pnpm install, typecheck, build, test, audit
#   codeql.yml         — SAST (read-only)
#   opengrep.yml       — SAST (read-only, binary cosign-verified)
#   osv-scan.yml       — SCA (read-only)
#   secret-scan.yml    — gitleaks (read-only)
#   vuln-scan.yml      — pnpm audit on a daily schedule (read-only)
#   scorecard.yml      — OpenSSF Scorecard (read-only)
#   zizmor.yml         — workflow audit (read-only)
#   release.yml        — split into 'verify' (no creds) and 'publish'
#                        (id-token: write, gated on the npm-publish
#                        Environment with manual approval)
#
# Even if a scanner action were compromised the way trivy-action was,
# the runner it executes on never has an npm publish token in scope.
# The publish job has zero scanner steps and zero third-party scanner
# actions — only the four pinned actions above plus 'npm stage publish
# --provenance'.
yaml
# packages.json / release.yml — npm Trusted Publishing via GitHub OIDC.
# No long-lived NPM_TOKEN exists as a repo or org secret. The publish job
# trades the short-lived OIDC token for a single-use publish credential
# inside the gated 'npm-publish' Environment.
#
# Equivalent to what PyPI ships as "Trusted Publishers" — except LiteLLM
# wasn't using it, so a static PYPI_PUBLISH token was sitting in the
# CI environment when the poisoned Trivy action ran.
permissions:
  id-token: write    # ONLY in the publish job, ONLY after env approval
  contents: read

# - Manual approval gate on the 'npm-publish' Environment.
# - Releases require a git tag pushed by a maintainer.
# - Every published artifact carries a Sigstore provenance attestation.

The Snyk post quietly buries the most useful sentence in the whole article: "The package passes all standard integrity checks because the malicious content was published using legitimate credentials." That's the whole game. If the attacker can't get the credentials, every hash check in the world still passes — because the bad version never ships. Removing the long-lived publish token removes the prize.

Stage 2 — The install-time payload

Execution on install
What happened
litellm 1.82.8 added litellm_init.pth to site-packages/. Python's startup-hook mechanism fires on every interpreter launch — including 'pip install', 'python -c', and IDE language servers — with no import required. pip's hash check passed because the .pth file was correctly listed in the wheel's RECORD.
DaloyJS posture
npm's equivalent execution vector is lifecycle scripts (preinstall / install / postinstall / prepare). The root .npmrc sets ignore-scripts=true, every CI install runs with --ignore-scripts, and 'verify:no-lifecycle-scripts' rejects PRs that try to add one. The create-daloy templates ship the same .npmrc. There is no in-repo equivalent of the .pth trick — and 'verify:no-encoded-payloads' would also flag a base64-embedded blob the way TeamPCP smuggled theirs.
ini
# .npmrc — what makes "a freshly published bad version" not auto-install.
ignore-scripts=true            # postinstall / preinstall / prepare = no
minimum-release-age=1440       # 24h cooldown; bad versions get unpublished
frozen-lockfile=true
verify-store-integrity=true
strict-peer-dependencies=true
auto-install-peers=false
provenance=true
registry=https://registry.npmjs.org/

The other half of this defense is the 24-hour minimum-release-age cooldown. The malicious LiteLLM versions were on PyPI for about three hoursbefore PyPI quarantined them. A pnpm install against a registry that honors release-age would have refused to fetch them at all. Most worm campaigns we've seen — chalk, debug, the Shai-Hulud sweeps — are caught and unpublished inside the same window.

Stage 3 — Persistence and lateral movement

Persistence + Kubernetes worm
What happened
Wrote ~/.config/sysmon/sysmon.py, registered a 'sysmon.service' systemd user unit polling https://checkmarx.zone/raw every 5 minutes. If a Kubernetes service-account token was present, deployed privileged 'node-setup-*' pods to every node in kube-system.
DaloyJS posture
Once the payload can't run at install time, none of this happens on a build runner — the systemd / pod-deployment behavior never gets a chance. For the runtime side, harden-runner runs in egress-audit mode on every job, so the first 'curl POST to models.litellm.cloud' lands in the workflow log. The publish job specifically sets disable-sudo: true so a runtime payload can't elevate even if one did slip through.
yaml
# Every job (scanner OR publish) starts with harden-runner in audit mode.
# That alone would have flagged 'curl POST to models.litellm.cloud' as
# anomalous egress — that domain was registered the day before the
# compromise.
- name: Harden runner
  uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2
  with:
    egress-policy: audit          # log every outbound connection
    disable-sudo: true            # no privilege escalation on the runner
    disable-file-monitoring: false

Stage 4 — The framework's own dependency surface

Blast radius
What happened
litellm is downloaded ~3.4 million times a day and is a transitive dependency of DSPy, MLflow, OpenHands, CrewAI, Phoenix, langwatch, and others — every consumer inherited the .pth.
DaloyJS posture
@daloyjs/core has zero runtime dependencies. 'verify:no-runtime-deps' fails the build if anyone adds one. The transitive blast radius is, by construction, the size of the framework itself.

This is the single most boring decision in DaloyJS, and it's the one I'm proudest of. Frameworks that pull in 30 transitive packages at runtime cannot honestly claim a hardened supply chain — the attacker only has to compromise the smallest of those 30. A zero-runtime-deps core has exactly one supply-chain target: the core itself, published through the gated OIDC pipeline above.

The full CI gate list, for the receipts

bash
# Every PR and every release runs these. A poisoned dep would have to
# pass all of them, not just one.
pnpm verify:actions-pinned             # every GH Action pinned to a SHA
pnpm verify:no-lifecycle-scripts       # no install / postinstall / prepare
pnpm verify:no-runtime-deps            # @daloyjs/core has ZERO runtime deps
pnpm verify:no-remote-exec             # no curl|sh, no eval(fetch())
pnpm verify:no-registry-exfiltration   # no sneaky POSTs to a registry URL
pnpm verify:no-encoded-payloads        # no base64 blobs (the 'init.pth' trick)
pnpm verify:no-invisible-unicode       # Trojan Source / zero-width chars
pnpm verify:no-leaked-credentials      # AWS / GCP / GitHub / npm token shapes
pnpm verify:no-weak-random             # no Math.random in security paths
pnpm verify:no-unsafe-buffer           # no Buffer.allocUnsafe
pnpm verify:no-vulnerable-sandboxes    # no vm2 / Function() escape patterns
pnpm verify:no-native-addons           # no .node binaries in the tree
pnpm verify:lockfile                   # lockfile sources are the public registry
pnpm verify:secret-comparisons         # all secret compares use timingSafeEqual
pnpm verify:dep-licenses               # license allowlist
pnpm verify:sbom                       # CycloneDX SBOM generated + checked

Every one of those runs in release.yml before npm stage publish ever fires, and most of them run on every PR too. None of them are aspirational — a failure blocks merge. The full reasoning for each is in the supply-chain hardening post.

What this attack would have needed to do to ship DaloyJS

  1. Compromise a SHA-pinned action at that exact SHA(tag rewrites do nothing). The OIDC trust posture on the publish job means even that wouldn't hand over a usable npm token.
  2. Land a PR that adds an unpinned action — blocked by verify:actions-pinned.
  3. Land a PR that adds a lifecycle script to any package in the tree — blocked by verify:no-lifecycle-scripts.
  4. Land a PR that adds a runtime dependency to @daloyjs/core — blocked by verify:no-runtime-deps.
  5. Sneak in a base64-encoded payload or invisible-unicode trick — blocked by verify:no-encoded-payloads and verify:no-invisible-unicode.
  6. Trigger a publish without a maintainer approving it in the npm-publish Environment — impossible by design.
  7. Beat the 24-hour minimum-release-age cooldown into every downstream pnpm install — also impossible by design.

Could a determined attacker still find a way? Of course — security is never "done." But the path of least resistance the LiteLLM compromise took (rewrite an action tag, steal a static token, ship a release that auto-runs on install) is shut on all four steps in this repo. That's the point of secure-by-default: the obvious attack doesn't work, and the non-obvious ones cost real effort.

What you should do in your own DaloyJS project

  • Scaffold with pnpm create daloy@latest --with-ci. The generated .github/workflows/ and .npmrc already carry the SHA-pinned actions, ignore-scripts, and 24h release-age cooldown.
  • If you publish your own packages, use npm Trusted Publishing (OIDC). Delete any long-lived NPM_TOKEN from repo secrets. The blog post linked above has the exact permissions: block.
  • Don't run third-party security scanners in the same job as your publish step. Different workflow, different permissions, different runner identity. The LiteLLM team didn't — that's how they ended up here.
  • Turn on step-security/harden-runner with egress-policy: audit (or egress-policy: block if you know your allow-list). That alone would have surfaced the models.litellm.cloud POST in real time.

The honest answer to the original question

Are we doing anything to protect ourselves and the users of our framework against the LiteLLM-class supply-chain attack? Yes — and most of it was shipped before the Snyk post existed, for the same reasons that post lists. SHA-pinned actions, isolated publish jobs with OIDC + Environment gating, ignore-scripts, zero runtime deps, a 24h release-age floor, the dozen verify:* gates, and harden-runner egress logging on every job. The playbook the attacker used does not have a green path through any of those.

None of this is hypothetical. Open .github/workflows/ in the repo, run pnpm verify locally, look at the commit SHAs on every uses: line. The receipts are in the workflow files.

Related reading on this blog: Supply-chain hardening for TypeScript libraries, Secure by Default, Vibe coding security, Scaffolding a production-ready DaloyJS app in 60 seconds. Relevant docs: /docs/security, supply chain.