TL;DR

On August 4, 2026, a single compromised maintainer account poisoned the keyv/cacheable package family — and within a day, a self-replicating worm had infected 444+ npm packages with a combined ~2 billion monthly installs. The uncomfortable part: the malicious releases shipped with valid provenance, signed by GitHub Actions. Your scanners and SBOMs didn’t fail you — your install-time trust model did. The fixes are boring and unglamorous: release cooldowns, disabled lifecycle scripts, a curated artifact choke point, and credentials too short-lived to be worth stealing.

Originally published at portfolio.hagzag.com.

The Slack Message at 09:40

The message that landed in one of our engagement channels was short: “are we exposed to the keyv thing?” A reasonable question with an unreasonable answer — because keyv wasn’t in anyone’s package.json. Nobody chose it. It arrived the way most of your dependency tree arrives: as a transitive dependency, pulled in through gotcacheable-requestkeyv chains that nobody had looked at since the lockfile was first committed.

Running the check across repos, we found a few edge cases where keyv (or its siblings flat-cache and file-entry-cache, which ride in via ESLint tooling) resolved to a version range that could have picked up the poisoned release. Those had to be mitigated — pinned, cache-purged, credentials rotated — on the assumption of exposure, because “probably fine” is not an answer you give a security team during an active worm outbreak.

That morning repeated itself across the ISV market. And that’s the point of this post: not the malware analysis (the vendors did that better than I can — links below), but what an organization that ships software should change so the next Shai-Hulud is a non-event.

What Just Happened, in Three Paragraphs

Attackers compromised the GitHub account of the maintainer behind keyv — a key-value storage library with hundreds of millions of monthly downloads — and pushed malicious code directly to main, then cut releases through the legitimate GitHub Actions pipeline. Every poisoned package gained a "preinstall": "node setup.mjs" hook: an obfuscated dropper that silently pulls the Bun runtime and executes a ~728 KB credential stealer before your npm install even finishes. Aikido’s write-up has the full breakdown of the keyv family compromise; Microsoft published the anatomy of the worm, which they track as ChainDrop.

The payload harvests everything a platform engineer holds dear: npm and GitHub tokens, AWS credentials (files, env vars, IMDS, even Secrets Manager enumeration), Kubernetes service account tokens, HashiCorp Vault tokens, .env files, SSH keys, Terraform state. Then it uses what it steals: npm publish tokens republish infected patch bumps of every package that identity can touch, and GitHub tokens inject execution hooks into .claude/settings.json and .vscode/tasks.json across repository branches — commits authored as claude with the message chore: update config. Within roughly a day, community spread reached 444+ packages across 1,381 versions.

Nobody was too big for this. OpenAI disclosed that the earlier May wave of the same “Mini Shai-Hulud” campaign (via TanStack) landed on two employee laptops and reached repositories containing their code-signing certificates — forcing a rotation of certs across every desktop platform they ship. Their most honest sentence: the controls that would have blocked it were mid-rollout when the attack hit.

Why This Shook ISVs Specifically

If you’re an ISV, you sit on both sides of the supply chain. You consume thousands of open-source packages, and you publish artifacts your customers run — SDKs, agents, container images, npm packages of your own. The worm design weaponizes exactly that position: your developer’s stolen publish token becomes the attacker’s distribution channel, and the infected release carries your name and, thanks to CI-signed provenance, your cryptographic reputation.

That’s the contrarian take worth sitting with: provenance told everyone the truth, and the truth didn’t help. The poisoned keyv releases genuinely were built by the maintainer’s GitHub Actions workflow. Signatures and attestations answer “who built this?” — they don’t answer “should you run it?” We’ve spent two years telling teams to adopt SBOMs, signing, and SLSA levels (I wrote a whole Rebuilding for Compliance series on it), and those things still matter — but they are forensic and attributional tools, not install-time gates. Shai-Hulud slid straight through them.

The Measures: Layered, in Order of ROI

Here’s what I’d actually put in front of an engineering organization this quarter, cheapest first.

1. Kill lifecycle scripts and add a cooldown

The entire initial execution vector was a preinstall hook. Most applications don’t need install scripts at all — and the handful of packages that do (native builds) can be explicitly allowlisted. Combine that with a minimum release age: the poisoned versions were live for hours before takedown, so simply refusing to install anything published in the last few days would have made this whole campaign a no-op for you. npm v12’s minimumReleaseAge and pnpm’s equivalent make this a one-liner.

# .npmrc — mockup, adjust per package manager
ignore-scripts=true          # no preinstall/postinstall execution, allowlist exceptions
minimum-release-age=4320     # don't install anything younger than ~3 days
save-exact=true              # pin exact versions, no ^ or ~ drift

2. Make CI credentials not worth stealing

The worm’s cloud collectors are a checklist of every static credential anti-pattern: long-lived AWS keys in env vars, npm tokens in ~/.npmrc, Vault tokens in well-known paths. The zero-static-credentials architecture I’ve been building for FIPS/FedRAMP work — OIDC federation everywhere, tokens scoped to a single job and dead in minutes — is precisely the blast-radius control here. A stolen credential that expires in 15 minutes and can only publish one artifact is a nuisance; a classic PAT with org-wide scope is a worm’s fuel tank.

A mocked-up GitHub Actions job showing the shape of it:

# .github/workflows/build.yml — illustrative, not production
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write        # OIDC only — no long-lived secrets in the runner
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts        # lockfile-only, no lifecycle hooks
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/ci-build-role
          role-duration-seconds: 900        # 15 min — steal it, it's already dead
          aws-region: eu-west-1

And the GitLab CI equivalent, which mirrors the pattern we run in regulated environments:

# .gitlab-ci.yml — illustrative
build:
  id_tokens:
    AWS_ID_TOKEN:
      aud: https://gitlab.example.com      # short-lived OIDC token, per job
  script:
    - npm ci --ignore-scripts
    - aws sts assume-role-with-web-identity
        --role-arn "$CI_ROLE_ARN"
        --web-identity-token "$AWS_ID_TOKEN"
        --duration-seconds 900

3. Put a curated choke point between the internet and your builds

Developer laptops and CI runners should never talk to registry.npmjs.org directly. An internal registry proxy (Artifactory, CodeArtifact, or similar) gives you one place to enforce cooldowns, block known-bad versions in minutes instead of repo-by-repo archaeology, and quarantine new upstream releases for scanning. The same logic applies to base images — which is where hardened, minimal, SBOM-native images (the Wolfi/Chainguard approach from my compliance series) earn their keep: fewer packages, fewer maintainers, fewer accounts to compromise.

# Dockerfile — mockup of the pattern
FROM registry.internal.example.com/hardened/node:22   # curated proxy, not Docker Hub

# Builds resolve only against the internal registry — the choke point
RUN npm config set registry https://registry.internal.example.com/npm/ \
 && npm ci --ignore-scripts --omit=dev

USER nonroot                                          # worm wants your homedir; don't have one

4. Harden your own publish path

For ISVs, the consume-side controls above are half the story. On the publish side: require 2FA-gated or trusted-publisher releases only, and alert on any published version that has no matching source commit, tag, or pull request — the single loudest tell of this campaign, flagged in both Unit 42’s and Datadog’s analyses. A release your Git history can’t explain is an incident, not a curiosity.

5. Treat your AI harness config as supply chain

The detail that should worry platform teams most: the worm persisted by writing to .claude/settings.json and .vscode/tasks.json, so the payload re-executes when a developer opens the repo or starts an agent session — no npm install required. Your agentic tooling configuration is now executable attack surface. Review these files in PRs like you review CI config, pin agent hook behavior, and be suspicious of any commit touching them — especially one authored by a bot name you recognize.

What Actually Went Wrong

Honesty section. We had SBOMs. We had scanners. And on August 4th, both were reactive — they told us where keyv lived after we already knew to ask, and the mitigation window was measured in hours of lockfile archaeology across repos. None of our tooling would have prevented installation of a fresh, validly-signed, malicious patch release. The cooldown-and-no-scripts posture in measure #1 — the cheapest item on the list — is the one that would have, and it’s the one most organizations (ours included) had deprioritized as developer friction. OpenAI deprioritized it too, and said so publicly. That’s the pattern: the boring control loses the roadmap argument every quarter until the week it would have saved you.

Conclusion

Shai-Hulud will have a successor — the worm design is too effective and the npm trust model too permissive for this to be the last round. The organizations that shrug off the next one won’t be the ones with the most scanners; they’ll be the ones that assumed any dependency can go hostile between two patch versions and built the choke points accordingly. Provenance tells you who built the package. It will never tell you whether you should run it — that part is still your job.