Tools: The npm Package That Backdoored Every Build Pulling It Last Week (2026)

Tools: The npm Package That Backdoored Every Build Pulling It Last Week (2026)

The mechanism

Why your build is the soft spot

The defensive checklist

1. Lockfile pin with --frozen-lockfile

2. Integrity hash verification

3. Registry scoping

4. Disable lifecycle scripts where possible

5. Runtime tool-call audit for agent code

A 20-line audit script

The longer view

Run the checklist this week

If this was useful On April 22, 2026, Socket.dev flagged the first malicious version of @automagik/genie, an npm package published by Namastex Labs, an agentic-AI company. By the time the writeup went up, at least 16 packages across multiple namespaces were compromised, new malicious versions were still being published, and a 1,143-line credential-harvesting script was firing on every install via the postinstall hook. No interaction. No user prompt. Just npm install and the worm was inside. The campaign — researchers are calling it CanisterSprawl, a CanisterWorm-style worm — is the kind of incident that makes a CISO cancel their weekend. It is also one of three documented npm/PyPI/Docker-Hub supply-chain campaigns that hit between April 21 and 23, 2026, in addition to the Axios npm compromise three weeks earlier where a backdoored release sat live for under three hours and still hit a package with 100M+ weekly downloads. This post walks through the mechanism, the defensive checklist that would have caught it, and a 20-line script you can wire into CI today. The CanisterSprawl worm is a textbook example of what an AI-aware supply-chain attack looks like in 2026. Four moving parts: 1. Compromised maintainer. The initial breach was an account takeover. Researchers at Socket and StepSecurity noted strong overlap with the earlier TeamPCP CanisterWorm campaign, which hit Trivy, KICS, LiteLLM, and Telnyx between March 19 and 27, 2026. The attackers know how to phish npm publish tokens. 2. Postinstall trigger. The malicious payload runs from the postinstall lifecycle hook in package.json. Every consumer who runs npm install, npm ci, or any equivalent in a Docker build executes the script, no questions asked. There is no UI. There is no warning. The script fires before your app code ever runs. 3. Credential exfiltration. A 1,143-line script harvests environment variables, ~/.aws/credentials, ~/.npmrc tokens, SSH keys, and GitHub tokens. Stolen data goes to two endpoints: a regular HTTPS webhook and an ICP canister-backed C2, which is harder to seize than a traditional server. 4. Self-propagation. This is the part that turns a breach into a worm. The script checks for an npm publish token. If it finds one, it lists every package the token can publish, increments the patch version on each, injects the same payload, and republishes with --tag latest. Any developer who runs npm install <that-package> without an exact version pin in their lockfile pulls the new infected version and becomes the next propagation vector. The "AI angle" is not the worm itself. It's that AI-tooling packages — agentic AI libraries, MCP servers, LLM-tooling SDKs — are growing fast, often maintained by small teams, and frequently installed inside CI pipelines that hold cloud credentials. They are exactly the place a supply-chain attacker wants to land. A typical CI runner doing npm install for an agentic-AI service at deploy time has, in its environment, some combination of: an AWS access key, a database password, an OpenAI or Anthropic API key, a GitHub token, an npm publish token if it deploys packages, and a Slack webhook for notifications. A 1,143-line script does not need to be clever. It needs three seconds. The Axios incident makes the same point with a different vector. Two backdoored versions ([email protected] and 0.30.4) sat on npm for under three hours on March 31, 2026. They still affected a non-trivial number of CI builds because of how often npm install runs and how few teams pin to exact versions. The WAVESHAPER.V2 RAT was attributed to UNC1069, a North Korea-linked group. Three hours of exposure, global blast radius. Two different threat actors. Same root cause: trust in npm install is unreasonable, and most build pipelines act as though it isn't. Five controls. Pick all of them. None is sufficient on its own. The single highest-value control. If your package-lock.json pins exact versions and your CI runs npm ci (which fails on lockfile drift) instead of npm install, the worm cannot trick you into pulling a new patch release between the time it republishes and the time the bad version is yanked. Most teams "have a lockfile" but run npm install in CI anyway, which silently updates the lockfile when it diverges. npm ci fails the build instead. That failure is a feature. Lockfiles include integrity fields with subresource hashes. Confirm your CI actually verifies them. For Yarn, yarn install --immutable does this. For pnpm, pnpm install --frozen-lockfile. For npm, npm ci does. The Axios attackers republished new tarballs under the same minor versions, but a pinned integrity hash mismatch would have failed the install. Use a private registry mirror or proxy (Verdaccio, Nexus, JFrog Artifactory) that pulls from npmjs.org only when you ask it to. This gives you a single point where you can: A 24-hour quarantine on new releases would have caught both Axios and CanisterSprawl. The bad versions were yanked within hours. You can run npm install --ignore-scripts to skip postinstall, preinstall, and friends. Some packages legitimately need build steps (node-gyp, native bindings), so a global ignore breaks things. The pragmatic version: a CI step that installs with --ignore-scripts first, runs a static scan over the dependency tree, then runs the real install with scripts enabled. This catches the obvious cases. Specific to agentic AI codebases. If your service is an LLM agent that calls tools, ensure the tool-call audit log captures every child_process.exec, every network request, and every filesystem write. The same supply-chain risk applies to MCP servers and agent tool packages — a compromised tool definition is a compromised tool, and it runs in the same process as your secrets. A simple shape: pipe every tool-call invocation through a wrapper that records {tool_name, args, timestamp, caller} to an append-only audit log. Periodically diff that log against a baseline. The script below does one specific thing: scans an npm install event for the most common red flags from the CanisterSprawl playbook. Wire it into CI before the actual install runs. Three things this catches and one thing it does not. It catches packages with lifecycle hooks (the CanisterSprawl entry point), missing or stale lockfiles, and version drift since your last known-good install. It does not catch a backdoor in a package that has always had a postinstall hook for legitimate reasons (node-gyp, native modules). For those, the answer is the registry quarantine in step 3 of the checklist, not a script. Run this in CI before npm ci. Tune the allowlist for packages that have a justified postinstall. The signal-to-noise is reasonable on a typical Node service. Two patterns are showing up across the 2026 supply-chain incidents. The first is that AI-tooling packages are now first-class targets. The TeamPCP campaign hit LiteLLM on PyPI. CanisterSprawl hit an agentic-AI company's packages. There is also the prt-scan campaign Wiz disclosed that used AI-generated GitHub Actions exploits since March 11, 2026. Attackers are noticing the same thing the rest of us are: AI-coding teams ship fast, hold a lot of secrets, and tend to run with broad permissions. The second is that exfiltration infrastructure is getting harder to take down. ICP canisters, Discord webhooks, abandoned Cloudflare Workers — the C2 endpoint of choice keeps shifting toward platforms where takedowns are slower or impossible. The defender's only durable lever is what crosses the egress boundary in the first place. That puts the responsibility back on the install pipeline. If the secrets aren't in the build environment, the script can't exfiltrate them. If the install fails on lockfile drift, the worm can't propagate from your machine. If the registry quarantine soaks new releases for 24 hours, the bad versions are yanked before they reach you. None of these are exotic. All of them are off by default. Three things to do before the next deploy: The next CanisterSprawl is already being staged. The Axios attackers had a known toolkit. The CanisterWorm operators have been iterating since at least March. The next campaign is not a matter of if, and the controls above are the difference between a Slack alert and a security review. The AI Agents Pocket Guide covers the runtime side of this — how to instrument an agent's tool calls so a compromised tool definition shows up in your audit log instead of disappearing into the noise, and how to design the guardrails that survive a malicious dependency landing in your tool surface. The Database Playbook covers the same shape of question for the data layer: where credentials should live, what the blast radius of a leaked CI secret looks like, and how to design schemas and access patterns that limit damage when something gets out. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# good -weight: 500;">npm ci # bad -weight: 500;">npm -weight: 500;">install # good -weight: 500;">npm ci # bad -weight: 500;">npm -weight: 500;">install # good -weight: 500;">npm ci # bad -weight: 500;">npm -weight: 500;">install #!/usr/bin/env bash # scan--weight: 500;">install.sh — flag suspicious -weight: 500;">npm -weight: 500;">install events set -euo pipefail PKG_JSON="${1:-package.json}" LOCK="${2:-package-lock.json}" ALERTS=0 flag() { echo "ALERT: $1"; ALERTS=$((ALERTS + 1)); } # 1. Lockfile must exist and be up to date [ -f "$LOCK" ] || flag "no lockfile found" # 2. Detect packages with postinstall scripts node -e ' const lock = require(process.argv[1]); const pkgs = lock.packages || {}; for (const [path, p] of Object.entries(pkgs)) { if (p.scripts && (p.scripts.postinstall || p.scripts.preinstall || p.scripts.-weight: 500;">install)) { console.log("HOOK:" + path); } } ' "$LOCK" | while read line; do flag "$line" done # 3. Recently published versions are suspicious -weight: 500;">npm outdated --json --all 2>/dev/null \ | node -e ' let buf=""; process.stdin.on("data",d=>buf+=d); process.stdin.on("end",()=>{ if(!buf) return; const o = JSON.parse(buf); for (const [name, info] of Object.entries(o)) { if (info.latest === info.wanted) continue; console.log("UPDATE:" + name + ":" + info.latest); } });' # 4. Block -weight: 500;">install if any alert tripped without sign-off if [ "$ALERTS" -gt 0 ] && [ -z "${ALLOW_RISKY:-}" ]; then echo "Aborting. Set ALLOW_RISKY=1 after manual review." exit 1 fi #!/usr/bin/env bash # scan--weight: 500;">install.sh — flag suspicious -weight: 500;">npm -weight: 500;">install events set -euo pipefail PKG_JSON="${1:-package.json}" LOCK="${2:-package-lock.json}" ALERTS=0 flag() { echo "ALERT: $1"; ALERTS=$((ALERTS + 1)); } # 1. Lockfile must exist and be up to date [ -f "$LOCK" ] || flag "no lockfile found" # 2. Detect packages with postinstall scripts node -e ' const lock = require(process.argv[1]); const pkgs = lock.packages || {}; for (const [path, p] of Object.entries(pkgs)) { if (p.scripts && (p.scripts.postinstall || p.scripts.preinstall || p.scripts.-weight: 500;">install)) { console.log("HOOK:" + path); } } ' "$LOCK" | while read line; do flag "$line" done # 3. Recently published versions are suspicious -weight: 500;">npm outdated --json --all 2>/dev/null \ | node -e ' let buf=""; process.stdin.on("data",d=>buf+=d); process.stdin.on("end",()=>{ if(!buf) return; const o = JSON.parse(buf); for (const [name, info] of Object.entries(o)) { if (info.latest === info.wanted) continue; console.log("UPDATE:" + name + ":" + info.latest); } });' # 4. Block -weight: 500;">install if any alert tripped without sign-off if [ "$ALERTS" -gt 0 ] && [ -z "${ALLOW_RISKY:-}" ]; then echo "Aborting. Set ALLOW_RISKY=1 after manual review." exit 1 fi #!/usr/bin/env bash # scan--weight: 500;">install.sh — flag suspicious -weight: 500;">npm -weight: 500;">install events set -euo pipefail PKG_JSON="${1:-package.json}" LOCK="${2:-package-lock.json}" ALERTS=0 flag() { echo "ALERT: $1"; ALERTS=$((ALERTS + 1)); } # 1. Lockfile must exist and be up to date [ -f "$LOCK" ] || flag "no lockfile found" # 2. Detect packages with postinstall scripts node -e ' const lock = require(process.argv[1]); const pkgs = lock.packages || {}; for (const [path, p] of Object.entries(pkgs)) { if (p.scripts && (p.scripts.postinstall || p.scripts.preinstall || p.scripts.-weight: 500;">install)) { console.log("HOOK:" + path); } } ' "$LOCK" | while read line; do flag "$line" done # 3. Recently published versions are suspicious -weight: 500;">npm outdated --json --all 2>/dev/null \ | node -e ' let buf=""; process.stdin.on("data",d=>buf+=d); process.stdin.on("end",()=>{ if(!buf) return; const o = JSON.parse(buf); for (const [name, info] of Object.entries(o)) { if (info.latest === info.wanted) continue; console.log("UPDATE:" + name + ":" + info.latest); } });' # 4. Block -weight: 500;">install if any alert tripped without sign-off if [ "$ALERTS" -gt 0 ] && [ -z "${ALLOW_RISKY:-}" ]; then echo "Aborting. Set ALLOW_RISKY=1 after manual review." exit 1 fi - Book: AI Agents Pocket Guide - Also by me: Database Playbook - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub - Quarantine new versions of critical packages until a 24-hour soak window passes. - Block packages with a postinstall hook unless explicitly allowlisted. - Audit who is pulling what. - Switch your CI from -weight: 500;">npm -weight: 500;">install to -weight: 500;">npm ci. Audit any failures. They are signal, not noise. - Stand up a registry mirror or turn on the one your enterprise license already includes. Configure a soak window for new versions of the top 50 packages your fleet depends on. - Wire the script above (or your equivalent) into the build, gated by an environment variable so engineers can override after manual review.