supply chain security

Detecting Dependency Confusion

Detect and prevent public-over-private name resolution in npm, PyPI, and Maven.

dependency-confusiondevsecopsmavennamespace-hijackingnpmpackage-managementpypisupply-chain-security
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

Legal Notice: This skill is for authorized security testing, defensive engineering, and educational purposes only. Registering or claiming package namespaces you do not own, or testing build pipelines without written authorization, may be illegal and may violate the terms of service of public registries. Only run namespace-claiming or resolution-testing activities against names and infrastructure you control or are explicitly authorized to assess.

Overview

Dependency confusion (also called a substitution or namespace-shadowing attack) was popularized by Alex Birsan in 2021 when he forced malicious code into the internal build systems of Apple, Microsoft, PayPal, and dozens of others. The root cause is that many package managers, when configured to resolve from both an internal/private registry and a public one, will prefer whichever copy has the higher version number rather than honoring the source. An attacker who learns the name of a private package (@acme/internal-utils, acme-billing-sdk) can publish a malicious package of the same name to the public registry (npmjs.com, PyPI, Maven Central) with a very high version (e.g. 99.0.0). When the victim's CI/CD runner or a developer machine resolves dependencies, it pulls the attacker's public package, executes its install scripts, and the supply chain is compromised.

This skill covers both halves of the problem: detection — enumerating internal package names that are not registered (squatted defensively) on public registries and are therefore claimable, using confused and OWASP dep-scan — and prevention — pinning scopes/namespaces to private registries, registering placeholder packages, and enforcing source restrictions in .npmrc, pip.conf/pyproject.toml, and Maven settings.xml. Internal package names leak constantly: in committed lockfiles, sourcemaps, public JS bundles, Docker layers, and error stack traces, so this is treated as an attack-surface management problem, not a one-time check.

When to Use

  • When onboarding a repository or organization to a supply-chain security program and you need to baseline which internal packages are claimable on public registries.
  • When CI/CD pipelines resolve dependencies from both private and public registries (mixed/hybrid feeds).
  • After any incident where internal package names may have been exposed (leaked source, public bundle, breached repo).
  • When auditing package.json, requirements.txt, pom.xml, composer.json, or Gemfile.lock files for confusable dependencies.
  • As a recurring scheduled control to detect newly added internal packages that have not yet been defensively registered.

Prerequisites

  • Go 1.20+ to install confused:
    go install github.com/visma-prodsec/confused@latest
    # binary lands in $(go env GOPATH)/bin/confused
  • Python 3.10+ for OWASP dep-scan:
    pip install owasp-depscan
    # or container: docker pull ghcr.io/owasp-dep-scan/dep-scan
  • Node.js + npm (for .npmrc and npm config remediation) and access to your private registry (Artifactory, Nexus, Azure Artifacts, GitHub Packages, AWS CodeArtifact).
  • Read access to the repositories / lockfiles being assessed and write access to your private registry for defensive registration.

Objectives

  • Enumerate every internal dependency declared in project manifests across npm, PyPI, Maven, Composer, and RubyGems.
  • Determine which internal names are not present on the corresponding public registry and are therefore claimable.
  • Distinguish true exposure from false positives (scoped packages, already-mirrored names).
  • Apply registry-pinning and scope-restriction controls that make public substitution impossible.
  • Defensively register placeholder packages for unclaimed internal names.
  • Establish a recurring detection control in CI to catch newly introduced confusable dependencies.

MITRE ATT&CK Mapping

Technique ID Technique Name Relevance
T1195.001 Supply Chain Compromise: Compromise Software Dependencies and Development Tools Core technique — attacker substitutes a malicious public package for an internal dependency.
T1195.002 Supply Chain Compromise: Compromise Software Supply Chain Broader category covering the compromised build artifacts produced once confusion succeeds.
T1059.007 Command and Scripting Interpreter: JavaScript npm preinstall/postinstall lifecycle scripts execute attacker JavaScript on resolution.
T1071.001 Application Layer Protocol: Web Protocols Substituted package beacons stolen environment/credentials to attacker HTTP(S) endpoint.

Workflow

1. Inventory manifests across the codebase

Locate every dependency manifest so nothing is missed.

# Find all supported manifests in a monorepo
find . -type f \( \
  -name package.json -o \
  -name requirements.txt -o \
  -name pom.xml -o \
  -name composer.json -o \
  -name Gemfile.lock \
\) -not -path '*/node_modules/*' -print

2. Scan npm manifests with confused

confused reads the manifest and reports every dependency name not found on the public registry — those are candidates for confusion.

# npm (default language is npm)
confused -l npm package.json
 
# Treat your known-good scopes as secure to suppress false positives (supports wildcards)
confused -l npm -s '@acme/*,@acme-internal/*' package.json
 
# Verbose, to see each lookup
confused -l npm -v package.json

3. Scan PyPI, Maven, Composer, and RubyGems manifests

The -l flag selects the ecosystem; each maps to its standard manifest file.

confused -l pip requirements.txt          # PyPI  -> requirements.txt
confused -l mvn pom.xml                    # Maven -> pom.xml
confused -l composer composer.json        # PHP   -> composer.json
confused -l rubygems Gemfile.lock         # Ruby  -> Gemfile.lock

4. Cross-check with OWASP dep-scan private-namespace mode

dep-scan confirms confusion exposure for declared private namespaces and folds it into a broader risk audit.

# Flag private namespaces accidentally claimable on public registries
depscan --src $PWD --reports-dir ./reports \
  --private-ns acme,acme_internal,@acme
 
# Enable deep package risk audit (npm + pypi): typosquats, takeover risk, etc.
depscan --src $PWD --reports-dir ./reports --risk-audit

5. Triage candidates and confirm claimability

For each flagged name, verify it is genuinely absent on the public registry (a 404 means claimable).

# npm: a 404 status means the name is unregistered on the public registry
curl -s -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/@acme%2finternal-utils
 
# PyPI: 404 from the JSON API means the project name is free
curl -s -o /dev/null -w "%{http_code}\n" https://pypi.org/pypi/acme-billing-sdk/json

6. Remediate npm with scope-to-registry pinning

Bind every internal scope to the private registry so a public package of the same name can never be resolved.

# .npmrc (project root, committed)
@acme:registry=https://artifactory.example.com/api/npm/npm-internal/
//artifactory.example.com/api/npm/npm-internal/:_authToken=${NPM_TOKEN}
 
# Force the default registry to a single proxy that does NOT merge public + private
registry=https://artifactory.example.com/api/npm/npm-virtual/

Verify the resolution source before installing:

npm config get @acme:registry
npm install --dry-run   # confirm @acme/* resolves from the private host

7. Remediate PyPI and Maven

Pin Python index resolution and Maven mirroring so public sources cannot shadow internal artifacts.

# pyproject.toml (PEP 621 / pip >= 23): explicit index pinning
[tool.pip]
index-url = "https://artifactory.example.com/api/pypi/pypi-internal/simple/"
# Do NOT use extra-index-url for internal packages — pip merges and picks highest version.
<!-- ~/.m2/settings.xml: mirror everything through a single virtual repo -->
<mirrors>
  <mirror>
    <id>internal-virtual</id>
    <mirrorOf>*</mirrorOf>
    <url>https://artifactory.example.com/artifactory/maven-virtual</url>
  </mirror>
</mirrors>

8. Defensively register placeholder packages

For names you cannot fully isolate, claim the public name yourself with an empty, non-functional placeholder so an attacker cannot.

# npm placeholder claim (scoped, public)
mkdir acme-internal-utils && cd acme-internal-utils
npm init -y
npm pkg set version=0.0.1-placeholder description="Reserved internal name. Do not use."
npm publish --access public

9. Wire detection into CI

Fail the pipeline if any new confusable dependency appears.

# .github/workflows/depconfusion.yml
name: dependency-confusion
on: [push, pull_request]
jobs:
  confused:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go install github.com/visma-prodsec/confused@latest
      - name: Scan npm manifest
        run: $(go env GOPATH)/bin/confused -l npm -s '@acme/*' package.json

10. Run the bundled helper for batch triage

Use the included agent.py to scan a tree and emit a structured report combining confused and live registry probes.

python scripts/agent.py --path . --ecosystem npm \
  --secure-namespaces '@acme/*,@acme-internal/*' \
  --output report.json

Tools and Resources

Tool Purpose Source
confused Detect lingering free namespaces for declared dependencies https://github.com/visma-prodsec/confused
ConfusedDotnet Same check for NuGet/.NET https://github.com/visma-prodsec/ConfusedDotnet
OWASP dep-scan Risk audit incl. --private-ns confusion check https://github.com/owasp-dep-scan/dep-scan
OWASP CI/CD Top 10 CICD-SEC-03 Dependency Chain Abuse https://owasp.org/www-project-top-10-ci-cd-security-risks/
Birsan research Original dependency confusion writeup https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610
npm scopes docs Scope-to-registry pinning reference https://docs.npmjs.com/cli/v10/using-npm/scope

Validation Criteria

  • All dependency manifests in the codebase enumerated.
  • confused run for every relevant ecosystem with secure namespaces supplied.
  • OWASP dep-scan --private-ns run and reconciled with confused output.
  • Each flagged name confirmed claimable (404) or dismissed as a false positive.
  • Internal scopes pinned to the private registry in .npmrc / pip config / Maven mirror.
  • extra-index-url and merged virtual feeds reviewed for highest-version pull risk.
  • Placeholder packages registered for names that cannot be isolated.
  • CI job enforces the check on every push/PR.
  • Findings documented with owner and remediation status.
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 2

api-reference.md2.2 KB

API and Command Reference

confused (visma-prodsec/confused)

Install: go install github.com/visma-prodsec/confused@latest

Syntax: confused [-l LANGUAGE] [-s SECURE_NAMESPACES] [-v] MANIFEST

Flag Values / Example Description
-l npm (default), pip, mvn, composer, rubygems Selects the package ecosystem / manifest type.
-s '@acme/*,@acme-internal/*' Comma-separated known-secure namespaces; supports * wildcards. Suppresses false positives.
-v (flag) Verbose output; prints every registry lookup.

Manifest mapping: npmpackage.json, piprequirements.txt, mvnpom.xml, composercomposer.json, rubygemsGemfile.lock.

OWASP dep-scan

Install: pip install owasp-depscan

Argument Example Description
--src --src $PWD Path to source repo (or container image).
--reports-dir --reports-dir ./reports Output directory for JSON/HTML reports.
--private-ns --private-ns acme,@acme Comma-separated private namespaces to check for confusion exposure.
--risk-audit (flag) Deep package risk audit (npm/pypi): takeover, typosquat, maintenance risk.
-t / --type -t nodejs Restrict to a project type.

Public registry probe endpoints (claimability check)

Registry Endpoint 404 means
npm https://registry.npmjs.org/<name> (URL-encode / in scopes as %2f) Name unregistered / claimable.
PyPI https://pypi.org/pypi/<name>/json Project name free.
Maven Central https://search.maven.org/solrsearch/select?q=g:<group>+AND+a:<artifact> (empty response.numFound) Coordinate not published.
RubyGems https://rubygems.org/api/v1/gems/<name>.json Gem not published.

Remediation config keys

Ecosystem File Key
npm .npmrc @scope:registry=<private-url>, top-level registry=
pip pyproject.toml / pip.conf index-url (avoid extra-index-url for internal pkgs)
Maven ~/.m2/settings.xml <mirror><mirrorOf>*</mirrorOf>
Composer composer.json repositories + "packagist.org": false
standards.md1.6 KB

Standards and Framework Mapping

MITRE ATT&CK

ID Name Rationale
T1195.001 Supply Chain Compromise: Compromise Software Dependencies and Development Tools Dependency confusion substitutes a malicious public package for a private dependency, compromising the dev/build toolchain.
T1195.002 Supply Chain Compromise: Compromise Software Supply Chain Covers the tainted build artifacts shipped downstream once confusion succeeds.
T1059.007 Command and Scripting Interpreter: JavaScript npm install lifecycle scripts run attacker JS during resolution.
T1071.001 Application Layer Protocol: Web Protocols Substituted packages exfiltrate stolen secrets over HTTP(S).

NIST Cybersecurity Framework 2.0

ID Name Rationale
ID.RA-09 The authenticity and integrity of hardware and software are assessed prior to acquisition and use Detecting confusable names and pinning registries validates that resolved packages are the authentic internal artifacts, not public substitutes.

Supporting Standards

  • OWASP Top 10 CI/CD Security Risks — CICD-SEC-03: Dependency Chain Abuse. Dependency confusion is the canonical example of dependency chain abuse; remediation guidance aligns with this control.
  • NIST SP 800-161r1 — Cybersecurity Supply Chain Risk Management Practices. Provides organizational SCRM controls (C-SCRM) into which namespace governance and registry pinning fit.
  • SLSA (Supply-chain Levels for Software Artifacts). Source/build provenance requirements reduce the blast radius of a successful substitution.

Scripts 1

agent.py5.8 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""
Dependency confusion detection helper.

Scans a source tree for package manifests, extracts declared dependency names,
and checks each against the corresponding PUBLIC registry. A dependency that
resolves on the public registry while being intended as private is a
substitution candidate; a name that 404s is defensively claimable.

Supports: npm (package.json), PyPI (requirements.txt), RubyGems (Gemfile.lock).
Optionally shells out to `confused` if it is installed for cross-validation.

Authorized defensive / authorized-testing use only.
"""
import argparse
import fnmatch
import json
import re
import shutil
import subprocess
import sys
import urllib.parse
import urllib.request
from pathlib import Path

REGISTRY = {
    "npm": "https://registry.npmjs.org/{name}",
    "pip": "https://pypi.org/pypi/{name}/json",
    "rubygems": "https://rubygems.org/api/v1/gems/{name}.json",
}
MANIFEST = {"npm": "package.json", "pip": "requirements.txt", "rubygems": "Gemfile.lock"}


def http_status(url: str, timeout: int = 15) -> int:
    req = urllib.request.Request(url, headers={"User-Agent": "depconf-agent/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.getcode()
    except urllib.error.HTTPError as exc:
        return exc.code
    except Exception as exc:  # noqa: BLE001 - network errors are reported, not fatal
        print(f"[warn] lookup failed for {url}: {exc}", file=sys.stderr)
        return -1


def parse_npm(path: Path) -> list[str]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        print(f"[warn] cannot parse {path}: {exc}", file=sys.stderr)
        return []
    names: set[str] = set()
    for key in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
        names.update((data.get(key) or {}).keys())
    return sorted(names)


def parse_pip(path: Path) -> list[str]:
    names: set[str] = set()
    try:
        for line in path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith(("#", "-", "git+", "http")):
                continue
            m = re.match(r"^([A-Za-z0-9._-]+)", line)
            if m:
                names.add(m.group(1))
    except OSError as exc:
        print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
    return sorted(names)


def parse_rubygems(path: Path) -> list[str]:
    names: set[str] = set()
    try:
        for line in path.read_text(encoding="utf-8").splitlines():
            m = re.match(r"^\s{4}([a-z0-9_-]+) \(", line)
            if m:
                names.add(m.group(1))
    except OSError as exc:
        print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
    return sorted(names)


PARSERS = {"npm": parse_npm, "pip": parse_pip, "rubygems": parse_rubygems}


def encode_name(eco: str, name: str) -> str:
    # npm scoped packages must URL-encode the slash
    if eco == "npm" and name.startswith("@"):
        return urllib.parse.quote(name, safe="@")
    return urllib.parse.quote(name, safe="")


def is_secure(name: str, patterns: list[str]) -> bool:
    return any(fnmatch.fnmatch(name, p) for p in patterns)


def run_confused(eco: str, manifest: Path, secure: str) -> str | None:
    if not shutil.which("confused"):
        return None
    cmd = ["confused", "-l", eco]
    if secure:
        cmd += ["-s", secure]
    cmd.append(str(manifest))
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
        return out.stdout + out.stderr
    except (subprocess.SubprocessError, OSError) as exc:
        print(f"[warn] confused failed: {exc}", file=sys.stderr)
        return None


def main() -> int:
    ap = argparse.ArgumentParser(description="Dependency confusion detector")
    ap.add_argument("--path", default=".", help="Root path to scan")
    ap.add_argument("--ecosystem", choices=list(REGISTRY), required=True)
    ap.add_argument("--secure-namespaces", default="", help="Comma-separated glob patterns")
    ap.add_argument("--output", help="Write JSON report to this file")
    args = ap.parse_args()

    eco = args.ecosystem
    root = Path(args.path)
    if not root.exists():
        print(f"[error] path not found: {root}", file=sys.stderr)
        return 2
    secure = [p.strip() for p in args.secure_namespaces.split(",") if p.strip()]

    manifests = [p for p in root.rglob(MANIFEST[eco]) if "node_modules" not in p.parts]
    if not manifests:
        print(f"[info] no {MANIFEST[eco]} found under {root}")
        return 0

    findings = []
    for manifest in manifests:
        names = PARSERS[eco](manifest)
        for name in names:
            if is_secure(name, secure):
                continue
            status = http_status(REGISTRY[eco].format(name=encode_name(eco, name)))
            verdict = (
                "CLAIMABLE (404 on public registry)" if status == 404
                else "present on public registry" if status == 200
                else f"unknown (HTTP {status})"
            )
            if status != 200:
                findings.append(
                    {"manifest": str(manifest), "name": name, "status": status, "verdict": verdict}
                )
                print(f"[!] {name}  ->  {verdict}  ({manifest})")

        cf = run_confused(eco, manifest, args.secure_namespaces)
        if cf:
            print(f"--- confused output for {manifest} ---\n{cf}")

    report = {"ecosystem": eco, "root": str(root), "findings": findings}
    if args.output:
        Path(args.output).write_text(json.dumps(report, indent=2), encoding="utf-8")
        print(f"[+] report written to {args.output}")
    print(f"[+] {len(findings)} confusion candidate(s) across {len(manifests)} manifest(s)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Keep exploring