Security Engineering · Supply-Chain Security
Dependency Confusion and the Order Registries Are Consulted
When a build resolves a private package name against a public registry, an attacker can claim the name first. The mechanism is registry precedence, not a bug.
The class of attack known as dependency confusion exploits a gap between two facts that are individually reasonable. Public registries are open: anyone may publish any unused name, for free, immediately. Private package feeds are trusted: they are configured by an organization and serve artifacts that organization built. When a client consults both for the same name, the public registry becomes an authority for names the private registry already uses, and the client’s precedence rule decides who wins. If precedence is “highest version” or “whichever answers first”, the attacker wins by simply publishing a larger version number.
The attack is not a bug in any single tool. It is a consequence of the resolution model described in how dependency resolvers turn version ranges into a fixed graph, where a name is a name and its meaning depends on which registry the resolver happened to ask.
How a client ends up asking two registries
Package managers support multiple sources because real organizations have real reasons for them: an internal mirror for speed, a private feed for proprietary code, a vendor’s registry for a commercial component, a public registry for everything else. The configuration surfaces differ but the shape is the same.
For pip, --index-url names the primary index and --extra-index-url may appear more than once. The documented behavior is that both the primary and the extra indexes are searched, and pip selects the best candidate across all of them by version. There is no notion of one index taking precedence by configuration order for the purposes of file selection; the extra index is not a fallback. A private package named acme-utils at version 1.4.0 and a public package of the same name at version 9.9.9 means pip installs the public one, and the result looks entirely normal in the install log unless someone reads the URL.
For npm and similar registries, scope syntax gives a structural answer: registry configuration can be scoped with @scope:registry=, so that @acme/logger resolves only from the private registry and lodash resolves only from the public one. The scope prefix is part of the package name, so a public package cannot occupy @acme/name unless the attacker also owns the @acme scope on the public registry. Scoped names are therefore the standard mitigation, and they work because they encode the authority into the identifier rather than relying on configuration ordering.
For Maven, precedence is a property of the repository list and the artifact coordinate is a group identifier, which gives the same structural protection when the group is controlled. For Go modules, the module path is a domain and the toolchain fetches from that domain, so a private prefix can be routed through GOPRIVATE and GONOSUMDB to skip the public proxy entirely.
The dangerous configurations are the ones where the name carries no authority information at all: an unscoped name in a registry ecosystem with no built-in namespacing, resolved from a client configured with a list of indexes and a version-based selection rule. That covers a large fraction of Python and Ruby installations and a significant share of internal JavaScript tooling that predates widespread scope use.
The attack only needs a name and a version
The published descriptions of dependency-confusion attacks share a simple structure: identify an internal package name from an artifact that leaks it, such as a lockfile in a public repository, a source map in a shipped bundle, an error message, or a job posting; publish a package of that name to the public registry with a version higher than the internal one; place code in an install-time hook. The hook is where the execution happens: npm runs preinstall, install, and postinstall scripts from a package’s manifest; pip executes code in setup.py during installation for source distributions. Both ecosystems have spent years narrowing these surfaces, but installation of a package with a build step still involves running code the package author supplied.
The version requirement is trivial to satisfy because the attacker controls the public version entirely. Nothing in a semver range like ^1.4.0 prevents version 2.0.0 from being selected by a resolver that treats version ranges as upper-bounded only when they are written with an upper bound, and many internal manifests use caret ranges that do allow the major bump when the internal version is below 1.0. Below 1.0, caret ranges behave differently: ^0.4.2 admits only 0.4.x, but ^0.4 still admits 0.4.9, and an attacker publishing 0.4.99 is inside the range.
The name side is the part that requires reconnaissance, and the reconnaissance is usually passive. Names leak through lockfiles committed to public repositories, through container images that embed node_modules or site-packages, through SBOMs published for compliance, and through build logs. The article on component identity in SBOMs describes how much of that leak is deliberate: an inventory that names every internal package is an inventory of names available to claim.
Namespace claiming versus index trust
Two mitigation strategies exist and they are not equivalent.
Claim the namespace first. If every internal name is registered on every public registry the client might consult, then the name is taken and an attacker cannot publish it. This works, and several organizations do it, but it is a treadmill: each new internal package requires a placeholder publication, placeholders expire if the registry reclaims dormant names, and the same discipline must be repeated for every registry the client consults, including ones added later by someone else’s tooling.
Make the name carry its registry. Scoped npm packages, group-qualified Maven coordinates, module paths that resolve to domains, and PURL-style identities all put the authority in the identifier. A resolver that is asked for @acme/thing cannot accidentally find it on the public registry if the public registry has no acme scope and the client is configured to route that scope privately. This scales without a treadmill, and it fails only when the client is misconfigured to ignore the scope routing or when the ecosystem lacks scoping entirely.
For ecosystems without scoping, the remaining options are configuration-level: point the client at exactly one index, a proxy that merges public and private with a deny-list for private names, or a resolve-then-verify step that rejects any artifact whose resolved URL is outside the expected set. The third option is the strongest, because it checks the outcome rather than the input, and it composes with a lockfile that records the URL.
The lockfile as the enforcement point
A lockfile that records the resolved URL for every artifact turns the precedence question into a review question. If a diff changes the URL of an internal package from the private feed to a public one, that is visible, and it is the exact change an attack requires. The hash recorded next to that URL is what makes the entry meaningful after review, as described in what a SHA-512 integrity hash in package metadata actually proves. This is why lockfile review matters more than manifest review, and why install commands that refuse to re-resolve, such as npm ci or --frozen-lockfile equivalents, are a security control and not just a speed optimization.
The complementary control is a registry that signs. TUF, the Update Framework, models a repository as a set of roles with separate keys and thresholds: a root role that delegates, a targets role that signs the list of artifacts, and snapshot and timestamp roles that prevent an attacker from serving an old but validly signed target list. A private feed that implements TUF-style role separation makes a name takeover on the private feed detectable and rollback detectable, which is covered in detail in threshold roles and rollback protection in TUF. It does not help with the public registry at all, which is why the two controls address different halves of the problem: name authority is fixed by naming, and repository integrity is fixed by signing.
# Cargo: source replacement routes an internal registry by URL, and the
# crates-io replacement must be explicit, never implicit precedence.
[source.crates-io]
replace-with = "internal-mirror"
[source.internal-mirror]
registry = "sparse+https://packages.example.invalid/index/"
[source.vendored]
directory = "vendor"
In Cargo’s model, source replacement is a total function: a crate resolves from exactly one source, determined by the replacement configuration, and a crate that is not present in the replacement source fails rather than falling back to crates.io. That fail-closed property is the key difference from a fallback list, and it is the property to look for when evaluating any package manager’s multi-source support.
Why the attack keeps working
Dependency confusion keeps appearing because the vulnerable configuration is the convenient one, and because the failure is invisible. A successful attack installs a package with the expected name and a plausible version; the only detectable signal is a URL, a hash, or a version that does not match the internal registry’s record. Telemetry that records resolved URLs and hashes for every install turns that from an invisible event into an auditable one, and a build that verifies those records against an expected set turns it into a blocked one.
The residual risk after all mitigations is the typosquat, which is a different mechanism with the same target: rather than exploiting precedence between registries, it exploits the resolver’s willingness to fetch a name that merely resembles a real one. Scoping and URL pinning do not help there, and the controls that do are name review at dependency-addition time and consistency checks that a newly added name appeared in the same commit as the manifest change that uses it.
References
- The Update Framework Specification — CNCF / TUF project
- npm documentation: scopes and private packages — npm documentation
- pip documentation: index and --extra-index-url behaviour — Python Packaging Authority