npm 12 Blocks Install Scripts by Default. Node 26 LTS, Out Oct 28, Still Ships npm 11.
On October 8, [email protected] was on npm for about 1 hour 40 minutes. It was not a real release. Someone with access to the maintainer's GitHub account committed a payload and ran the project's own release workflow, s
On October 8, [email protected] was on npm for about 1 hour 40 minutes. It was not a real release. Someone with access to the maintainer's GitHub account committed a payload and ran the project's own release workflow, so the package even carried valid npm provenance. A preinstall hook ran the payload the moment it was installed on a developer machine.
The payload went after GitHub and npm tokens, AWS and GCP credentials, SSH keys, .env files and browser passwords, then used those tokens to republish itself into other packages. It also installed a small service called gh-token-monitor that deletes your home directory if the stolen GitHub token gets revoked (SafeDep found it only arms for tokens on accounts with no organizations). That's all in the maintainers' own GitHub advisory (GHSA-8g63-53c9-65j2), and it's part of the Mini Shai-Hulud campaign that has been hitting npm since April.
npm already has a fix for the "runs on install" part. npm 12, out since July 8, doesn't run dependency preinstall, install or postinstall scripts unless your project allows them.
Here's the catch. Node 26 became LTS on October 28, and the npm it ships is 11.20.0. That's not npm 12. I checked the bundled npm version for every current LTS line in the Node release index today:
| Node release | Date | npm it ships |
|---|---|---|
| 26.13.0 (LTS "Lithium") | Oct 28, 2026 | 11.20.0 |
| 24.22.2 (LTS "Krypton") | Oct 27, 2026 | 11.19.1 |
| 22.23.4 (LTS "Jod") | Oct 20, 2026 | 10.9.9 |
The only Node line with npm 12 bundled right now is the 27 alpha. So if you install Node the normal way and never run npm install -g npm, you're on a version that still runs every install script in your tree.
What each package manager actually did
I wanted to see this rather than trust release notes, so on a scratch box I made a tiny local package whose preinstall writes a marker file to the temp dir. I installed it next to esbuild (a normal, harmless package that has a postinstall) with each tool, then checked whether the marker file existed.
| Tool | Script ran? | What it printed |
|---|---|---|
| npm 10.9.9 (what Node 22 ships) | yes | nothing at all |
| npm 11.21.1 | yes | a warning listing both packages |
| npm 12.2.1 | no | "2 packages had install scripts blocked" |
| pnpm 10.34.6 | no | "Ignored build scripts: [email protected], harmless-preinstall@file:..." |
| bun 1.4.2 | no | "Blocked 1 postinstall" |
Two things stood out. On npm 10 there's no hint anything ran. On npm 11 the warning reads like a heads-up about the future, but the marker file was there, so the script had already run. The script also had a console.log, and that line never showed up in the install output on npm 10 or 11. You don't see install scripts run.
pnpm and bun already block by default. If you're on either, most of this post is about your CI and your teammates, not you.
Check a project in 10 seconds
This prints your npm version, whether install scripts are on, which packages in your lockfile actually have install scripts (npm records hasInstallScript in package-lock.json), and whether any of the tensorlake persistence files exist on the machine.
#!/usr/bin/env bash
# install-scripts-check.sh: who gets to run code when you type npm install?
# usage: bash install-scripts-check.sh [project-dir] (needs jq)
cd "${1:-.}" || exit 1
v=$(npm -v); major=${v%%.*}; ign=$(npm config get ignore-scripts)
allow=$(jq -c '.allowScripts // empty' package.json 2>/dev/null)
echo "node $(node -v), npm $v, ignore-scripts=$ign"
if [ "$major" -ge 12 ]; then echo "OK npm 12+: dependency install scripts blocked unless allowed"
elif [ "$ign" = "true" ]; then echo "OK ignore-scripts=true: install scripts off"
else echo "WARN npm $major runs every dependency install script (it only warns)"; fi
[ "$ign" = "true" ] && [ -n "$allow" ] && echo "WARN ignore-scripts=true silently overrides allowScripts"
echo "allowScripts: ${allow:-none}"
if [ -f package-lock.json ]; then
n=$(jq '[.packages | keys[] | select(. != "")] | length' package-lock.json)
echo "lockfile: $n packages; these have install scripts:"
jq -r '.packages | to_entries[] | select(.value.hasInstallScript == true)
| " \(.key | sub("^.*node_modules/"; ""))@\(.value.version)"' package-lock.json
fi
echo "tensorlake worm persistence (all should be missing):"
for f in ~/.config/gh-token-monitor ~/.local/bin/gh-token-monitor.sh \
~/Library/LaunchAgents/com.user.gh-token-monitor.plist; do
if [ -e "$f" ]; then echo " FOUND $f <- remove BEFORE revoking GitHub tokens"
else echo " missing $f"; fi
done
systemctl --user list-unit-files 2>/dev/null | grep -q gh-token-monitor \
&& echo " FOUND gh-token-monitor systemd user service <- stop it first"
for f in .claude/settings.json .vscode/tasks.json; do
grep -qE 'SessionStart|folderOpen' "$f" 2>/dev/null && echo " CHECK $f runs a command on open. Did you write it?"
done
exit 0
I ran it on a fresh lockfile for a pretty normal small-SaaS stack (next, react, react-dom, prisma 7, @prisma/client 7, stripe, tailwindcss, sharp) with the npm that was on the box:
node v24.22.1, npm 11.21.1, ignore-scripts=false
WARN npm 11 runs every dependency install script (it only warns)
allowScripts: {"[email protected]":true,"@prisma/[email protected]":true}
lockfile: 191 packages; these have install scripts:
@prisma/[email protected]
[email protected]
tensorlake worm persistence (all should be missing):
missing /home/box/.config/gh-token-monitor
missing /home/box/.local/bin/gh-token-monitor.sh
missing /home/box/Library/LaunchAgents/com.user.gh-token-monitor.plist
That allowScripts list is already there because I approved Prisma with npm 12 first (next section). The script still says WARN on npm 11, and that's fair. On 11.21.1 a package you explicitly deny does get skipped (I tested that), but anything new that isn't on the list still runs. Only npm 12 flips the default. With npm 12.2.1 first on the PATH, the same project gives OK npm 12+: dependency install scripts blocked unless allowed.
The number worth noticing: 191 packages in the lockfile, 2 with install scripts. Your allowlist will probably be short. Short enough to read every entry.
Moving to npm 12 without breaking your build
npm install -g npm@12
npm install # blocked scripts get listed at the end
npm install-scripts ls # same list, read-only
npm install-scripts approve prisma @prisma/engines
npm install-scripts deny some-package-you-dont-trust
npm rebuild # runs the scripts you just approved
git add package.json && git commit -m "allowScripts for npm 12"
On the starter project above, npm 12 listed @prisma/[email protected] (postinstall: node scripts/postinstall.js) and [email protected] (preinstall: node scripts/preinstall-entry.js). After the approve step, package.json had:
"allowScripts": {
"[email protected]": true,
"@prisma/[email protected]": true
}
Approvals are pinned to the version you reviewed by default. When Prisma bumps, the new version is blocked again until you look at it. That's a little annoying and it's exactly the point. A hijacked patch release of a package you trust is how these worms spread. npm install-scripts prune cleans out entries for versions you no longer have.
GitHub's npm v12 breaking-changes note covers two other defaults that changed at the same time: git dependencies and remote tarball URLs are no longer resolved unless you allow them. If your install breaks on a github: dependency after upgrading, that's why.
Three gotchas
ignore-scripts=true beats your allowlist. Setting it in .npmrc was a common hardening tip after the first Shai-Hulud wave in September 2025. On npm 12 it silently wins over allowScripts. I approved my test package, ran npm install --ignore-scripts, and the marker file never appeared. Same install without the flag, it did. Pick one setup and remove the other.
CI has its own npm. Upgrading on your laptop does nothing for the build server. Cloudflare's Oct 23 changelog says their Workers Builds and Pages images now ship Node 22.23.4 and 24.22.1, and per the Node release index those come with npm 10.9.9 and 11.19.1. If your CI sets up Node with something like actions/setup-node, you get that Node's bundled npm unless you upgrade it. Add an npm install -g npm@12 step before npm ci, or check what your builder uses.
Your laptop is the real target. SafeDep's teardown of the tensorlake payload shows the loader checks CI, GITHUB_ACTIONS, GITLAB_CI and RUNNER_ENVIRONMENT and simply exits on CI. It wants the machine where your SSH keys, .env files and logged-in browser live. That's also where AI coding agents type npm install for you without you reading the package name. I wrote down some rules for keeping coding agents from installing junk or reading your secrets on a side project, and they matter more once you know worms skip CI on purpose.
If you think you installed something bad
The order matters here, and it's the opposite of what your instincts say.
- Take the machine off the network.
- Remove
gh-token-monitorbefore you revoke anything. The advisory gives the commands: on Linuxsystemctl --user disable --now gh-token-monitor.serviceand delete~/.config/gh-token-monitor/and~/.local/bin/gh-token-monitor.sh; on macOSlaunchctl bootoutthecom.user.gh-token-monitorLaunchAgent and delete the plist. - Rotate tokens from a different, clean machine.
- Look through the repos and packages your tokens could write to. The tensorlake advisory says to look for unexpected releases, commits authored
claude, and a branch nameddependabot/github_actions/format/setup-formatter. Sonatype and Socket also flag changed.claude/settings.jsonand.vscode/tasks.jsonfiles, which is why the script greps them. - Rebuild the machine from a clean image.
What npm 12 doesn't fix
Blocking install scripts closes one door. Code that runs when you import the package is untouched. A GitHub advisory from October 9 for an npm package called testrrrd describes exactly that: it fires the same beacon from postinstall and again from its main file at import time, so --ignore-scripts alone doesn't stop it. If a bad version lands in your lockfile, you'll run it the first time your app starts.
So the lockfile still matters. Commit it, use npm ci in CI, and don't auto-merge dependency bumps the same hour they're published. It's the same lesson as the trivy-action tags that got repointed in one night: a name you trust is not the same as the code you get.
For most small projects the whole fix is upgrading npm, one install, approving two or three packages and committing package.json.
If you've already moved to npm 12: how many packages ended up in your allowScripts, and which one broke first when you upgraded?
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.