Dependency confusion and typosquatting
Two name-resolution attacks that turn your install step into RCE: dependency confusion (a public package shares your internal name at a higher version) and typosquatting (a near-miss of a popular name). Pin the scope to your registry, install with npm ci, block scripts.
The build was green for years. Then one morning CI started failing a fraud check downstream, and the on-call engineer pulled the runner logs to find an install step phoning a host nobody recognized. The package was named exactly like one of the company’s internal libraries — the kind of name that only appears inside the monorepo and in stack traces. Except this copy came from the public registry, carried a version number an order of magnitude higher than anything the team had ever shipped, and ran a postinstall script the moment npm install resolved it. Nobody added a dependency. Nobody changed a version. An attacker had simply published a package with the company’s own internal name, set the version to 99.9.9, and waited for a build machine to prefer it. This lesson is about the two attacks that weaponize how a package manager turns a name into an artifact: dependency confusion and typosquatting.
Dependency confusion: highest version wins, across a shared namespace
When a package manager resolves a name, it asks a registry for the versions available and, by default, prefers the highest one that satisfies the range. The trap is that “the registry” is often plural: many setups treat the public registry (npm, PyPI) as a fallback in the same namespace as your private one. So if your internal package acme-internal-utils exists privately at 1.4.0, and an attacker publishes a public package with the identical name at 99.0.0, the resolver compares them in one namespace and picks the higher version — the attacker’s.
The attacker’s only prerequisite is your internal name. Those names leak constantly: a package.json committed to a public repo, a dependency tree printed in a CI log, a path in a stack trace pasted into an issue. None of that is secret, and none of it feels like a vulnerability — until it becomes the seed of an attack.
# What the resolver effectively does for an UNSCOPED internal name:
# query private registry -> acme-internal-utils@1.4.0 (yours)
# query public registry -> acme-internal-utils@99.0.0 (attacker's, higher)
# "highest version wins" -> installs 99.0.0 from public
# The install/postinstall script then runs with the CI job's secrets and network.The payload runs because package managers execute lifecycle scripts (postinstall, preinstall) during installation by design — that is how native modules build themselves. On a CI runner that means the attacker’s code executes with whatever the job holds: registry tokens, cloud credentials, the ability to reach internal hosts. The root cause is structural, not a bug: a single resolution namespace where the public registry is a silent fallback and highest version wins, while your internal names are not reserved publicly.
The fix that actually closes it: scope + registry pinning
The decisive defense is to make your internal names unresolvable from the public registry, so the comparison above never happens. You do that by putting internal packages under a scope (@acme/...) and pinning that scope to your private registry in .npmrc:
# .npmrc — the scope @acme NEVER falls through to the public registry
@acme:registry=https://registry.internal.acme.com
//registry.internal.acme.com/:_authToken=${INTERNAL_NPM_TOKEN}Now a request for @acme/utils is routed only to the internal registry. The resolver does not even query the public registry for that scope, so there is no public @acme/utils@99.0.0 to win — the version-comparison trap is structurally removed. For legacy unscoped internal names, the equivalent is to ensure the resolver never falls through to public for them (a proxy that fails closed, or migrating them under a scope). Privacy of the registry is not the same as isolation of the namespace; only the pin gives you isolation.
▸Why this works
Why are you still vulnerable just by having an internal package, if you don’t pin the scope to your registry? Because most resolvers treat the public registry as a fallback in the SAME namespace, and “highest version wins” — so a public package with your internal name and a higher version is preferred over your private one. Making the registry private protects its contents; it does nothing about the name. The resolver still happily queries public for a name it can’t satisfy privately, and an attacker who knows the name publishes a higher version there. Only explicitly pinning the scope to your registry (so the resolver never queries public for that scope) makes the name unforgeable. Reserving the public name or scope — publishing a harmless placeholder so no one else can claim it — is the belt-and-suspenders second layer.
Lockfiles, integrity, and install scripts: the layers under the pin
The pin closes the namespace; three more layers limit blast radius if anything slips through.
Commit the lockfile and install with npm ci, not npm install. npm install is allowed to resolve — it can pull a new higher version and rewrite the lockfile, which is exactly the behavior dependency confusion exploits. npm ci installs only what the lockfile pins, and verifies each artifact against the integrity hash (a subresource-integrity SHA recorded at lock time). If a registry serves a swapped artifact for the same version, the hash check fails and the install aborts.
# npm install: may resolve a higher version and REWRITE the lockfile (the attack window)
npm install
# npm ci: installs EXACTLY the locked versions, verifies each integrity hash, fails on mismatch
npm ci --ignore-scripts # also refuse lifecycle scripts where the build allows itDisable install scripts where you can (--ignore-scripts). A malicious package’s leverage is almost always its postinstall; if scripts don’t run, a swapped or confused package is inert code on disk rather than executing code. You re-enable scripts deliberately for the few dependencies that genuinely need to compile.
Typosquatting: the sibling attack on the same install step
Dependency confusion targets your names; typosquatting targets popular names. The attacker publishes packages a fat finger away from something well-known — expresss, lodahs, crossenv — and waits for a developer to mistype an install command or copy a typo from a blog. The squatted package then runs the same kind of malicious install script. The defense layers differently because there is no scope to pin: verify the exact name before installing, run installs through a private proxy registry (Artifactory, Verdaccio) that serves only vetted, allow-listed packages, scan dependencies with tooling (Socket, npm audit), and pin everything via the lockfile so a typo can’t silently enter a reproducible build.
You must stop dependency confusion for your internal @acme packages — a public package with the same name and a higher version must never reach your build. Which approach actually closes the hole?
How does a dependency-confusion attacker get their code running inside your CI build?
Why is `npm ci` against a committed lockfile safer than `npm install` for resisting this attack?
- 01Explain the dependency-confusion mechanism end to end: what the attacker needs, why the resolver picks their package, and where their code runs.
- 02Lay out the layered defenses and why each is necessary: scope+registry pin, reserving the name, lockfile + npm ci + integrity, ignore-scripts, and the typosquatting variant.
Two name-resolution attacks turn a routine install step into remote code execution. Dependency confusion abuses how a resolver turns a name into an artifact: many setups treat the public registry as a fallback in the SAME namespace as your private one, and “highest version wins” — so an attacker who learns your internal name (leaked in a public package.json, a CI log, or a stack trace) publishes a same-named PUBLIC package at version 99.0.0, the resolver prefers it over your private 1.4.0, and its install/postinstall script runs in CI with the job’s secrets and network. The decisive fix is scope + registry pinning: put internal packages under a scope (@acme/…) and pin that scope to your private registry in .npmrc, so the resolver never even queries public for @acme/* and there is no higher public version to win — privacy of the registry is not isolation of the namespace, only the pin is. Reserve the public scope as a second layer. Underneath, commit the lockfile and install with npm ci (not npm install): npm ci installs exactly the locked versions and verifies each integrity hash, refusing a swapped artifact, whereas npm install may re-resolve to a malicious higher version and rewrite the lockfile. Block install scripts with —ignore-scripts where the build allows, so a confused or swapped package is inert rather than executing. Typosquatting is the sibling attack on popular names (expresss, lodahs, crossenv) betting on a fat-finger; with no scope to pin, defend by verifying names, routing installs through a private proxy registry that serves only an allow-list, scanning dependencies, and pinning everything via the lockfile. Now when you see a new internal package being added without a scope prefix, that’s your cue to ask: is this name reserved on the public registry, and is its scope pinned in .npmrc?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.