Testing for Dependency Confusion and Typosquatting Attacks in Your Supply Chain
In February 2021, security researcher Alex Birsan compromised 35 major companies — including Apple, Microsoft, PayPal, and Tesla — using a technique called dependency confusion. He uploaded malicious packages to public registries with the same names as internal private packages, then watched as the build systems of major corporations automatically downloaded and executed his code.
The attack required no credentials, no phishing, no zero-days. Just a cleverly named npm package.
This guide explains how dependency confusion and typosquatting attacks work, and more importantly, how to systematically test your organization's defenses against them.
Understanding the Attack Vectors
Dependency Confusion (Namespace Confusion)
Dependency confusion exploits a priority mismatch between public and private package registries.
The scenario:
- Your organization uses an internal package:
@company/auth-utilspublished to a private registry - An attacker publishes
auth-utils(without the scope) to npm with version99.0.0 - If your package manager checks public registries first and the public version number is higher, it downloads the attacker's package
// package.json (vulnerable configuration)
{
"dependencies": {
"auth-utils": "^1.0.0", // Public package
"@company/internal-utils": "^2.0.0" // Private package — at risk!
}
}# .npmrc (vulnerable — no scope configuration)
registry=https://registry.npmjs.org/Typosquatting
Typosquatting relies on human error — developers accidentally typing a package name slightly wrong.
Common typosquatting patterns:
lodash→Lodash,1odash,lodahsexpress→expres,expres5,expresssreact→Reactjs,react-js,reaktrequests(Python) →request5,requestes
Testing Your Registry Configuration
Test 1: Scope Isolation Validation
#!/bin/bash
# test-scope-isolation.sh
# Tests that scoped packages only resolve from the correct registry
NPM_CONFIG_FILE=".npmrc"
echo "=== Testing Registry Scope Configuration ==="
# Check that internal scopes are configured
if ! grep -q "@company:registry" "$NPM_CONFIG_FILE" 2>/dev/null; then
echo "FAIL: @company scope not configured in .npmrc"
echo "Internal packages may resolve from public registry"
exit 1
fi
# Verify the scope registry URL is an internal endpoint
INTERNAL_REGISTRY=$(grep "@company:registry" "$NPM_CONFIG_FILE" | cut -d'=' -f2)
if echo "$INTERNAL_REGISTRY" | grep -q "npmjs.org"; then
echo "FAIL: @company scope is pointing to public registry: $INTERNAL_REGISTRY"
exit 1
fi
echo "PASS: Internal scope configured to: $INTERNAL_REGISTRY"
# Test that npm respects scope configuration
echo ""
echo "Testing npm scope resolution..."
npm view "@company/auth-utils" version --registry="$INTERNAL_REGISTRY" 2>/dev/null && \
echo "PASS: Package resolves from internal registry" || \
echo "INFO: Package not found (may not exist yet)"Test 2: Check for Public Package Name Collisions
#!/usr/bin/env python3
# check-dependency-confusion.py
"""
Check if your internal package names exist on public registries.
If they do, you may be at risk of dependency confusion.
"""
import subprocess
import json
import sys
import requests
from typing import List, Tuple
def get_internal_packages(package_json_path: str) -> List[str]:
"""Extract package names from package.json."""
with open(package_json_path) as f:
pkg = json.load(f)
deps = {}
deps.update(pkg.get("dependencies", {}))
deps.update(pkg.get("devDependencies", {}))
# Return only scoped internal packages
return [name for name in deps.keys()
if name.startswith("@") and "company" in name.lower()]
def check_npm_registry(package_name: str) -> Tuple[bool, str]:
"""Check if package exists on public npm registry."""
# Remove scope for confusion check
unscoped_name = package_name.split("/")[-1]
response = requests.get(
f"https://registry.npmjs.org/{unscoped_name}",
timeout=10
)
if response.status_code == 200:
data = response.json()
latest = data.get("dist-tags", {}).get("latest", "unknown")
return True, latest
return False, ""
def check_pypi(package_name: str) -> Tuple[bool, str]:
"""Check if package exists on PyPI."""
response = requests.get(
f"https://pypi.org/pypi/{package_name}/json",
timeout=10
)
if response.status_code == 200:
data = response.json()
version = data.get("info", {}).get("version", "unknown")
return True, version
return False, ""
def main():
print("=== Dependency Confusion Risk Assessment ===\n")
# Check npm packages
internal_pkgs = get_internal_packages("package.json")
risks_found = False
for pkg in internal_pkgs:
unscoped = pkg.split("/")[-1]
exists, version = check_npm_registry(pkg)
if exists:
print(f"⚠️ RISK: {pkg}")
print(f" Unscoped name '{unscoped}' found on public npm (v{version})")
print(f" If your package manager resolves public packages for this name,")
print(f" an attacker could publish a higher version to hijack it.")
risks_found = True
else:
print(f"✓ OK: {pkg} (unscoped name not on public npm)")
if not internal_pkgs:
print("No internal scoped packages found in package.json")
if risks_found:
sys.exit(1)
else:
print("\nPASS: No dependency confusion risks detected")
if __name__ == "__main__":
main()Test 3: Detect Typosquatting Candidates
#!/usr/bin/env python3
# detect-typosquatting.py
"""
Generate typosquat variants of your dependencies and check if they exist on npm.
"""
import itertools
import requests
import json
from typing import List
KEYBOARD_ADJACENCY = {
'a': 'qwsz', 'b': 'vghn', 'c': 'xdfv', 'd': 'erfcxs', 'e': 'wrds',
'f': 'rtgvcd', 'g': 'tyhbvf', 'h': 'yugnbj', 'i': 'uojk', 'j': 'uikhbn',
'k': 'iolmj', 'l': 'opk', 'm': 'jkn', 'n': 'bhjm', 'o': 'iplk',
'p': 'ol', 'q': 'wa', 'r': 'etdf', 's': 'wedzxa', 't': 'ryfg',
'u': 'yhij', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu', 'z': 'asx'
}
def generate_typosquats(package_name: str) -> List[str]:
"""Generate likely typosquatting variants."""
variants = set()
name = package_name.lower().split('/')[-1] # Handle scoped packages
# Character substitution (adjacent keys)
for i, char in enumerate(name):
if char in KEYBOARD_ADJACENCY:
for adj in KEYBOARD_ADJACENCY[char]:
variant = name[:i] + adj + name[i+1:]
variants.add(variant)
# Character omission
for i in range(len(name)):
variants.add(name[:i] + name[i+1:])
# Character duplication
for i, char in enumerate(name):
variants.add(name[:i] + char + name[i:])
# Hyphen/underscore swaps
if '-' in name:
variants.add(name.replace('-', '_'))
variants.add(name.replace('-', ''))
if '_' in name:
variants.add(name.replace('_', '-'))
variants.add(name.replace('_', ''))
# Common misspellings
if name.endswith('s'):
variants.add(name[:-1]) # Missing final s
return list(variants - {name})
def check_package_exists(name: str) -> bool:
"""Check if package name exists on npm."""
response = requests.get(
f"https://registry.npmjs.org/{name}",
timeout=5
)
return response.status_code == 200
def scan_for_typosquats(dependencies: List[str]) -> dict:
"""Scan a list of dependencies for typosquatting risks."""
results = {}
for dep in dependencies:
variants = generate_typosquats(dep)
found_variants = []
for variant in variants[:20]: # Limit checks to avoid rate limiting
if check_package_exists(variant):
found_variants.append(variant)
if found_variants:
results[dep] = found_variants
return results
def main():
with open("package.json") as f:
pkg = json.load(f)
all_deps = list(pkg.get("dependencies", {}).keys())
all_deps += list(pkg.get("devDependencies", {}).keys())
# Focus on non-scoped packages (most at risk)
public_deps = [d for d in all_deps if not d.startswith("@")][:20]
print(f"Scanning {len(public_deps)} dependencies for typosquatting risks...\n")
risky = scan_for_typosquats(public_deps)
if risky:
print("⚠️ TYPOSQUATTING RISKS FOUND:")
for dep, variants in risky.items():
print(f"\n {dep}")
for v in variants:
print(f" → '{v}' exists on npm (potential typosquat)")
else:
print("✓ No obvious typosquatting variants found")
if __name__ == "__main__":
main()Hardening Your Configuration
npm: Prevent Dependency Confusion
# .npmrc (hardened)
# Lock all @company packages to internal registry
@company:registry=https://npm.company.internal/
@internal:registry=https://npm.company.internal/
# Require authentication for internal registry
//npm.company.internal/:_authToken=${NPM_INTERNAL_TOKEN}
# Audit logs enabled
audit=true
audit-level=moderate
# Lockfile must be present
package-lock-only=falsePython: Configure Trusted Hosts
# pip.conf (hardened)
[global]
index-url = https://pypi.company.internal/simple/
extra-index-url = https://pypi.org/simple/
# IMPORTANT: With extra-index-url, pip checks BOTH registries
# If the internal one has priority, list it as index-url
# Dependency confusion can still occur with extra-index-url!Safer Python approach — use --no-index for internal packages:
# Install internal packages only from internal registry
pip install --no-index --find-links=https://pypi.company.internal/ company-package
# Install public packages from PyPI
pip install requests numpyMaven: Repository Priority
<!-- pom.xml (hardened) -->
<repositories>
<repository>
<id>company-internal</id>
<url>https://maven.company.internal/repository/maven-internal/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<!-- settings.xml: Mirror all requests through Nexus/Artifactory -->
<mirrors>
<mirror>
<id>company-mirror</id>
<mirrorOf>*</mirrorOf>
<url>https://artifactory.company.internal/artifactory/maven-virtual/</url>
</mirror>
</mirrors>CI/CD Integration
# .github/workflows/supply-chain-check.yml
name: Supply Chain Security Check
on:
pull_request:
paths:
- 'package*.json'
- 'requirements*.txt'
- 'pom.xml'
- 'go.mod'
jobs:
dependency-confusion-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install check tools
run: pip install requests
- name: Check for dependency confusion risks
run: python check-dependency-confusion.py
- name: Scan for typosquatting
run: python detect-typosquatting.py
- name: Verify package integrity with lockfiles
run: |
# Verify lockfile hasn't been tampered with
npm ci --dry-run
- name: Check package signatures (npm provenance)
run: |
# For packages that support npm provenance
npm audit signaturesMonitoring with HelpMeTest
Dependency confusion attacks can happen silently during builds. HelpMeTest lets you write continuous monitoring tests in plain English that run against your CI artifacts:
Test: No unexpected external package downloads during build
Steps:
1. Trigger a fresh npm ci with network traffic logging
2. Verify all @company/* packages resolve from internal registry
3. Verify no @company/* package names exist on public npm
4. Verify package hashes match lockfile
5. Alert if any package resolves from unexpected registryThese tests run every time code is pushed, giving you an audit trail of your supply chain's integrity.
Response Playbook
If you detect a potential dependency confusion attack:
- Immediately: Pin all affected packages to specific versions from trusted sources
- Within 1 hour: Check build logs for any instances where the malicious package may have been downloaded
- Within 4 hours: Rotate all secrets/tokens that ran on affected build machines
- Within 24 hours: Audit all deployments that used potentially compromised builds
- Within 1 week: Publish a "placeholder" package on the public registry to claim your package name
The last point is important: publishing a benign placeholder on public registries for your internal package names prevents attackers from exploiting the name. Many organizations have adopted this as standard practice after the Birsan disclosure.
Supply chain security isn't about preventing all possible attacks — it's about making your organization a harder target than the next one. Systematic testing of your registry configuration, combined with monitoring and quick response procedures, significantly reduces your risk surface.