ZIV LABS
Engineering notes

What npm install runs on your machine

Installing a package can run its code before you have imported a single line. npm runs lifecycle scripts such as preinstall when a package is installed, with your user's permissions and environment. In August 2026 public advisories described a self-spreading worm that used this mechanism. This note reproduces the mechanism with a harmless package, shows what stops it and what that costs on a real project, and explains what signature and provenance checks can and cannot tell you. Commands were run on macOS (arm64) with Node.js v24.7.0 and npm 11.5.1 (npm 12.2.0 where stated).

What was reported

We rely on two primary sources and do not add details from secondary write-ups. On August 4, 2026, Microsoft Threat Intelligence described an npm supply chain attack affecting more than 400 packages from multiple unrelated publishers. According to Microsoft, the malicious releases contain a variant of the "Mini Shai-Hulud" worm, typically start through an npm preinstall hook, steal credentials (from local files, shell histories, cloud configuration, SSH keys, and environment variables), and spread by using a stolen npm publishing token to republish the packages that token's owner can publish. In some targeted workflows, Microsoft says the worm abused GitHub Actions OIDC publishing, so those releases can carry valid provenance. Microsoft says the evidence points towards stolen maintainer credentials as the initial access, which it presents as an inference, not a confirmed finding.

The Cyber Security Agency of Singapore published an advisory on August 6, 2026 (last updated October 20) that counts more than 1,300 compromised package versions, with combined downloads of about 2 billion per month, and recommends removing the versions, rebuilding affected systems, and rotating exposed credentials. The two sources count different things (packages and versions) at different times, so we do not reconcile their numbers. We do not list affected packages or indicators of compromise; follow the advisories for those. Microsoft's post does not discuss disabling install scripts. We test that ourselves below.

What runs when you install

According to the npm documentation, npm install runs a package's preinstall, install, and postinstall scripts, among others. It also says that when a package has a binding.gyp file and defines no install or preinstall script, the install step defaults to node-gyp rebuild. The scripts that run are the ones in the installed package's own package.json, written by whoever published that version.

Experiment 1: a harmless install hook

This package does nothing harmful. Its preinstall script writes a marker file into the directory where npm install was run, and reports what the script could see:

{
  "name": "demo-pkg",
  "version": "1.0.0",
  "scripts": { "preinstall": "node setup.mjs" }
}
// Harmless stand-in for what a malicious install hook could do. It only writes a marker file.
import { writeFileSync } from "node:fs";
import { join } from "node:path";

const names = Object.keys(process.env).filter((k) => /TOKEN|SECRET|KEY/i.test(k));
const lines = [
  `ran in:        ${process.cwd()}`,
  `invoked from:  ${process.env.INIT_CWD}`,
  `variables with TOKEN/SECRET/KEY in the name: ${names.length} (${names.join(", ") || "none"})`,
];
writeFileSync(join(process.env.INIT_CWD ?? process.cwd(), "PREINSTALL_RAN.txt"), lines.join("\n") + "\n");

We installed it into an empty project, with the environment cleared so that only a made-up variable is visible (the path is shortened):

$ cd demo-pkg && npm pack && cd ..
$ mkdir app1 && cd app1 && npm init -y > /dev/null
$ env -i PATH="$PATH" HOME="$HOME" FAKE_API_TOKEN=do-not-print npm install ../demo-pkg/demo-pkg-1.0.0.tgz
added 1 package in 188ms
$ cat PREINSTALL_RAN.txt
ran in:        ~/demo/app1/node_modules/demo-pkg
invoked from:  ~/demo/app1
variables with TOKEN/SECRET/KEY in the name: 1 (FAKE_API_TOKEN)

Three things are visible in the marker. The script ran, with the dependency's own directory as its working directory. It knew the directory the installer was started from (INIT_CWD) and could write there. And it could see environment variables, here the made-up FAKE_API_TOKEN. On a developer machine or a CI runner, the environment can hold real tokens, which is why install-time execution is attractive to an attacker. The script printed only variable names; a real one could read the values.

Experiment 2: turn install scripts off

$ mkdir app2 && cd app2 && npm init -y > /dev/null
$ env -i PATH="$PATH" HOME="$HOME" FAKE_API_TOKEN=do-not-print npm install ../demo-pkg/demo-pkg-1.0.0.tgz --ignore-scripts
added 1 package in 122ms
$ ls PREINSTALL_RAN.txt
ls: PREINSTALL_RAN.txt: No such file or directory

With --ignore-scripts the marker was not created. (We installed a tarball here, which goes through the same script path as a registry package according to the documentation; we did not test a package fetched from the registry or one installed as a dependency of a dependency.) The same behavior can be set permanently with ignore-scripts=true in an .npmrc file (the documented default is false). The documentation notes that commands that name a script explicitly, such as npm run and npm test, still run it.

The implicit binding.gyp rule is easy to miss because no script appears in package.json. We checked it with a package that contains only an empty binding.gyp:

$ cat demo-gyp/package.json
{ "name": "demo-gyp", "version": "1.0.0" }
$ cat demo-gyp/binding.gyp
{}
$ mkdir app3 && cd app3 && npm init -y > /dev/null
$ npm install ../demo-gyp/demo-gyp-1.0.0.tgz --foreground-scripts
> demo-gyp@1.0.0 install
> node-gyp rebuild
gyp info it worked if it ends with ok
gyp info using node-gyp@11.2.0
...
$ cd .. && mkdir app4 && cd app4 && npm init -y > /dev/null
$ npm install ../demo-gyp/demo-gyp-1.0.0.tgz --ignore-scripts --foreground-scripts
added 1 package

In a fresh project, npm ran node-gyp rebuild on its own, and in another fresh project --ignore-scripts prevented it.

What turning scripts off costs on a real project

First, find out which of your dependencies declare install scripts. The lockfile records this as hasInstallScript:

// Lists the packages in package-lock.json that declare an install script (preinstall, install, or postinstall).
import { readFileSync } from "node:fs";

const lock = JSON.parse(readFileSync(process.argv[2] ?? "package-lock.json", "utf8"));
const packages = Object.entries(lock.packages).filter(([path]) => path !== "");
const withScripts = packages
  .filter(([, info]) => info.hasInstallScript)
  .map(([path, info]) => `${path.replace(/^.*node_modules\//, "")}@${info.version}`);

console.log(`${packages.length} packages in the lockfile, ${withScripts.length} with install scripts`);
for (const name of withScripts) console.log(`  ${name}`);
$ node list-install-scripts.mjs
423 packages in the lockfile, 4 with install scripts
  esbuild@0.28.2
  fsevents@2.3.3
  unrs-resolver@1.12.2
  workerd@1.20261006.1

When we ran this on October 21, 2026, 4 of the 423 packages locked for this site declared one; the numbers will change as the site's dependencies change. We then installed the project with scripts disabled and ran its tools:

$ npm ci --ignore-scripts
added 423 packages in 4s
$ npx eslint . && npx tsc --noEmit && npm run build     # all succeeded
$ npx wrangler deploy --dry-run                         # succeeded
$ npx wrangler dev                                      # GET / returned 200
$ npm rebuild esbuild --foreground-scripts
> esbuild@0.28.2 postinstall
> node install.js
rebuilt dependencies successfully

For this project on this machine, everything we ran still worked, and npm rebuild esbuild can run a single package's script on purpose. That is a result for this project, macOS arm64, and these versions, not a general rule. Packages that compile native code during install may not work without their scripts, and we did not test any others.

We also checked that the setting is not limited to dependencies: in a test project with its own prepare script, a plain npm install ran it and npm install --ignore-scripts did not, so your own project's install-time scripts need to be run on purpose. Two limits matter. Disabling install scripts does not stop code that runs when you import a package, run your tests, or build. And it does nothing about a compromised version you have already installed with scripts enabled; the advisories say to treat such systems as potentially compromised and to rotate credentials.

Avoid versions that were published a moment ago

Microsoft recommends npm CLI v12 and its min-release-age setting. The npm configuration documentation describes it as an option that builds the dependency tree from versions published more than the given number of days ago, with before as a date-based variant and min-release-age-exclude for exceptions. The npm 11.5.1 we have installed does not know min-release-age (it printed an unknown-config warning and carried on, so setting it there would silently protect nothing), while npm 12.2.0 does, so we used before on 11.5.1 and min-release-age on 12.2.0. We tried them on a package that releases often. We ran this on October 21, 2026 (a re-run on another day will give different versions). @google/gemini-cli 0.64.0 was published on October 13 and 0.63.0 on October 6, and the "30 days" cutoff was September 21:

$ npm install @google/gemini-cli --dry-run                              # npm 11.5.1
add @google/gemini-cli 0.64.0
$ npm install @google/gemini-cli --dry-run --before=2026-09-21          # npm 11.5.1
add @google/gemini-cli 0.60.0
$ npm install @google/gemini-cli --dry-run --min-release-age=30         # npm 12.2.0
add @google/gemini-cli 0.60.0
$ npm install @google/gemini-cli --dry-run --min-release-age=30 \
    --min-release-age-exclude=@google/gemini-cli                        # npm 12.2.0
add @google/gemini-cli 0.64.0

A cooling-off period works only against malicious versions that are noticed and removed within it, and it delays legitimate fixes too, which is why the exclude option exists. It does not help if you pin a version that is old but already malicious. npm 12.2.0 printed a warning that it does not support Node.js v24.7.0 (it asks for a newer Node.js) but completed the dry runs; use a supported Node.js version in practice.

What signatures and provenance tell you

npm audit signatures on this project:

$ npm audit signatures
audited 423 packages in 7s

423 packages have verified registry signatures

153 packages have verified attestations

According to the npm documentation, the command verifies registry signatures, which are made over name@version:integrity, and provenance attestations. A signature tells you that the tarball you received is the one the registry signed. It does not say that the version is safe: a malicious version published through a compromised account is signed like any other. Provenance links a package to its source repository and build workflow, and the npm documentation states that it does not guarantee the package has no malicious code. Microsoft's report adds a concrete case: releases published through a legitimate workflow identity can carry valid provenance. Here, 153 of 423 packages had verified attestations, so provenance is also incomplete across a normal dependency tree.

These checks are useful for detecting tampering after publication and for knowing where a package was built. They are not a verdict on whether to run the code.

A practical checklist

  • Know which packages run scripts. Run the lockfile listing above and read the short list.
  • Install with scripts off, then enable only what you need. Use --ignore-scripts (or .npmrc) and npm rebuild <package> for the few that must run. Test that your project still works, as we did.
  • Prefer reproducible installs. Commit the lockfile and install from it with npm ci.
  • Add a cooling-off period for new versions where you can (min-release-age in npm 12, before in older versions), with an explicit way to override for urgent fixes.
  • Keep secrets away from install steps. This is our recommendation, not a finding from the sources: install dependencies in a CI step that has no deployment or publishing credentials, because the environment of the install process is visible to every script it runs.
  • If a compromised version was installed, follow the advisories: remove it, rebuild from a clean baseline, and rotate the credentials that were reachable from that machine.

Reproduce it

Create demo-pkg/package.json and demo-pkg/setup.mjs from above, a demo-gyp folder containing the one-line package.json and binding.gyp shown, and save the script shown in the lockfile section as list-install-scripts.mjs if you want that experiment too, and run the commands in the order shown. The --before and --min-release-age results depend on the day you run them. All installs go into new, empty folders. The marker file appears only in the project where scripts were allowed.

What we did not verify

  • We did not analyze any malware, and we did not run or install any real compromised package. The experiments use packages we wrote.
  • The incident description comes from the two advisories; we did not verify their claims independently, and the initial access is Microsoft's inference.
  • We tested macOS arm64 with the versions named above. We did not test Windows, Linux, pnpm, Yarn, or Bun, whose handling of install scripts may differ.
  • We tested one project and one package with --min-release-age. We did not test private registries, workspaces, or monorepos.
  • The install-hook experiments used tarballs we built locally. We did not test a package fetched from the registry, scripts in transitive dependencies, or the case in which npm decides which version to install.
  • We did not examine GitHub Actions OIDC publishing, trusted publishing, or token scopes beyond what the sources say.

References