What is dependency confusion?

Your private package name, published on the public registry.

How a public package ends up installed in place of an internal one, the cases on record, and the settings that stop it in each package manager.

Questions and answers

What is dependency confusion?

Dependency confusion is an attack that gets a build to install a public package in place of a private one with the same name. The attacker learns the name of an internal package, publishes a package under the same name on the public registry, often with a very high version number such as 9000.0.0, and waits for a package manager configured to look in both places to pick the public copy. The public package’s install script or code then runs inside the company’s network. The term comes from Alex Birsan’s research, published in February 2021, which found the problem in more than 35 organisations across npm, PyPI and RubyGems, among them Apple, Microsoft, PayPal and Shopify. Unlike typosquatting, dependency confusion uses the exact private name, not a lookalike, so the name looks correct in every config file.

Where does the term come from?

From Alex Birsan’s research, published in February 2021. He found internal package names in package.json files that had been bundled into public JavaScript, as well as on GitHub and in forum posts. He then published packages under those names to npm, PyPI and RubyGems, each reporting only the username, hostname and current path of the machine that installed it, over DNS. Every company he tested had given permission through a bug bounty programme or a private agreement.

He found the problem in more than 35 organisations. Apple, PayPal and Shopify each paid a $30,000 bounty, and Microsoft paid $40,000. Shopify’s build system installed a gem named shopify-cloud a few hours after he uploaded it. Almost 75% of the callbacks came from npm packages, mostly because JavaScript dependency names were the easiest to find.

Why does a package manager pick the public package?

Because it was told to look in more than one place, and nothing tells it that the private copy should win. The details differ by ecosystem.

  • pip. --extra-index-url adds indexes alongside the main one. The pip documentation says “There is no priority in the locations that are searched”: pip takes the best matching version from any of them. Birsan’s example is a package published to PyPI as version 9000.0.0, which beats any internal version. The pip docs call this use of --extra-index-url unsafe and name it dependency confusion.
  • npm. A scope points to exactly one registry, so @yourco/* packages mapped to your registry are never fetched from the public one. An unscoped private name is looked up wherever unscoped packages go. If that is the public registry, or a repository that merges public and internal packages, a public package with the same name can be the one installed.
  • Maven. Maven queries repositories in order “until one returns a valid result”: global and user settings.xml, the project POM, its parent POMs, the standard repositories in the Super POM, then repositories declared in the POMs of your dependencies. A coordinate missing from the repository you meant can be found in another one.
  • NuGet. By default NuGet searches every configured source, and Microsoft’s documentation says that when a package exists on several of them, “it may not be deterministic which source the package will be downloaded from.”
  • Go. Module paths start with a domain, so a public module cannot take a private path’s name in the same way. The risk the Go documentation describes is fallback: with GOPROXY=https://proxy.corp.example.com,https://proxy.golang.org, a 404 from your proxy sends the request on to proxy.golang.org, along with the private module path.

Has it happened outside research?

Yes. Dependency confusion hit PyTorch users between 25 and 30 December 2022: anyone who installed PyTorch-nightly on Linux with pip also got a malicious torchtriton package from PyPI. PyTorch shipped a dependency named torchtriton on its own nightly index, someone uploaded a package with the same name to PyPI, and PyPI took precedence over the nightly index. The malicious binary collected system details, /etc/passwd, files in $HOME/.ssh and the first 1,000 files in $HOME, and sent the data out over encrypted DNS queries. PyTorch renamed the dependency to pytorch-triton and registered a placeholder torchtriton on PyPI so nobody could take the name again. The PyTorch case is the pattern Alex Birsan described in 2021, with a public package name winning over a private index.

How do you prevent dependency confusion?

You prevent dependency confusion by making sure each package name can only come from one place, owning your names on the public registries, and pinning what you install by hash. In practice, one source per name means a single --index-url instead of --extra-index-url for pip, a scope mapping in .npmrc for npm, a <mirror> whose <mirrorOf> is * in Maven’s settings.xml, package source mapping in NuGet’s nuget.config, and GOPRIVATE for Go. Owning your names means creating the @yourco organisation on npm and registering your groupId namespace with Sonatype on Maven Central. Pinning by hash means pip’s --require-hashes, npm’s package-lock.json installed with npm ci, and Go’s go.sum. Each defence is set per project or per machine, and the config for each one is below.

Use one source per name

  • pip: do not use --extra-index-url. Use one --index-url that serves your internal packages and PyPI and never serves a PyPI package under one of your internal names, such as a repository manager or proxy with those names reserved.
  • npm: map your scope to your registry in .npmrc with @yourco:registry=https://npm.yourco.example/.
  • Maven: send every request to one repository manager with a <mirror> whose <mirrorOf> is * in settings.xml. The Maven guide says this “is most useful when using an internal company repository” that proxies external requests.
  • NuGet: turn on package source mapping in nuget.config, so each package ID can only be restored from the sources you list for it:
<packageSourceMapping>
  <packageSource key="nuget.org">
    <package pattern="*" />
  </packageSource>
  <packageSource key="contoso.com">
    <package pattern="Contoso.*" />
  </packageSource>
</packageSourceMapping>

The most specific pattern wins, so Contoso.* packages only come from contoso.com. Mapping applies to restore, install and update; it is skipped for a package already in the global packages folder, so Microsoft suggests declaring a global packages folder for the repository.

  • Go: set GOPRIVATE=corp.example.com so private modules are fetched directly and never sent to the public proxy or checksum database, or point GOPROXY at one proxy that serves everything.

Own your names on the public registries

On npm, “only you can add packages in your scope”, so creating the @yourco organisation stops anyone else publishing @yourco/*. On Maven Central, a groupId such as com.yourco is a namespace that Sonatype only grants after you prove you own the domain with a DNS TXT record. PyPI has no namespaces, which is why PyTorch registered a placeholder package under the stolen name.

Pin with hashes

A lockfile that records a hash refuses a different file with the same name and version. pip’s hash-checking mode (--require-hashes, or any --hash=sha256: line) needs a hash for every requirement. npm’s package-lock.json records resolved and integrity for each package, and npm ci installs exactly that. Go checks every download against go.sum.

What do these defences miss?

Each one is set per project or per machine, and a single forgotten --extra-index-url or unscoped name reopens the gap. Scopes and namespaces only protect names under them. A lockfile protects versions already locked; adding a dependency or updating one resolves the name again. And none of them looks at what a public package does when it does get installed.

Where does a package firewall fit?

A firewall sits on the one URL every package manager uses, so the rule is applied once for the organisation instead of once per config file. In Chainsaw this is the reserved-namespaces setting: you declare patterns such as @acme/* on npm, com.acme.* on Maven or acme. on PyPI, and the proxy refuses any package from a public registry whose name matches. It is evaluated on every ecosystem, but only for the patterns you declare. Chainsaw ships a recommended starter pack and warns at startup when none is applied; it does not apply one by default, because it cannot know your internal names.

The lockfile scan checks where each locked package came from. If a package comes from a host your org has not declared as a mirror, its name also exists on the public registry, and the lockfile records no hash in a format that normally hashes every package, it is flagged as a medium dependency-confusion risk. On Cargo, npm and PyPI, a hash that matches no public release of that name and version is flagged high, as tampering or a private package shadowing a public name. In pull requests, chainsaw pr-scan flags a dependency whose source changes, for example from the registry to a git repository or to a different registry host. How the policy covers internal packages.

Keep the scope mappings, single index and source mapping as well. They still protect a machine that does not go through the firewall.

Sources

Internal names leak

Reserve your namespaces on every install path

Start a free org in monitor mode and see which installs Chainsaw would refuse, then turn rules on one at a time.