You have an internal package, acme-auth-utils, published to a private registry. Nobody outside your company can install it.
An attacker publishes acme-auth-utils to the public npm registry at version 99.0.0. Your build machine, configured to check both, resolves the higher version. From the public registry, and runs its install script.
The attacker never needed access to anything. They needed the name.
Where names leak
More easily than you'd expect. Package names appear in committed lockfiles, in package.json of open source projects that reference internal tooling, in stack traces posted to issue trackers, in job adverts, in conference talks, and in source maps shipped to browsers.
Assume your internal package names are public.
Scoped packages
The main defence on npm. A scoped name is tied to an organisation:
"@acme/auth-utils": "^1.2.0"
Nobody else can publish under @acme once you own it. Register the scope on the public registry even if you never publish there. That's the point.
@acme:registry=https://npm.acme.internal
Scope-specific registry configuration means @acme/* resolves privately and everything else goes public, with no ambiguity.
Don't let the registry fall back
The underlying problem is a resolver checking two sources and picking by version. Configure your private registry so internal names never fall through to public.
Artifactory, Nexus and most hosted equivalents support exclusion patterns for exactly this. Set them.
Pin and verify
Commit lockfiles and install with npm ci rather than npm install, so resolution doesn't happen fresh in CI.
Disable install scripts where your build doesn't need them (--ignore-scripts), since that's the usual execution vector.
Other ecosystems
Python, pip with --extra-index-url has the same behaviour, and it's arguably worse because there's no scoping equivalent. Use --index-url pointing at a private index that proxies public packages, rather than adding a second index.
Ruby. source blocks in the Gemfile scope which gems come from where.
Java / Maven, repository ordering and mirror configuration; less exposed by default but worth checking.
Reserve the names
Cheap insurance: publish placeholder packages under your internal names to the public registry. Nobody can then squat them.
Slightly awkward, occasionally confusing to outsiders, and it closes the hole permanently for names you can't rename.
Detection
Audit your lockfiles for internal-sounding names resolving to public registry URLs. That's the signature, and it's a quick grep.
A sudden major version jump on an internal package is worth investigating rather than merging.