Skip to content
osbytes
← back to blog

ChainDrop: npm worm republishes tarballs and dumps GitHub Actions secrets

2026-08-05by@osbytes6 min read
#security #supply-chain #npm #github-actions #credentials #ci-cd

TL;DR

  • Microsoft's ChainDrop analysis (August 4) and JFrog's write-up describe a Mini Shai-Hulud worm that started at keyv@6.0.0 and cacheable@2.5.1, then spread to 400+ packages and 1,700+ versions by August 5. Aikido's count put combined installs above 2 billion/month.
  • The entry point is a preinstall": "node setup.mjs" hook that downloads Bun and runs an obfuscated collector (Math_Symbol.js / math_init.js). On CI it stays attached to the job so it can read runner memory and OIDC publish tokens, not just files on disk.
  • Propagation is the scary part: stolen npm tokens trigger tarball surgery (bump patch, inject hook, republish). Many malicious versions had no matching git commit but still carried valid provenance from the publisher's GitHub Actions identity.
  • For ghp_/gho_ tokens with workflow scope, JFrog traced a Run Copilot workflow that assigns ${{ toJSON(secrets) }} to an env var, uploads it as format-results.txt, then deletes the branch and run. Hunt that artifact name, not just the preinstall IOC.
  • Reader action: if a compromised version ran on a laptop or CI runner, revoke npm/GitHub/cloud tokens, rebuild the host, audit every writable branch for .vscode/tasks.json + .claude/settings.json hooks and the branch dependabot/github_actions/format/setup-formatter. On npm 12+, dependency preinstall hooks are blocked by default unless listed in allowScripts (npm RFC 0054).

How ChainDrop differs from "another compromised package"

The shape rhymes with November 2025's Shai-Hulud wave and the TanStack OIDC compromise we covered in May. Three details in the August primaries are worth separating:

1. Tarball republish without a source commit. Microsoft notes many poisoned patch releases had no pull request, tag, or legitimate release behind them. The attacker modified the registry tarball directly using stolen publish credentials. Your "did main branch change?" check misses this class entirely.

2. Provenance can still look clean. The initial keyv push went through the maintainer's real GitHub account and Actions workflow (Aikido). Downstream worm copies inherited that trust surface. Sigstore attestations tell you which workflow published, not whether the workflow author intended that tarball.

3. GitHub Actions becomes part of the payload. JFrog's workflow trace is specific enough to hunt:

  1. Create branch dependabot/github_actions/format/setup-formatter.
  2. Add .github/workflows/codeql_analysis.yml (commit message Add CodeQL Analysis, author forged as github-advanced-security[bot]).
  3. Workflow name Run Copilot writes secrets to format-results.txt via ${{ toJSON(secrets) }}, uploads artifact format-results.
  4. Download artifact, delete workflow run and branch.

That is a different detection problem than blocking setup.mjs on install. Egress filters will not see it; GitHub audit logs and artifact retention policies might.

The install hook and the CI path

Every infected package adds setup.mjs plus a ~700 KB JavaScript payload. The loader prefers a local Bun binary; otherwise it pulls Bun 1.3.13 from GitHub releases (JFrog), which is a normal-looking fetch.

On developer machines the sample detaches and keeps running after npm install finishes. On GitHub Actions it stays in-process so it can:

  • Read ACTIONS_ID_TOKEN_REQUEST_TOKEN / ACTIONS_ID_TOKEN_REQUEST_URL for OIDC tokens used in npm trusted publishing (Aikido).
  • On Linux runners, use sudo python3 to read Runner.Worker process memory and extract objects marked isSecret:true (JFrog).

Exfiltration goes to a dynamic HTTPS endpoint (domains rotated via Ethereum contract 0xE1f2395ee43e45A1556EC6438a88c31B83493103) or, on failure, a public GitHub repo whose description is Shai-Hulud: Here We Go Again. Treat any match on that string as a compromise receipt, not a meme.

Community-spread generations rename the payload file to math_init.js instead of Math_Symbol.js (Aikido). Hash both names if you are scanning registries or node_modules.

Persistence without another npm install

The worm also commits IDE hooks when it gets repo write access (JFrog):

  • .vscode/tasks.json runs node .claude/setup.mjs on folder open.
  • .claude/settings.json runs node .vscode/setup.mjs on session start.

Commits use message chore: update config and a forged Co-authored-by: claude trailer. Opening the repo in VS Code or Claude Code can re-run the collector even after you scrub node_modules.

What to do if you might have pulled a bad version

Assume compromise if keyv@6.0.0, cacheable@2.5.1, or any package on JFrog's growing compromised list landed in a lockfile during the window.

  1. Revoke first. npm tokens with bypass_2fa and package write scope are the worm's fuel (JFrog). Rotate GitHub PATs/OAuth/App tokens, cloud keys, Vault tokens, and Kubernetes secrets reachable from the host.
  2. Rebuild CI runners and dev laptops from clean images. Cleaning node_modules is not enough if runner memory or IDE hooks already ran.
  3. Audit GitHub, all branches. Search for the five hook files, branch dependabot/github_actions/format/setup-formatter, workflow codeql_analysis.yml with artifact format-results, and repos described Shai-Hulud: Here We Go Again.
  4. Tighten install policy. npm 12+ blocks dependency lifecycle scripts unless approved in package.json allowScripts (GitHub changelog). JFrog explicitly notes infected preinstall hooks do not run on npm 12 by default. On npm 11, ignore-scripts=true in CI .npmrc is the blunt equivalent (npm config). Pair either with pnpm minimumReleaseAge so a worm's same-day patch bump does not enter your pipeline immediately.
  5. Review trusted publishing. JFrog documents a targeted OIDC path when GITHUB_WORKFLOW_REF contains release-drafter.yml in opensearch-js, including Sigstore bundles minted from the real workflow context. Inventory which workflows can publish to npm and whether a fork PR could reach them.

Sources