You reviewed an MCP server package six months ago and it passed. This morning your CI ran npm install, pulled a fresh version whose maintainer account was hijacked overnight, and that version quietly streams conversation context and scanned secrets to an external host. The symptom is one of: the agent opens outbound connections to an unfamiliar domain, sensitive values that should only live in agent memory appear in logs, npm audit flags a dependency, or a postinstall script writes a .github/workflows/ file you never authored.
Fastest containment (do this first, in order): stop the agent and any process running the MCP server, then git diff / npm pack the suspect version against the last known-good one to confirm. Treat every credential the process could read as compromised and revoke-and-reissue (not rotate-in-place) — npm tokens, cloud keys, OPENAI_API_KEY, CI/CD secrets. Then re-pin to the last clean version with npm ci against a committed lockfile. Hardening (release-age cooldown, ignore-scripts, egress firewall) comes after containment.
This is not hypothetical. The self-replicating Shai-Hulud npm worm hit 500+ packages in late 2025, and Mini Shai-Hulud (Microsoft, May 2026) compromised 170+ npm packages across 404 malicious versions, specifically scanning the filesystem with TruffleHog for high-entropy secrets and dumping process.env (capturing GITHUB_TOKEN, AWS_ACCESS_KEY_ID, and similar). The 2.0 variant added any package with mcp-server in its name to its target list, because an MCP server runs inside your agent process with the same file system, environment, and tool-call permissions as a legitimate one.
Which bucket are you in
| Symptom you see | Most likely cause | Jump to |
|---|---|---|
| Outbound connection to an unknown host from the node/python process | Compromised version is exfiltrating; or a transitive dep is | Step 1 (confirm), Step 5 (egress) |
| Secret appears in logs / a key you never logged shows up | Server inherited the full parent env; payload dumped process.env | Step 3 (minimal env), incident response |
A .github/workflows/*.yml file you didn’t write appeared | Worm persistence (Shai-Hulud pattern) via postinstall | Incident response, Step 4 (ignore-scripts) |
npm audit or Socket flags the package after a recent install | Known malicious version reached you before takedown | Step 1, Step 2 (pin + cooldown) |
| It worked yesterday, broke after an auto-deploy | Range version (^/~) + no npm ci pulled a bad release | Step 1, Step 2 |
Common causes
1. Package installed with a range version, not a pin
npm install @vendor/mcp-server with "^1.0.0" in package.json lets the next install on a fresh machine pull a newer, possibly compromised 1.0.1 automatically. The caret allows minor/patch upgrades.
How to spot it: open package.json and look for ^ or ~ on any MCP server or security-sensitive dependency. Anything but an exact pin ("1.2.3") plus a committed lockfile is exposed.
2. No integrity check on installed packages
npm records an integrity hash (sha512-...) per package in package-lock.json. If the lockfile was not committed, or was committed without integrity fields, there is no tamper detection and a tarball-swap attack passes silently.
How to spot it:
# Count integrity hashes in the lockfile — should roughly equal package count
grep -c '"integrity"' package-lock.json
3. A postinstall lifecycle script runs at install time
npm’s postinstall script executes automatically on npm install and can run arbitrary code. This is the single most-used supply-chain entry point — Shai-Hulud, Mini Shai-Hulud, and the April 2026 Axios compromise all triggered via install hooks.
How to spot it:
# List every install/postinstall script in the tree before trusting it
npm query ':attr(scripts, [postinstall])' 2>/dev/null
# or inspect the package directly
cat node_modules/@vendor/mcp-server/package.json | grep -A2 '"scripts"'
4. MCP server spawned with the full parent environment
The server process inherits every OPENAI_API_KEY, DATABASE_URL, and AWS_SECRET_ACCESS_KEY visible in the shell. A payload that dumps process.env gets all of them.
How to spot it: log the env keys visible to the spawned process. Any key the server does not strictly need is unnecessary exposure.
5. Outbound network access is unrestricted
A compromised server opens a connection to its command-and-control host and streams context. Mini Shai-Hulud used HTTPS to an encrypted C2 on port 443 with a Git Data API fallback, so a “looks like normal HTTPS” connection is not automatically safe.
How to spot it: check egress logs or firewall counters for the process UID running the server. Any destination not in an explicit allowlist is suspect.
6. Running MCP servers via bare npx (uncontrolled resolution)
MCP servers are commonly launched with npx @vendor/mcp-server, and every invocation is an uncontrolled package resolution that can fetch and run whatever the registry currently serves. Because MCP servers have filesystem and env access, this is among the riskiest patterns.
How to spot it: grep your agent/MCP config (.cursor/mcp.json, claude_desktop_config.json, CI scripts) for npx invocations of MCP servers without a pinned version or pre-installed lockfile.
7. The transitive dependency, not the server, is compromised
The server can be clean while one of its nested dependencies was hijacked. A deep dependency with network access is just as dangerous.
How to spot it: npm audit for known advisories and npm ls @vendor/mcp-server to see the full tree; new malicious versions may not be in the advisory DB yet, so combine with a release-age cooldown (Step 2).
Incident response: you think it already happened
Do these in order before any hardening. Speed matters — many malicious versions are taken down within hours, but the secrets they read are already gone.
- Stop the blast radius. Kill the agent and the MCP server process; take affected CI runners offline.
- Confirm the compromise. Diff the suspect version against the last known-good (see Step 1). Look for new network calls,
process.envreads, base64 blobs, and added install scripts. - Hunt for persistence (Shai-Hulud pattern). Check for files you did not author:
.github/workflows/shai-hulud-workflow.ymlor any unexpected workflow, plus repos created under your account during the window. The 2.0 strain marked compromised repos with a reversed-text descriptionniagA oG eW ereH :duluH-iahS. - Revoke and reissue every reachable secret — not rotate-in-place. Treat as burned: npm tokens,
GITHUB_TOKEN, SSH keys, cloud keys (AWS_*, GCP, Azure), and any model API keys the process could read. GitHub invalidated 61,274 npm tokens during the May 2026 wave for exactly this reason. - Review tool-call and egress logs for the exposure window to scope what data left.
- Re-pin to a clean version and rebuild from a committed lockfile with
npm ci.
Shortest path to harden
Step 1: Confirm by diffing the suspect version
# Pull both versions side by side and diff for high-risk patterns
npm pack @vendor/mcp-server@1.2.2 # last known-good
npm pack @vendor/mcp-server@1.2.3 # suspect
mkdir old new
tar xzf vendor-mcp-server-1.2.2.tgz -C old
tar xzf vendor-mcp-server-1.2.3.tgz -C new
diff -r old new | grep -Ei "fetch|http|axios|net\.|child_process|eval|process\.env|postinstall|atob|base64"
Step 2: Pin exact versions, commit the lockfile, add a release-age cooldown
Pinning stops silent upgrades. A release-age cooldown is the 2026 addition that buys time for the community to flag a bad release before you install it — most malicious versions are reported and removed within hours.
// package.json — exact pin, no ^ or ~
{ "dependencies": { "@vendor/mcp-server": "1.2.3" } }
# .npmrc — npm 11.10.0+ (Feb 2026): refuse versions newer than 3 days
min-release-age=3
# CI: enforce the lockfile exactly; fail if anything would change
npm ci
# pnpm v11 (Apr 2026) ships minimumReleaseAge=1440 (1 day) by default
As of June 2026, npm’s min-release-age has no per-package exclusion mechanism yet, and npm v12 is expected to block install scripts by default. pnpm v11 already defaults to a 1-day cooldown.
Step 3: Restrict the server’s environment to the minimum
import { spawn } from "child_process";
const mcpProcess = spawn("npx", ["@vendor/mcp-server"], {
env: {
PATH: process.env.PATH,
MCP_SERVER_TOKEN: process.env.MCP_SERVER_TOKEN, // only what it needs
// Do NOT pass OPENAI_API_KEY, DATABASE_URL, AWS_* — a payload that
// dumps process.env can only steal what you handed it.
},
stdio: ["pipe", "pipe", "pipe"],
});
Step 4: Block install-time code execution
# .npmrc — disable lifecycle scripts globally
ignore-scripts=true
# Or per-run; allow scripts only for vetted packages you control
npm ci --ignore-scripts
Step 5: Restrict outbound network access at the OS level
# Linux: scope egress to the UID running the server; reject all but the allowlist
iptables -A OUTPUT -p tcp -m owner --uid-owner mcp-server-user ! -d 10.0.0.0/8 -j REJECT
# macOS: pf rules or Little Snitch scoped to the node process
# Kubernetes: a NetworkPolicy Egress block restricted to your internal CIDR/443
Step 6: Audit tool calls against an allowlist
const EXPECTED_TOOL_CALLS = new Set(["read_file", "write_file", "list_directory"]);
function auditToolCall(toolName: string, sessionId: string): void {
if (!EXPECTED_TOOL_CALLS.has(toolName)) {
logger.error({ event: "unexpected_tool_call", tool: toolName, sessionId });
throw new Error(`Unexpected tool call blocked: ${toolName}`);
}
}
Step 7: Verify package hash in CI before deploy
#!/bin/bash
# ci/verify-mcp-hashes.sh
EXPECTED_HASH="sha256:abc123def456..."
ACTUAL_HASH=$(sha256sum node_modules/@vendor/mcp-server/dist/index.js | cut -d' ' -f1)
if [ "sha256:$ACTUAL_HASH" != "$EXPECTED_HASH" ]; then
echo "SECURITY: MCP server hash mismatch — aborting deploy"
exit 1
fi
echo "MCP server integrity verified."
How to confirm it’s fixed
npm cireproduces the install with no lockfile changes, and the pinned version matches your last clean diff.npm config get ignore-scriptsreturnstrueandnpm config get min-release-agereturns your cooldown (e.g.3).- A test run shows no outbound connections from the server process to anything outside your allowlist (watch egress logs or firewall counters during a session).
- No unauthorized
.github/workflows/files and no repos created under your account during the exposure window. - Every reachable secret has been reissued, and old tokens fail when tested.
Prevention
- Pin exact versions for every MCP server and security-sensitive dependency; never
^/~. Commit the lockfile and runnpm ciin CI — never barenpm install. - Add a release-age cooldown (
min-release-ageon npm 11.10.0+, default on pnpm v11) so brand-new versions wait out the takedown window. - Set
ignore-scripts=trueand only allow lifecycle scripts for packages you have vetted. - Pre-install pinned MCP servers in a lockfile-controlled workspace rather than launching them via bare
npx; if you must usenpx, pin the exact version. - Spawn MCP servers with a minimal, explicit environment; restrict outbound network access with an OS-level firewall or NetworkPolicy.
- Subscribe to GitHub Security Advisories and npm advisories for every MCP package; review the changelog and diff before bumping a pin.
- Use a scanner (Socket,
npm audit, Snyk) in CI and fail the build on high-severity findings.
FAQ
Q: How fast can a compromised npm package reach production?
A: With a range version and bare npm install, within hours of a malicious publish. A lockfile-pinned npm ci deploy is protected until you deliberately bump the pin — and a min-release-age cooldown adds a buffer even then.
Q: I rotated my keys — am I done? A: Only if you reissued them, not rotated in place, and only after confirming the malware had no other persistence (no rogue workflow file, no created repos). Microsoft’s guidance for the 2026 waves was to treat all reachable credentials as burned and reissue them, because the payload exfiltrates the secret value itself.
Q: How do I tell if my current install is already compromised?
A: Diff the installed version against a known-good npm pack (Step 1), grep for process.env dumps and base64 blobs, check egress logs for unknown hosts, and look for unexpected .github/workflows/ files. If Index.js/cat.py-style files or a reversed Shai-Hulud repo description appear, assume compromise.
Q: Does a release-age cooldown break my workflow? A: Rarely. It only delays brand-new releases; anything older than the threshold installs immediately. As of June 2026 npm has no per-package exclusion, so set the window to a few days (3 is common) rather than weeks if you need fresh releases occasionally.
Q: What’s the difference between tool poisoning and a supply-chain attack? A: Tool poisoning changes the tool description/behavior sent to the model without altering installed code (detect by auditing the manifest). A supply-chain attack changes the installed code (detect by file-integrity and diff). Both cause unexpected behavior but need different detection.