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.
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 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`);
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.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS