Dependency confusion: how package resolvers choose the attacker's version

libertas_quae_s1 pts0 comments

Blog — Omni Line

dependency-confusion-how-package-resolvers-choose-the-attackers-version

Sign in

Get started

Sign in

Back

The cheapest supply-chain attack ever published

In February 2021, security researcher Alex Birsan collected more than $130,000 in bug bounties from Apple, Microsoft, Shopify, PayPal, and dozens of other companies by doing something every engineer does weekly: publishing packages.

There was no exploit in the classic sense. Birsan found internal package names in public artifacts—package.json files shipped inside JavaScript bundles, requirements files committed to public repos, paths leaked in error messages. He registered those exact names on the public npm, PyPI, and RubyGems registries, gave them absurdly high version numbers, and waited. Build servers inside corporate networks installed his packages, ran his install scripts, and phoned home over DNS.

Dependency confusion is not a bug in any single tool. It is a design property: when the same package name exists in two places, something has to choose which one wins. The attack simply rents that choice. Five years later it still works, because the mitigations are per-ecosystem, per-config-file, and easy to regress with one careless line in a CI pipeline.

This post walks through where the choice actually happens in each ecosystem, and how to make it deterministic.

Where the choice actually happens

"Package managers are insecure" is not a useful model. Each resolver has specific, documented behavior, and the failure modes differ enough that a fix for one ecosystem does nothing for the next.

npm: one name, one registry—until a middle layer merges them

npm never races two registries for the same name at install time. Every package name maps to exactly one registry: the default, or whatever a scope mapping in .npmrc says. Confusion enters through the gaps around that rule:

Unscoped internal names on an unconfigured machine. Your internal acme-utils resolves from your registry only where the .npmrc exists. A new laptop, a fresh CI image, or a docker build that never COPYs the file falls back to registry.npmjs.org. If an attacker has published acme-utils there, the install succeeds—which is exactly the problem. Nothing fails, nobody looks.

Merging middle layers. Some virtual-repository setups historically resolved by comparing versions across internal and public members. That reintroduces the race npm itself avoids: the attacker's 99.99.99 outranks your 1.4.2.

Transitive spread. An internal package that depends on other unscoped internal packages exports the problem to every consumer. One misconfigured machine resolves the whole internal subtree from the public registry.

The structural fix is scoping. A scoped name is pinned to a registry explicitly:

# .npmrc<br>@acme:registry=https://registry.example.com/acme/npm/<br>//registry.example.com/:_authToken=${NPM_TOKEN}

Then register the @acme organization on npmjs.com. Nobody else can publish under a scope you own, so even a machine with no .npmrc at all cannot be handed an attacker's @acme/utils—the install fails loudly instead of succeeding quietly. Scope reservation is one of the few defenses that is structural rather than configurational.

pip: --extra-index-url is a version auction

pip has no notion of index priority. --index-url and every --extra-index-url contribute candidates to a single pool, and pip picks the best version across all of them. This is documented behavior, not a bug. The common pattern—public PyPI as the index, internal index as the extra—means your internal acme-billing 1.4.2 loses to a public acme-billing 99.0.0 every time. Attackers know the convention and publish absurd versions on purpose.

PEP 708 adds repository metadata ("tracks") designed to close this hole, but support across tools remains incomplete; do not build your defense on it yet.

Two fixes compose here:

One index, never two. Point index-url at a single endpoint that serves your internal names itself and proxies PyPI for everything else. The internal-versus-public decision moves server-side, into a rule you control, instead of being re-decided by version comparison on every developer machine.

# pip.conf<br>[global]<br>index-url = https://registry.example.com/acme/pypi/simple/

Hash pinning. pip-compile --generate-hashes plus pip install --require-hashes makes pip reject any artifact whose hash was not pinned at compile time. This does not prevent confusion at pin time, but it freezes resolution afterwards—a swapped artifact fails the install instead of shipping.

Everyone else, briefly

Ecosystem<br>Behavior<br>Defense

Maven<br>Repositories consulted in declared order, but snapshots and version ranges leave room<br>mirrorOf=* to force one endpoint

NuGet<br>Multiple sources race by default<br>Package Source Mapping (NuGet 6+) pins name patterns to sources

Go<br>Module paths are URLs, so ownership is structural<br>Explicit GOPROXY fallback semantics; GOPRIVATE for internal paths

RubyGems<br>source blocks in the Gemfile...

internal registry package acme index public

Related Articles