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

Omni Line ·

9 min read Original article ↗

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
@acme:registry=https://registry.example.com/acme/npm/
//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:

  1. 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
[global]
index-url = https://registry.example.com/acme/pypi/simple/
  1. 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 Behavior Defense
Maven Repositories consulted in declared order, but snapshots and version ranges leave room mirrorOf=* to force one endpoint
NuGet Multiple sources race by default Package Source Mapping (NuGet 6+) pins name patterns to sources
Go Module paths are URLs, so ownership is structural Explicit GOPROXY fallback semantics; GOPRIVATE for internal paths
RubyGems source blocks in the Gemfile pin gems to a source Never declare internal gems outside a block
Docker Image refs include the registry host—no cross-registry race Unqualified names default to Docker Hub; qualify FROM lines

Go is worth a sentence of admiration: naming modules by URL makes squatting a domain-ownership problem instead of a first-come-first-served race. Every ecosystem designed since has had the chance to learn from this; most have not.

Constraints

If the fix were "rename everything and configure every machine," this post could end here. Real organizations hit four constraints:

  • Renaming is a migration, not an edit. Moving acme-utils to @acme/utils touches every consumer's manifest and lockfile. With hundreds of internal packages, this is quarters of work that must be sequenced, not a sprint task.
  • Configuration lives everywhere. Laptops, CI images, Dockerfiles, base images, ephemeral runners. Any fix that requires a file to be present on every machine will regress the week someone rebuilds a CI image from a public base.
  • Ecosystem sprawl multiplies the surface. A polyglot org repeats this exercise per ecosystem, each with its own config file, its own escaping rules, its own failure mode.
  • The failure is silent. A successful install of the wrong package is indistinguishable from a successful install. You will not get a stack trace; you will get a DNS beacon from a build server, if you are lucky enough to be looking.

Making resolution deterministic

The procedure, in dependency order:

  1. Inventory your names. List every internal package name per ecosystem and check each against the public registry. Every unclaimed name is standing attack surface. Automate this—the inventory rots the week a new service ships.
  2. Reserve the namespace publicly. Register your npm scope as an org on npmjs.com. PyPI has no generally available scope equivalent (namespace reservation is being explored via PEP 752), so claim the exact names you use—or stop leaking them into public artifacts at all.
  3. Namespace internal packages wherever the ecosystem supports it: npm scopes, Maven groupIds under your domain, Go module paths under your domain.
  4. Collapse to one resolution endpoint per ecosystem. One URL that serves internal packages and proxies the public registry for the rest. The internal/public decision becomes a server-side policy—by name, in a fixed priority order—auditable in one place instead of N config files. Critically, the rule must be name-level: if a name exists internally, the upstream is never consulted for it, at any version.
  5. Pin and verify. npm ci against a committed lockfile; --require-hashes for pip. Integrity hashes freeze what was resolved; they are your defense-in-depth after resolution is fixed.
  6. Add tripwires in CI. Resolution is configuration, and configuration regresses. Make the pipeline prove where every artifact came from:
# fail if any dependency resolved outside the internal registry
if jq -r '.packages[].resolved? // empty' package-lock.json |
    grep -qv '^https://registry.example.com/'; then
  echo "dependency resolved outside the internal registry" >&2
  exit 1
fi
# pip: audit resolved download URLs without installing
pip install --dry-run --quiet --report report.json -r requirements.txt
jq -r '.install[].download_info.url' report.json |
  grep -v '^https://registry.example.com/' && exit 1

Failure modes

Each fix has a way of quietly not working:

  • Version-level fall-through. A merging layer that checks upstream per version—internal has 1.4.2, so the request for 99.0.0 goes upstream—reintroduces the auction with extra steps. Verify your registry's semantics: existence of the name internally must stop upstream lookups entirely.
  • The bootstrap gap. The .npmrc or pip.conf must arrive before the first install. Docker builds are the classic hole: COPY .npmrc . must precede RUN npm ci, and the token should come in as a build secret, not baked into a layer.
  • Migration tail. After moving to scoped names, the old unscoped name still exists in old branches, cached lockfiles, and forgotten cron jobs—and is still claimable publicly. Deprecate the old name internally and keep the public claim on it indefinitely.
  • Inventory rot. Defensive registration of today's names does nothing for the package a new team ships next month. The inventory check has to run on a schedule, not live in a spreadsheet.
  • Laptops. Developers with a global config pointing at the public registry bypass everything. Make the internal endpoint the path of least resistance: onboarding scripts, template repositories, and a registry URL that handles both internal and public traffic so there is never a reason to configure anything else.
  • Trusting the lockfile too early. Integrity hashes pin content only after first resolution. The npm install that writes the lockfile entry is exactly where confusion strikes. Treat resolved-URL changes in lockfile diffs as review-worthy, and let the CI check above catch what review misses.

What good looks like

You are done when these are observable, not asserted:

  • Every internal package name is either namespaced under something you own or explicitly claimed on the public registry—verified by a scheduled job.
  • Every manifest and CI config points at exactly one resolution URL per ecosystem; a grep for registry.npmjs.org or pypi.org across your repos returns nothing.
  • CI fails any build whose lockfile resolves outside your registry.
  • Your registry's access logs can answer "which builds pulled package X in the last 24 hours"—the question you will need answered within minutes on the day a collision alert fires.

Where a consolidated registry fits

Most of the fixes above converge on one structural claim: the internal-versus-public decision has to happen server-side, behind a single URL, under a rule you can audit—not in a config file replicated across every machine. That is what registry virtual modes exist for. In Omni Line, a virtual registry aggregates members in an explicit priority order behind one URL—your hosted internal registry first, a proxy of the public upstream second—so a public copy of an internal name never outranks yours, and clients configure one endpoint and one token per ecosystem (npm, PyPI). Because it is self-hosted, the resolution policy and the access logs that answer "who pulled what" stay on your infrastructure.

Takeaways

  • Dependency confusion is a resolution problem: when a name exists in two places, something chooses. Find where that choice happens in each of your ecosystems.
  • npm scopes and Go module paths are structural defenses—own the namespace and the race disappears. Claim your npm org today; it costs nothing.
  • --extra-index-url is a version auction with attackers bidding. One index URL, always.
  • Server-side, name-level priority beats client-side configuration: one auditable rule instead of a config file on every machine.
  • Resolution config regresses silently. Put tripwires in CI that verify where artifacts actually came from, and keep the name inventory on a schedule.