In February 2021, security researcher Alex Birsan disclosed a technique that got malicious code executing inside the internal build systems of 35 companies — including Microsoft, Apple, PayPal, Netflix, and Uber — without exploiting a single traditional vulnerability. He simply found internal package names leaked in public package.json files and lockfiles on GitHub, then published identically-named packages to the public npm registry with postinstall scripts that phoned home with hostname and username data. When internal build pipelines resolved those names, many silently pulled the public, attacker-controlled version instead of the private one, because their registry configuration had no explicit rule for which source should win. Birsan collected over $130,000 in bug bounties — $40,000 from Microsoft alone, $30,000 from Apple — for a class of bug that requires no authentication bypass and no code vulnerability, just an ambiguous namespace. Five years later, dependency confusion is still routinely rediscovered against companies that never scoped their internal packages or locked their install pipeline down to verified hashes. This post explains exactly how the resolution ambiguity happens and which concrete npm/yarn controls close it.
What actually makes dependency confusion possible?
It's possible because npm's package namespace is flat by default, and install tooling historically resolved an unscoped name by querying whichever registries were configured, in priority order, with no binding that says "this name must come from this registry." A company running an internal registry (Artifactory, Verdaccio, npm Enterprise) for a package like internal-auth-utils still, by default, lets npm install fall through to registry.npmjs.org if the internal server doesn't have a match — or, more dangerously, some CI runners were configured to check the public registry first. Birsan's research showed this wasn't a hypothetical: he found internal package names inside leaked package.json dependency lists, node_modules paths committed accidentally, and even employee résumés, then registered the exact same names publicly. Nothing about the install command changed on the victim's end — the ambiguity lived entirely in registry configuration, which is why the fix has to live there too.
How does scoped-package registry mapping stop it?
Publishing internal packages under an npm scope — @yourorg/internal-auth-utils instead of a bare name — and then binding that scope explicitly to your internal registry in .npmrc removes the ambiguity npm resolves by name lookup order. The configuration line @yourorg:registry=https://npm.internal.yourorg.com/ tells npm and yarn that any package under the @yourorg scope must come from that URL, full stop; there is no fallthrough to registry.npmjs.org for that scope even if the internal server is unreachable, which fails safe rather than silently resolving elsewhere. Safeguard's own Gold Registry documentation uses the same underlying mechanism for pulling vetted packages: a single, pinned registry= line in .npmrc with a scoped auth token, rather than relying on a global default registry setting that can drift between a developer's laptop and a CI runner. Gold's pattern substitutes a trusted upstream for the whole registry rather than binding by scope, but the principle — put the trust decision in a reviewed config file, not implicit resolution order — is the same one that stops dependency confusion. Unscoped internal names are the failure mode Birsan exploited — scoping is the specific, narrow fix for that exact ambiguity, and it costs nothing to adopt on a new package.
Why does npm ci with lockfile integrity matter more than pinning versions?
Version pinning alone doesn't stop dependency confusion, because an attacker can publish a malicious package at the exact version number your package.json requests — the fix has to verify what was actually downloaded, not just its version string. package-lock.json records two things per dependency beyond the version: a resolved URL showing exactly which registry and path it came from, and an integrity field holding a SHA-512 Subresource Integrity hash of the tarball contents. Running npm ci in CI — not npm install — enforces that hash: if a registry substitution attack (or any tampering) changes even one byte of the package, the computed hash won't match the lockfile's recorded value and the install fails hard, before any script executes. npm install will happily update the lockfile to match whatever it fetches, which defeats the protection; npm ci refuses to write to the lockfile at all, treating it as the source of truth. This is a real, documented cryptographic check, not a naming convention — it's why every serious npm hardening guide treats npm ci in CI as non-negotiable rather than a style preference.
Does reserving package names on the public registry actually help?
It helps as a coarse, defense-in-depth measure, but it doesn't scale and shouldn't be treated as a substitute for registry scoping. The widely recommended mitigation is to publish empty placeholder packages under your internal names on npmjs.org and PyPI, denying an attacker the name before they can claim it — several of the companies in Birsan's disclosure adopted this afterward. The limitation is coverage: it requires enumerating every internal package name, across every ecosystem, and repeating the exercise every time a new internal package is created, which is exactly the kind of manual process that lapses under deadline pressure. It also does nothing for private registries that mirror public ones by convention, or for typosquats one edit-distance away from a reserved name. Scoped registry mapping and lockfile integrity checking prevent the resolution ambiguity structurally; name reservation only removes one specific opportunity for one specific package at one point in time.
Where does registry configuration drift creep back in?
It creeps back in through per-machine or per-runner .npmrc files that never get reviewed the way source code does. A scope-to-registry mapping checked into a repo's .npmrc is only effective if every CI runner and every developer machine actually reads that file instead of a conflicting global ~/.npmrc or an environment variable set by a base Docker image. Teams that pass every other check in this post have still shipped a dependency confusion incident because one self-hosted runner had a stale global registry config that took priority over the project-level one — npm resolves configuration through a documented precedence order (CLI flags, environment variables, project .npmrc, user .npmrc, global .npmrc, npm's built-in config), and any layer above the project file can silently override your scoping. The practical fix is treating registry configuration as a reviewed, version-controlled artifact and auditing CI images for pre-existing .npmrc files the way you'd audit for hardcoded secrets.
How Safeguard helps
Safeguard doesn't ship a feature named "dependency confusion detection," but existing capabilities cover the attack pattern it depends on. The Typosquat guardrail blocks any package whose name sits within edit-distance 2 of a top-1000 package and isn't owned by the expected publisher, catching the naming collisions that dependency confusion and typosquatting both rely on before install completes. Eagle, Safeguard's classification model, scores install-script behavior — including postinstall hooks that contact unusual hosts, install additional packages, or write outside the package's own directory — as one of seven indicator classes it evaluates on every artifact, which is precisely the payload mechanism Birsan used. And the Gold Registry's .npmrc pattern (registry=https://registry.safeguard.sh/gold/npm/, pinned with a scoped auth token) demonstrates the same underlying principle in production: a single trusted registry pinned in a reviewed config file, with no implicit fallthrough. It isn't a substitute for scoping your own internal package names — that still requires adding an @yourorg:registry= line for your internal scope — but it closes the equivalent ambiguity for public-package resolution.