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-utilsresolves from your registry only where the.npmrcexists. A new laptop, a fresh CI image, or adocker buildthat neverCOPYs the file falls back toregistry.npmjs.org. If an attacker has publishedacme-utilsthere, 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.99outranks your1.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:
- One index, never two. Point
index-urlat 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/
- Hash pinning.
pip-compile --generate-hashespluspip install --require-hashesmakes 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-utilsto@acme/utilstouches 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:
- 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.
- 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.
- Namespace internal packages wherever the ecosystem supports it: npm scopes, Maven groupIds under your domain, Go module paths under your domain.
- 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.
- Pin and verify.
npm ciagainst a committed lockfile;--require-hashesfor pip. Integrity hashes freeze what was resolved; they are your defense-in-depth after resolution is fixed. - 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
.npmrcorpip.confmust arrive before the first install. Docker builds are the classic hole:COPY .npmrc .must precedeRUN 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 installthat 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.orgorpypi.orgacross 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-urlis 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.