Dependency confusion: npm and PyPI in 2026
In 2021 Alex Birsan published bug bounty results for a technique he called dependency confusion: registering public packages with the same names as a target's internal packages, with a higher version number. Several Fortune 100 companies' build systems pulled the malicious public packages instead of the legitimate internal ones. Five years later we still see the same misconfigurations during scans.
The mechanic
Internal package registries (Artifactory, Nexus, GitHub Packages, custom proxies)
typically operate as a "fall-through" cache: ask for my-internal-utils,
the registry checks its internal index first, falls back to the public registry if not
found. The vulnerability: many tools (older npm, pip with --index-url
plus --extra-index-url) check both registries and pick the
highest version they find, regardless of source.
Attacker workflow:
- Find an internal package name. Sources: leaked package.json in a GitHub repo, source maps in production JS, an open S3 bucket, an old job description listing internal libraries.
- Register that name on npmjs.com (or PyPI, RubyGems, etc.) with version
99.99.99. - Wait. The next CI run that resolves the package picks up the public version because
99.99.99 > 1.4.7. - Malicious package's
postinstallhook runs in the CI runner with whatever credentials are in the environment.
What we still see in 2026
During Extended scans, our checkers surface several signals correlated with dependency confusion exposure:
- Source maps exposed in production JS. The bundler usually inlines internal package paths in the sourceContent.
web.source_maps_exposedfinding. - Package-lock.json or composer.lock at /. Includes the exact internal package names.
web.leaksfinding. - __webpack_require__.r() patterns in JS bundles. Modern webpack/turbopack reveal module IDs that often mirror package names.
- NEXT_DATA disclosure. Build-time configs sometimes embed package versions or registry URLs.
web.nextjs_data_present.
These don't prove a vulnerability — they tell an attacker which package names to register on the public registry.
npm: scoped packages are the answer
Use @your-org/ scoped packages and configure the scope to point to your
internal registry exclusively:
# .npmrc (committed to repo root)
@your-org:registry=https://npm.internal.example.com/
//npm.internal.example.com/:_authToken=${INTERNAL_NPM_TOKEN}
registry=https://registry.npmjs.org/
Now @your-org/anything resolves only from the internal registry.
Even if an attacker registers @your-org/anything on npmjs.com (which they
can't because the scope is owned by your account anyway), npm doesn't look there for
scoped packages.
Critically: own the scope on the public registry. Register
@your-org on npmjs.com even if you don't publish anything there. If you
don't, an attacker can register the scope and squat your namespace.
PyPI: there's no scope concept
Python packaging has no scoping. pip install internal-utils will look
anywhere your indexes are configured. The mitigations:
- Single index, not multiple. Configure your internal index as
--index-url(the only one). It proxies to PyPI for non-internal packages. Don't use--extra-index-url— that's the configuration where pip merges results and picks highest version. - Squat the public name. Register your internal package names on PyPI with empty stub releases at version 0.0.0. An attacker can't outbid you on freshness.
- Pin with hashes.
pip install --require-hasheswith a generated hash list. A new public package with version99.99.99won't match the hash, so install fails.
Other ecosystems
| Ecosystem | Has namespacing? | Mitigation |
|---|---|---|
| Maven Central (Java) | Yes (groupId) | Own your com.example.* groupId on Sonatype OSSRH |
| NuGet (.NET) | Prefix-reservation | Reserve your YourCompany.* prefix |
| RubyGems | No | Same as PyPI — squat names + single index |
| Cargo (Rust) | No (until 2025 namespaces RFC) | Squat names + private registry |
| Go modules | Yes (import path = host) | Inherent: go.example.com/internal can only come from your VCS |
Go's import-path-as-URL is the only ecosystem where this is structurally hard. Unfortunately Go made other supply-chain choices (no signing, vendored proxies) that create different problems.
Postinstall lockdown
Even with namespacing right, malicious legitimate-namespace packages exist
(event-stream 2018, ua-parser-js 2021, dozens of shorter
incidents). A defense-in-depth: disable postinstall hooks in CI:
# For npm
npm install --ignore-scripts
# Or globally for the org via .npmrc
ignore-scripts=true
Some packages legitimately need postinstall (native compilation, asset download). Solve this by maintaining an allowlist of packages whose postinstall is trusted. Snyk's "can-i-ignore-scripts" tool does the heavy lifting.
Sigstore is finally landing
In 2026 Sigstore-based signed publishing is finally rolling out across npm and PyPI. Packages signed by their maintainers via OIDC (workflow identity in GitHub Actions, no long-lived keys). Verifiers can check the signature came from the expected repo+workflow. This makes typosquats and account-takeover attacks much harder. Adoption is still early — assume mostly unsigned for now.
Find what your prod JS leaks
Free Basic scan reports source map exposure. Extended adds Next.js data inspection, lock-file probes, version-disclosing JS lib detection.
Run a scan