Dependency confusion happens when a resolver chooses a public package over your private one of the same name. Bind every internal name to its real registry (npm scopes and .npmrc, pip/uv single index, Maven mirror, NuGet packageSourceMapping), install from a lockfile with integrity hashes, and keep install scripts off by default. Add a release-age cooldown and never install an AI-suggested name you have not verified.
- Do map every internal package name to one trusted source; a resolver that sees the name on a public index too will usually take the higher version.
- Do install from a committed lockfile with integrity hashes (
npm ci,pip --require-hashes) so the bytes are fixed, not just the version range. - Do keep dependency install scripts off by default (npm 12
allowScripts, pnpmallowBuilds, YarnenableScripts) and approve only what you reviewed. - Do add a release-age cooldown (npm
min-release-age, pnpmminimumReleaseAge, Renovate/Dependabot) so a malicious version is not installed the minute it is published. - Don't rely on
--extra-index-urlor a second public mirror for internal names; there is no source priority and the name is leaked to the public index. - Don't install a package name a model suggested without confirming it exists and is the one you meant.
| Setting | Value | Note | Source |
|---|---|---|---|
| npm scoped registry | @scope:registry=https://host/ in .npmrc | Pins every package under @scope to one registry; unscoped names still resolve against the default registry. | https://docs.npmjs.com/cli/v12/using-npm/scope |
| npm install scripts (v12) | blocked by default | Dependency preinstall/install/postinstall scripts are skipped unless listed in the package.json allowScripts field; approve with npm install-scripts approve. | https://docs.npmjs.com/cli/v12/commands/npm-install-scripts |
npm allow-git / allow-remote (v12) | none | git and tarball-URL dependencies no longer resolve unless set to all or root. | https://github.com/npm/cli/blob/latest/CHANGELOG.md |
npm min-release-age | null (opt in, days) | Added in npm 11.10.0; min-release-age-exclude added in 11.17.0. | https://docs.npmjs.com/cli/v12/using-npm/config |
| pip index precedence | no priority; highest version wins | --index-url and --extra-index-url are searched together; prefer one index. | https://pip.pypa.io/en/stable/cli/pip_install/ |
pip --uploaded-prior-to | off (ISO date or P3D) | Datetime added in pip 26.0, duration form (e.g. P3D) in 26.1; needs upload-time metadata. | https://pip.pypa.io/en/stable/news/ |
uv index-strategy | first-index | Only the first index that has the name is used; unsafe-best-match restores pip-like merging. | https://docs.astral.sh/uv/concepts/indexes/ |
| pnpm minimumReleaseAge | 1440 minutes | Default since pnpm v11; 0 before. minimumReleaseAgeExclude lists exempt packages. | https://pnpm.io/settings/dependency-resolution |
| pnpm build scripts | blocked (strictDepBuilds: true) | pnpm v10+ does not run dependency build scripts, and since v11 an unreviewed one fails the install; list trusted ones in allowBuilds. | https://pnpm.io/settings/build |
Yarn npmMinimalAgeGate | e.g. 1w | Age gate on the publish date; enableScripts defaults to false, and enableHardenedMode switches on by itself for pull requests to public GitHub repositories. | https://yarnpkg.com/configuration/yarnrc |
| NuGet package source mapping | off until configured | Requires NuGet 6.0 / .NET SDK 6.0.100+; once set, every package including transitive must match a pattern or restore fails (NU1101). | https://learn.microsoft.com/en-us/nuget/consume-packages/package-source-mapping |
| Dependabot version-update cooldown | 3 days (default) | Applies to version updates only; security updates open immediately. GHES 3.23. | https://github.blog/changelog/2026-07-14-dependabot-version-updates-introduce-default-package-cooldown/ |
- Grep every lockfile for your internal names and confirm the resolved URL or source is the private registry, not the public one (
package-lock.jsonresolved,uv.locksource,project.assets.jsonlibraries). - In a throwaway environment, publish a higher version of one internal name to a scratch public index and confirm your configured install still takes the private one.
- Run
npm ci(orpip install --require-hashes) after tampering with one cached tarball and confirm it fails with an integrity error instead of installing. - Install a dependency whose
postinstallwrites a marker file and confirm the marker is absent until you explicitly approve the script. - Set a release-age cooldown and confirm a version published minutes ago is skipped in favour of an older one.
- Run your SCA tool and check that it flags unpinned or unmapped dependencies, run OpenSSF Scorecard's Pinned-Dependencies check for unpinned build dependencies, and re-run both after the fix.
- Applies to
- npm 11.x, 12.x
- pnpm 10.x, 11.x, 12.x
- pip 26.x
- uv 0.13.x
- NuGet 6.0+
- Maven 3.9.x
npm, PyPI, Maven and NuGet consumers that mix internal and public registries. A lockfile protects only the installs that actually use it.
- Part of
- Workflows: Fix a finding, Respond to a vulnerability, Build an AppSec program
Learning goals and prerequisites
What you will learn
- Explain how a resolver can pick a public package over a private one of the same name.
- Bind internal names to a trusted source in npm, pip/uv, Maven/Gradle and NuGet.
- Fix installed bytes with lockfiles and integrity hashes, and verify the source in CI.
- Reduce install-time execution and add a release-age cooldown against typosquatting and slopsquatting.
Before you start
Developers and AppSec or platform engineers who configure package managers, private registries and CI installs
- Read a package manifest and a lockfile, and tell a version range from a resolved version.
- Know the difference between a public registry, a private registry and a repository-manager proxy or group.
- Have a package manager installed to try the commands (npm/pnpm, pip/uv, Maven/Gradle or dotnet).
How a public look-alike wins the install, and the fix
- The name leaksAn attacker learns an internal-only name, such as
acme-billing, from a leaked manifest or error message and publishes it on the public index as99.0.0. - The build asks for the nameCI runs
pip install acme-billingwith--index-urlset to the internal index and--extra-index-urlto PyPI. An unscoped npm name behind a registry group that proxies npmjs.com behaves the same way. - The resolver asks every sourceThe resolver asks both indexes, which also reveals the internal name to the public one. The internal index offers 1.4.2; the public index offers 99.0.0.
- The highest version winspip has no index priority: it merges the candidates and picks the highest compatible version, 99.0.0.
- Install-time code runsThe attacker's package installs on the build host, and its build code or npm
preinstallorpostinstallscript runs with the job's credentials. - The fix: one source per nameGive each internal name one source: an npm scope with
@acme:registry, a uv index withexplicit = true, a single pip index that proxies PyPI, NuGetpackageSourceMappingor a Maven mirror. Install from a lockfile with hashes.
Dependency confusion is a resolution bug, not a code bug. When your build asks for a package by name, the installer looks for that name across every source it knows and then picks a "best" candidate, usually the highest version. If the same name also exists on a public registry, and nothing tells the installer that this name is private, the public copy can win. An attacker who learns an internal package name (from a leaked manifest, an error message, a public fork or a job posting) publishes that name on npmjs.com or PyPI with a very high version number and waits for a build to pull it. MITRE files this under CWE-427, Uncontrolled Search Path Element, whose alternate term is literally "dependency confusion" or "substitution attack."
Typosquatting is the related trick aimed at the human rather than the resolver: the attacker registers a name one keystroke from a real one (crossenv for cross-env, or a hyphen/underscore swap) and waits for a mistype. The 2017 crossenv packages wrapped the real module so installs looked normal while the install script shipped environment variables to the attacker. Slopsquatting is the newest variant and aims at AI coding assistants: models invent plausible but non-existent package names, and an attacker pre-registers them. A 2025 study generated 576,000 code samples across 16 models and found 19.7% of recommended packages did not exist, with 205,474 unique hallucinated names, many of them repeatable enough to squat profitably.
All three share one fix shape. Decide in advance exactly which name may come from which source, install only the bytes you reviewed, and give yourself time and a human gate before brand-new code runs on a build machine. The rest of this guide is that shape, per ecosystem, with the current flags.
The strongest npm control is a scoped name bound to a registry. Publish internal packages under an organization scope such as @acme, and in a committed .npmrc map @acme to your private registry while leaving the default registry public. A scope you own cannot be claimed by anyone else on the public registry, so @acme/ledger simply does not exist there to confuse the resolver. Keep the auth token in an environment variable rather than in the file. The unscoped case is weaker: a bare name like acme-ledger lives in the same namespace as the public registry, so if your private registry is reached through a repository-manager group that also proxies the public one, the higher public version can still win. Prefer scopes for anything internal.
pnpm reads the same .npmrc registry mapping. Both npm 12 and pnpm v10+ also changed a dangerous default: dependency install scripts no longer run automatically. In npm 12 the install skips preinstall/install/postinstall for any dependency not listed in the package.json allowScripts field, prints the blocked packages, and lets you approve or deny them; pnpm 11 and later fail the install (strictDepBuilds defaults to true since v11) until you list trusted packages in allowBuilds. That matters here because the first thing a malicious package wants is code execution at install time, before you ever import it; /domains/supply-chain/dependencies/package-attack-response/ covers the response when one already ran.
; .npmrc at the repository root, committed with the code
@acme:registry=https://npm.acme.example/
//npm.acme.example/:_authToken=${NPM_TOKEN}
registry=https://registry.npmjs.org/
min-release-age=3
min-release-age-exclude=@acme/*Treat the install-script allowlist as a reviewed artifact. Run the install once, read what npm blocked, and approve only packages whose build step you understand (native modules such as those with a binding.gyp are the common legitimate case). An approval written by npm install-scripts approve is pinned to the exact version you reviewed, for example "@acme/native@2.0.0": true; a denial is name-only, for example "acme-telemetry": false, so it holds for every future version. In CI, add --strict-allow-scripts so an unreviewed script fails the build loudly instead of being skipped with a warning. The pre-12 escape hatch --ignore-scripts still works for a blunt "run nothing" policy.
npm 12 also flipped allow-git and allow-remote to none, so a transitive git: or tarball-URL dependency no longer resolves unless you opt in; tarballs on the configured registry's own host still install. That removes a common way for a malicious transitive edge to pull code from outside the registry entirely.
# npm 12: dependency install scripts are skipped, then listed at the end.
npm install
npm install-scripts ls # what was blocked, and the command it wanted to run
npm install-scripts approve @acme/native # writes "@acme/native@2.0.0": true to allowScripts
npm install-scripts deny acme-telemetry # writes "acme-telemetry": false
npm rebuild # runs the scripts that are now approvedpip has no notion of index priority. It searches --index-url and every --extra-index-url together and installs the highest compatible version, wherever it lives. That is exactly how the 2022 PyTorch nightly compromise worked: a package named torchtriton on PyPI shadowed the one on PyTorch's own index because "the PyPI index takes precedence." So do not use --extra-index-url to reach a private index for internal names. Point --index-url at a single private index that proxies or mirrors PyPI, and resolve everything through it, so there is one ordered source and no merge step.
uv is safer by default: its first-index strategy only considers the first index that contains a given name, so an internal package found on your index is never also fetched from PyPI. Pin a package to a named index with [tool.uv.index] marked explicit = true plus a [tool.uv.sources] entry, and uv will refuse to take that name from anywhere else. Avoid the unsafe-best-match strategy, which restores pip-style merging and reintroduces the confusion risk.
# Vulnerable: pip merges candidates from both indexes and installs the
# highest version it can find. A public "acme-billing 99.0.0" beats the
# internal 1.4.2, so the attacker's package wins.
pip install --index-url https://pypi.acme.example/simple \
--extra-index-url https://pypi.org/simple \
acme-billingThe fixed uv configuration below binds acme-billing to an internal index and marks that index explicit, so no other package drifts onto it and acme-billing is never resolved from PyPI. This is the uv analogue of an npm scope map: the name and its source are decided in the project file, not by whichever index happens to answer first.
For the classic pip workflow, keep the same discipline with requirements and constraints files that pin exact versions, and lean on hash checking (next section) so the resolved bytes are fixed even if an index is swapped.
# pyproject.toml: acme-billing may come only from the internal index.
# "explicit = true" keeps every other package off that index, and the
# tool.uv.sources pin keeps acme-billing off PyPI.
[[tool.uv.index]]
name = "acme"
url = "https://pypi.acme.example/simple"
explicit = true
[tool.uv.sources]
acme-billing = { index = "acme" }Pinning a version range is not enough, because the same version can be served as different bytes by a different source. Pin the bytes. npm ci installs strictly from package-lock.json and fails with an integrity error if a downloaded tarball does not match the recorded sha512; it also refuses to run if the lockfile and package.json disagree. For pip, hash-checking mode (--require-hashes) requires every requirement to be pinned with == and requires a hash for every dependency, transitive ones included, so an unpinned or unexpected package is rejected before download. Add --only-binary :all: when you want to refuse source distributions, which run arbitrary code at build time.
Go one step further in CI and check the source, not just the hash. The lockfile records where each package came from, so a short jq (or equivalent) check can fail the build when any internal name resolves from a public host. The snippet below combines strict install, hash-checked pip, and a host allowlist over the npm lockfile. It catches the case where someone's local install quietly repointed a dependency at an unexpected registry.
# CI: install exactly what the lockfiles record, or fail.
npm ci --strict-allow-scripts
pip install --require-hashes --only-binary :all: -r requirements.txt
# Fail the build if any npm package resolves from a host that is not one of
# the two approved registries.
bad=$(jq -r '.packages | to_entries[]
| select(.value.resolved and (.value.resolved
| test("^https://(npm\\.acme\\.example|registry\\.npmjs\\.org)/") | not))
| "\(.key) \(.value.resolved)"' package-lock.json)
[ -z "$bad" ] || { printf 'Unexpected package source:\n%s\n' "$bad"; exit 1; }Most malicious versions are caught and pulled within hours to days. A release-age cooldown turns that window to your advantage by refusing to install a version until it has existed for a few days. npm has min-release-age (a day count in .npmrc, added in 11.10.0) with min-release-age-exclude for packages you control; pnpm has minimumReleaseAge (minutes; default 1440 since v11) with minimumReleaseAgeExclude; Yarn has npmMinimalAgeGate; bun has minimumReleaseAge in bunfig.toml. Exclude your own internal scope so your freshly published internal versions are not delayed. The four hours in 2021 when ua-parser-js served malware (/domains/supply-chain/dependencies/case-study-event-stream/) are the kind of window a cooldown closes.
If a bot opens your dependency updates, set the cooldown there too. Renovate ships a security:minimumReleaseAgeNpm preset that waits three days (it also notes npm's own 72-hour unpublish window), and in 2026 Dependabot made a three-day cooldown the default for version updates while still opening security updates immediately. A cooldown is a delay, not a scanner, so keep it alongside hashes and an allowlist rather than in place of them.
# pnpm-workspace.yaml (pnpm 11 or later)
minimumReleaseAge: 4320 # minutes (3 days); the default since v11 is 1440
minimumReleaseAgeExclude:
- '@acme/*' # internal packages are not delayed
allowBuilds:
'@acme/native': true # only this dependency may run a build script
strictDepBuilds: true # the default: an unreviewed build script fails the installThe JVM and .NET ecosystems have the same resolution model and the same fix. For Maven, route everything through one repository manager with a settings.xml mirror whose mirrorOf is *, and configure the manager so your internal groupId (com.acme) is served only from the hosted repository and never proxied to Central. With a single mirror, a repository declared inside a project POM is not even contacted. Maven's resolver also supports remote repository filtering (prefix and groupId filters under the local repository's .remoteRepositoryFilters directory) to assert which groupIds a given remote may answer for. Gradle expresses the same intent with exclusiveContent, which ties a group to one repository so Gradle searches nowhere else for it. Reserving your groupId on Maven Central (via DNS TXT verification, or the io.github.<user> namespace) stops anyone else publishing under it.
For NuGet, packageSourceMapping makes the mapping explicit and strict: map Acme.* to your private feed and * to nuget.org, and every package, including transitive ones, must match a pattern or restore fails with NU1101 instead of silently falling back. Start the config with <clear /> so no inherited source sneaks in, and set a repository-local globalPackagesFolder so a package cached earlier from another source is not reused. Reserve your ID prefix on nuget.org so the public registry rejects a look-alike under Acme.*. The two snippets below are a working settings.xml mirror and a NuGet config; the Gradle exclusiveContent form is in the exercise.
<!-- ~/.m2/settings.xml, or the file your CI passes with -s -->
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0">
<mirrors>
<mirror>
<id>acme-repo-manager</id>
<name>All repository traffic goes through the repository manager</name>
<url>https://maven.acme.example/repository/all/</url>
<mirrorOf>*</mirrorOf>
</mirror>
</mirrors>
</settings>This nuget.config restricts Acme.* to the private feed and sends everything else to nuget.org. Because mapping is enforced during restore, an attacker's Acme.Billing 99.0.0 on nuget.org is never considered: the pattern says that name comes from the acme source only. If the private feed does not have it, restore fails rather than reaching for the public copy, which is the behaviour you want. Package source mapping does not yet cover every metadata command (for example dotnet list package --outdated still queries all sources), so treat it as a resolution control, not a secrecy guarantee for the name.
Across every ecosystem the artifact-repository layer (Artifactory priority resolution, Nexus routing rules, or the exclude-pattern approach) is where you enforce this centrally: a routing or priority rule says an internal namespace is served only from the hosted repository and is never requested from an upstream proxy, so the internal name never leaks to the public index and a public look-alike is never fetched.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<!-- Repo-local cache: a package cached earlier from another source is
not silently reused when a mapping would send it elsewhere. -->
<add key="globalPackagesFolder" value=".nuget/packages" />
</config>
<packageSources>
<clear />
<add key="acme" value="https://nuget.acme.example/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="acme">
<package pattern="Acme.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>The controls above assume the name you asked for is the name you meant. Typosquatting and slopsquatting break that assumption. For human error, lean on lockfiles and the cooldown, let your SCA tool flag low-reputation packages, and let OpenSSF Scorecard's Pinned-Dependencies check flag unpinned build dependencies. For AI-suggested code, add one habit: before you add a package a model recommended, confirm it actually exists, is the one you intended, and has a plausible history (age, downloads, repository, maintainers); /domains/ai-emerging/agent-security/ai-assisted-development/ includes a check that blocks names that do not exist or are too new. OWASP's LLM Top 10 for 2026 treats registering hallucinated names as a supply-chain vector under LLM04. A cheap gate is to run installs with a cooldown and a curated allowlist so a brand-new, never-before-seen name cannot be pulled on the first build that mentions it.
None of these controls is sufficient alone. Scope or source mapping decides where a name may come from; a lockfile with hashes fixes the bytes; disabled install scripts remove the install-time execution path; a cooldown buys detection time; name verification covers the human and the model. Deploy them together and dependency confusion stops being a single missing flag away. In the OpenSSF S2C2F, binding names to sources is requirement ENF-1 at Level 2 and enforcing a curated feed is ENF-2 at Level 3; /domains/supply-chain/dependencies/s2c2f-adoption/ places them in a staged plan.
Guided practice
Your organization publishes internal packages under @acme (npm) and the com.acme group (Maven). A reviewer finds an npm install that reaches the private registry through a repository-manager group that also proxies npmjs.com, a pip install that uses --extra-index-url for internal names, and a Gradle build that lists an internal repository after mavenCentral(). Close each gap and prove the fix with a local test, without touching production registries.
Supplied scenario material
npm .npmrc (current):
registry=https://npm.acme.example/group/ # proxies npmjs.com and the internal feed
(no @acme mapping)
Internal: @acme/ledger 1.4.2 ; Public decoy on npmjs.com: @acme/ledger 1.99.0 (hypothetical)
pip (current):
pip install --index-url https://pypi.acme.example/simple \
--extra-index-url https://pypi.org/simple internal-reports
Internal: internal-reports 2.1.0 ; Public decoy on PyPI: internal-reports 9.9.0
Gradle (current):
repositories { mavenCentral(); maven { url = uri("https://maven.acme.example/repository/all/") } }
Internal: com.acme:billing:1.4.2 ; Public decoy on Central: com.acme:billing:9.9.0 (hypothetical)1. Name the resolution rule being exploited
For each of the three builds, state which source the resolver would pick for the internal name today and why.
Expected result and reasoning
npm: through a merged group with no scope map, the higher public
@acme/ledger1.99.0 can win. pip:--extra-index-urlhas no priority, sointernal-reports9.9.0 from PyPI wins. Gradle: without content filtering,com.acme:billingis searched on Central too and 9.9.0 wins. All three are CWE-427.2. Bind each internal name to its source
Rewrite the three configurations so each internal name can resolve only from the internal source.
Expected result and reasoning
npm: add
@acme:registrypointing at the internal feed (a scope you own cannot exist publicly). pip: drop--extra-index-urland resolve through one private index that proxies PyPI. Gradle: wrap the internal repo inexclusiveContentwithincludeGroupByRegex("com\\.acme(\\..*)?").3. Fix the bytes, not just the version
State the install command CI should use so a swapped source is caught even if a name resolves.
Expected result and reasoning
npm ci(fails on integrity mismatch and on lockfile/manifest drift);pip install --require-hasheswith every requirement pinned by==and hashed. Optionally a lockfile host-allowlist check that fails on any unexpected resolved URL.4. Prove it locally
Describe a disposable test that demonstrates each fix without using production registries.
Expected result and reasoning
Start two loopback indexes/feeds, put the internal name on one and a higher decoy on the other, and show the configured install takes the internal version. Then tamper with one cached tarball and show
npm cifails with an integrity error. Record versions installed and the resolved sources.5. Add time and a human gate
Specify the release-age and install-script settings you would add, and what you would exclude.
Expected result and reasoning
A 3-day cooldown (npm
min-release-age/ pnpmminimumReleaseAge/ Renovate or Dependabot) excluding@acme/*; keep install scripts blocked by default (npmallowScripts/ pnpmallowBuilds) and approve only reviewed, pinned entries. State that these buy time and remove install-time execution but do not replace hashes or the source map.
Decoy versions are hypothetical and the test uses loopback registries; it demonstrates resolver behaviour and integrity checking, not that any real public name is malicious. Package source mapping and content filtering control resolution, not metadata queries, so they are not a guarantee that an internal name is secret.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- MITRE CWE-427: Uncontrolled Search Path Element (dependency confusion alternate term). CWE 4.20.
- OWASP Top 10:2025 A03 Software Supply Chain Failures
- OWASP ASVS 5.0 V15 Secure Coding and Architecture (15.2.4 dependency confusion). Version 5.0.0.
- npm docs: npm install-scripts and the allowScripts policy (npm 12). npm v12.
- npm CLI CHANGELOG: v12.0.0 breaking changes (install scripts, allow-git/allow-remote). 12.0.0 entry (8 July 2026).
- pip docs: pip install (index options) and secure installs (hash-checking mode). pip 26.2.1.
- uv docs: package indexes and index strategy
- pnpm docs: dependency-resolution (minimumReleaseAge, trustPolicy) and build settings
- NuGet docs: package source mapping
- Spracklen et al., package hallucination study (arXiv 2406.10279, USENIX Security 2025). v3 (March 2025).
- OWASP Top 10 CI/CD Security Risks: CICD-SEC-3 Dependency Chain Abuse. v1.0 (2022).
- CISA, NSA and ODNI (Enduring Security Framework): Securing the Software Supply Chain, Recommended Practices Guide for Developers, section 2.3 Verify Third-Party Components. August 2022.
Reference information
- SDLC phase
- Development
- ASVS 5.0.0 requirements
- v5.0.0-15.2.4 · v5.0.0-15.1.2
- CAPEC attack patterns