Dependency Confusion Attacks: When a Private Package Name Goes Public

How a resolver that consults a private index and the public registry installs the wrong package, the three states a private name can be in, and what to do about each.
A dependency confusion attack turns an unclaimed private package name into a build compromise. The three states of a private name, and how to close each.
November 6, 2026

Every organization that publishes internal packages has a list of names it never registered anywhere public. acme-auth-core on a private PyPI index, billing-utils on an internal npm registry. Those names feel private because the code is private. The name is not.

A dependency confusion attack starts with one of those names. Someone publishes a package under it on PyPI or npmjs with a version number higher than anything you have shipped. A build that consults both your private index and the public registry picks the higher version, downloads it, and runs its install hooks on your build machine.

This post explains how the attack works, the three states a private name can be in, why registry configuration alone does not close the gap, and a checklist for taking the names off the table.

How a dependency confusion attack works

Package managers resolve a name to a version. When more than one source can answer for the same name, the resolver has to pick, and most of them pick by version number, not by which source you trust more.

The attack needs three things to line up:

  • A resolver that consults more than one source. pip with an extra index, npm with a default registry and a private one, or a proxy registry that aggregates a local repository and a public upstream.
  • A public package with the same name and a higher version. Public registries accept most names on a first-come basis. Nothing stops a stranger from publishing acme-auth-core 99.0.0.
  • Code that runs at install time. A setup.py or a preinstall script executes on whatever machine performs the install, usually a CI runner holding deploy credentials and cloud tokens.

The 2021 research that named the technique showed it against real build pipelines at large companies, and the mechanism has not changed since. The resolver is doing exactly what it was configured to do. That is the uncomfortable part: there is no bug to patch, only a configuration and a naming decision that nobody made deliberately.

It also helps to be precise about what this is not. Dependency confusion is not typosquatting, where the attacker registers requets and waits for a typo. Confusion targets the exact name you already use, so the developer did nothing wrong and the lockfile may look identical to yesterday's.

The three states of a private package name

Treating "private name" as one risk category hides what matters. For any name you publish internally, there are three distinct states on the public registry, and each one calls for a different response.

Exposed

A public package with the same name exists, and its highest version is higher than anything your private registry has published. A resolver that sees both sources will install the public package. This is the state the attack needs, and it is the one to fix first.

Name clash

A public package with the same name exists, but your private registry publishes the highest version, so your package currently wins. The name is contested rather than lost. One public release with a bigger version number flips it to Exposed, and you will not get a notification when that happens unless you built one.

Unclaimed

No public package serves the name yet. This looks safe and is the most common state, which is why it gets ignored. It means anyone can publish the name tomorrow and go straight to Exposed. Unclaimed names are the inventory of future incidents.

A fourth outcome is worth naming: indeterminate. If you cannot read what your private registry has actually published for a name, you cannot say which state it is in. Treat "we could not check" as a finding in its own right, not as a pass.

Why registry configuration alone does not settle it

The usual answer to dependency confusion is "configure the package manager correctly." That is necessary. It is not sufficient, because the configuration lives in several places and each one has a way to leak back to the public registry.

pip: index-url versus extra-index-url

pip treats --index-url and --extra-index-url very differently, and the names make them sound interchangeable. index-url replaces PyPI as the single source. extra-index-url adds a source alongside PyPI, and pip considers every index equally when choosing the best version. The pip install reference documents both options.

A pip.conf that keeps PyPI in play looks like this:

[global]

extra-index-url = https://pypi.internal.example.com/simple

The same file with a single index, where the private index proxies PyPI under your control, looks like this:

[global]

index-url = https://pypi.internal.example.com/simple

The same choice appears in pyproject.toml for tools that read index settings there, in PIP_EXTRA_INDEX_URL environment variables set on CI images, and in requirements.txt files that start with an --extra-index-url line. A repository can be clean in one file and exposed in another.

npm: scoped registries in .npmrc

npm resolves unscoped names against the default registry. The only reliable way to pin a private package to a private registry is to put it under a scope and map that scope in .npmrc:

@acme:registry=https://npm.internal.example.com/

An unscoped private package named billing-utils has no such pin. Whichever registry is the default at install time answers for it. The npm docs on scopes and .npmrc describe where these settings can live: per project, per user, globally and in the npm build itself. The project file wins, but a developer's home directory or a CI image can carry a different answer.

Proxy registries pass public versions through

Many organizations already run a single index. It is a JFrog Artifactory virtual repository, a Sonatype Nexus group repository, or an AWS CodeArtifact repository with an upstream. The build talks to one URL, so the pip and npm configuration looks right. The question moves to what that URL answers.

A virtual or group repository aggregates members. A local member holds what you publish. A remote member proxies PyPI or npmjs. When a build asks for acme-auth-core, the virtual repository checks every member and, by default, returns the highest version it can find across them.

If the remote member can see a public package with a higher version, the build gets the public package. The single index did not remove the confusion; it hid it behind one hostname.

AWS CodeArtifact works the same way through upstream repositories and external connections to public registries. CodeArtifact adds package origin controls that can block upstream resolution for a package, which is the right tool, but it has to be set for each name, and a name that was never published locally has no origin configuration to block anything.

So "we use one index" answers the pip question and raises the proxy question. The check that matters is: for each private name, what does the aggregating repository answer, and where did that version come from, a local member or a public upstream?

What to do about dependency confusion

The work splits into knowing your names, taking them off the public registry where you can, and configuring resolvers so the public registry cannot win where you cannot.

  1. Inventory your private names. List every package your organization publishes to a private index, and every name your repositories consume that is not on the public registry. The second list is often longer than the first, because it includes names from retired teams and vendored forks.
  2. Classify each name as Exposed, Name clash or Unclaimed. Query the public registry for each name and compare the highest public version with the highest version in your local repositories. Exposed names get fixed this week.
  3. Claim or reserve names on the public registry where policy allows. Publishing a placeholder under your organization's account closes the Unclaimed state for good. Some organizations forbid this, and some registries have rules about placeholder packages, so check both before you start.
  4. Scope every npm package. Move unscoped private packages under an organization scope, reserve that scope on npmjs, and pin the scope in the project .npmrc. Scoping is the one npm control that makes the default registry irrelevant for the name.
  5. Prefer a single index with upstream control. Use index-url, not extra-index-url, and point it at a proxy you administer. Then configure that proxy so local packages win: exclude patterns on the remote member, priority resolution on the virtual repository, or origin controls in CodeArtifact.

Verify, alert and pin

The first five steps change what exists and where resolvers look. The last three catch the cases that change without you.

  • Verify what your virtual repository answers for each name. Do not trust the configuration page. Request each private name through the same URL your build uses and confirm the version and the member it came from.
  • Alert when a public package appears for a private name. Name clash and Unclaimed are states that change without any action on your side. A recurring check against the public registry, with an alert on a new or higher public version, is the control that catches the attack before a build does.
  • Pin and hash where the ecosystem supports it. Hash-checked requirements in pip and lockfiles with integrity fields in npm do not prevent confusion on a fresh resolution, but they make a swapped package fail loudly on every install after the first.

The OWASP npm security cheat sheet covers the npm-side controls in more depth, and the general practice belongs with the rest of your supply chain security program rather than as a one-time audit.

Where a private name fits in the rest of the supply chain

Dependency confusion sits between two problems that get more attention. On one side are compromised public packages: a maintainer account is taken over and a release everyone already depends on turns malicious. On the other is first-party code: the libraries your own teams publish and consume, which most scanners treat as a blind spot. We covered the second in first-party dependency security.

Confusion is the seam between them. It targets first-party names and arrives through the public registry. A program that scans public dependencies for CVEs and treats internal packages as trusted by definition will not see it, because the malicious package carries your name and no advisory.

That is why the verdict has to come from registry data on both sides. What has your private registry actually published for the name, and what does the public registry answer for it today? Any answer that rests on reading configuration files alone will miss the proxy that passes public versions through, and will miss the day a Name clash becomes Exposed.

How Heeler shows exposed private package names

Heeler added a Dependency Confusion view under Security > Supply Chain, beside Compromised Dependencies. It lists the first-party package names in your repositories that a public registry also serves, or that nobody has claimed there, with one row per package name per module.

Each row carries a level: Exposed, Name clash or Unclaimed, with the same meanings as above. The verdict is built from registry data, the versions your private registries have published for the name against what the public registry answers for the same name. A package manager setting on its own does not raise a finding. PyPI and npm packages are covered, and for an npm scoped name the verdict also records whether the scope itself exists on npmjs.

The Config column names the setting that exposes each name, such as an extra index, a fallback registry or a missing scope pin, and a collapsible Config reference explains each value by ecosystem, PyPI and npm, with how to close it. Filters cover the finding, package, config setting and the repository's tier, environment, visibility and team, and each row links to the module where the package is consumed.

Reading the private side from your registries

The private side is read from the registries you have connected. JFrog Artifactory virtual repositories, Sonatype Nexus group repositories and AWS CodeArtifact are supported, and only versions published to the registry itself count as private; versions mirrored from the public registry do not. A connection that passes the public registry's versions through to your builds is named on the finding. When a private registry does not answer, no verdict is recorded for the names it covers, rather than a false pass.

One setup note for AWS CodeArtifact: the dedicated CodeArtifact role needs codeartifact:ListPackageVersions, codeartifact:DescribePackage and codeartifact:GetAssociatedPackageGroup to read published versions and origin controls. A role without them still pulls packages, the registry shows Degraded with the missing actions named, and every CodeArtifact name reads as indeterminate until they are granted. The view is part of Heeler's SCA coverage and draws on the same Context Engine inventory of repositories, modules and registries.

FAQ

What is a dependency confusion attack?

A dependency confusion attack is when an attacker publishes a public package under the name of a private internal package, with a higher version, so that a build resolving against both sources installs the attacker's package. The install hooks then run on the build machine.

Is index-url or extra-index-url the safe pip setting?

index-url replaces PyPI with a single source; extra-index-url adds a source alongside PyPI and lets the highest version win across both. Use index-url pointed at a proxy you control, then make sure the proxy itself does not pass public versions through for private names.

Does using a JFrog virtual repository or Nexus group prevent dependency confusion?

Not by itself. A virtual or group repository aggregates local and remote members and by default answers with the highest version across them, so a public package with a higher version can still win. Configure exclusion patterns, priority resolution or origin controls so local packages win, then verify what the repository actually returns for each private name.

Should I register my private package names on PyPI and npm?

Where policy allows, yes: claiming the name or the npm scope under your organization's account closes the Unclaimed state permanently. Check your registry's rules on placeholder packages and your own publishing policy before you do.

What is the difference between Exposed, Name clash and Unclaimed?

Exposed means a public package with a higher version already wins. Name clash means a public package exists but your private version is currently higher. Unclaimed means no public package exists yet, so anyone could publish one.

Private names are only private until someone publishes them. If you want to see which of your package names are Exposed, Name clash or Unclaimed today, read from the registries you already run. Get a demo

What’s new on Heeler
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Related resources

See All Resources