8 September 2026 · 6 min read

The install script is the attack

Every big package compromise since 2018 has used one mechanism: code that runs at install time with your credentials. Count those scripts in every lockfile and drive them to zero.

Every major package compromise of the last eight years has had a different story and the same mechanism. A maintainer's token is phished, a popular package gets a new version, and the new version carries a script that runs the moment someone installs it, with the installing user's credentials, on the installing user's machine, before a single line of the package has been imported. The story changes each time. The install script is the attack every time, and it is the one part of the chain that a team can count and remove.

The same shape, eight years running

The event-stream incident of November 2018 was the template: a widely depended-on npm package handed to a new maintainer, who added a dependency that targeted one wallet application's build. In September 2025 the shape returned as a worm. The Shai-Hulud campaign, documented by Wiz and in a CISA alert, published malicious versions of over two hundred packages and more than five hundred versions between 14 and 18 September, each carrying a post-install script that harvested credentials and, if it found an npm token, published itself into every package that token could reach. A second wave followed on 24 November. And in February 2026, version 2.3.0 of the Cline CLI, a widely used coding-agent tool, shipped with a post-install script that silently installed a second package on every machine that ran the install, after an attacker reached its publishing credentials through a prompt injection in a GitHub issue title.

Install-time supply chain incidents, 2018 to 2026 A timeline with five events, four compromises in brick and one defence in gold: November 2018, event-stream; 14 to 18 September 2025, Shai-Hulud, over 200 packages; 24 November 2025, the second Shai-Hulud wave; 9 December 2025, npm revokes classic tokens; 17 February 2026, Cline CLI 2.3.0 post-install compromise. compromise defence Nov 2018 event-stream Sept 2025 Shai-Hulud, 200+ packages Nov 2025 second wave Dec 2025 classic tokens revoked Feb 2026 Cline CLI 2.3.0
Source: the Wiz analysis and CISA alert on Shai-Hulud, GitHub's npm token changelog, and reporting on the Cline incident.

What makes the worm form possible is one loop: the post-install script runs with the developer's environment, the environment contains a publishing token, the script uses the token to publish itself into other packages, and those packages' post-install scripts run on the next developer. Three of the four steps are outside the package ecosystem's control. The first one is not.

The worm loop, and the one step a team controls Four boxes in a cycle: a post-install script runs on install; it harvests the npm token from the environment; it publishes malicious versions of every package the token can reach; those packages run their own post-install script on the next machine. The first box is highlighted as the step a team can refuse. Script runs at install time Harvests token from the environment Publishes itself into reachable packages Next install on another machine The loop closes through the first box. A tree with no install scripts has no first box. expiring tokens and two-factor publishing weaken boxes two and three
Illustrative: the propagation described in the Shai-Hulud analyses, drawn as a cycle.

The ecosystem has finally accepted it

The defences that arrived in 2025 were aimed at the loop's later steps and at the first one. GitHub's September 2025 announcement and its November update ended classic npm tokens, with all existing ones revoked on 9 December 2025, capped write-capable granular tokens at ninety days, and made two-factor authentication the default for tokens that can publish. Those shorten the second and third steps: a harvested token expires, and publishing needs a second factor the script does not have.

The first step got its defence from pnpm. Version 10 blocks dependency lifecycle scripts by default: a dependency's pre-install, install and post-install scripts do not run unless the project explicitly approves that package. The install completes, the packages are on disk, and the code that would have run with your credentials sits there unexecuted. It is a breaking change, deliberately, and it is the correct default, because the number of packages that genuinely need to run code at install time is small and known.

The postinstall tax

The practice that follows is to count. For every lockfile a team ships, count the packages that declare an install-time lifecycle script, and treat that number as a tax paid in blast radius, tracked in continuous integration the way bundle size is tracked. Each package on the list gets one of two treatments: an explicit allow, with a written reason, for the handful that compile native code or download a binary they cannot ship otherwise; or a replacement, with a package that does the same job without running code on install. A new entry on the list fails the build until it has been classified.

The count is a few lines of script over the installed tree, reading each package's manifest for preinstall, install and postinstall entries. I ran it on two trees I maintain to see the shape. The dependency tree behind this site, 75 packages for a Tailwind build and an icon sprite, declares none. A small tool tree of 27 packages, including a browser automation library, declares none either. That is not typical of a large application, where native modules and binary downloads push the number into double figures, and it is the point: for most of the software a small team ships, the tax can be zero, and the packages that raise it above zero are visible by name.

// count install-time scripts across node_modules
const k = ['preinstall', 'install', 'postinstall'];
const taxed = manifests.filter(m => k.some(s => m.scripts && m.scripts[s]));
console.log(`${taxed.length} of ${manifests.length} packages run code on install`);
Defences by which step of the loop they weaken A table of four rows. Ignoring install scripts by default, as pnpm 10 does: removes the first step entirely. Short-lived granular tokens: limit the harvested token's life, weakening the second step. Two-factor publishing: blocks the script from publishing, weakening the third step. Pinning versions in a lockfile: delays the fourth step until someone updates, but does not stop it. Which defence breaks which step DEFENCE STEP EFFECT Ignore install scripts by default 1: script runs removes the step entirely Short-lived granular tokens 2: harvest token dies in ninety days Two-factor publishing 3: publish script has no second factor Pinned lockfile 4: next install delays until someone updates Only the first row makes a compromised version harmless on arrival.
Illustrative: the mapping between the 2025 defences and the loop; the token and two-factor changes are those in GitHub's npm changelogs.

Turning it on without breaking the build

The migration is less dramatic than it sounds, and the order matters. Start by turning scripts off globally: pnpm 10 does it by default, and for npm an ignore-scripts=true line in the project's .npmrc does the same, so that every install on every machine and in every pipeline runs with dependency scripts disabled. Then run the install and the test suite and see what breaks. Usually it is two or three packages: something that compiles a native module, something that downloads a platform binary it cannot ship in the tarball, and a browser automation library that fetches a browser. Those go on the allow list. In pnpm that is the onlyBuiltDependencies list in package.json, which names the packages whose scripts may run and nothing else; in npm it is a targeted rebuild of the named packages after the scripts-off install.

The allow list should be reviewed, not just written. For each package on it, ask whether there is a version or an alternative that ships prebuilt binaries as optional platform packages instead of running a download at install, because several popular native packages moved to that pattern precisely so that they could be installed with scripts off. Every package removed from the list is one fewer place a compromised version can execute on arrival.

Finally, put the count in continuous integration. A job that installs with scripts ignored, counts the packages declaring lifecycle scripts, and compares the set against the allow list will fail the moment a new dependency brings a script with it, which is the moment someone should look. The failure message should say which package and which script, so that the review takes a minute rather than a search.

What the count does not cover

Two honest limits. A package that does not run code at install can still run malicious code at import, the first time your application requires it, and the count says nothing about that. The install-time step is special because it runs on every machine that installs, including build servers and the laptops of people who never use the package, with credentials that the application itself may never see; it is the widest blast radius and the one worms need. Import-time code is a narrower problem, caught by the same review and pinning practices that were always needed, and it is not solved by this count.

The other limit is the allow list itself. A package on the allow list runs its install script with full credentials, and a compromised version of that package is exactly as dangerous as before. The list has to be short, each entry has to be pinned to a version and a checksum, and the entries have to be the packages the team would notice a new version of. A long allow list is the old default with extra steps.

The rule

Count the install scripts in every lockfile. Run installs with dependency scripts ignored by default, and allow the exceptions by name with a reason. Fail the build when the count rises. Then let the token and publishing defences do their slower work on the rest of the loop. The stories will keep changing. The first box does not have to be in yours.

Supply ChainNpmSecurity
All writing

Written by Mohd Shayan

Get new posts by email

Occasional essays on engineering, AI, and building for the people technology leaves behind.

One email per new post. Unsubscribe any time.

Subscribe with RSS