In 2021, security researcher Alex Birsan earned over $130,000 in bug bounties by uploading malicious packages to public registries with the same names as internal packages used by Apple, Microsoft, PayPal, and dozens of other companies. His packages automatically executed on installation. This is dependency confusion—and it affects any organization with private package registries.

How Dependency Confusion Works

Most package managers prefer public registries over private ones when a package name exists in both. The attack works in three steps:

  1. The attacker discovers the name of an internal package (often found in package.json, requirements.txt, or error messages in JS bundles).
  2. They publish a package with the same name to the public registry (npm, PyPI, RubyGems) at a version number higher than the internal one.
  3. A developer or CI/CD system runs npm install or pip install. The package manager finds the public version (with the higher version number) and installs it instead of the internal package—executing the attacker’s code during install.
Internal registry: mycompany/auth-utils @ 1.2.0
Public npm:        auth-utils @ 9.9.9 (malicious)

npm install → resolves to npm's 9.9.9 → runs postinstall hook → exfiltrates env vars

Discovering Your Exposure

Check your existing packages for names that could be registered on public registries:

# For npm projects — list all dependencies
cat package.json | jq '.dependencies, .devDependencies | keys[]'

# Check if any are registered publicly (returns 404 if safe)
for pkg in $(cat package.json | jq -r '.dependencies | keys[]'); do
  status=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/$pkg")
  if [ "$status" = "404" ]; then
    echo "PRIVATE (unregistered): $pkg"
  else
    echo "PUBLIC: $pkg"
  fi
done

# For Python
pip list --format=json | jq '.[].name' | while read pkg; do
  status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/$pkg/json")
  echo "$pkg: $status"
done

npm: Scoped Packages

The most robust protection for npm is using scoped packages for all internal code. Scoped packages (@mycompany/package-name) can only be published to the scope owner’s namespace:

{
  "name": "@mycompany/auth-utils",
  "version": "1.2.0"
}

An attacker cannot publish @mycompany/auth-utils to npm without access to the @mycompany organization—unlike unscoped names which anyone can register.

Configure your .npmrc to route scoped packages to your registry:

# .npmrc
@mycompany:registry=https://npm.mycompany.internal/
//npm.mycompany.internal/:_authToken=${NPM_INTERNAL_TOKEN}
registry=https://registry.npmjs.org/

npm: Using a Proxy Registry (Verdaccio / Artifactory)

A proxy registry caches public packages and enforces that internal packages are served from the internal source. This blocks confusion attacks even for unscoped names:

# .npmrc — route everything through your proxy
registry=https://artifactory.mycompany.com/artifactory/api/npm/npm-virtual/
//artifactory.mycompany.com/:_authToken=${ARTIFACTORY_TOKEN}

In Artifactory, configure virtual repositories to only resolve internal packages from internal repos, blocking public registry fallback for packages with matching names.

pip: Index Priority Configuration

Python’s pip searches indexes in order and takes the first match. The dangerous default is that --extra-index-url causes pip to pull from whichever index has the highest version—exactly what attackers exploit.

# DANGEROUS — searches both, takes highest version
pip install mypackage --extra-index-url https://pypi.org/simple/

# SAFER — pip 22.3+: use --index-url with private proxy that also mirrors PyPI
pip install mypackage --index-url https://artifactory.mycompany.com/simple/

pip.ini / pip.conf:

[global]
index-url = https://artifactory.mycompany.com/artifactory/api/pypi/pypi-virtual/simple/
trusted-host = artifactory.mycompany.com

With this config, all packages (including PyPI packages) go through your proxy. The proxy is configured to block any public package whose name matches an internal package.

Namespace Squatting: Register Before Attackers Do

For packages you can’t easily scope or rename, register the name on public registries preemptively—publish an empty placeholder package that’s clearly marked as reserved:

{
  "name": "mycompany-internal-auth",
  "version": "0.0.1",
  "description": "PLACEHOLDER: This package is reserved by MyCompany. Do not install.",
  "scripts": {
    "preinstall": "echo 'WARNING: This is a reserved internal package name.' && exit 1"
  }
}

Supply Chain Attack Patterns Beyond Confusion

Typosquatting: reqeusts vs requests, crypt0 vs crypto. Use pip install with hashes and lock files.

Compromised maintainer accounts: event-stream (2018), ua-parser-js (2021). Mitigate with lockfiles and integrity checking:

# npm — use lockfile integrity
npm ci  # Respects package-lock.json exactly, fails on mismatch

# pip — pin with hashes
pip-compile --generate-hashes requirements.in
# requirements.txt will contain:
# requests==2.31.0 \
#     --hash=sha256:abc123...

Malicious postinstall hooks: Any npm package can run arbitrary code on npm install via the scripts field. Audit your dependencies:

# Check for install scripts in your dependencies
npm ls --parseable | xargs -I{} node -e "
  try {
    const pkg = require('{}/package.json');
    if (pkg.scripts && (pkg.scripts.install || pkg.scripts.postinstall || pkg.scripts.preinstall)) {
      console.log(pkg.name, Object.keys(pkg.scripts).filter(k => k.includes('install')));
    }
  } catch(e) {}
"

CI/CD Pipeline Hardening

# GitHub Actions — pin actions to full commit SHA, not tags
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

# Never use floating tags like @v4 or @main — they can be hijacked

# Use dependency review for PRs
- uses: actions/dependency-review-action@72eb03d02c7872a771aacd928f3123ac1272d461
  with:
    fail-on-severity: high

Key Takeaways

  1. Scope all internal npm packages under @yourcompany/ — this is the simplest and most effective mitigation.
  2. Route all package installs through a proxy registry (Artifactory, Nexus, Verdaccio) configured to block public versions of internal package names.
  3. Never use --extra-index-url with pip; use a full proxy that mirrors PyPI instead.
  4. Pin dependencies with lockfiles and hash verification in CI.
  5. Preemptively register internal package names on public registries if you can’t rename them.