Dependency confusion is one of those attacks that sounds like it should’ve died the day it was named. Dependency Confusion Prevention: Naming, Registries, And Ci Safeguards
And yet, it keeps working. Not because teams are dumb. Because real build systems are messy, and the “obvious” assumptions engineers make about package resolution are often wrong.
I’ve seen it show up as:
-
a CI build that suddenly starts pulling a package you’ve never heard of
-
a private package that “mysteriously” updates even though nobody touched your repo
-
a registry config change that “should be harmless” but flips precedence
-
a security near-miss caught only because someone stared at install logs at 2am and said “wait… why is it hitting the public registry?”
Dependency confusion thrives in the gaps between what people think their tooling does and what it actually does under pressure: multiple registries, mixed public/private deps, CI caching, automation that “helps,” and package managers that will happily fall back to the public internet unless you force them not to.
If you want to prevent it, you need a correct mental model of naming + registries + resolution + CI behavior. Not a checklist. Not “we have lockfiles.” Not “it’s in a private scope.” Those can help, but they don’t save you if your defaults are wrong.
What dependency confusion actually is
Dependency confusion isn’t magic. It’s a boring, brutal outcome of normal package resolution
- Your build asks for
- Your package manager can’t find where you think it should
- It looks elsewhere
- It finds on the public registry
- It installs it
- Now you’re running someone else’s code in CI and maybe production.
The realistic version looks like this:
-
Your company has a private package
-
It lives in a private registry (or a hosted repo manager)
-
Some repos reference it as a normal dependency
-
Your CI environment is configured to use both private and public registries, often with “fallback” behavior
Now your install step runs in CI. The private registry is flaky (or misconfigured, or missing that package because it was never mirrored correctly). The package manager tries the private registry, doesn’t get a match, and then goes to the public registry.
You see it in logs if you’re looking:
-
requests to your private registry
-
404 / not found / auth failure
-
then: requests to the public registry
If you weren’t watching logs, you might only notice later when:
-
tests start behaving weird
-
builds get slower (because it’s downloading something new)
-
or worse: nothing breaks, because the attacker kept the API surface compatible and just added “extra” behavior
Why dependency confusion happens
Here are the root causes I see most often in actual engineering systems. Not theory. The stuff that breaks builds.
Fallback to public” is the default in more places than people realize
Engineers assume
“If it’s not in the private registry, it should fail.”
Package managers often assume
“If it’s not here, I’ll try somewhere else.”
That “somewhere else” is usually the public registry
Because that’s the default. And because someone configured it that way years ago to fix a different problem.
Registry precedence gets messed up by tiny config differences
I’ve debugged issues where two CI runners had slightly different registry config:
-
one had an env var set
-
one had an auth token missing
-
one had a config file cached from a previous job
Result: one runner consistently pulled private packages; the other “helpfully” fell back to public and pulled whatever matched.
Same pipeline. Same repo. Different behavior. Because config is part of your build input, even if you don’t treat it like it is.
Names are global in public ecosystems, and internal naming is often sloppy
Teams name internal basically begging to collide with public packages, because the public ecosystems are huge and full of generic names.
Even if the exact name isn’t taken today, it might be tomorrow. Or an attacker can publish it first.
We have lockfiles, so we’re safe” is false confidence
Lockfiles help, but they’re not a forcefield.
Common failure modes I’ve seen:
-
lockfile not committed for some repos (“it’s a library, not an app”)
-
lockfile regenerated in CI (yes, people do this)
-
lockfile format differs across package manager versions
-
lockfile bypassed by flags like, or “fresh install”
-
lockfile pins a version, not a registry. If your tooling resolves the name to a different source before it hits the lock, you can still get surprised depending on ecosystem behavior and config
Lockfiles reduce drift. They don’t fix a broken trust model.
CI/CD is the easiest place to exploit because it’s automated and credentialed
CI usually has:
-
network access
-
credentials for private registries
-
fast iteration (so people ignore warnings to keep pipelines green)
Dependency confusion loves CI because it’s where “just make it work” decisions accumulate. And because it’s a great place to exfiltrate secrets if you install attacker code during build.
Private packages aren’t always actually “private” in practice
Sometimes they’re private by policy, not by enforcement.
Examples I’ve seen:
-
internal packages published to the public registry by accident (“wrong token, wrong registry”)
-
internal packages that were public temporarily, then “fixed”
-
orgs using a proxy registry that caches public packages and makes it hard to tell what came from where
-
teams assuming “it’s in our repo manager” means “it can’t be shadowed”
If public and private are in the same resolution path, you need to explicitly control who wins.
How scoped / namespaced packages help and where they don’t
Scopes (or namespaces) are one of the best tools we have because they do two things:
-
They reduce name collision
-
They allow registry rules per scope in some ecosystems
In practice, scoped naming is how you stop a ton of accidental risk. If your internal npm packages live under you’ve already avoided most random collisions.
But scopes don’t magically prevent confusion. They fail in a few common ways:
Scopes help when
-
your tooling is configured so that resolves only against your private registry
-
publishing to the public registry under that scope is impossible or controlled
-
you treat the scope boundary as a security boundary (not just a naming convention)
Scopes don’t help when
-
you don’t actually enforce per-scope registry rules (so the package manager still tries public)
-
you mix scoped and unscoped internal packages (“legacy” is always the reason)
-
your build uses multiple package managers or install paths (monorepos love doing this)
-
your scope exists on the public registry and someone can publish into it (depends on ecosystem rules and org ownership)
Also: some ecosystems don’t have “scopes” in the same way. Maven group IDs help. NuGet has package IDs but not the same built-in scoping semantics. Python has no true namespace protection in the public index (there are namespace packages, but that’s not the same thing as “only install from our registry”).
So yes: use namespaces. But treat them as one layer, not the layer.
Registry strategy: how to make public packages lose by default
This is the part most teams get wrong.
They set up a private registry, point CI at it, and assume they’re done.
Then the private registry has a hiccup, or auth expires, or a package is missing, and the tooling goes: “Cool, I’ll just grab it from the public internet.”
What you want is the opposite:
If an internal package can’t be resolved privately, the build should fail loudly
Not “fall back.”
That means designing registry strategy around defaults and failure modes, not happy paths.
The mental model: “explicit allow” beats “implicit fallback”
-
Public dependencies can come from public registries, through a controlled path (proxy, mirror, allowlist, whatever fits your world)
-
Internal dependencies must come from private registries only
-
If internal resolution fails, that’s a broken build, not a reason to try the public registry
Practical strategies that work
Separate internal and external dependency namespaces
This is boring, but it’s effective.
-
Internal packages: always namespaced/prefixed (company scope, groupId prefix, etc.)
-
External packages: everything else
Then enforce registry rules based on that division.
If your internal package is named , you’re choosing pain.
Use a proxy/mirror, but understand what it changes
Many orgs use a repo manager that proxies public registries.
This can be good: one stable endpoint, caching, audit logs.
But it can also hide where things came from. Teams stop knowing if a package was internal, proxied, cached, or pulled live.
If you use a proxy, make sure you can answer:
-
come from our hosted repo or from the public upstream?”
-
“Can an unrecognized internal name ever resolve from upstream?”
If the answer is “I’m not sure,” you have work to do.
Make internal packages non-resolvable from public sources
You can’t stop the public registry from existing.
You can stop your environment from treating it as a valid source for internal names.
That usually means:
-
per-scope registry rules (where supported)
-
repository routing rules (hosted vs proxy)
-
explicit repo lists (not “default + fallback”)
Fail on auth/config issues, don’t degrade gracefully
This sounds obvious until you see the “helpful” configs people add:
-
“If auth fails, try without auth”
-
“If private registry is down, use public so builds keep going”
That’s how you get owned.
If the private registry is down, your build should be down. That’s the point. That outage is cheaper than running attacker code.
CI safeguards that actually stop this
CI is where you have leverage because it’s consistent and enforceable (in theory). It’s also where teams accidentally loosen things “temporarily” and then forget.
Here are safeguards that actually catch dependency confusion in the real world.
Lock installs to the intended registry set
Your CI job should not be able to silently talk to random registries.
At minimum:
-
explicitly configure registries in the job
-
don’t rely on developer machine config
-
don’t rely on “whatever is in the runner image”
-
fail if registry config is missing
If someone can run CI with “default registry behavior,” you have a gap.
Treat “unexpected network calls” during install as a failure
I’m serious. Install steps are chatty, but they shouldn’t be mysterious.
You want to know:
-
what hostnames were contacted
-
whether any public registry was accessed unexpectedly
-
whether any internal packages were resolved from an unexpected upstream
How you implement this varies (proxy logs, firewall rules, egress allowlists, CI network policies), but the principle is stable:
If install reaches out to something it shouldn’t, it fails.
This catches misconfigurations and active exploitation.
Add a “dependency provenance” check
Most package managers can tell you what they resolved and from where (directly or via logs/artifacts).
Practical version: in CI, after install, verify that internal packages came from internal sources.
This is not about SBOM theater. It’s about catching “why is coming from upstream?” before it ships.
Don’t let CI regenerate dependency graphs silently
If your build process can update the lockfile, update versions, or “float” dependencies during CI, you’re asking for surprise.
In CI:
-
install should be deterministic
-
changes to lockfiles should be explicit and reviewed, not side effects of “build”
Make “package not found internally” a loud, actionable failure
Most people see “package not found” and assume “registry outage.”
Sometimes it’s:
-
the package was never published to the private registry properly
-
the package exists but auth is wrong
-
the repo manager routing rules are wrong
-
the package manager is looking in the wrong place first
Make the error message actionable. Even if that means wrapping installs with scripts that detect common failure patterns and print something human.
Because at 2am, you don’t want to reverse engineer why it tried upstream. You want it to tell you.
Minimum viable protection
If you’re in a messy org and you can’t fix everything this quarter, do these. They give you the biggest practical risk reduction.
-
Namespace internal packages
Stop using generic names. Use scopes/group prefixes. Make internal names obviously internal.
-
Make internal names fail closed
Internal packages must never be allowed to resolve from public registries. No fallback.
-
Pin registry config in CI
CI should set registry config explicitly and fail if it’s missing. No “it worked on runner A.”
-
Block unexpected egress during installs
Even a basic allowlist (“only our repo manager”) kills a ton of attack surface.
-
Audit for unscoped internal deps and fix them first
Find the packages that are internal. These are the ones that bite you.
If you do only these, you’ll prevent most real-world dependency confusion incidents I’ve seen.
Ecosystem-specific notes
This is where theory dies and weird tool behavior lives.
npm
-
Scopes are your friend
-
The big footgun precedence and where it comes from
-
user-level config
-
project-level config
-
CI runner images
-
“helpful” bootstrap scripts
-
I’ve seen builds flip behavior because a runner had an old global ached.
Practical takeaways:
-
configure registry and scope mappings in the repo
-
ensure scope points only to private registry
-
consider blocking network access to the public registry from CI entirely, except through a controlled proxy path
lockfiles help (package-lock/yarn.lock/pnpm-lock), but don’t assume they stop registry confusion if your resolution path is wrong.
Python
Python is tricky because the default public index is deeply baked in culturally and operationally.
Common real-world failure mode:
-
teams use to add a private index
-
pip will happily search both
-
precedence and version selection can surprise you
-
someone publishes the same name publicly at a higher version
-
pip picks it unless you’ve forced the behavior you want
Practical takeaways:
-
don’t use “extra index” for internal packages unless you understand the risk
-
prefer a single controlled index endpoint (proxy/mirror) where routing rules enforce internal-first and fail-closed for internal names
-
if you must use multiple indexes, be explicit about which packages can come from where (yes, it’s annoying)
Also: internal package naming matters a lot here because Python package names are global on the public index.
Maven / Gradle
Group IDs help a lot. If your internal packages live under a company-controlled groupId, collisions get harder.
But you can still shoot yourself with repository order and mirrors.
Practical takeaways:
-
ensure internal groupIds resolve only from internal repositories
-
use repository managers/mirrors to control upstream access
-
don’t let builds silently go to public repos when internal resolution fails
-
watch for “helpful” default repos included by build tooling or parent configs
NuGet
NuGet sources are explicit, but teams often keep both public and private sources enabled.
Real-world issue:
-
private feed is missing a package or has auth issues
-
NuGet resolves from public source if the ID matches
-
or developers have different source order on their machines vs CI
Practical takeaways:
-
lock down source order and enabled sources in CI
-
make internal package IDs unambiguously internal (prefixing helps)
-
consider disabling public sources in CI unless going through a controlled proxy
Monitoring and audits
The uncomfortable truth: a lot of teams “catch” dependency confusion by accident.
Someone notices:
-
a new outbound hostname in CI logs
-
a weird version jump
-
an install that suddenly got slower
-
a package tarball that doesn’t look right
-
a dependency tree change nobody expected
You can make this less accidental.
Things that work in practice
Log and alert on registry access patterns
Same for Python indexes, Maven central, NuGet gallery, etc.
Even if you can’t block it yet, visibility changes behavior.
Periodic scans for “internal-looking names” in public registries
Teams do this after a scare. It’s not perfect, but it’s useful:
-
search for your internal naming patterns on public registries
-
look for squatted names or suspicious high versions
-
treat it like domain squatting: you don’t want to discover it during an incident
Audit dependency sources, not just versions
A dependency list isn’t enough. You want provenance:
-
which registry/source
-
which path (direct, proxy, mirror)
-
which job pulled it first
This is where repo managers and proxy logs can help if you actually use them.
Make install logs easy to inspect
Sounds silly until you’ve tried to debug a CI failure with 50,000 lines of output.
You want:
-
registry hostnames visible
-
resolution decisions visible
-
enough logging to answer “why did it pick that?”
Then you can catch weirdness before it becomes a postmortem.
You Might Be Interested In
- Secrets Scanning: What To Scan Code, Logs, Tickets And How To Respond?
- How Cybersecurity Threats Affect Users?
- What Data Security Really Means?
- How Ransomware Protection Works?
- Why Endpoint Security Is Essential?
Conclusion
Dependency confusion isn’t a clever trick. It’s the natural outcome of build systems that are allowed to guess, fall back, and “do something reasonable” when things go wrong. In real environments, things do go wrong all the time registries time out, tokens expire, runners drift, and legacy decisions linger. If your system treats those failures as a reason to trust the public internet, you’ve already lost the most important battle.
What actually works is boring and explicit. Internal packages need names that clearly mark them as internal. Registry resolution needs to be deterministic, not best-effort. CI must install dependencies in an environment that cannot silently improvise or reach places it shouldn’t. And when something is missing or misconfigured, the build should fail loudly, even if that’s inconvenient. That inconvenience is cheaper than incident response.
If there’s one mindset shift to take away, it’s this: dependency management is part of your security boundary, not just a developer convenience. Once you treat registries, naming, and CI behavior as trust decisions and design them to fail closed dependency confusion stops being a lurking risk and becomes just another class of bug you catch early, fix once, and don’t lose sleep over again.
FAQs about Dependency Confusion Prevention: Naming, Registries, And Ci Safeguards
“We use lockfiles. Are we safe?”
Lockfiles reduce version drift, not trust ambiguity. They pin what version you install, but they don’t inherently pin where that package came from or why it was chosen. I’ve seen lockfiles happily lock in a malicious public package after a registry misconfiguration, because from the package manager’s point of view, nothing was “wrong.” It resolved a name, found a version, and recorded it. Mission accomplished.
Where teams get burned is assuming lockfiles override registry behavior. They don’t. If your resolver looks at the public registry first (or second, after a private failure), the lockfile just records the outcome of that decision. Lockfiles are necessary for stability and reproducibility, but they sit on top of your registry trust model. If that model is wrong, the lockfile faithfully preserves the mistake.
“If we scope our npm packages, does that solve it?”
Scopes reduce accidental collisions and make intent explicit. Seeing immediately tells both humans and tooling “this is internal.” The catch is that npm (and CI environments) will still happily try the public registry for that scope unless you explicitly configure otherwise. I’ve seen teams assume “it’s scoped, so it can’t come from public,” only to discover their CI runner had no for that scope and quietly pulled from upstream.
Think of scopes as a label, not a lock. They give you a clean lever to apply registry rules, but they don’t apply those rules for you. If can ever resolve from public due to missing config, expired tokens, or runner drift, you still have dependency confusion risk just with a nicer name.
“Isn’t this just a registry misconfiguration problem?”
Yes and that’s exactly why it keeps happening.
Registry misconfigurations are not rare edge cases. They’re normal operational failures: expired credentials, partial outages, proxy bugs, misordered sources, “temporary” fallbacks that become permanent. The mistake is treating registry config as static infrastructure instead of live, failure-prone input into your build system.
Dependency confusion isn’t about one bad config; it’s about what your system does when config is wrong. If the answer is “it tries public and keeps going,” you’ve built an attack path. Prevention means designing your system so that expected failures (auth issues, missing packages, repo downtime) turn into loud, blocking errors not silent trust changes.
“What’s the simplest CI check that catches this early?”
Watch where your install step actually goes on the network.
You don’t need fancy tooling to get value here. If your CI job is supposed to install internal dependencies from your private registry (or repo manager), then any direct call to a public registry during that step is suspicious by definition. Logging, blocking, or alerting on unexpected registry hostnames catches both misconfigurations and active attacks very early.
I’ve seen teams discover serious issues just by answering a basic question: “Why is this build talking to the public registry at all?” That single visibility check often exposes forgotten fallbacks, inconsistent runner config, or legacy tooling paths nobody realized were still active.
“We need public dependencies. Do we have to block public registries?”
No but you do need to control how public dependencies are reached.
Most mature setups don’t ban public packages; they funnel them through a controlled path. That might be a proxy, mirror, or repo manager that centralizes access, caching, and auditing. The key is that CI shouldn’t be making ad-hoc decisions about when and how to talk to the public internet. Public access should be explicit, predictable, and observable.
The real goal isn’t isolation for its own sake it’s eliminating ambiguity. Internal packages should never come from public, and public packages should only come from public through a known route. Once those boundaries are enforced, dependency confusion stops being a lurking surprise and becomes a configuration error you catch immediately.

