npm Supply-Chain Rapid Response
npm Supply-Chain Rapid Response
Fire this runbook when ANY new npm supply-chain advisory drops (TanStack-style worm, malicious package, registry compromise). Designed to take ≤5 minutes per project for the cache-and-reinstall pass.
Standing defense (already in place)
minimumReleaseAge = 86400in~/.bunfig.tomland~/.claude/bunfig.tomlrejects any package published in the last 24 hours. The May 11 Mini Shai-Hulud worm lived 3 hours from publish to npm-pull; this filter neutralizes that attack window without requiring vendor trust.- Bun’s
trustedDependenciesallowlist — transitive packages don’t get to run lifecycle scripts unless their name is in yourpackage.json’strustedDependenciesarray. Default-deny on transitive scripts. - All active CI Actions SHA-pinned in each project’s
.github/workflows/directory with Dependabot auto-PR’ing bumps weekly.
Incident response steps
1. Read the advisory (1 min)
Pull the named compromised packages and their version ranges into a working note. Sources in order of priority: Wiz Blog, Snyk advisories, the upstream maintainer’s postmortem, npm’s security advisory feed.
2. Direct-IOC scan (2 min per scan)
# Pattern: every compromised package name, alternation-separated
IOC='@evil/pkg-one|@evil/pkg-two|"some-package":'
# Scan all lockfiles
find ~/Projects ~/LocalProjects ~/.claude -maxdepth 6 \
\( -name "bun.lock" -o -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" \) \
-not -path "*/node_modules/*" -not -path "*/LIFEOS_RELEASES/*" -not -path "*/.next/*" \
2>/dev/null | xargs rg -l "$IOC"
# Scan all package.json (for declared deps that may have lockfile drift)
find ~/Projects ~/LocalProjects ~/.claude -maxdepth 6 -name "package.json" \
-not -path "*/node_modules/*" -not -path "*/LIFEOS_RELEASES/*" -not -path "*/.next/*" \
2>/dev/null | xargs rg -l "$IOC"
If both return empty → declared-dependency exposure is zero. Proceed to step 3 for transient-install coverage.
If either returns hits → record the project path + version, then per-project remediation: bump to a known-clean version, run cache-nuke (step 4), verify.
3. Mtime check for the worm window (1 min)
# Replace dates with the advisory's worm window
for lf in $(find ~/Projects ~/LocalProjects ~/.claude -maxdepth 6 -name "bun.lock" -not -path "*/node_modules/*" 2>/dev/null); do
mt=$(stat -f "%Sm" -t "%Y-%m-%d" "$lf" 2>/dev/null)
case "$mt" in
YYYY-MM-DD|YYYY-MM-DD) # <-- replace with worm window dates
echo " $mt $lf"
;;
esac
done
Any lockfile modified during the worm window is a candidate for cache-nuke even if direct IOC is clean (defense against transient/cached installs).
4. Cache-nuke and reinstall (per project, ≤2 min)
cd <project>
bun pm cache rm
rm -rf node_modules
bun install --frozen-lockfile
--frozen-lockfile ensures the lockfile is treated as authoritative; this catches any lockfile/cache drift that may have allowed a transient install.
5. Rotate suspect credentials (only if step 2 hit OR step 3 flagged transient-install)
- npm token (if any) —
npm token list, revoke, regenerate - GitHub PAT (any token used in CI on the host) —
gh auth refresh - AWS / GCP / Azure on dev hosts where install ran during worm window
- See the incident-response skill for the full rotation protocol
Hardening levers (one per attack surface)
| Surface | Lever |
|---|---|
| Just-published malicious packages | minimumReleaseAge in bunfig (in place) |
| Lifecycle-script execution in CI | bun install --ignore-scripts in workflow steps |
| Lifecycle-script execution locally | Add malicious package’s name to trustedDependencies allowlist only when verified safe |
| GitHub Actions tag-move attacks | SHA-pin all uses: lines + Dependabot weekly bumps (in place for Website) |
| Pull-request-target workflow exfil | Ban pull_request_target in any workflow that checks out PR code (currently zero use across {{PRINCIPAL_NAME}}‘s repos) |
| Credentials in CI | Default-deny permissions: block at workflow level; per-job grants |
| Stale npm cache | bun pm cache rm before any reinstall during incident response |
| Dev-host credential blast radius | ~/.bun/install/cache is per-user; install lifecycle scripts run as the user → all reachable credentials are in scope |
Anti-patterns
- Don’t
bun installagainst a fresh advisory before completing step 2. - Don’t rotate every credential reflexively — match rotation scope to actual exposure.
- Don’t trust
npm audit/bun auditas a primary signal during an active worm; the registry’s signal lags by hours. - Don’t silence the
minimumReleaseAgewarning globally by emptying the excludes list. Add the specific package name tominimumReleaseAgeExcludesand document why.
Examples
An advisory drops at 9pm — the fast path
A new advisory names two malicious packages, @evil/pkg-one and @evil/pkg-two, published earlier that day. Run the book, not your nerves:
- Read it, list the names, set the IOC —
IOC='@evil/pkg-one|@evil/pkg-two'. Two minutes. - Direct-IOC scan across every lockfile and package.json — both come back empty. No project declares or locks either package.
- Mtime check for the worm window — the scan flags one lockfile touched during the advisory’s window. Direct IOC was clean, but a transient install could have pulled the package into the cache, so this project is a cache-nuke candidate.
- Cache-nuke that one project —
bun pm cache rm,rm -rf node_modules,bun install --frozen-lockfile. The frozen lockfile refuses any drift. - Rotate nothing — direct IOC never hit and no bad package ever ran; matching rotation scope to actual exposure means the credentials stay put. Done in under ten minutes.
The standing defenses did most of the work before you woke up: minimumReleaseAge = 86400 in bunfig means a package published that same day couldn’t have been installed at all — the 24-hour quarantine neutralized the attack window without trusting the registry.
When to rotate, and when not to
The tempting mistake under pressure is to rotate every credential reflexively. Don’t. Rotation scope follows evidence:
- Direct IOC hit, or a lifecycle script ran during the window → rotate the credentials reachable from that dev host: npm token, CI PAT, cloud creds. The install ran as your user, so its blast radius is everything your user can reach.
- Clean IOC, no install during the window → rotate nothing. A reinstall is precaution, not compromise.
The incident decision flow
flowchart TD
A[New npm advisory drops] --> B[Read advisory, list bad packages]
B --> C{Direct IOC scan hits?}
C -->|yes| D[Bump to clean version, cache-nuke, reinstall]
C -->|no| E{Lockfile touched in worm window?}
E -->|yes| F[Cache-nuke and reinstall as precaution]
E -->|no| G[Exposure zero — done]
D --> H{Bad package ran, or transient install flagged?}
F --> H
H -->|yes| I[Rotate only creds in the host's blast radius]
H -->|no| G
The shape of the runbook is a funnel: cheap scans first, escalation only where evidence demands it, and credential rotation gated behind proof of actual install — never fired on the mere existence of an advisory.
Reference
- May 11, 2026 Mini Shai-Hulud worm postmortem: https://tanstack.com/blog/npm-supply-chain-compromise-postmortem
- Audit ISA that produced this runbook:
~/.claude/LIFEOS/MEMORY/WORK/20260520-npm-exposure-audit/ISA.md - Implementation ISA:
~/.claude/LIFEOS/MEMORY/WORK/20260520-npm-hardening-implementation/ISA.md - Bun bunfig docs: https://bun.sh/docs/runtime/bunfig
- The incident-response skill for the parallel credential rotation protocol.
