← All posts
Supply chain

Your dependencies run code before your code does

An install is not a download. npm and pip will execute code from a package you have never imported, on a machine that usually holds your cloud credentials — which is why a mistyped package name is a compromise rather than a failed build.

The mental model most people carry is that npm install downloads files, and the risk starts when you import something. If that were true, a mistyped package name would be a failed build.

It is not true, and that is the whole article. Installing a package can execute code from that package, on your machine, as your user, before any of your own code runs. Not when you import it. Not when you call it. At install.

Which means the blast radius of a typo is not your application. It is whatever that laptop or that runner can reach — your SSH keys, your cloud credentials, your ~/.aws/credentials, your kubeconfig, your npm token, the environment variables of a CI job.

What actually runs, and when

npm packages can declare lifecycle scripts: preinstall, install, postinstall, prepare. They are arbitrary shell. They run for your dependencies, and for your dependencies' dependencies, not just for the thing you asked for.

These exist for a reason that is not sinister. Native modules compile against your Node version and your libc; sharp and node-gyp builds need a hook, and tools like Puppeteer use one to fetch a browser. The mechanism is load-bearing, which is exactly why removing it is awkward and why it is still there.

npm ci does not change this. It installs strictly from the lockfile, which is a genuine improvement over npm install for reproducibility, and it runs lifecycle scripts the same way. Reproducibly installing a hostile package is still installing a hostile package.

Python has the same shape with a different distribution of risk. A source distribution executes setup.py during installation, so it is the same problem. A wheel is an archive that gets unpacked, with no build step and no arbitrary execution at install time — code in a wheel runs when something imports it. That is a meaningful difference and it is the basis of one of the better mitigations below.

Three ways a hostile package arrives

You typed it wrong. lodahs for lodash, urlib3 for urllib3, crossenv for cross-env. Transpositions, dropped hyphens, doubled letters, British spellings — colourama sat on PyPI doing clipboard hijacking because half the English-speaking world spells it that way. The one I find most uncomfortable is python3-dateutil, because it is not a typo at all. It is the name you would guess if you assumed there must be a Python 3 edition. The victim was being careful and was still wrong.

Your private package name was not private. Dependency confusion, which Alex Birsan demonstrated against a long list of large companies in 2021. Your internal package is called acme-auth-client and lives on your private registry. Nobody registered that name publicly. Someone does, with version 99.0.0, and a resolver configured to consult both picks the higher version from the public one. You did not type anything wrong. Your build simply preferred a stranger.

A package you correctly depend on turned hostile. event-stream in 2018, after the maintainer handed the project to a volunteer who turned out to want it for a reason. ua-parser-js in 2021, through a compromised npm account. The xz backdoor in 2024, from an account that had spent roughly two years building a maintainer reputation on a project with one exhausted volunteer.

The first two you can defend against with discipline. The third you mostly cannot, and I would rather say that plainly than pretend a policy document covers it.

The runner is the target, not the laptop

If I were doing this, I would not be interested in a developer's machine. I would be interested in CI.

A build runner holds the things worth having, all decrypted, all at once: the registry publish token, the cloud role, the deploy key, the signing key, database credentials for the integration tests. It runs installs constantly, nobody watches the output, and postinstall noise scrolls past in a log that is only read when something fails.

It also has egress. A developer exfiltrating from a laptop has to get past whatever is on that laptop. A runner usually has unrestricted outbound network because builds need to fetch things, which makes it the cleanest exfiltration path in most companies I have worked in.

The corollary is uncomfortable and worth stating: your CI runner is production. It just does not appear on the diagram where production is drawn.

"It is only a dev dependency"

This one comes up every time and it is backwards.

A dev dependency is not shipped to users, which limits one category of harm. But it is installed on the machines that hold the publish token and the cloud credentials, by engineers and by CI, more often than production dependencies are. The lifecycle script does not know it is a dev dependency, and it runs with exactly the same permissions.

A linter plugin with a postinstall is a better target than a runtime library, not a worse one.

What actually helps, in order

Turn off install scripts by default. npm config set ignore-scripts true, and in CI pass it explicitly. This is the single highest-value change here and the reason it is not universal is that it breaks things — native modules will fail to build, and a handful of tools genuinely need their hook. The workable version is to turn it off globally, find the small number of packages that actually need it, and run those deliberately. The failure mode becomes a broken build rather than a silent compromise, and a broken build is a thing you notice.

Install from a lockfile, and review the lockfile like code. npm ci, not npm install, everywhere that is not a human deliberately adding a dependency. Then treat a lockfile diff in a pull request as something to read. The name of every package that will execute code on your runner is in that file, and a transposed letter is visible there if anyone looks — which is also an argument for reading diffs in a monospaced font, where a capital I and a lowercase l finally look different.

On Python, prefer wheels and pin hashes. --only-binary=:all: avoids the setup.py execution path. --require-hashes with hashes in your requirements file means a swapped artefact fails the install instead of running. Together these get you closer to "an install is a download" than npm can currently manage.

Register your internal names publicly, or scope them. If your private packages share a namespace with the public registry, claim the names or use scoped packages with the scope pinned to your registry. Dependency confusion is one of the few supply-chain problems with a complete fix, and it costs an afternoon.

Make CI credentials short-lived and narrow. Short-lived credentials issued per-job beat a long-lived token in a secret, because the window is minutes rather than until someone rotates it. Publish credentials should be separate from build credentials, and the ability to publish a package should not be sitting in the environment of every test run.

Do not run installs on a machine holding production credentials. Including yours. Especially yours.

What none of this stops

A legitimate, correctly-named package that you have depended on for three years, whose maintainer account was compromised last Tuesday.

Lockfiles do not help, because you will eventually update. Typo discipline does not help, because the name is right. Scanning does not reliably help, because the payload in these incidents is usually small, obfuscated and specific.

What helps a little is not taking new versions the day they ship — a cooling-off window of a few days catches a surprising share of these, because the npm and PyPI incidents of the last decade were mostly found within hours by somebody else. Renovate and Dependabot both support a delay. Pinning exact versions and updating on a schedule you control is less convenient than automatic patch updates, and it means the thing that lands on your runner is a thing you chose on a day you were paying attention.

That is a smaller claim than "we have supply-chain security". It is the true one, and I would rather run on the true one.


If you want the typosquat half of this to become reflex rather than knowledge, the daily drill puts four package names in front of you every fifth morning and asks which one is the lookalike. It takes about forty seconds, and the reasoning under each answer is the part that stays.

Supply chain