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.
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)
- 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.
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
- 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.
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
- 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.
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
- 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.
Stage 4 — The framework's own dependency surface
- 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
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
- 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.
- Land a PR that adds an unpinned action — blocked by
verify:actions-pinned. - Land a PR that adds a lifecycle script to any package in the tree — blocked by
verify:no-lifecycle-scripts. - Land a PR that adds a runtime dependency to
@daloyjs/core— blocked byverify:no-runtime-deps. - Sneak in a base64-encoded payload or invisible-unicode trick — blocked by
verify:no-encoded-payloadsandverify:no-invisible-unicode. - Trigger a publish without a maintainer approving it in the
npm-publishEnvironment — impossible by design. - Beat the 24-hour
minimum-release-agecooldown 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.npmrcalready 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_TOKENfrom repo secrets. The blog post linked above has the exactpermissions: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-runnerwithegress-policy: audit(oregress-policy: blockif you know your allow-list). That alone would have surfaced themodels.litellm.cloudPOST 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.