# 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

Source: https://socket.dev/glossary/dependency-confusion

Last updated: 2026-10-26

Dependency confusion, also called a substitution attack, is a [supply chain attack](https://socket.dev/glossary/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](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610). 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](https://socket.dev/glossary/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](https://socket.dev/blog/weaponizing-oast-how-malicious-packages-exploit-npm-pypi-and-rubygems) 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](https://socket.dev/blog/black-basta-dependency-confusion-ambitions-and-ransomware-in-open-source-ecosystems) 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](https://socket.dev/blog/the-risks-of-misguided-research-in-supply-chain-security) 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.

> 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.
>
> Three resolver behaviors that let a public package replace a private one: pip index merging, npm scope misrouting, and registry proxy merging

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](https://pip.pypa.io/en/stable/cli/pip_install/) 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](https://pytorch.org/blog/compromised-nightly-dependency/) 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.json | Version npm installed | Bytes came from |
| --- | --- | --- |
| ^1.3.0 | 1.3.0 | Internal package |
| \* | 1.3.0 | Internal package |
| ^2.0.0 | 2.0.1 | Public npmjs.com package |
| ~0.1.0 | 0.1.8 | Public 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](https://github.com/npm/npm-pick-manifest#readme), 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](https://socket.dev/glossary/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](https://www.microsoft.com/en-us/security/blog/2026/05/29/33-malicious-npm-packages-abuse-dependency-confusion-profile-developer-environments/) 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](https://socket.dev/blog/npm-rat-targets-alibaba). 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.

> 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.
>
> How long four 2026 dependency confusion package versions stayed live on npm, on a log scale, compared with 24-hour and 7-day cooldowns

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](https://socket.dev/glossary/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](https://socket.dev/blog/npm-12) 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](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) in January 2025. Against the `oob.moika[.]tech` packages, that default skips the stager entirely. Default-on [install scripts](https://socket.dev/glossary/postinstall-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.

| Control | What it stops | What it misses |
| --- | --- | --- |
| Pin each name to one source: npm scope mapping, uv explicit indexes, NuGet package source mapping, no upstream proxy for private names | Index merging, misrouting, and proxy fall-through for every pinned name | Any machine or pipeline that does not carry the configuration |
| Claim your npm scopes and publish placeholder names | Lookups that reach the public registry for names you claimed | Names you forgot; PyPI has no namespaces to claim |
| Lockfile with integrity hashes | Silent changes on reinstall | The first resolution and any regeneration; it records the proxy URL, not the origin |
| Release-age cooldown | Versions younger than the threshold, like the 14-hour 99.5.7 | Pre-staged versions like 99.0.7, and campaigns live for months |
| Install scripts off | postinstall stagers like the May 2026 campaign | Code that runs on import, like torchtriton |
| CI egress allowlist | HTTP exfiltration to unknown hosts | DNS exfiltration, which Birsan and torchtriton both used |
| Scan packages before install | Known-malicious and behaviorally malicious packages, whatever the route | The routing bug itself, and wrappers whose files look benign one at a time |
| Verdict | Source 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:

```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](https://socket.dev/glossary/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](https://docs.astral.sh/uv/concepts/indexes/) 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
[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](https://socket.dev/glossary/pip-python-package-index) 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](https://learn.microsoft.com/en-us/nuget/consume-packages/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](https://socket.dev/blog/hidden-blast-radius-of-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](https://socket.dev/glossary/package-firewall) checks each package before it is installed. [Socket Firewall](https://docs.socket.dev/docs/socket-firewall-enterprise-registry-mode-upstream-deployment-guide) 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](https://socket.dev/blog/npm-introduces-minimumreleaseage-and-bulk-oidc-configuration) in February 2026 came without a way to exempt internal packages.

## Python still has no protocol-level fix

[PEP 708](https://peps.python.org/pep-0708/) 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.
