Dependency confusion attacks on projects with private npm packages
作者:Seaworthy · 2026年6月25日
Photo by Luke Heibert on Unsplash
What is a dependency confusion attack?
In 2021, security researcher Alex Birsan collected over $130,000 in bug bounties by uploading malicious packages to the public npm registry with names that matched internal packages used by Apple, Microsoft, PayPal, and others. The packages did nothing harmful in the bug bounty context, but the technique was real. Attackers could run arbitrary code on developer machines and CI servers just by publishing a package with the right name.
This is a dependency confusion attack, and it still works today.
How npm resolves packages
When you run npm install, npm checks the public registry at registry.npmjs.org by default. If you're using a private registry (Verdaccio, GitHub Packages, Artifactory, etc.), you configure npm to check there too, usually via an .npmrc file.
The problem is version precedence. npm will install whichever version of a package has the highest version number, regardless of which registry it came from. An attacker who knows your internal package is named acme-auth and is at version 1.4.2 can publish a package called acme-auth at version 9.0.0 to the public registry. npm sees a higher version number and installs the public one.
Your CI pipeline runs. The malicious package executes its postinstall script. It now has access to environment variables, filesystem, and network, all with the permissions of your build system.
How attackers find your internal package names
You might think internal package names are secret. They rarely are. Package names leak through:
package.jsonfiles committed to public repos- Stack traces in public GitHub issues
- Job postings that mention your tech stack
- npm audit output accidentally pasted into public tickets
- Docker images with unminified code inside
If a package name appears in any public artifact, it is a candidate for this attack.
The fix: scope your packages
The most reliable defense is scoping. A scoped package name looks like @acme/auth instead of acme-auth. You can reserve your scope on the public npm registry, which means an attacker cannot publish @acme/auth without controlling the @acme organisation on npm.
Register your company scope on npmjs.com, then configure your .npmrc to route that scope to your private registry:
# .npmrc
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
With this in place, anything under @acme/ goes to your private registry. Everything else goes to the public one. npm never tries to resolve @acme/auth from the public registry, so there is nothing for an attacker to squat on.
Locking the registry explicitly
Scoping is the main fix, but you can add a second layer. Set publishConfig in your internal package's package.json to explicitly declare where it should be published:
{
"name": "@acme/auth",
"version": "1.4.2",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
This will not stop npm from pulling a public package with the same name, but it does prevent accidental publish to the wrong registry if someone runs npm publish without checking first.
What about unscoped internal packages you already have?
If you have existing unscoped internal packages like acme-auth, you have a few options.
You can rename them to scoped equivalents and update all imports. That is the right long-term move. If renaming is too disruptive right now, you can at least mitigate the version-squatting risk by publishing a placeholder package on the public npm registry yourself. Publish acme-auth at a very high version number, like 99.0.0, with no code in it, just a README.md explaining it is a reserved internal package name. npm will install your placeholder rather than an attacker's package because your version wins.
This is not a great solution. It requires you to maintain placeholder packages forever. Scoping is genuinely cleaner.
Check your CI environment
Whatever you configure locally, make sure it applies in CI too. A common mistake is setting up .npmrc correctly on developer machines while CI uses a fresh environment that inherits the public registry as the only source.
In GitHub Actions, for example, you need to set the registry URL explicitly when using actions/setup-node:
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://npm.pkg.github.com'
scope: '@acme'
Without the scope field, the action configures the registry at the project level but does not route scoped packages through it.
One more thing: check your package-lock.json
After you make registry changes, look at package-lock.json. Each package entry has a resolved field that shows exactly which registry URL npm fetched it from. If you see your internal packages resolving from registry.npmjs.org, your configuration is not working as intended.
grep -A2 '"@acme/auth"' package-lock.json
You want to see your private registry URL there, not the public one.
Seaworthy flags unscoped internal package names and missing registry scope configuration as part of its dependency hygiene checks.
本文由 AI 生成,总结了公开可用的来源。
Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。
安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。