Back to glossary

Dependency Confusion

Dependency confusion is an attack where a package manager installs an attacker's public package in place of a same-named private one.

Also known as dependency confusion attack, substitution attack, package substitution attack, dependency substitution, namespace confusion, npm dependency confusion

Dependency confusion, also called a substitution attack, is a supply chain attack in which an attacker publishes a public package under the exact name of a company's private package, and the company's own package manager installs it. Resolvers identify packages by name and version, not by source, so a merged or misrouted lookup runs the attacker's code.

The usual explanation, that package managers "prefer public registries," is wrong in a way that matters for the fix. Some clients pool every configured source and pick the best version; pip works this way by design. Others, like npm, send each name to exactly one registry and fail when that routing is wrong, or when a registry proxy silently merges public versions into a private name. By default, none of these clients asks where a name is allowed to come from.

Dependency confusion is still unfixed at the standards level. Python's proposed protocol fix, PEP 708, was rejected in April 2026 after three years without an implementation, and pip's documentation still says: "There is no priority in the locations that are searched." Attackers now register the npm scopes companies forgot to claim, not just the names.

Our position: dependency confusion is a resolver configuration bug first and a malware problem second. Bind every private name to exactly one source, then scan what still gets through. Detection alone loses, because a well-built confusion package can be a working wrapper around your real internal code.

What is dependency confusion?

#

Dependency confusion is the abuse of a public name that matches a private one. A package manager resolves a dependency by asking a registry for a name, reading the list of published versions, and picking one that satisfies the declared range. Companies publish internal libraries to private registries such as Artifactory, Nexus, or Verdaccio, under names like billing-utils or @acme/auth. Those names are private only in the sense that nobody has published them publicly yet.

Alex Birsan named and demonstrated the attack in February 2021. He harvested internal package names, mostly from package.json contents that build tools had embedded in companies' public JavaScript bundles. He then published same-named packages to npm, PyPI, and RubyGems with high version numbers and had them report back over DNS. His code ran inside more than 35 organizations, including Apple, Microsoft, Shopify, and PayPal, and Microsoft paid Azure's highest possible bounty, $40,000. Describing pip with an extra index, Birsan wrote: "If the package exists on both, it defaults to installing from the source with the higher version number."

Dependency confusion differs from typosquatting in one decisive way. A typosquat waits for a human to mistype a name. A confusion package carries the exact name your manifest already declares, so no review of the dependency list looks wrong, and your own code imports it.

Criminals adopted the technique. Socket's OAST exfiltration research documented an npm package impersonating Adobe APIs with versions 99.99.95 through 99.99.99, built to look like the newest release to people and resolvers alike. The Black Basta chat log leak of February 2025 showed a ransomware gang planning to test dependency confusion against npm and PyPI. Even security vendors misuse it: in January 2025, a vendor's research team published npm packages named after Cursor's bundled extensions that sent environment variables off the machine, without Cursor's permission.

Three resolver behaviors that let the public copy win

#

The dependency confusion cases in this article each exploit one of three resolver behaviors. We reproduced each one locally with current tools: pip 26.2.2, uv 0.11.32, npm 12.2.1, and Verdaccio 6.11.1. Figure 1 shows them side by side.

Failure 1 · Merging

pip pools every index

  • Private index: acme-billing-utils 1.4.2
  • Public index: acme-billing-utils 9000.0.0
  • One candidate pool, no source priority
  • Highest version that fits wins
Installs 9000.0.0 from the public index

Fix: one index, or a per-name source (uv)

Failure 2 · Misrouting

npm asks the wrong registry

  • Laptop .npmrc maps @acme to the private registry
  • CI image has no .npmrc
  • GET registry.npmjs.org/@acme%2fauth
  • Attacker registered @acme on npmjs.com
Installs the attacker's @acme/auth

Fix: commit the scope map, own the scope

Failure 3 · Proxy merge

The proxy adds upstream versions

  • Internal shared-utils 1.3.0, tagged latest
  • Proxy adds 66 public versions, up to 2.0.1
  • "^2.0.0": latest does not satisfy it
  • Highest public match is chosen
Installs public 2.0.1 via the proxy URL

Fix: no upstream proxy for private names

Figure 1. Three resolver behaviors behind dependency confusion. We reproduced the pip and proxy cases end to end, and the npm routing step, with pip 26.2.2, npm 12.2.1, and Verdaccio 6.11.1.

In words: pip pools every configured index into one candidate list with no source priority, so the private 1.4.2 loses to a public 9000.0.0. npm sends each name to one registry, so an environment without the scope mapping in .npmrc asks registry.npmjs.org, where an attacker may own the scope. A registry proxy that forwards to npmjs.com merges 66 public versions into an internal package's metadata; npm keeps the internal 1.3.0 for ranges that latest satisfies, and installs public 2.0.1 for a ^2.0.0 range, through the proxy's own URL.

pip merges every index and takes the highest version

#

pip treats --index-url and each --extra-index-url as a single pool. pip's documentation states the rule directly: all locations are checked, and the "best" match by version number is selected. To show it, we served two local indexes: a private one with acme-billing-utils 1.4.2, and a stand-in for PyPI with acme-billing-utils 9000.0.0.

Bash
$ pip --version
pip 26.2.2 from .../site-packages/pip (python 3.13)
$ pip install --index-url http://localhost:8001/ \
      --extra-index-url http://localhost:8002/ acme-billing-utils
Looking in indexes: http://localhost:8001/, http://localhost:8002/
Collecting acme-billing-utils
  Downloading acme_billing_utils-9000.0.0-py3-none-any.whl (1.3 kB)
Installing collected packages: acme-billing-utils
Successfully installed acme-billing-utils-9000.0.0
$ python -c "import acme_billing_utils as a; print(a.WHO)"
attacker

Nothing in that output flags the change of source. PyTorch shipped the real-world version of this in December 2022. Its nightly builds depended on torchtriton from PyTorch's own index, someone registered torchtriton on PyPI, and Linux users who installed nightlies with pip between December 25 and December 30 got the PyPI copy. PyTorch's disclosure lists what its binary uploaded over DNS: /etc/passwd, ~/.gitconfig, files under 99,999 bytes in ~/.ssh, and the first 1,000 files in the home directory.

npm routes each name to exactly one registry

#

npm does not merge registries in the client. Each name resolves against one registry: the one mapped to its scope in .npmrc, or the default registry for everything else. The same lookup, with and without the mapping:

Bash
$ npm view @acme-internal/billing-utils version --loglevel http
npm http fetch GET 404 https://registry.npmjs.org/@acme-internal%2fbilling-utils 170ms

$ echo '@acme-internal:registry=http://localhost:4873/' > .npmrc
$ npm view @acme-internal/billing-utils version --loglevel http
npm http fetch GET 404 http://localhost:4873/@acme-internal%2fbilling-utils 55ms

npm's one-registry-per-scope design is safer than pip's, and it fails in two ordinary ways. First, any environment that lacks the mapping sends internal names to registry.npmjs.org: a fresh CI runner, a Dockerfile that never copies .npmrc, a contractor's laptop. Unscoped internal names have no mapping at all. Second, a scope protects you only if you own it on the public registry. npm scopes are user and organization names on npmjs.com, and anyone can register an unclaimed one.

Registry proxies merge public versions into private names

#

Most companies do not point npm at two registries. They point it at one proxy that serves internal packages and forwards everything else upstream. As of October 2026, Verdaccio's default configuration proxies both scoped and unscoped names to npmjs.com:

YAML
packages:
  '@*/*':
    access: $all
    publish: $authenticated
    proxy: npmjs
  '**':
    access: $all
    publish: $authenticated
    proxy: npmjs

We published an internal shared-utils@1.3.0 to a Verdaccio instance with that configuration. The name also exists on npmjs.com, where it belongs to an unrelated audio utility with versions 0.0.2 through 2.0.1, which makes it a convenient stand-in for an attacker's package. The proxy merged them: the metadata it served listed 67 versions, one of them ours, with latest still pointing at 1.3.0. We then installed with npm 12.2.1 using different ranges.

Range in package.jsonVersion npm installedBytes came from
^1.3.01.3.0Internal package
*1.3.0Internal package
^2.0.02.0.1Public npmjs.com package
~0.1.00.1.8Public npmjs.com package

npm's resolver gives the latest dist-tag "top priority if it matches a supplied semver range," according to npm-pick-manifest, the library npm uses to choose versions. Our internal latest survived the merge, so every range it satisfied stayed internal. Every range it did not satisfy fell through to the highest matching public version. A service pinned to an older major of an internal library is enough, and so is a range an attacker read in a leaked manifest.

The lockfile then records the substitution as if nothing happened:

JSON
"node_modules/shared-utils": {
  "version": "2.0.1",
  "resolved": "http://localhost:4873/shared-utils/-/shared-utils-2.0.1.tgz",
  "integrity": "sha512-Vcs19dPxvGA7mQ5AuWqNuQl3YAPf+IbDajXOhiDYE1V5yQ9InxFKyrAUIwut/akGZlMV1zKP9nMgZB0pVwiUkg=="
}

The integrity hash is byte-for-byte the one registry.npmjs.org publishes for shared-utils@2.0.1, but the resolved URL names our proxy. A lockfile records which bytes you got and which server you asked, not where that server got them.

How does npm dependency confusion work in 2026?

#

npm dependency confusion in 2026 works by claiming the scopes and names companies left unregistered, then publishing versions built to win resolution. Two campaigns this year show the two halves of the technique.

Squatting internal-looking scopes

#

On May 29, 2026, Microsoft's Defender research team reported a campaign across nine npm scopes that looked like internal namespaces. It began with 33 packages on May 28 under scopes such as @cloudplatform-single-spa, and a second wave on May 29 added @t-in-one, @capibar.chat, and @sber-ecom-core. Microsoft's indicators include lure domains such as docs.t-in-one[.]io and jira.t-in-one[.]io, dressed up as internal infrastructure. A postinstall hook ran an obfuscated script that downloaded a platform-specific payload from oob.moika[.]tech and launched it as a detached process. Microsoft's summary: "The malicious code executes the moment a victim runs npm install; no require() from victim code is needed."

The version numbers tell you how the attackers think. Microsoft lists 100.100.100 for 26 packages, 99.5.7 and 99.5.8 for the @capibar.chat and @sber-ecom-core packages, and 3.5.22 and 5.7.1 for others. A version like 100.100.100 only wins against open ranges such as * or >=1.0.0; a caret range like ^3.5.0 rejects it. Ordinary-looking versions such as 3.5.22 look aimed at landing inside a particular range. That is our reading of the version choices, not a claim in Microsoft's report.

The registry keeps the timeline even after takedown. npm replaced each package with a 0.0.1-security holding package, but the time map in the package document survives:

Bash
$ curl -s https://registry.npmjs.org/@capibar.chat%2fui-kit \
    | jq '{description, "dist-tags", time}'
{
  "description": "security holding package",
  "dist-tags": { "latest": "0.0.1-security" },
  "time": {
    "created": "2026-05-29T23:51:25.914Z",
    "modified": "2026-05-29T23:51:31.440Z",
    "99.0.7": "2026-05-04T14:13:12.020Z",
    "99.5.7": "2026-05-29T09:04:22.444Z",
    "0.0.1-security": "2026-05-29T23:51:30.155Z"
  }
}

The 99.5.7 release was live for 14 hours and 47 minutes. The 99.0.7 release went up 25 days earlier and stayed until the same takedown; Microsoft calls the May 4 versions pre-staging. That pre-staged version matters for cooldowns, as Figure 2 shows below.

Wrappers that keep the build working

#

Socket's threat research team documented the other half in the Alibaba-targeted npm RAT cluster. Lures such as aone-kit and lib-mtop were unscoped names copied from private packages in Alibaba's @ali scope, and each lure declared the real @ali package as its own dependency. Inside Alibaba's network, where the private package resolves, the lure installed and worked. It also pulled in a three-package chain that read rules from a GitHub repository and escaped a Node.js vm sandbox to fetch a cross-platform remote access trojan. The chain was published on April 27 and 28, 2026. npm replaced smart-config-manager, its shared middle layer, with a security holder on July 28, 2026, 91 days later.

A wrapper that preserves functionality removes the one signal developers rely on, a broken build. It also defeats package-by-package review: local-config-parser did exactly what its description said. The malice existed only in how the packages combined, which is why we analyze what each package actually does in the context of its dependency graph, not what its metadata claims.

stopped by a 24-hour cooldown   outlives a 7-day cooldown

@capibar.chat/ui-kit@99.5.714 h 47 m
@capibar.chat/ui-kit@99.0.725 d 10 h
smart-config-manager@1.0.091 d 12 h
lib-mtop@1.0.1119 d 7 h
Figure 2. Time from publish to npm's security-holder takedown, from the registry's own timestamps. The pre-staged 99.0.7 release survives any cooldown that blocks 99.5.7.

Figure 2 data, from npm registry timestamps: @capibar.chat/ui-kit@99.5.7 was live 14 hours 47 minutes, @capibar.chat/ui-kit@99.0.7 25 days 10 hours, smart-config-manager@1.0.0 91 days 12 hours, and lib-mtop@1.0.1 119 days 7 hours, each measured to its 0.0.1-security replacement. Only the first falls under a 24-hour cooldown, and the other three outlive a 7-day cooldown as well. A dependency cooldown would have refused 99.5.7 and resolved 99.0.7 instead, which belonged to the same campaign.

Blocking install scripts does not stop dependency confusion

#

Blocking install scripts removes one trigger, not the attack. Since npm v12's switch to install scripts off by default in July 2026, dependency preinstall, install, and postinstall scripts are skipped unless approved. pnpm made the same change when pnpm 10.0.0 blocked lifecycle scripts by default in January 2025. Against the oob.moika[.]tech packages, that default skips the stager entirely. Default-on install scripts were a design mistake, and most supply chain attacks still execute at install time, so this change was overdue.

Dependency confusion is the case install-script blocking handles worst. A typosquat is installed by accident and may never be imported. A confusion package is installed because your code asked for it by name, so your code will load it. PyTorch's disclosure says the torchtriton binary ran when the triton package was imported, not at install. Moving the trigger from install time to import time costs an attacker almost nothing.

How do you prevent dependency confusion attacks?

#

You prevent dependency confusion attacks by binding every private name to exactly one source, then adding controls for the environments where that binding is missing. Each control below stops something and misses something.

ControlWhat it stopsWhat it misses
Pin each name to one source: npm scope mapping, uv explicit indexes, NuGet package source mapping, no upstream proxy for private namesIndex merging, misrouting, and proxy fall-through for every pinned nameAny machine or pipeline that does not carry the configuration
Claim your npm scopes and publish placeholder namesLookups that reach the public registry for names you claimedNames you forgot; PyPI has no namespaces to claim
Lockfile with integrity hashesSilent changes on reinstallThe first resolution and any regeneration; it records the proxy URL, not the origin
Release-age cooldownVersions younger than the threshold, like the 14-hour 99.5.7Pre-staged versions like 99.0.7, and campaigns live for months
Install scripts offpostinstall stagers like the May 2026 campaignCode that runs on import, like torchtriton
CI egress allowlistHTTP exfiltration to unknown hostsDNS exfiltration, which Birsan and torchtriton both used
Scan packages before installKnown-malicious and behaviorally malicious packages, whatever the routeThe routing bug itself, and wrappers whose files look benign one at a time
VerdictSource pinning is the only control that removes the cause. Deploy it everywhere first.Claim names as a backstop, scan for the residue, and treat lockfiles and cooldowns as damage limits, not fixes.

Pin each name to one source

#

For npm, put every internal package under a scope you own, and commit the scope mapping at the repository root so CI runners and container builds inherit it:

TOML, also INI
@acme:registry=https://npm.acme.internal/
//npm.acme.internal/:_authToken=${NPM_TOKEN}

If that registry is a proxy, give the private scope its own rule without a proxy line, so the proxy never asks npmjs.com about it:

YAML
packages:
  '@acme/*':
    access: $authenticated
    publish: $authenticated
  '**':
    access: $all
    publish: $authenticated
    proxy: npmjs

For Python, uv stops at the first index that has a name, a default its documentation says exists to prevent dependency confusion. The protection depends on index order, and the order surprises people. uv's index documentation treats --index-url as the lowest-priority default and searches --extra-index-url entries first. We ran the same command as the pip example under uv 0.11.32, with the public stand-in as the extra index, and uv installed 9000.0.0 too:

Bash
$ uv pip install --index-url http://localhost:8001/ \
      --extra-index-url http://localhost:8002/ acme-billing-utils
Resolved 1 package in 5ms
Prepared 1 package in 2ms
Installed 1 package in 0.58ms
 + acme-billing-utils==9000.0.0

The common pattern of a private --index-url plus PyPI as --extra-index-url is therefore unsafe under uv as well as under pip. Swapping them, with the private index as the extra, makes uv check it first, and in our test uv then installed 1.4.2; pip installs 9000.0.0 either way. Order is fragile, so pin internal names to an explicit index instead. With explicit = true, uv uses that index only for packages listed in tool.uv.sources, and the lockfile records the source:

TOML, also INI
[project]
name = "billing-service"
version = "0.1.0"
dependencies = ["acme-billing-utils"]

[[tool.uv.index]]
name = "internal"
url = "https://pypi.acme.internal/simple/"
explicit = true

[tool.uv.sources]
acme-billing-utils = { index = "internal" }

With the index URL pointed at our local private index, uv lock produced version = "1.4.2" with source = { registry = "http://localhost:8001/" } in uv.lock. pip has no per-name source. The safe pip setup is a single --index-url that points at a proxy serving internal names only from local storage. For .NET, NuGet added the same per-name binding as package source mapping in NuGet 6.0.

Does a lockfile prevent dependency confusion?

#

A lockfile prevents dependency confusion only after a clean first resolution, and it cannot tell you whether that resolution was clean. Our proxy test shows why: the resolved field names the registry you asked, not the one that served the bytes. We made the broader argument after the axios compromise: lockfiles make existing installs deterministic, but they do not make new installs safe, and code fetched by npx or a global install never appears in a project lockfile. Treat a clean lockfile as weak evidence, not proof.

Claim your names, then scan the rest

#

Register the npm organization behind every internal scope, and publish placeholder packages for internal names on npm and PyPI. PyTorch did exactly this after torchtriton, registering a dummy package on PyPI so the name could not be taken again. Name claiming is cheap and covers only the names you remember to claim.

Scanning covers mistakes in everything above. CVE scanners see nothing here, because a confusion package is malware published under your own package name, and malware does not get a CVE. A package firewall checks each package before it is installed. Socket Firewall can sit between an Artifactory or Nexus remote repository and the public registry, so a same-named public package has to pass a malware check before the proxy caches it. It does not fix the routing bug, and a wrapper whose files look benign one at a time is exactly where per-package verdicts are weakest. We treat it as the layer that catches what configuration misses, not a substitute for source pinning. Cooldowns help at the margin too, but npm's minimumReleaseAge launch in February 2026 came without a way to exempt internal packages.

Python still has no protocol-level fix

#

PEP 708 proposed letting a repository declare that its project tracks a same-named project elsewhere, or list alternate locations, so installers could refuse to merge unrelated projects that share a name. It was provisionally accepted on the condition that PyPI, at least one other repository, and pip implement it. That never happened, and after three years it was rejected in April 2026 "because the required conditions for acceptance were never met." As of October 2026, pip still merges indexes and picks by version.

The underlying flaw is that a package name carries no origin. npm scopes, NuGet source mapping, and uv's explicit indexes each attach an origin from the outside, in configuration that every machine has to carry and that a single fresh CI image can drop. Until resolvers refuse by default to take a name from anywhere but its declared source, every private package name is a public name that nobody has registered yet.