Software Engineering · Dependencies
How a Package Name Becomes a Download: Registry Lookup Order and Confusion
The chain from a bare name in a manifest to a fetched artifact, how registries and scoped namespaces are consulted in order, and why that order is exploitable.
A dependency declaration names a package, not an artifact. Between the name and the bytes there is a lookup: a client asks one or more servers, in an order determined by configuration, and takes the first acceptable answer. When the name is globally unique and the registry is single, the order is uninteresting. When either condition fails, the order becomes a security boundary, and the failure mode is that a package’s identity is decided by whoever answered first rather than by whoever the manifest’s author intended.
The class of attack that exploits this is called dependency confusion, and it works because installers are designed to prefer the newest version and to search multiple sources, while manifests are often written with names that exist in more than one of them.
The lookup chain
Every ecosystem has a resolution step that maps a requirement to a source. The details differ, but the shape is consistent:
- A requirement is parsed into a name, a version constraint, and optionally a source qualifier. The qualifier may be a scope prefix, a registry URL, a git URL, a filesystem path, or nothing at all.
- If the requirement carries an explicit source, that source is consulted and the search ends. A git URL is fetched from that URL; a path requirement is read from disk; a registry-qualified name is asked of that registry.
- If the requirement carries no source, the client consults a list of configured sources in order. The first source that has the package and satisfies the constraint supplies it.
That last rule is the whole vulnerability. A client configured with both an internal registry and a public one will, for an unqualified name, ask both, and take whichever answers. If the internal registry does not hold a package called internal-utils, but the public registry does, published by anyone at any version, the public package wins whenever it satisfies the range. A range like ^1.0.0 is satisfied by any public 1.x, and the public publisher can publish a 1.0.1 the moment they learn the name.
Binding a scope to a registry closes that path, because the client no longer asks any other source about names under the scope:
# .npmrc: every name under @internal is fetched from this registry only.
@internal:registry=https://packages.example.internal/
The exact spelling of source qualification varies by tool, but the mechanism is the same everywhere: a requirement or a client configuration binds a name, or a whole scope of names, to a single source, and only the bound form is immune. A requirement that names no source inherits whatever search order the client happens to have.
Why the client prefers newest
Installers select the highest version satisfying the constraint rather than the lowest. The reason is that version ranges express a floor — “at least this version” — and the ecosystem’s convention is that later versions contain fixes. The client cannot know whether the public registry’s 1.0.1 is a legitimate successor to the internal 1.0.0; it only knows that both are named internal-utils, both satisfy the range, and one is higher.
Preferring the highest version also means the attacker does not need to publish a plausible package. A version number above the internal one is enough, and the package content is never compared against anything, because there is nothing to compare it to. Name equality plus version ordering is the entire matching criterion, and neither registry is asked whether the other is authoritative.
Some ecosystems reverse the preference and select the minimum satisfying version, a policy associated with Go modules and with certain build systems. That reversal happens to make confusion attacks harder, since an attacker must publish a version below the internal one, and a version below an existing floor does not satisfy a range that starts at the internal version. It is a side benefit rather than a design goal, and it does not help when the internal package has no published version at all — a common situation for packages built from a monorepo and referenced by name only.
Scoped names are a namespace, not a guarantee
The standard mitigation is to move internal packages into a scope: a prefix that, in npm’s case, is @ followed by a name, and in other ecosystems is a namespace or a group. Scopes exist so that many publishers can use the same short name without collision, and they are the natural place to attach a registry binding.
The binding is what matters, not the syntax. A scoped package on the public registry is public; the scope merely groups it. The mitigation works only when the client is configured to route that scope exclusively to the internal registry and to refuse the package from anywhere else. Configured that way, a public package under the same scope is never considered, because the client does not ask the public registry about that scope at all.
Two failure modes survive even with scopes. First, a scope can be unclaimed on the public registry and then registered by someone else, which is why publishing internal packages publicly under the same scope — even as placeholders — is a defensive practice. Second, a client configuration can be incomplete: routing by scope requires every developer and every CI job to share the configuration, and a single environment with default settings reintroduces the unqualified lookup. Configuration that lives in a per-user file rather than in the repository is a common weak point.
What the client does with a name that resolves twice
Some clients, given a name present in several configured sources, fetch metadata from all of them and merge. Merging is worse than ordering, because the merged view can contain versions from a source the project never intended to use, and the selection then proceeds over the union. A merged view also means the internal registry’s newest version does not shadow the public one; both are candidates.
The defensible behaviour is strict precedence with no merge: the first source in the configured order that can supply the name is the only source consulted for that name. Then the ordering itself is the policy, and the policy is reviewable. Internal-first is the correct order for a project with internal packages; public-first is correct only for a project with none.
Names as an attack surface beyond confusion
Confusion is the sharpest case, but names carry other risks.
Typo-squatting relies on the same lookup being name-based and unforgiving: a package named one character away from a popular one is fetched by any manifest containing the typo, and the client cannot tell intent. There is no technical fix at the lookup layer, because the name in the manifest is exactly what the author wrote. Defence lives in review of manifest diffs and in tooling that flags new names similar to existing ones.
Name takeover is the inverse: a legitimate package name changes hands, either through account compromise or through a maintainer transferring ownership. The name still resolves, the version still satisfies the range, and the content changes. This is why integrity digests in the lockfile are load-bearing: they pin the bytes, so a takeover that republishes the same version under different content is caught at install. A takeover that publishes a new version is not caught, and is admitted by any range that permits it.
Namespace exhaustion is a lesser variant: a scope or prefix that a project relies on but does not control can be populated by others, and every name under it becomes ambiguous.
Making the mapping explicit
The durable fix is to treat the name-to-source mapping as configuration under version control, not as client state. Concretely:
- Every internal name lives under a scope, and the scope is bound to the internal registry in a configuration file that ships with the repository, so a fresh clone and a CI job behave identically to a developer’s machine. A reviewer reading the scope sees the origin without knowing anyone’s registry configuration.
- The internal registry holds a placeholder or real publication for every internal name, so the public registry is never the only source for a name the project uses. This closes the window where a name exists internally as a build artifact but has never been published anywhere.
- Requirements use the scoped name, never the bare one. A bare name is precisely the form whose source is undetermined, and no amount of client configuration makes it safe in a project that also consults a public registry.
- Name resolution is checked in CI: a job that resolves the graph and asserts that every package came from an allowlisted source set catches both misconfiguration and a new dependency that silently points elsewhere. This is a property of the resolved graph rather than of the manifest, which makes it insensitive to configuration drift.
The general principle is that a name is not an identity. Identity requires a source, and a source requires an explicit declaration somewhere in the chain from manifest to artifact. Where that declaration is absent, the identity is supplied by whoever answers the lookup first, and that party is not the project.
The related problem of a package that resolves correctly but is nonetheless the wrong choice — because it is unmaintained, or because its licence is incompatible — is a separate judgment that no lookup order can make. Choosing which version of a package to standardise on begins after identity is settled.
References
- npm docs: package scope, access, and naming — npm documentation
- PEP 508: Dependency specification for Python Software Packages — Python Software Foundation
- Dependency Confusion: How I Hacked Into Apple, Microsoft and Dozens of Other Companies — Alex Birsan, 2021