Supply Chain · Jul 11, 2026 · 7 min read

Dependency confusion: your private package name is not reserved

A private package name that exists only in your internal feed can be squatted on npm, PyPI or RubyGems - and the manager fetches the public one unless the feed policy is explicit.

Most supply chain scanning focuses on known-malicious packages or disclosed CVEs. Dependency confusion is different: the package name is yours, the version number is made up, and the attacker does not need to compromise anyone. They only need the package manager to do exactly what it was designed to do.

01How the namespace race works

Package managers that support both private and public feeds resolve the same package name across both indexes. The policy that breaks safety is version preference: when the public registry hosts a higher version number than the internal one, most default configurations install the public one - no compromise, no man-in-the-middle, just normal registry logic. A postinstall script in that public package runs at install time, inside the CI environment, before any artifact-level gate sees it.

Internal feed billing-core v1.4.2 (the real package) Public npm billing-core v99.0.0 (attacker-published) npm install ──► resolves highest version across all configured feeds ──► fetches v99.0.0 from public npm ──► postinstall runs attacker code in the build env Scoped + pinned @myorg/billing-core org claimed on npmjs.com + registry-only policy Unscoped billing-core no namespace control on any public registry
Version preference makes the public registry win without any infrastructure compromise.

The attacker's cost is a free registry account and a manifest that sets the version high. The payload runs before the built artifact is ever scanned - which is why artifact scanning alone does not close the gap.

02Confirming exposure without installing anything

The confirming probe is read-only against the public registry API. If the internal package name resolves on the public index when it should only exist privately, the namespace is unprotected. If the public version is already higher than the internal one and the feed policy allows fallthrough, the build is at risk right now - not hypothetically.

dep-confusion-probe.pypython
# read-only: does the internal name exist on the public registry?
import requests, semver

def check_npm(pkg_name: str, internal_ver: str) -> str:
    r = requests.get(
        f"https://registry.npmjs.org/{pkg_name}",
        timeout=8, headers={"Accept": "application/json"})
    if r.status_code == 404:
        return "EXPOSED_NAMESPACE"  # unclaimed - squattable
    pub = r.json().get("dist-tags", {}).get("latest", "0.0.0")
    if semver.compare(pub, internal_ver) > 0:
        return f"CONFUSION_RISK pub={pub} internal={internal_ver}"
    return "OK"

# no download, no install, no code exec - registry metadata API only

The probe reads the same metadata endpoint a package manager consults before downloading anything. Nothing installs. The result is the confirming artifact: the public registry either acknowledges the name (and which version it knows) or returns 404.

Confirmed - HighDependency confusion - namespace exposed - billing-core
GET https://registry.npmjs.org/billing-core ──► 200 OK {"dist-tags":{"latest":"99.0.0"},"_id":"billing-core"} Internal feed: billing-core@1.4.2 Public npm: billing-core@99.0.0 Feed policy: --extra-index-url allows public fallthrough -- manager prefers public version
Oracle: registry metadata API, read-only, no package installed. High - a postinstall payload in v99.0.0 runs in the CI environment (secrets, tokens and source in scope) before any artifact gate fires.

03Cross-ecosystem differences that change the probe

npm, PyPI, Maven and RubyGems each have different namespace rules and different resolution policies. A check keyed only on npm misses every Python or JVM package in the same pipeline.

EcosystemNamespace controlExposed ifPublic API to probe
npmScoped packages if the npm org is claimedUnscoped name, or org unclaimed on npmjs.comregistry.npmjs.org/:name
PyPINone - first claim winsName absent from PyPI; --extra-index-url in usepypi.org/pypi/:name/json
MavenGroupId via domain verificationGroupId domain unverified or namespace never published to Centralsearch.maven.org/solrsearch/select?q=g:...
RubyGemsFirst claim, no org scopeName absent from rubygems.org; multi-source Gemfilerubygems.org/api/v1/gems/:name.json

The resolution policy matters as much as the namespace. npm with a single private registry and no public fallthrough is safe. pip with --extra-index-url opens the version-preference race. Maven with a mirror-only settings.xml can be protected; an additional Central repo with no ordering constraint is not. The probe records both: the namespace status and the feed policy in the manifest and CI configuration.

04Severity and what moves the needle

An exposed namespace where the public index returns 404 is high: the door is open and has not been walked through yet. A namespace where the public version already exceeds the internal one is confirmed risk: the manager will prefer the attacker version on the next install unless the feed policy is corrected. Severity rises further based on where the package installs: a build-time dependency runs in a context that typically holds CI secrets, service account tokens and full source - postinstall access there is not limited to the package's declared purpose.

0
packages installed during the probe
4
ecosystems checked per build manifest
3
independent control layers (namespace, feed policy, lockfile hash)

The fix has three independent layers, each blocking a different failure mode. First, namespace ownership: claim the org on npmjs.com for scoped packages, and publish a benign placeholder on PyPI for every internal name that has not been claimed yet. Second, feed policy: configure the package manager to resolve from the private feed only - not as an optional flag but as the enforced default in every CI pipeline, so --extra-index-url and multi-source configs cannot slip in. Third, integrity pinning: a lockfile with hash verification means that even if the feed policy is misconfigured, a hash mismatch fails the build before the package runs. Relying on only one layer leaves the other two open.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →