Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
Dependency confusion (also called substitution or namespace confusion) is a supply-chain attack in which an attacker publishes a malicious package to a public registry (npm, PyPI, RubyGems, Maven Central) using the *exact name* of an internal, private package that an organization uses. If the organization’s build or dependency-resolution configuration is not explicitly told to prefer its private registry for that name, many package managers will resolve to whichever source offers the higher version number, and a public package with an inflated version (e.g. 9.9.9) wins over an internal 1.2.0 — regardless of which registry is “supposed” to be authoritative. The malicious package’s install script then runs arbitrary code on every developer laptop and CI runner that resolves it.
The technique was popularized publicly by security researcher Alex Birsan in 2021, who used it to get code execution inside the build systems of Apple, Microsoft, PayPal, and dozens of other companies, simply by publishing public packages that matched internal package names he found referenced in leaked package.json/requirements.txt files, npm scripts, and internal documentation. It remains one of the highest-yield, lowest-cost supply-chain attacks because it requires no access to the target’s network at all — only a guessable internal package name and a public registry account.
Attack Prerequisites
For dependency confusion to succeed, several conditions have to line up on the victim’s side:
- An internal package name that is also resolvable from a public registry — i.e. the organization has not reserved that name/scope on the public registry, and the name is discoverable (leaked manifest, job posting mentioning an internal tool, open-source repo referencing an internal dep).
- A package-manager configuration that does not pin the private registry as authoritative for internal scopes — no scoped registry mapping, no private index priority, or a multi-index
pipconfig that checks the public index first or without exclusivity. - Automatic execution on install — npm’s
postinstall/preinstallscripts, Python’ssetup.py, or similar hooks that run attacker code the momentnpm installorpip install -r requirements.txtresolves the poisoned package, with no sandboxing. - A CI/CD pipeline or developer machine that has meaningful access once compromised — cloud credentials, source-control tokens, signing keys — which is what turns a supply-chain foothold into a real breach.
How It Works
Modern package managers resolve a bare dependency name against one or more configured sources. When multiple sources can satisfy the same name, the resolution algorithm’s tie-breaking rule is what attackers exploit. npm’s classic behavior (absent an explicit scope-to-registry mapping) and pip’s behavior with multiple --index-url/--extra-index-url entries both historically favored whichever registry returns the highest matching semantic version, treating public and private registries as one merged namespace rather than two isolated ones. An attacker who knows (or guesses) the internal name acme-auth-utils simply publishes acme-auth-utils version 99.0.0 to npm; a build that pulls from both npm and the internal registry without an explicit override installs the attacker’s package, because 99.0.0 > 1.4.2.
The payload delivery is almost always the install-time script hook, because it requires zero interaction from the victim beyond running npm install — no one has to import or call anything from the malicious package for it to execute. npm’s package.json scripts.postinstall, setup.py‘s arbitrary Python at import/build time, and similar hooks in Maven (annotation processors), RubyGems (extconf.rb), and Composer all run with the same privileges as the person or CI job performing the install — typically full filesystem access, environment variables (frequently including cloud and registry credentials), and network egress.
The attack is distinct from typosquatting (reqeusts instead of requests), which relies on a human typo, and from a compromised legitimate maintainer account. Dependency confusion needs no typo and no compromise of anything the victim controls — it only needs the internal name to exist publicly with a higher version, which is why namespace reservation and explicit registry scoping are the actual fixes, not vigilance.
Practical Example / Configuration
A vulnerable package.json that pulls both public npm packages and an unscoped internal package by bare name, with no .npmrc registry mapping:
{
"name": "checkout-service",
"version": "3.2.0",
"dependencies": {
"express": "^4.19.2",
"acme-auth-utils": "^1.4.2",
"acme-payment-sdk": "^2.0.1"
}
}
JSONIf acme-auth-utils and acme-payment-sdk are internal-only packages that were never reserved on public npm, and there is no .npmrc telling npm which registry owns the acme- names, a plain npm install will happily resolve them from the public registry if someone has published higher versions there. The vulnerable default .npmrc (missing scope mapping):
; VULNERABLE: no scope->registry mapping, everything resolves from npmjs.org
registry=https://registry.npmjs.org/
INIThe fixed version pins the internal scope to the private registry explicitly, so the public registry is never consulted for those names at all:
; FIXED: @acme-scoped packages ALWAYS resolve from the internal registry
@acme:registry=https://npm.internal.acme.com/
//npm.internal.acme.com/:_authToken=${INTERNAL_NPM_TOKEN}
registry=https://registry.npmjs.org/
ININote this fix requires the internal packages to also be renamed under the reserved @acme scope (@acme/auth-utils) — an unscoped bare name like acme-auth-utils can never be made registry-exclusive on npm, because scope is the only mechanism that binds a name to a specific registry.
Walkthrough / Implementation
Auditing and closing dependency confusion exposure for an existing codebase follows a consistent sequence:
1. Enumerate every internal-only package name referenced across manifests:
grep -rhoE '"[a-zA-Z0-9@/_.-]+"\s*:\s*"[\^~]?[0-9]' package.json \
| sort -u
(and equivalent for requirements.txt / pyproject.toml / pom.xml)
2. For each internal name, check public-registry availability:
npm view acme-auth-utils # exists publicly? what version?
pip index versions acme-auth-utils
3. If a name is unclaimed publicly, register a placeholder (empty package,
no install scripts) to squat it defensively - many orgs do this
proactively for every internal package name.
4. Move internal packages under a reserved, registry-verified scope/prefix
(@acme/*, acme-internal-*) and configure scoped registry mapping as
above so resolution is exclusive, not merely higher-version-wins.
5. Pin exact versions and enable lockfile-only installs in CI:
npm ci --ignore-scripts # or pip install --no-deps -r locked.txt
TEXTnpm ci --ignore-scripts (or an equivalent flag/plugin for pip/Poetry) is worth calling out specifically: it disables the install-time script hooks that dependency confusion relies on for code execution, so even a resolution mistake does not automatically become RCE in CI.
Note: Multi-index pip installs are a particularly common source of this bug because
--extra-index-urlis *additive*, not a fallback — pip queries all configured indexes and, in vulnerable configurations, can select the highest version across all of them. Use--index-urlpointed only at an internal proxy/mirror (e.g. Artifactory, Nexus, devpi) that itself explicitly whitelists which packages come from upstream PyPI.
Note: Lockfiles (
package-lock.json,poetry.lock,Gemfile.lock) mitigate this for *already-locked* dependencies by pinning an exact resolved source and integrity hash, but only ifnpm ci/--frozen-lockfilestyle installs are enforced in CI — a plainnpm installcan still silently update the lockfile to a newer, malicious resolution.
Detection and Defense
Defense here is almost entirely configuration and registry hygiene, not runtime detection:
- Reserve every internal package name on every public registry you use, even as an empty placeholder, so an attacker cannot claim it.
- Use scoped/namespaced packages (
@org/nameon npm, a private PyPI index behind a proxy) and configure package managers to resolve those scopes/prefixes exclusively from the internal registry — never merge public and private into one flat namespace. - Enforce lockfiles and reproducible installs in CI (
npm ci,pip install --require-hashes,poetry install --sync) so resolution cannot silently drift between builds. - Disable install-time scripts where possible (
--ignore-scripts) and audit any package that legitimately needs one. - Run software composition analysis (Grype, Snyk, npm audit) and monitor for newly published packages that match internal naming patterns via registry-watching services.
Real-World Impact
Alex Birsan’s original 2021 research achieved code execution inside the internal networks of Apple, Microsoft, Tesla, Uber, Shopify, and PayPal purely through dependency confusion, earning over $130,000 in combined bug bounties, and it triggered rapid changes across npm and PyPI tooling around scoped registries and dependency pinning guidance. It remains a standard finding in supply-chain security assessments because the prerequisite — an internal package name that leaked into a public manifest, job posting, or open-source repo — is extremely common, and the fix is purely configuration, so it is often still unaddressed simply because no one has audited it.
Conclusion
Dependency confusion works because package resolution treats “highest version wins” as a reasonable default across merged namespaces, which is safe for open source but dangerous the moment a private name exists alongside a public one. The fix is not vigilance but architecture: scope internal packages, bind those scopes exclusively to an internal registry, pin and verify lockfiles in CI, and disable install-time code execution wherever it is not strictly required.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments