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.
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.
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.
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.
# 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.
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.
| Ecosystem | Namespace control | Exposed if | Public API to probe |
|---|---|---|---|
| npm | Scoped packages if the npm org is claimed | Unscoped name, or org unclaimed on npmjs.com | registry.npmjs.org/:name |
| PyPI | None - first claim wins | Name absent from PyPI; --extra-index-url in use | pypi.org/pypi/:name/json |
| Maven | GroupId via domain verification | GroupId domain unverified or namespace never published to Central | search.maven.org/solrsearch/select?q=g:... |
| RubyGems | First claim, no org scope | Name absent from rubygems.org; multi-source Gemfile | rubygems.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.
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.
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.