Stop dependency confusion and typosquatting in private package feeds
For developers and build engineers running CI/CD with private packages. This guide shows exactly how dependency confusion and typosquatting happen in real package manager resolution paths, and how to block them with scoped names, registry pinning, lockfile discipline, and proxy repository policy.
TL;DR — Dependency confusion happens when your build tool is willing to resolve a package name from a public registry even though you intended a private one; typosquatting is the adjacent problem where a near-miss name gets installed instead of the real package. The single most reliable fix is to use names that are unambiguous in your ecosystem (for example npm scopes), pin those names to your private registry in config committed to the repo, and fail CI if any internal package resolves from a public host. Reading time: ~7 min
What it is and where it sits
This sits in the package resolution path of your build: developer laptop, CI runner, container build, release job. The vulnerable component is usually not your app code; it is the package manager plus its registry configuration and whatever repository manager or proxy sits in front of registries.
The architecture question is simple: when a build asks for internal-lib@1.4.2, which registry answers? If the answer can vary by environment, or if the same unscoped name exists publicly, you have a dependency-confusion path. If the name requested is a typo like internall-lib and your tooling does not constrain where names come from, you have a typosquatting path.
Typical components in the flow:
npm,pip,poetry,uv,pnpm,yarn,NuGet,Maven, etc.- A config source:
.npmrc,pip.conf,pyproject.toml,settings.xml, environment variables, CI secrets. - Optional repository manager/proxy: Artifactory, Nexus, Verdaccio, Azure Artifacts, GitHub Packages, a simple private index, or a reverse proxy in front of one.
- Public registries: npmjs, PyPI, Maven Central, NuGet.org.
What it replaces: in a mature setup, direct client access to public registries gets replaced by either:
- explicit per-scope/per-index registry mapping, or
- a single internal proxy that applies allow/deny policy and caches approved public packages.
View as text
[Dev/CI Build] ---- 1. install deps ----> [Package Manager] [Package Manager] ---- 2. resolve name ----> [Config] [Config] ---- 3a. private scope/index ----> [Private Registry/Proxy] [Config] ---- 3b. fallback/default ----> [Public Registry] [Private Registry/Proxy] ---- 4. serve private pkg ----> [Package Manager] [Public Registry] ---- 4. serve public pkg ----> [Package Manager] [Package Manager] ---- 5. write lock/cache ----> [Workspace]
The dangerous bit is step 3b: fallback/default. Many incidents are not exotic attacks; they are ordinary resolver behavior plus ambiguous names.
How it actually works
Walk one realistic example: Python in CI, private package named acme-utils, internal builds use a private index, but pip is configured with --extra-index-url instead of a strict primary index. An attacker publishes acme-utils to PyPI with a higher version.
End-to-end example
Your requirements.txt contains:
acme-utils>=1.0
requests==2.32.3
Your CI step does this:
python -m pip install --extra-index-url https://packages.example.com/simple -r requirements.txt
That looks plausible, but it is backwards for private packages. pip still treats PyPI as the primary source unless you set --index-url to the private index. If the same name exists on both indexes, version selection can prefer the public package.
Step by step:
- CI runner starts with default
pipconfig pointing at PyPI. - Command adds your private index as an extra source, not the authoritative source.
- Resolver asks both indexes for
acme-utilscandidates. - PyPI has
acme-utils 9.9.9; your private index hasacme-utils 1.4.2. - Resolver picks the best matching version, often the highest satisfying version.
- Build succeeds. That is the problem: no obvious failure.
- The malicious package executes at install time via build backend hooks or at runtime when imported.
What the output can look like:
$ python -m pip install --extra-index-url https://packages.example.com/simple -r requirements.txt
Looking in indexes: https://pypi.org/simple, https://packages.example.com/simple
Collecting acme-utils>=1.0 (from -r requirements.txt (line 1))
Downloading acme_utils-9.9.9-py3-none-any.whl.metadata (4.1 kB)
Collecting requests==2.32.3 (from -r requirements.txt (line 2))
Using cached requests-2.32.3-py3-none-any.whl.metadata (4.6 kB)
Downloading acme_utils-9.9.9-py3-none-any.whl (12 kB)
Installing collected packages: acme-utils, requests
Successfully installed acme-utils-9.9.9 requests-2.32.3
Notice there is no warning saying “you probably meant the private one.” The resolver did what you asked.
A more defensive command would make the private index primary and avoid fallback for internal names entirely:
python -m pip install --index-url https://packages.example.com/simple --no-deps acme-utils==1.4.2
Or better, use a private proxy as the only index for CI and let that proxy decide what public packages are allowed.
Typosquatting is mechanically simpler. The package manager resolves exactly the misspelled name you typed. If acme-utlis exists publicly and your lockfile does not pin the intended package, install succeeds. This is why lockfiles and exact names matter even when you think “we only use private packages.”
Why CI/CD is where this hurts most
CI has three traits attackers like:
- It often has broader network access than developer laptops.
- It often has secrets in environment variables.
- It often runs non-interactively, so a successful install is trusted.
If your build image bakes in a permissive package config, every repo using that image inherits the same exposure.
When to use it (and when not to)
Here “it” means the full set of controls: private naming convention, registry pinning, lockfiles, and usually an internal proxy/registry policy layer.
| Scenario | Recommendation |
|---|---|
| You publish internal npm packages used by multiple repos | Use scoped package names like @acme/pkg, commit .npmrc with scope-to-registry mapping, and block unscoped internal names. |
| You install Python packages in CI from both private and public sources | Use one internal index/proxy as index-url; do not rely on extra-index-url for internal packages. |
| You have a monorepo with only workspace/local packages and no external registry fetch in CI | You probably do not need a proxy registry yet, but still reserve your org scope/name on public registries where possible. |
| You ship air-gapped or highly regulated builds | Use a single internal mirror/proxy with explicit allowlists and lockfile verification. |
| Small app, no private packages, only public deps pinned in lockfiles | You probably do not need private-name protection beyond normal lockfile and review hygiene. Typosquatting risk still exists, so use dependency review tooling. |
| You cannot change package names because of legacy consumers | Keep names, but remove public fallback, pin registry per package namespace, and reserve the names publicly if the ecosystem allows it. |
You probably do not need the heavy version of this if all of these are true: no private packages, reproducible lockfile-based builds, no install-time scripts, and CI only pulls from a single approved mirror. That said, most teams claiming this still have at least one exception.
Trade-offs
Every control here buys safety by taking away convenience.
- Scoped/private naming reduces ambiguity → Cost: migration work, package rename churn, consumer updates, possible breaking import paths.
- Single internal proxy/mirror gives central policy and auditing → Cost: another critical service, cache warming, outage blast radius, storage and ops burden.
- Committing registry config in the repo makes builds deterministic → Cost: per-repo maintenance and occasional friction for contributors outside your network.
- Lockfiles and exact versions reduce typo and substitution risk → Cost: more dependency update ceremony, larger PR noise, occasional merge conflicts.
- Blocking install scripts or source builds reduces malicious execution paths → Cost: some legitimate packages need build backends, native compilation, or postinstall hooks.
- Reserving names on public registries prevents collisions → Cost: administrative overhead, and it does not help if your internal names are already generic or leaked.
Latency is usually a small cost if you use a proxy cache well; operational burden is the bigger one. Lock-in risk appears when your build semantics depend on a specific repository manager’s policy features. Prefer controls that also exist in plain client config so you can move later.
In practice
npm: scope internal packages and pin the scope to your private registry
# .npmrc committed at repo root
@acme:registry=https://registry.acme.example/npm/
always-auth=true
registry=https://registry.npmjs.org/
strict-ssl=true
# optional: fail faster in CI by avoiding user-level config surprises
userconfig=/dev/null
This tells npm that anything named @acme/* must resolve from your private registry, while public packages still come from npmjs. Gotcha: if your internal packages are unscoped like core-utils, this mapping does nothing; rename them or front everything through a single internal proxy.
Useful diagnostics:
npm config get @acme:registry
npm view @acme/build-utils version --registry=https://registry.acme.example/npm/
npm view left-pad version --registry=https://registry.npmjs.org/
Typical failure shape when auth or mapping is wrong:
$ npm install
npm ERR! code E404
npm ERR! 404 Not Found - GET https://registry.npmjs.org/@acme%2fbuild-utils - Not found
npm ERR! 404 '@acme/build-utils@^2.1.0' is not in this registry.
That specific registry.npmjs.org/@acme%2f... URL is the clue: npm is asking the public registry for a private scoped package, so your .npmrc was not loaded where you thought.
pip: make the private index authoritative, not “extra”
# pip.conf or $PIP_CONFIG_FILE used by CI
[global]
index-url = https://packages.example.com/simple
trusted-host = packages.example.com
disable-pip-version-check = true
no-cache-dir = false
This makes your private index the primary source. If that private service proxies approved public packages, CI never needs to talk to PyPI directly. Gotcha: trusted-host disables TLS hostname verification behavior for that host in some contexts; use it only when you actually need it, and prefer valid TLS plus your CA bundle.
A CI guard that fails if an internal package came from the wrong host:
python -m pip install -r requirements.txt --report /tmp/pip-report.json
python - <<'PY'
import json, sys
internal = {"acme-utils", "acme-auth"}
allowed = "packages.example.com"
report = json.load(open('/tmp/pip-report.json'))
for item in report.get('install', []):
name = item.get('metadata', {}).get('name', '').lower()
url = item.get('download_info', {}).get('url', '')
if name in internal and allowed not in url:
print(f"ERROR: {name} resolved from unexpected source: {url}")
sys.exit(42)
print('OK: internal packages resolved from approved host')
PY
This uses pip’s install report to inspect actual download URLs after resolution. Gotcha: normalize package names to lowercase and account for dashes vs underscores if your ecosystem treats them as equivalent.
Generic network check: verify your registry endpoint is not redirecting somewhere unsafe
curl -I https://packages.example.com/simple/acme-utils/
Expected healthy shape is a direct 200, 401, or 403 from your registry/proxy. A suspicious shape is a redirect to a public registry or a generic website:
$ curl -I https://packages.example.com/simple/acme-utils/
HTTP/2 302
location: https://pypi.org/simple/acme-utils/
server: nginx
That means your “private index” URL is really just bouncing clients to public infrastructure for misses. For internal names, that is exactly the confusion path you are trying to remove.
⚠️ If you switch CI from direct public registry access to a strict internal proxy or private index, expect some builds to fail immediately because they were relying on undeclared transitive behavior or user-level config. Roll this out first in a non-production branch pipeline and compare lockfiles before enforcing it org-wide.
Further reading
- npm Docs:
.npmrcandscope - pip Documentation:
Requirements File Formatandpip install - Python Packaging User Guide:
Package Index Mirrors and Caches - OWASP Software Supply Chain Security Cheat Sheet
- Sonatype research on dependency confusion
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI