DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

Dependency Confusion: When the Public Registry Overwrites Your Private Package

In December 2022, 2,717 developers installed a PyPI package named torchtriton that immediately read their SSH keys, environment variables, and the first 1,000 files in their home directory. Exfiltration ran over encrypted DNS to *.h4ck[.]cfd. The attacker's only prerequisite was knowing the package's name.

Dependency confusion is a structural flaw in how package managers resolve ambiguity between public and private registries. The attack requires only a package name discovered in an error message, JS bundle, or public repository to execute. No credentials. No access to the internal network.

Version wins the tie: the root cause is the resolution algorithm

Package managers were designed to fetch the highest available version across all configured sources. That logic worked well when private registries did not exist at scale. Today, it creates an exploitable guarantee for any attacker who controls a package name.

In npm, when a scope is not pinned, the client queries all configured registries and installs the highest semver version. If @company/internal-lib@1.0.0 exists in the private registry and @company/internal-lib@9.9.9 appears on npmjs.com, npm installs the public version. There is no tiebreaker by registry origin. Priority goes entirely to the version number.

pip follows the same logic with --extra-index-url: PyPI takes precedence over the private index by default. PyTorch documented the mechanism after the December 2022 incident. The project confirmed: "Since the PyPI index takes precedence, this malicious package was being installed instead of the version from our official repository." The attacker controls the version number. 9.9.9 on public beats 1.0.0 on private, deterministically.

Maven and NuGet follow the same logic in their contexts. In Maven, repository priority in settings.xml determines search order, but version arbitration still applies when the same artifact appears in multiple repositories. In NuGet, nuget.config allows configuring packageSources priority, but the default selects the highest version available across all active sources.

This is not a bug. It is the intended behavior for a world where private registries did not exist at scale. The attack exploits a design assumption, not a code defect.

One researcher, $130,000, 35 companies, and the attack still works in 2026

Alex Birsan published his research in February 2021. He registered public packages with names matching private package names found in metadata from Apple, Microsoft, Netflix, Shopify, Yelp, and 30 other companies. The names came from public package.json files and CI error messages indexed on the open web.

Total bug bounties: $130,000. Microsoft paid $40,000. Apple paid $30,000. Both ran internal registries with unscoped names. The payload was a preinstall script that exfiltrated hostname, username, current directory, and network information over DNS. No user interaction was required beyond npm install.

In May 2026, Microsoft's Security Blog documented 33 malicious npm packages using the same technique against developer environments (Source: Microsoft Security Blog, May 2026). The campaign had been active since April 2024, with reconnaissance payloads targeting CI environments. Birsan's 2021 research was not an isolated experiment.

The PyTorch incident in December 2022 confirms the same mechanics outside the npm ecosystem. The torchtriton package was uploaded to PyPI with version 2.0.0, higher than the private PyTorch build. Beyond SSH keys and environment variables, the payload read .gitconfig and the first 1,000 files in $HOME. 2,717 downloads in six days, December 25-30, 2022.

The package name is the only prerequisite: four leak surfaces

Internal package names are not operational secrets. They appear in public repositories, build logs, error messages, and compiled JS bundles, all indexable without credentials.

Error messages: npm install failures include the full package name in output. Stack traces in GitHub issues, Sentry logs, and public Datadog dashboards expose private package names. A CI log containing npm ERR! 404 Not Found - GET https://registry.npmjs.org/@company/utils confirms the name is in private use to anyone who reads it.

JS bundles: webpack-bundle-analyzer output, sourcemaps, and minified bundles frequently contain readable require('@company/internal-lib') strings. The Wayback Machine indexes these pages. Engineers publish bundle analysis posts without redacting internal names. This is how many of Birsan's targets were identified.

package.json in public repositories: monorepos accidentally committed with internal dependency names in devDependencies or peerDependencies are indexed by GitHub Search. The name @company/internal-auth is searchable without authentication.

Registry logs: npm audit responses include package names even for packages that do not exist publicly. The non-existence response confirms the name is in private use. Birsan used this method to confirm targets before registering the public packages.

Beyond npm: PyPI, Maven, NuGet, and RubyGems

Every major package ecosystem has a variant of this vulnerability. Mitigations focused only on npm leave teams exposed on Python, Java, and .NET stacks.

On PyPI, pip install with --extra-index-url follows the same highest-version logic. Internal PyPI servers running devpi or Artifactory are affected identically. The PyTorch incident quantifies the vector: 2,717 systems compromised with full credential exfiltration before package removal, in six days over the holiday period.

In Maven, there is no npm scope equivalent. groupId collisions are exploitable via repository priority in settings.xml. Version arbitration applies when the same artifact appears in multiple repositories. Claiming a groupId on public Maven repositories requires less verification than controlling an npm scope.

In NuGet, nuget.config allows packageSources with configurable priority, but organizations frequently leave the public nuget.org feed at higher priority. RubyGems checks sources in order and uses the first match, semantics different from npm but equally exploitable for an attacker who knows the gem name.

Rust's Cargo is the relative exception: crates.io is authoritative, and [patch] is required for private registries, creating a narrower surface. It is not immune.

Detection before the install

Reliable pre-install detection requires monitoring public registries for your internal names. After install, preinstall scripts have already executed. The damage is done.

OWASP Dependency-Track is used by more than 20,000 organizations. It maintains an SBOM for every project and flags when a private package name appears on a public registry. The alert arrives before npm install runs, making it the earliest barrier in the detection pipeline.

CI policy gates: npm install combined with package origin validation at install time. Failing the build when resolution touches the public registry for allowlisted packages is a policy in a single CI configuration file. npm audit alone does not detect dependency confusion: it checks CVEs in known packages, not name collisions between registries.

DepConfuse parses CycloneDX SBOMs and queries the ecosyste.ms API for each name on public registries daily. It is open source and complements Dependency-Track with continuous cross-registry verification.

Behavioral detection as a third line: packages installing themselves should not make outbound DNS requests or read $HOME. Runtime policies like Falco and Tetragon (eBPF-based syscall monitors) catch the exfiltration even if the package installs undetected earlier. PyTorch identified the malicious torchtriton by anomalous DNS activity during installation.

Eliminating the ambiguity: three controls

The most reliable mitigations remove the ambiguity entirely. Scoping packages and disabling upstream proxying eliminate the attack surface rather than managing it.

Scope every private package. In npm, @company/package-name requires authentication as owner to publish under the namespace on public registries. Public registries do not allow publishing scoped packages under namespaces another organization owns. The equivalent exists on PyPI via organization namespaces and in Maven via groupId control on OSSRH. Scoping eliminates name collision by design.

Lockfiles and registry pinning. .npmrc with @company:registry=https://private.registry.company.com routes scoped packages to the private registry unconditionally. Artifactory and Nexus allow disabling upstream proxying for internal scopes explicitly. Do not rely on priority order alone: disable the fallback. For Python: use --index-url pointing exclusively to the private index, not --extra-index-url. --index-url replaces the PyPI default; --extra-index-url adds the private index while keeping PyPI as a fallback.

npm v12 (2026): the postinstall change. According to Semgrep's 2026 analysis of the npm v12 release, postinstall scripts are disabled by default (Source: Semgrep, 2026 - npm v12 release notes). preinstall/install/postinstall scripts no longer run from dependencies without an explicit allowlist via the --scripts flag. This removes the primary execution vector: malicious packages can no longer run code on install without deliberate developer action. It is the most significant ecosystem change since Birsan's research.

Package managers treat public and private registries as peers with version-based arbitration. Any unscoped private package with a discoverable name is a latent attack surface. Scope your packages, isolate your registries, and monitor public indexes for your internal names.

Disclosure: The MAGO Intel tool (intel.mago.team) scans JS bundles, error pages, and public repository metadata for private package names. It identifies the prerequisite information for dependency confusion before an attacker does. MAGO Intel is a product from this publication's team.

Top comments (0)