How does dependency confusion work?
A package manager asked for acme-billing-utils looks it up by name, and if it can see both your private registry and a public one, it can pick the public copy. The attacker never touches your network: they register the name you forgot to claim, publish a higher version, and wait for your build to install it.
The attack has three ingredients:
- An internal package name that leaks. Names show up in
package.jsonfiles committed to public repositories, in bundled JavaScript and source maps served to browsers, in error messages, and in job posts. - A resolver that can reach the public registry for that name. This is a configuration problem, and it looks different in each ecosystem (below).
- Code that runs on install. npm runs
preinstallandpostinstallscripts by default, and a Python source distribution runssetup.pyduring the build. The payload executes before anyone imports the package.
Alex Birsan published the technique on February 9, 2021. He collected internal package names from files like those above, published same-named packages to npm, PyPI and RubyGems, and used install-time scripts that reported back over DNS, which tends to leave corporate networks when other protocols are blocked. The callbacks came from inside more than 35 organizations, including Apple, Microsoft, PayPal, Shopify, Netflix, Tesla, Uber and Yelp, and the research earned over $130,000 in bug bounties. Every target had authorized the testing.
How do npm and pip decide where a package comes from?
npm maps each scope to one registry and sends unscoped names to the default registry, while pip checks every index it is given and takes the highest version it finds anywhere. Those two behaviors produce different failure modes.
npm. A name like @acme/billing-utils resolves against whatever registry is configured for @acme. An unscoped name like acme-billing-utils resolves against the single default registry. Confusion happens when that default points at the public registry on some machine (a new laptop, a CI image without the project's .npmrc), or when a proxy repository merges your private packages with a mirror of npmjs.org under one URL and serves whichever version is newest.
pip. The pip documentation is explicit: locations are not searched in priority order, they are all checked, and the best match by version number wins. It also warns that using --extra-index-url to find private packages "is unsafe", naming dependency confusion as the reason. So this common setup is exploitable:
# Vulnerable: pip queries both indexes and installs the higher version
pip install acme-billing-utils \
--index-url https://pypi.org/simple \
--extra-index-url https://pypi.acme.example/simpleIf pypi.acme.example holds acme-billing-utils 1.4.2 and an attacker publishes 99.0.0 to PyPI, pip installs the attacker's 99.0.0.
How do you test for dependency confusion?
Collect every internal package name you can find, then ask each public registry whether the name is free. An unclaimed name that one of your build configurations would fetch from the public registry is a finding.
- List internal package names from lockfiles and manifests (
package-lock.json,yarn.lock,requirements*.txt,poetry.lock,uv.lock) across all repositories, including archived ones. - Check what outsiders can see: search public repositories for your org's manifest files, and grep your production JavaScript bundles and source maps for
node_modules/paths. - Query the public registry for each unscoped name. A 404 means anyone can register it:
GET /acme-billing-utils HTTP/1.1
Host: registry.npmjs.org
Accept: application/json
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error":"Not found"}GET /pypi/acme-billing-utils/json HTTP/1.1
Host: pypi.org
HTTP/1.1 404 Not Found
Content-Type: application/json
{"message": "Not Found"}- For scoped npm names, confirm your organization owns the scope on npmjs.com. An unowned scope is as exposed as an unscoped name.
- Read every resolver configuration that builds your code:
.npmrcat project, user and CI level,pip.conf,PIP_EXTRA_INDEX_URLin CI variables, Dockerfiles, and the proxy repository's upstream order. You are looking for any path where a private name can reach a public source. - If your rules of engagement allow it, publish a placeholder with a benign install script that only makes a DNS lookup to a domain you control, to prove which build systems fetch it. Coordinate this first, since it executes code on other people's machines.
How do you fix dependency confusion?
Make it impossible for a private name to resolve from a public source, at the client and at the registry. Claiming the name publicly helps, but configuration is the real fix.
npm: scope everything and pin the scope to your registry. Move internal packages under a scope your organization owns on npmjs.com, and commit an .npmrc to each repository:
# .npmrc (committed to the repo)
@acme:registry=https://npm.acme.example/
//npm.acme.example/:_authToken=${NPM_TOKEN}With this line, npm fetches every @acme/* package from npm.acme.example only. Owning the @acme org on npmjs.com means no one else can publish @acme/billing-utils there either. Scope the auth token to the registry host, as shown, so npm never sends it to the public registry.
pip: one index, which you control. Point --index-url at a private proxy that serves your packages and mirrors PyPI, and drop --extra-index-url entirely:
# /etc/pip.conf or pip.conf in the CI image
[global]
index-url = https://pypi.acme.example/simpleAdd hash pinning (pip install --require-hashes -r requirements.txt) so a swapped artifact fails the install. If you use uv, its default first-index strategy only considers versions from the first index that contains a package, and you can pin a package to a named index with tool.uv.sources in pyproject.toml.
Registry side. The proxy has to refuse to look upstream for your names. In Sonatype Nexus, add a routing rule in BLOCK mode on the proxy repository matching your namespace. In Artifactory, add your prefix to the remote repository's exclude patterns. Test the rule by requesting an internal name through the proxy's public-mirror path and confirming it fails.
Defensive registration. Publishing empty placeholder packages under your unscoped internal names on npm and PyPI closes the gap for names you cannot rename yet. Treat it as a stopgap: it does nothing for names you have not found.
What does not work: a higher internal version number (the attacker picks 99.0.0), and relying on code review, since the malicious code never appears in your repository.
Dependency confusion vs typosquatting
Typosquatting waits for a human to mistype a public package name (reqeusts for requests). Dependency confusion needs no mistake: the build asks for the exact right name and the resolver picks the wrong source. The fixes differ too. Typosquatting is caught by reviewing new dependencies; dependency confusion is fixed in resolver and registry configuration. Both belong to the same family of pipeline attacks as poisoned pipeline execution, where the attacker gets code run by your CI without access to it.
[ Sources ]
- Alex Birsan: Dependency Confusion: How I Hacked Into Apple, Microsoft and Dozens of Other Companies (2021)
- pip documentation: pip install, finding packages and --extra-index-url
- npm documentation: scope and scoped registries
- OWASP Top 10 CI/CD Security Risks: CICD-SEC-3 Dependency Chain Abuse
- uv documentation: package indexes and index strategy
- Sonatype Nexus Repository: routing rules
Written by Parameter · Last reviewed

