On August 4, 2026, an attacker took over the maintainer account behind keyv and cacheable — two caching libraries with a combined install base north of 2 billion downloads a month — and pushed trojanized releases straight to npm. Within hours the payload had spread to more than 450 packages and 2,200+ versions, in an attack researchers dubbed ChainDrop (a “Mini Shai-Hulud” variant). It’s the clearest recent example of a vulnerability class every JavaScript shop should be defending against: install-time lifecycle script abuse.
What Happened
The malicious versions shipped an ordinary-looking preinstall hook in package.json:
{
"name": "keyv",
"version": "5.6.1",
"scripts": {
"preinstall": "node ./scripts/bootstrap.js"
}
}
That script downloaded the Bun runtime, then used it to execute a ~710 KB obfuscated second-stage payload before a single line of application code ran. The payload:
- Scanned the filesystem and environment for npm tokens, GitHub PATs, AWS/GCP keys, Kubernetes configs, and HashiCorp Vault tokens.
- Authenticated to npm using any stolen publish token it found, then republished the next package in that identity’s namespace with the same dropper injected — incrementing the patch version and pushing a new release automatically.
- Exfiltrated stolen secrets to attacker-controlled GitHub repos and, notably, an Ethereum smart contract used as a dead-drop C2 channel.
That self-propagation step is what made it a worm rather than a one-off compromise: every developer or CI runner that ran npm install on an infected package handed the attacker a fresh set of credentials to compromise the next package.
Why npm install Is the Attack Surface
The root problem isn’t keyv specifically — it’s that npm install executes arbitrary code from every transitive dependency by default, before you’ve had a chance to review anything. preinstall, install, and postinstall hooks run with the same privileges as your build process: your CI secrets, your cloud credentials, your SSH keys.
Detecting Exposure
Check what’s actually installed, not just what’s declared, since lockfiles can drift from node_modules:
# List installed versions across the dependency tree
npm ls keyv cacheable cacheable-request flat-cache file-entry-cache --all
# Grep the lockfile for known-bad version ranges
grep -E '"(keyv|cacheable|cacheable-request|flat-cache|file-entry-cache)"' \
package-lock.json -A 2
A small Node script to flag known-compromised versions across a monorepo:
// scan-lockfile.js — flag known ChainDrop IOC versions in package-lock.json
import { readFileSync } from 'node:fs';
const KNOWN_BAD = {
keyv: ['5.6.1', '5.6.2', '5.7.0'],
cacheable: ['2.6.0', '2.6.1'],
'flat-cache': ['4.0.2'],
};
const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'));
const hits = [];
for (const [pkgPath, entry] of Object.entries(lock.packages ?? {})) {
const name = entry.name ?? pkgPath.split('node_modules/').pop();
const bad = KNOWN_BAD[name];
if (bad && bad.includes(entry.version)) {
hits.push({ name, version: entry.version, path: pkgPath });
}
}
if (hits.length) {
console.error(`Found ${hits.length} compromised package(s):`);
console.table(hits);
process.exit(1);
}
console.log('No known-bad versions found.');
Run it in CI as a pre-build gate so a poisoned lockfile fails the pipeline instead of shipping.
Locking Down Lifecycle Scripts
The single highest-leverage fix is disabling install scripts by default and allowlisting the handful of packages that legitimately need them (native module builds like sharp or node-sass):
# Install without running any lifecycle scripts
npm install --ignore-scripts
# Then explicitly rebuild only the packages that need native compilation
npm rebuild sharp bcrypt
Enforce it repo-wide so no one forgets:
# .npmrc
ignore-scripts=true
If you’re on pnpm, use its built-in allowlist instead of a blanket flag — it’s less likely to silently break a legitimate build:
// package.json
{
"pnpm": {
"onlyBuiltDependencies": ["sharp", "bcrypt"]
}
}
CI Hardening
In GitHub Actions, scope tokens tightly and never let a routine npm install run with a long-lived publish token in scope:
# .github/workflows/ci.yml
jobs:
build:
permissions:
contents: read
id-token: none # no OIDC token exposure unless a job actually needs to publish
steps:
- uses: actions/checkout@v4
- run: npm ci --ignore-scripts
- run: node scan-lockfile.js
If You Were Exposed
- Pin, don’t just upgrade. Roll back to versions published before August 2026 (
[email protected]or earlier,[email protected]or earlier) until upstream confirms a clean release. - Rotate everything on any host that ran
npm installduring the exposure window — npm tokens, GitHub PATs, cloud credentials, Vault tokens. Assume they were read. - Rebuild, don’t clean. Treat any CI runner or workstation that executed the payload as compromised and rebuild the image rather than trying to remove the malware in place.
ChainDrop will not be the last worm to weaponize preinstall. Treat lifecycle scripts as untrusted code execution by default, and you remove the propagation mechanism the whole attack class depends on.