Dependency Confusion Attacks: How Private Package Shadowing Works and How to Stop It

In February 2021, security researcher Alex Birsan published a technique that earned him over $130,000 in bug bounty payouts from 35 companies — including Microsoft, Apple, PayPal, Netflix, Uber, and Tesla — by uploading malicious packages to public registries under the same names as those companies’ internal private packages. The packages executed arbitrary code on developer machines and CI systems the moment they were installed. No social engineering. No stolen credentials. Just a misunderstanding baked into how package managers resolve names.

This is dependency confusion, and it is not theoretical. It is structurally exploitable in any organisation that uses private package registries alongside public ones.

The Mechanics of Resolution Order

Every package manager that supports multiple registries must answer one question when a dependency is requested: which registry wins?

npm resolves packages by consulting the configured registry for a given package scope. If no scope-specific registry is configured, it defaults to the public registry at registry.npmjs.org. When a corporate npm proxy (Verdaccio, Nexus, Artifactory) is configured as the default registry, it typically falls through to the public registry for any package it does not host internally. This means: if your internal package payment-service-client is not scoped under @yourorg/, and an attacker publishes payment-service-client to npmjs.org at version 9999.0.0, a proxy configured with upstream: true will resolve the public version — because it has a higher version number.

pip checks the --index-url first, then each --extra-index-url in order. If payment-service-client exists in your private PyPI at 0.3.1 and the attacker publishes payment-service-client 99.0.0 on PyPI, pip will find both and install the one with the highest version. The --extra-index-url flag is the vector — it does not scope packages to registries, it simply merges all indexes and picks the highest version.

Maven resolves from the first repository in the effective settings.xml that contains the requested artifact. Organisations commonly add the public Maven Central repository alongside their Nexus or Artifactory instance. If the internal Nexus does not contain an artifact, Maven falls through to Maven Central. The checksum verification only validates that the downloaded artifact matches what the registry claims to have — it does not validate that the registry is the correct one.

Go modules use a different model. The GOPATH and module proxy at proxy.golang.org serve as intermediaries, and the Go toolchain enforces the module path as a globally unique identifier rooted in a domain name. Private modules typically use internal domain names (e.g., go.internal.corp/payments/client). The attack surface here is narrower because modules are addressed by their import path, not a flat name — but it exists where teams use GONOSUMCHECK or GONOSUMDB to bypass the checksum database for internal modules and the proxy configuration is misconfigured.

The Birsan Disclosure and Real Incidents

Birsan’s technique exploited two properties simultaneously: internal package names leak through build files (checked into repositories, or visible in npm lockfiles, requirements.txt, pom.xml), and package managers prioritise version numbers over registry origin.

He found internal package names by examining publicly visible package.json files, Python setup files, and npm lockfiles on GitHub. He then registered those names on public registries with a preinstall/postinstall hook that phoned home with the hostname, username, and working directory of the machine performing the install. The payload required no user interaction — npm install, pip install, or mvn compile triggered it automatically.

At Microsoft, internal npm packages were squatted successfully. Birsan received payouts confirming code execution on Microsoft infrastructure. At Apple, internal Python packages were targeted; the packages ran on Apple CI infrastructure. At PayPal, multiple internal npm packages were found through leaked lockfiles. All three companies confirmed the attack worked.

Birsan published his methodology: the attacker needs only the name of an internal package, a public registry account, and the ability to publish a package with a preinstall script.

The two variants of the attack are worth distinguishing:

Namespace squatting: The attacker registers a package under a name that does not yet exist in the public registry. The package manager fetches it because it cannot find it internally (or the proxy falls through). This is the Birsan variant.

Version shadowing: A package name already exists in both public and private registries. The attacker publishes a higher version to the public registry. The package manager selects the public version because version comparison runs before registry preference. This variant is subtler because defenders may assume an existing public entry is safe.

Countermeasures for npm

Use scoped packages exclusively for internal code. Scopes (@yourorg/package-name) let you pin a registry per scope in .npmrc. This is the single most effective mitigation.

# .npmrc at the repo root or user home
@yourorg:registry=https://registry.your-nexus.internal/
@another-team:registry=https://registry.your-nexus.internal/
registry=https://registry.npmjs.org

With this configuration, any package under @yourorg/ is fetched exclusively from your internal registry. The fallback to npmjs.org is never consulted for scoped packages when the scope is pinned.

Disable upstream proxying for your private scope on the registry side. In Verdaccio:

packages:
  '@yourorg/*':
    access: $authenticated
    publish: $authenticated
    proxy: []

Setting proxy: [] prevents Verdaccio from forwarding requests for your internal scope to any upstream registry. Even if an attacker publishes @yourorg/payment-client to npmjs.org, your registry will not serve it.

Claim your organisation’s scope on npmjs.org even if you do not publish publicly. This prevents a third party from publishing under @yourorg/ and having those packages surface to developers who have misconfigured .npmrc files.

Enforce lockfile integrity. package-lock.json records the resolved registry URL for each package. Verify this in CI:

npm ci --audit

npm ci refuses to install if the lockfile is missing or inconsistent. --audit checks installed packages against the npm advisory database.

Use Socket.dev or similar tools in CI to detect packages that contain install scripts and trigger network calls — the signature behaviour of a dependency confusion payload.

Countermeasures for pip

Never use --extra-index-url without hash pinning. The fundamental issue is that pip merges multiple indexes and selects by version. The correct approach is to use --index-url (singular) to point exclusively at your private registry, and configure that registry to proxy PyPI selectively.

pip install \
  --index-url https://your-artifactory.internal/api/pypi/pypi-local/simple/ \
  --no-deps \
  -r requirements.txt

If you must use --extra-index-url, combine it with hash pinning in requirements.txt:

payment-service-client==0.3.1 \
    --hash=sha256:e3b0c44298fc1c149afbf4c8996fb924... \
    --hash=sha256:ca978112ca1bbdcafac231b39a23dc4...

With --require-hashes mode enabled, pip refuses to install any package whose hash does not match — regardless of which index served it. The attacker’s higher-versioned package will have a different hash.

pip install --require-hashes -r requirements.txt

Use pip-compile with hash generation:

pip-compile --generate-hashes requirements.in -o requirements.txt

Configure Artifactory or AWS CodeArtifact as a unified proxy. Rather than giving CI access to both your internal registry and PyPI directly, route all traffic through your private registry and control which upstream packages it proxies. AWS CodeArtifact supports blocking specific package versions or entire packages from upstream PyPI:

aws codeartifact put-package-origin-configuration \
  --domain your-domain \
  --repository your-repo \
  --format pypi \
  --package payment-service-client \
  --restrictions upstream=BLOCK,publish=ALLOW

This blocks the package from ever being served from upstream PyPI — even if an attacker publishes it there.

Countermeasures for Maven

Configure a single, controlled repository in settings.xml and block external access from CI.

<settings>
  <mirrors>
    <mirror>
      <id>internal-mirror</id>
      <mirrorOf>*</mirrorOf>
      <url>https://your-nexus.internal/repository/maven-public/</url>
    </mirror>
  </mirrors>
  <profiles>
    <profile>
      <id>secure</id>
      <repositories>
        <repository>
          <id>internal-mirror</id>
          <url>https://your-nexus.internal/repository/maven-public/</url>
          <releases>
            <checksumPolicy>fail</checksumPolicy>
          </releases>
          <snapshots>
            <checksumPolicy>fail</checksumPolicy>
          </snapshots>
        </repository>
      </repositories>
    </profile>
  </profiles>
  <activeProfiles>
    <activeProfile>secure</activeProfile>
  </activeProfiles>
</settings>

The <mirrorOf>*</mirrorOf> setting redirects all repository requests through the internal mirror. The <checksumPolicy>fail</checksumPolicy> setting causes the build to fail if the repository does not provide a matching checksum — rather than warning or ignoring. This does not prevent the confusion attack on its own, but it ensures tampered artifacts cannot be installed silently.

Block internal group IDs from being resolvable from Maven Central on your Nexus/Artifactory instance. In Nexus Repository Manager, configure a routing rule that prevents requests for your internal group ID prefix from being forwarded upstream:

Routing Rule: block-internal-from-central
Mode: Block
Matchers:
  +com/yourorg/.*
Applied to: maven-central (proxy repository)

Set the Nexus proxy repository policy to block external versions of internally-owned coordinates. This means that even if an attacker publishes com.yourorg:payment-client:9999.0.0 to Maven Central, your Nexus will refuse to serve it.

Go Module Proxy Configuration

Go’s module system is partially protected by design — import paths encode the origin domain, so go.internal.corp/payments/client cannot be confused with a public module. The risk points are:

GONOSUMCHECK bypass. Teams often set GONOSUMCHECK=go.internal.corp/* or GONOSUMDB=off for internal modules, disabling checksum database verification. This is necessary because the Go checksum database only covers public modules. Ensure GONOSUMCHECK is scoped as narrowly as possible:

GONOSUMCHECK=go.internal.corp/*
GONOSUMDB=go.internal.corp/*
GOPROXY=https://goproxy.internal.corp,direct
GOFLAGS=-mod=readonly

Use GOFLAGS=-mod=readonly in CI to prevent the go.sum file from being modified during builds. Any dependency not already in go.sum causes the build to fail.

Run a private GOPROXY. Athens (github.com/gomods/athens) acts as a private proxy that caches modules and can be configured to block direct access to specific module paths. Route all module fetches through Athens and restrict network egress from CI to the Athens endpoint only.

Detection: Monitoring for Internal Package Names in Public Registries

Mitigation at the resolver level stops attacks before they happen. Detection catches attackers who are probing or have already claimed your package names.

Register your internal package names on public registries proactively. For npm, claim the unscoped names on npmjs.org and the scope itself. For PyPI, register placeholder packages under your internal package names with a description making clear they are reserved. This prevents the squatting variant entirely.

Subscribe to registry event feeds. PyPI exposes a JSON event feed at https://pypi.org/rss/updates.xml. npm’s public registry provides a continuous replication feed via CouchDB at https://replicate.npmjs.com. Build a monitor that alerts when any package name matching your internal package list appears or receives a new version:

import feedparser
import re

INTERNAL_PACKAGE_NAMES = {"payment-service-client", "auth-token-utils", "internal-config"}

feed = feedparser.parse("https://pypi.org/rss/updates.xml")
for entry in feed.entries:
    pkg_name = entry.title.split(" ")[0].lower()
    if pkg_name in INTERNAL_PACKAGE_NAMES:
        alert(f"Internal package name '{pkg_name}' appeared on PyPI: {entry.link}")

Monitor DNS and outbound network from CI runners for unexpected registry connections. A dependency confusion payload phones home. If your CI runners make outbound connections only to your internal registry, any connection to registry.npmjs.org or pypi.org directly from a build step is an indicator. Use egress filtering (iptables, AWS Security Groups, GCP Firewall Rules) to block direct registry access from build agents, forcing all traffic through your proxy.

Alert on install-time script execution. npm’s preinstall, install, and postinstall scripts are the primary execution vector. In CI, use npm install --ignore-scripts for production dependency installation, and audit any package that requires scripts to run:

npm install --ignore-scripts
npm audit --audit-level=moderate

CI Policy: SBOM-Based Registry Verification

Prevention and detection converge at the SBOM layer. Every build should generate an SBOM that records not just which packages were installed, but which registry they came from.

For npm, npm sbom --sbom-format cyclonedx generates a CycloneDX document that includes the resolved registry URL for each package. A CI gate can parse this and fail the build if any package was resolved from an unexpected registry:

npm sbom --sbom-format cyclonedx --output-file sbom.json

python3 - <<'EOF'
import json, sys

ALLOWED_REGISTRIES = {
    "https://registry.npmjs.org",
    "https://registry.your-nexus.internal",
}

with open("sbom.json") as f:
    sbom = json.load(f)

violations = []
for component in sbom.get("components", []):
    for ext_ref in component.get("externalReferences", []):
        if ext_ref.get("type") == "distribution":
            url = ext_ref.get("url", "")
            if not any(url.startswith(r) for r in ALLOWED_REGISTRIES):
                violations.append(f"{component['name']}@{component.get('version','?')}: {url}")

if violations:
    print("SBOM registry violations:")
    for v in violations:
        print(f"  {v}")
    sys.exit(1)
EOF

For pip, pip-audit --format cyclonedx combined with pip-audit --require-hashes covers both SBOM generation and hash verification in a single step.

For a more comprehensive treatment of SBOM generation and how to consume SBOMs as policy gates, see SBOM Generation and Consumption. For SLSA-level provenance attestation that ties every installed artifact to a verified build process, see SLSA Build Provenance.

SLSA provenance is particularly relevant here: a Level 2+ SLSA attestation for an internal package includes the source repository and build system that produced it. A CI gate that verifies SLSA attestations before installing internal packages can reject any version of a package — even one with a higher version number — if its provenance does not originate from your internal build system.

slsa-verifier verify-artifact payment-service-client-0.3.1.tar.gz \
  --provenance-path payment-service-client-0.3.1.provenance.json \
  --source-uri github.com/yourorg/payment-service \
  --source-branch main

If the attacker’s package lacks a valid provenance attestation signed by your internal build system, the verifier rejects it before installation.

Summary

Dependency confusion is a structural property of multi-registry package resolution, not a bug that will be patched. The countermeasures operate at several layers:

Registry isolation: Force all package resolution through a single controlled registry. Block upstream fallthrough for your internal package names. Use egress filtering from CI to enforce this at the network layer.

Namespace ownership: Use scoped packages in npm. Register your internal package names on public registries as placeholders. Configure scope-to-registry pinning in .npmrc.

Hash pinning: In pip, use --require-hashes with requirements.txt. In Maven, set checksumPolicy to fail. In Go, commit go.sum and use -mod=readonly.

Detection: Monitor public registry event feeds for your internal package names. Alert on unexpected outbound connections from CI runners to public registries.

CI gates: Generate SBOMs and verify that every resolved package came from its expected registry. For internal packages, require SLSA provenance attestations signed by your build system.

Birsan’s 2021 disclosure gave the industry a clear view of the attack. Five years later, the tooling to prevent it is mature and available in every major language ecosystem. The gap is not tooling — it is configuration discipline and enforcement at the CI layer.