Supply Chain Security

What Is Dependency Confusion?

HuntRule Team · · 11 min read

Two identical parcels on a dark loading dock, one with a torn and crooked shipping label
On this page

pip install --extra-index-url https://pypi.internal.example.com/simple internal-auth-lib queries two indexes at once. pip's own documentation says what happens next.

There is no priority in the locations that are searched. Rather they are all checked, and the "best" match for the requirements (in terms of version number) is selected.

pip documentation, pip install, Finding Packages

That is the whole vulnerability. Nothing in the package is malformed. Nothing in the manifest is wrong. The resolver was handed two candidate sources for one name and told to break the tie on version number, so an attacker who publishes internal-auth-lib 9000.0.0 to PyPI wins the tie every time. The name is the only thing the attacker needs to guess.

The resolver is the vulnerability

Dependency confusion is a resolution bug in the client or the proxy, not a compromise of any package. There is no stolen maintainer token, no yanked-and-republished version, no typo. The internal package still sits in the internal index exactly as it did yesterday.

The payload lands because install is not a download. It is code execution. npm runs preinstall, install and postinstall lifecycle scripts through the configured script shell. Python source distributions execute setup.py in a subprocess during the build step. The moment the resolver picks the public candidate, the attacker has a shell on a build agent or a developer laptop, usually with registry credentials, a cloud metadata endpoint and the source tree within reach. MITRE tracks this as T1195.001, Compromise Software Dependencies and Development Tools, with the lifecycle script itself as T1059.004.

February 2021

Alex Birsan published the technique on 9 February 2021. The origin was mundane. During the summer of 2020, Justin Gardner shared a piece of internal PayPal Node.js source found on GitHub whose package.json mixed public npm dependencies with names that did not exist on the public registry.

Birsan uploaded packages under those unclaimed names to npm, PyPI and RubyGems. Each one had a preinstall script that logged the username, hostname and current path, hex-encoded the result and sent it out as a DNS query to an authoritative name server he controlled. DNS was chosen because it leaves well-protected corporate networks more reliably than HTTP.

The technique reached more than 35 organizations. Almost 75 percent of the logged callbacks came from npm packages, which Birsan attributes to javascript dependency names being easier to find rather than to npm being weaker. Shopify's build system installed a Ruby gem named shopify-cloud within a few hours of upload and tried to run the code inside it. Apple paid $30,000 after a Node package uploaded in August 2020 executed on multiple machines in its network, on projects that appeared to relate to Apple ID. Apple confirmed remote code execution on its servers would have been achievable and rejected the characterization of the impact as a backdoor. Shopify paid $30,000 as well.

Birsan also found the Python root cause by searching GitHub for the string --extra-index-url, which surfaced vulnerable scripts including a component of Microsoft's .NET Core build tooling.

How the names leak

The name is the precondition, so treat internal package names as semi-public and plan accordingly. Birsan's best source was not GitHub. It was compiled javascript.

  • Bundled front-end assets. Internal package.json contents get embedded into public script files during the build, and leaked internal paths or require() calls inside those bundles carry dependency names. Apple, Yelp and Tesla all had names exposed this way.

  • Public repositories containing internal manifests, lockfiles or Dockerfiles.

  • Internal packages accidentally published to the public registry, then unpublished. The name stays in the registry's history.

  • Container images. RUN pip install and RUN npm ci lines and the resulting node_modules tree survive in published layers.

  • Job postings and conference talks that name internal libraries.

  • Stack traces and verbose build logs pasted into public issue trackers.

What each package manager actually does

The behaviour differs per ecosystem, and the differences are the reason a single blanket fix does not exist.

pip merges indexes and picks the highest version. This is documented and intentional. The current docs carry an explicit warning on the --extra-index-url example: "Using the --extra-index-url option to search for packages which are not in the main repository (for example, private packages) is unsafe." The fix is --index-url, which replaces the index rather than adding to it, so the internal index becomes the only index and public dependencies must be mirrored through it.

npm does not merge two registries on its own. For an unscoped name it asks whichever single registry is configured and takes the answer. The confusion is usually introduced upstream, by a proxy that presents an internal repository and a public upstream through one endpoint and decides internally which version wins. Birsan called out JFrog Artifactory's ability to mix internal and public packages as one of the harder variants to mitigate. That resolution order is product-specific and we did not test any proxy for this post, so verify yours rather than assuming.

npm's structural answer is scopes. Each npm user and organization owns its scope, and only the owner can publish into it, so @myorg/internal-auth-lib cannot be registered by someone else. Bind the scope to the private registry and the name stops being ambiguous:

npm config set @myorg:registry=https://npm.internal.example.com

Any npm install for a package in that scope requests it from that registry instead of the default.

NuGet searches all configured sources by default, and when a package exists on more than one it is not deterministic which source it comes from. Package Source Mapping, added in NuGet 6.0 and requiring .NET SDK 6.0.100, nuget.exe 6.0.0 or Visual Studio 2022 or later, makes it deterministic per package pattern:

<packageSourceMapping>
  <packageSource key="nuget.org">
    <package pattern="*" />
  </packageSource>
  <packageSource key="contoso.com">
    <package pattern="Contoso.*" />
  </packageSource>
</packageSourceMapping>

Exact package IDs beat prefix patterns, the longest prefix beats shorter ones, and * always loses. The catch is version drift in tooling. Older tooling ignores the packageSourceMapping element entirely, so one build agent on an old SDK silently reverts to searching every source.

We could not confirm current Bundler and RubyGems resolution behaviour from official documentation while writing this, so we are not stating it. What is confirmed is that shopify-cloud was installed and executed through RubyGems within hours. Go and Cargo both take the ambiguity out of the name. A Go module path starts with the repository root path, the portion that corresponds to the root directory of the version control repository where the module is developed, so an internal module path is bound to a host an attacker does not control, and GOPRIVATE and GONOPROXY keep those paths off the public proxy. Cargo looks for dependencies on crates.io by default, and a dependency from any other registry has to name that registry with a registry key, so a dependency resolves against one registry rather than a merged set. We did not check Maven or Gradle at all.

Mitigations and their limits

Control

What it stops

Where it breaks

Scoped or namespaced names

Public registration of the same name

Only npm-style scopes are registry-enforced. Unscoped legacy names stay exposed

Source pinning (--index-url, source mapping)

Two candidates for one name

Requires mirroring every public dependency internally, and older tooling ignores the config

Defensive name reservation on the public registry

The specific names you thought of

You are guessing which names leaked. New internal packages are unprotected on day one

Lockfiles with integrity hashes

A swap after the lockfile is written

Does nothing for the first resolution of a new dependency, and npm install can still update a lockfile

Registry allowlist at the egress proxy

Any resolution from the public registry

Breaks legitimate first-party mirroring unless the internal proxy is the exception

None of these is complete on its own. Pin the source, then reserve the names you know leaked, then detect the residual.

Detection: egress from build agents

Start with the network, because the resolution decision happens before any payload runs. A build agent that is configured to pull from an internal proxy has no reason to talk to a public registry directly. Hunt that gap in proxy or flow logs:

SELECT src_host, dest_host, COUNT(*) AS hits, MIN(event_time) AS first_seen
FROM proxy_logs
WHERE dest_host IN (
        'registry.npmjs.org',
        'pypi.org',
        'files.pythonhosted.org',
        'rubygems.org',
        'api.nuget.org'
      )
  AND src_host IN (SELECT hostname FROM asset_inventory WHERE role = 'build_agent')
  AND event_time > NOW() - INTERVAL '7' DAY
GROUP BY src_host, dest_host
ORDER BY hits DESC

A clean result is zero rows. Any row means either a misconfigured agent or a resolver that reached past your proxy, and both are worth a ticket. The query needs an asset inventory that labels build agents. Without one, substitute a hostname pattern and accept the noise.

Detection: install-time process lineage

The second hook is the lifecycle script. npm's configured script shell defaults to /bin/sh on POSIX systems, which produces a shell child under the Node process during install. Confirm the shape on your own agents before you rely on it, because npm documents that default against npm exec, npm run and npm init rather than against install lifecycle scripts specifically.

title: Shell Spawned by Node Package Install
id: 6b2f9c41-3d7a-4e58-a0c9-1f8b5d27ae03
status: experimental
description: |
    Detects a shell spawned by a Node process whose command line indicates a
    package install. npm runs preinstall, install and postinstall lifecycle
    scripts through the configured script shell, so this is the point where a
    dependency-confusion package first executes code on a build agent.
references:
    - https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610
    - https://attack.mitre.org/techniques/T1195/001/
author: HuntRule
date: 2026-07-31
tags:
    - attack.initial-access
    - attack.execution
    - attack.t1195.001
    - attack.t1059.004
logsource:
    category: process_creation
    product: linux
detection:
    selection_parent:
        ParentImage|endswith:
            - '/node'
            - '/npm'
        ParentCommandLine|contains:
            - ' install'
            - ' ci'
            - ' add'
    selection_child:
        Image|endswith:
            - '/sh'
            - '/bash'
            - '/dash'
    condition: all of selection_*
falsepositives:
    - Native module builds. node-gyp, prebuild-install and sharp all shell out
      during a normal install
    - Monorepo tooling that runs workspace scripts as part of install
    - Container image builds where every layer runs an install
level: low

What it catches: the first execution point of any package that ships an install script, dependency confusion included. What it misses: pure Python installs (swap ParentImage for /python3 and /pip3, though pip's build isolation changes the lineage), Windows agents where the child is cmd.exe or powershell.exe, and any package whose payload runs at import time instead of install time.

This is a hunting rule, not an alert. Fired blind across a developer fleet it will bury you. Scoped to build agents, where the set of legitimately installing packages is fixed, the volume becomes a baseline you can diff. The signal is not the shell. It is a shell under an install of a package name that was not in yesterday's baseline.

The pivot after a hit is the grandchild process. Look for curl, wget, nslookup, dig or base64 running from that shell within a second or two. Birsan's packages used DNS, and DNS is still the exfiltration path that survives most egress filtering. Linux process lineage rules live at /rules?domain=linux, package ecosystem coverage at /rules?q=supply chain. For the wider context, see what is supply chain security.

Summary

Dependency confusion turns a private package name into a public claim on your build. The resolver picks the higher version, the higher version comes from the attacker, and the install script runs before anyone reviews a line of code. Fixing it means removing the ambiguity at the source, not scanning the package. Detection covers the residual: unexpected public-registry egress from build agents, and shells spawned under package installs.

Collect on build agents:
  process_creation with ParentImage, ParentCommandLine, CommandLine, CurrentDirectory
  DNS query logs (full query name, not just resolved IP)
  proxy or flow logs with destination hostname

Baseline weekly:
  set of package names installed per pipeline
  set of registry hostnames contacted per agent

Config to audit:
  pip:    --extra-index-url in any Dockerfile, CI YAML, requirements file or pip.conf
  npm:    unscoped internal names in package.json, registry value in .npmrc
  nuget:  nuget.config without a packageSourceMapping element
  proxy:  virtual repositories that mix an internal repo with a public upstream

MITRE ATT&CK:
  T1195.001  Compromise Software Dependencies and Development Tools
  T1059.004  Command and Scripting Interpreter: Unix Shell

Related articles