Last updated on
Auditing my own site for the npm worm, then planning the defense
In Part 1 I pulled apart the Shai-Hulud "Here We Go Again" worm and landed on one uncomfortable fact: installing a dependency runs code, and that code runs with your privileges, before your application does anything at all. The worm's payload wired itself to a preinstall script and named the exact files a repo like mine keeps around, .claude/settings.json and .vscode/tasks.json, as places to hide.
So the honest next question isn't "which tool should I buy." It's two questions I have to answer in order. Am I already exposed? And if I clean up, what am I actually defending, and against what? This post answers both, then turns the answers into a plan that Parts 3 and 4 will build against. No tools get installed here. This is the part where I read my own lockfile with the lights on.
Am I exposed? The audit
The threat from Part 1 was concrete, so the audit can be too. I ran four checks against this repo.
Do the poisoned packages live in my tree?
The worm started with keyv and eight caching libraries around it. The first thing to know is whether any of them are in my dependency graph, transitively or otherwise. pnpm why walks the whole tree, not just the top level:
Three of the five names are present. Not because I chose them, but because eslint pulls in file-entry-cache, which pulls in flat-cache, which pulls in keyv. This is exactly the blast-radius point from Part 1: the risk isn't the packages you pick, it's everything they pick. cacheable and cacheable-request produced no output at all. They aren't in the tree.
Presence of the name is not the same as presence of the poisoned version, though. The compromised releases were specific: keyv@6.0.0, flat-cache@6.1.24, file-entry-cache@11.1.6, cacheable-request@13.0.20, cacheable@2.5.1. My lockfile pins older ones. So I grepped the lockfile for the exact bad versions:
No match, exit code 1. The versions I actually resolve are keyv@4.5.4, flat-cache@4.0.1, and file-entry-cache@8.0.0, all published long before the compromise. Clean.
That is the good kind of boring. But "clean today" is a snapshot, and a lockfile changes every time a transitive dependency bumps. The point of Parts 3 and 4 is to make this check automatic instead of something I remember to run after reading the news.
What am I allowing to run at install time?
pnpm 10 changed the default here in my favor: it does not run a package's build scripts unless the package is on an explicit allowlist. So the next question is what my allowlist contains. Checking it surfaced something I did not expect:
My allowlist has been sitting in package.json under a pnpm key:
"pnpm": {
"onlyBuiltDependencies": ["sharp", "esbuild", "unrs-resolver"]
}pnpm 10.28 no longer reads it from there. The setting moved to pnpm-workspace.yaml, and mine doesn't have one. So my curated allowlist is a no-op. The only reason this hasn't broken anything is that pnpm's default is to run nothing, and the three packages I meant to allow ship prebuilt binaries that don't need a build step here. In other words, I've been protected by the default, not by my configuration, and my configuration has been quietly lying to me about which side of the line it's on.
That's a real finding, even though it points the safe direction. The fix is small: move the list to where pnpm now looks, so the allowlist is a deliberate decision I can review instead of dead text.
# pnpm-workspace.yaml
onlyBuiltDependencies:
- sharp
- esbuild
- unrs-resolverAre the worm's fingerprints anywhere on disk?
Part 1 catalogued the artifacts: a setup.mjs bootstrap, a Math_Symbol.js / math_init.js payload, and unauthorized edits to .claude/settings.json and .vscode/tasks.json to gain persistence. So I looked for all of it.
No stray payload files. My .claude/settings.json does contain a SessionStart hook that runs shell scripts, which is precisely the kind of thing the worm plants, so I read it. Every line traces back to a commit I made for a reason I remember, and there's no .vscode/tasks.json at all (just extensions.json and launch.json). Clean, but the exercise made the target obvious: those config files run code, and I'd forgotten to think of them as executable surface rather than settings.
The verdict
This site was not hit. No poisoned versions, no payload files, no tampered config. The single wrinkle, the misplaced pnpm allowlist, actually leans safe. If the audit had stopped there I could close the issue and move on. But a clean bill of health under one worm is not a defense, and the same install-time reflex the worm abused is still wide open. That's what the rest of this is about.
What's actually at risk
A supply-chain stealer doesn't care about my source code. It cares about what my environment can reach the instant pnpm install runs, on my laptop or in CI. So before picking controls, I enumerated the secrets this project actually holds and sorted them by how much damage a stolen one does. These are the environment variable names, no values:
- Propagation and identity tokens, what the worm wants most.
GITHUB_TOKEN/GH_TOKEN: myghCLI is authenticated, and GitHub credentials are the worm's spreading mechanism (commit to repos, republish packages).VERCEL_OIDC_TOKEN: short-lived, but Shai-Hulud specifically scrapes OIDC tokens out of CI runner memory. I have exactly the two credential types it hunts for first. - Control plane, the biggest blast radius.
NEON_API_KEYplusNEON_PROJECT_ID. This isn't a database password, it's the Neon account API: create and delete branches, and potentially delete the project. A stolen data-plane credential loses data; a stolen control-plane key loses the database itself. - Data plane.
DATABASE_URIandPROD_DATABASE_URI(full read/write to Postgres: every post, user, and session).BLOB_READ_WRITE_TOKENfor Vercel Blob, with a sharp edge I've written about before: local dev and production share one Blob store, so a token lifted from my laptop can overwrite or delete production media. - App secrets.
PAYLOAD_SECRETsigns the CMS auth tokens, so leaking it means forged admin sessions.BACKUP_SECRETdecrypts my database backups.CRON_SECRETauthorizes the cron endpoints.RESEND_API_KEYsends email as me.
Three surfaces hold these: my laptop's .env, Vercel's environment store, and a CI runner's memory during a build. A malicious preinstall on any one of them reaches whatever that surface has. The laptop is the softest of the three, and thanks to the shared Blob store and my logged-in gh, it's also one of the richest.
The plan: defense in depth
No single control stops this. A scanner that reads the lockfile can't stop a script that's already executing; a tool that intercepts execution can't tell you a version went malicious yesterday. So I'm mapping controls to layers and giving each layer an owner, so I know what each later post is responsible for and where the seams are.
Control Layer Owner (tool) Part
--------------------------------------- --------------- --------------------------- ----
Block install-time script execution Install / build pnpm default-deny + 2, 3
minimal onlyBuiltDeps;
Aikido Safe Chain
Detect known-malware / bad versions Dep intake Aikido SCA + malware scan 3
Reachability-aware dep + SAST + secrets Code / config Semgrep in CI 4
Catch worm persistence edits Code / config Custom Semgrep rules 4
(.claude/, .vscode/, lifecycle scripts)
Gate PRs on all of the above CI Aikido + Semgrep Actions 3, 4
Shrink blast radius Identity Scoped, short-lived tokens; 2, 5
rotation runbook
Recover Data Encrypted backups + Neon done
PITRRead top to bottom, it's a funnel. Stop a bad package from ever installing (Aikido Safe Chain, Part 3). If one gets in, detect the known-bad version (Aikido SCA, Part 3). If it's novel enough to have no signature yet, catch the behavior it needs, a new lifecycle script or an edit to my agent config, with a custom rule (Semgrep, Part 4). Gate every pull request on those checks so nothing merges unreviewed. And because no scanner catches a maintainer-account compromise before a signature exists, the last two layers assume breach: scope every token so a stolen one is worth less, and keep the recovery path (encrypted backups, point-in-time restore) that this site already has.
The token work is the part that needs no vendor and pays off against attacks that don't exist yet, so it's mine to do in this planning phase and revisit in Part 5: least-privilege scope on each credential above, and a written rotation runbook so "rotate everything" is a checklist and not a panic.
What "done" looks like
Vague goals don't survive contact with a busy week, so here's the bar Parts 3 through 5 are measured against. This is the checklist the later posts execute:
Next
The audit came back clean and the plan has an owner for every layer. Part 3 makes it real: install Aikido, connect this repo, run the first malware and dependency scan, and wire up Safe Chain to intercept a bad install before its preinstall ever fires. Then I'll plant one of the poisoned versions from Part 1 on a throwaway branch and watch CI catch it, without ever running the payload. Detect, don't execute.
Comments
No comments yet. Be the first to comment.