Every package you install brings its own dependencies, and those bring theirs. Dependency hell is what happens when a small project quietly ends up trusting hundreds of strangers' code, and one bad package deep in the chain is enough to break it or turn it against you.
Direct and transitive dependencies
A dependency is code your project uses but didn't write. The ones you ask for by name are direct dependencies: they are listed in your package.json (npm), requirements.txt or pyproject.toml (pip) or .csproj file (NuGet).
Each of those packages has dependencies of its own, and so on down. Everything you get without asking for it is a transitive dependency. You rarely read their names, but their code sits in your project and runs with the same access as yours.
Here is a made-up project that asks for one package:
{
"name": "my-site",
"dependencies": {
"site-builder": "^3.2.0"
}
}And here is what that one line can install. npm ls --all prints the whole tree:
my-site@1.0.0
└─┬ site-builder@3.2.0
├─┬ template-kit@2.0.1
│ └─┬ string-utils@1.4.0
│ └── pad-left@1.1.3
├── is-even-number@1.0.2
└─┬ file-watcher@5.1.0
└── path-tools@0.9.4One direct dependency, six transitive ones. Real trees are far bigger: a single web framework or build tool can pull in hundreds of packages, most of them maintained by one or two volunteers you have never heard of.
Why so many tiny packages exist
Package managers made sharing code nearly free. Publishing a package takes minutes, and installing one takes a single command, so developers split code into very small pieces. Some packages do almost nothing: pad a string, check whether a number is odd, add two values.
The cost of each tiny package is invisible when you add it, and it adds up. The best-known example is left-pad. In 2016 its author unpublished it from npm. It was a function of about a dozen lines, but so many popular packages depended on it, directly or through others, that builds across the JavaScript world started failing until it was restored. Nobody had chosen to depend on left-pad. They had chosen packages that did.
How a supply chain attack works
A software supply chain attack targets the code you install rather than the code you write. Instead of breaking into your app, the attacker breaks into something your app trusts. The common routes are:
- Account takeover: the attacker phishes or steals a maintainer's login or publish token, then releases a new version with malicious code inside.
- Typosquatting: publishing a package with a name one typo away from a popular one, and waiting for someone to mistype it.
- A handover: a tired maintainer gives a package to a helpful stranger, who later adds something harmful.
Version ranges make the first route spread fast. A range like ^1.4.0 means 'any 1.x version from 1.4.0 up', so a fresh install happily picks up a brand-new 1.4.1 nobody has reviewed yet. Here is how that plays out:
How a compromised deep package reaches your app
Step 1 of 6: An attacker steals the publish token of a small helper deep in many dependency trees.
Real incidents on npm have followed exactly this pattern: a maintainer of widely used packages is phished, malicious versions go out, and thousands of projects install them before anyone notices. The projects affected didn't do anything unusual. They ran the same install command they always run.
It isn't only an npm problem
npm gets the headlines because JavaScript leans so heavily on tiny packages, but pip and NuGet work the same way: one command, a registry anyone can publish to, and transitive dependencies you didn't pick.
The term 'dependency hell' is older than supply chain attacks, and it also covers version conflicts. If two of your libraries need incompatible versions of a third, something has to give. In a Python environment you get one version of each package, so the install fails or one library breaks. npm sidesteps this by nesting separate copies, which avoids the conflict but makes the tree, and your exposure, even bigger.
Reducing the risk
You can't avoid dependencies entirely, and you shouldn't try. You can make each one a choice.
Ask whether you need the package
Before adding a package, check what it does and what it pulls in. Many tiny packages copy something the language already has. Padding a string is built into JavaScript today:
// With a package, and everything it brings
const leftPad = require("left-pad");
leftPad("7", 3, "0"); // "007"
// Built in, no dependency at all
"7".padStart(3, "0"); // "007"The same thinking applies to containers: images that ship only what the app needs, like distroless images, leave attackers less to work with.
Lock exact versions
A lockfile records the exact version, and a hash of the contents, of every package in the tree, transitive ones included. Commit it, and install from it in CI:
# Install exactly what the lockfile says
npm ci
# Also skip dependencies' install scripts
npm ci --ignore-scriptsnpm ci fails if package.json and the lockfile disagree, instead of quietly picking new versions. --ignore-scripts blocks install-time code, though a few packages genuinely need their scripts to build. For pip, pin versions with == and add hashes, then install with pip install --require-hashes -r requirements.txt. NuGet can write a packages.lock.json and restore in locked mode.
Watch what you already have
Run npm audit (or pip-audit for Python) to list known vulnerabilities in your tree, and turn on automated alerts such as Dependabot. Update deliberately: read what changed in a new version rather than accepting every bump the moment it appears.
When a package is the right call
'Write it yourself' only works for small, simple code. Some problems are hard enough that a well-maintained library is far safer than your own attempt: cryptography, password hashing, date and time zone maths, and parsers for formats like HTML or YAML. Getting those wrong is a security bug in itself.
The useful question is whether the code saved is worth the trust you are extending. A mature library with active maintainers and few dependencies usually is. A three-line helper with its own chain of dependencies usually isn't.
Common mistakes
- Counting only direct dependencies. Five lines in
package.jsoncan mean five hundred packages on disk. - Leaving the lockfile out of version control, so every machine and every build can install something different.
- Assuming the registry vets packages. npm, PyPI and NuGet host what people publish. They catch some malware, but nobody reviews every new version before you can install it.
- Copying install commands without reading them, especially package names from a search result, where one typo can install a typosquat.
Key takeaways
- Every dependency brings its own dependencies, and you trust all of them.
- One compromised package deep in the tree can reach thousands of apps at once.
- Lockfiles,
npm ciand hash checking stop surprise versions slipping in. - Before adding a package, check whether the language already does it.
- Write small, simple code yourself; use trusted libraries for hard problems like cryptography.