Home
/
Blog
/
Blog article

3/30/2026

The npm Supply Chain Attack That Self-Spreads: What Every Node Dev Needs to Know in 2026

Last week, a self-spreading worm called CanisterWorm infected 141+ npm packages across 66+ unique packages. If you run Node.js in production — or even just npm install in your CI/CD pipeline — this one hits close to home.

Here's what happened, how it works, and what you should do right now to protect your projects.

What Actually Happened

On March 20, 2026, security researchers at Aikido detected something unusual: dozens of npm packages from multiple organizations were receiving unauthorized patch updates. All of them contained the same hidden malicious code.

The attack started with Trivy, a popular open-source security scanner used in thousands of CI/CD pipelines. A threat group called TeamPCP exploited a GitHub Actions misconfiguration to steal a Personal Access Token (PAT). With that token, they force-pushed malicious commits over 75 version tags on trivy-action and replaced the legitimate scanner with a credential harvester.

The irony? A security tool became the attack vector.

The Trivy payload collected everything: SSH keys, AWS credentials, database passwords, Kubernetes tokens, Docker configs, and — critically — npm authentication tokens. Those stolen npm tokens became the launchpad for CanisterWorm.

How CanisterWorm Spreads

This is where it gets scary. CanisterWorm isn't your typical malicious package that sits there waiting. It actively spreads itself.

Stage 1 — Token Theft via postinstall

When you run npm install on an infected package, the postinstall hook silently executes index.js. This script scrapes every npm token it can find — from .npmrc files, environment variables, and live npm config. On any platform. macOS, Windows, Linux.

Here's what the token harvesting code looks like:

function findNpmTokens() {
  const tokens = new Set();
  const npmrcPaths = [
    path.join(os.homedir(), '.npmrc'),
    path.join(process.cwd(), '.npmrc'),
    '/etc/npmrc',
  ];
  for (const rcPath of npmrcPaths) {
    const content = fs.readFileSync(rcPath, 'utf8');
    for (const line of content.split('\n')) {
      const m = line.match(/(?:_authToken\s*=\s*|:_authToken=)([^\s]+)/);
      if (m && m[1]) tokens.add(m[1].trim());
    }
  }
}

Stage 2 — Persistent Backdoor

On Linux systems, the worm drops a Python backdoor disguised as PostgreSQL monitoring (pgmon). It registers itself as a systemd user service with Restart=always, so it survives reboots and keeps coming back if killed.

The systemd service definition it creates:

[Unit]
Description=pgmon

[Service]
Type=simple
ExecStart=/usr/bin/python3 ~/.local/share/pgmon/service.py
Restart=always
RestartSec=5

[Install]
WantedBy=default.target

Stage 3 — Blockchain-Based Command & Control

Here's the novel part. Instead of phoning home to a regular server (which can be taken down), the backdoor polls an ICP canister — a smart contract on the Internet Computer blockchain. This is decentralized infrastructure. There's no single server to seize, no hosting provider to send a takedown request to.

The canister exposes three methods: get_latest_link, http_request, and update_link. The attacker can rotate payloads at will — every infected machine picks up the new binary on its next 50-minute poll cycle. As of when researchers analyzed it, the URL returned by the C2 was a rickroll YouTube video — the attacker's kill switch for dormant mode.

Stage 4 — Self-Propagation

The later variants don't even need the attacker to manually spread. The worm takes your stolen npm tokens and automatically publishes infected versions of every package those tokens have access to. Your packages get infected. Your downstream users install them. If they have tokens, the cycle repeats.

Every developer or CI pipeline that installs an infected package becomes an unwitting propagation vector.

The Packages That Were Hit

The attack compromised packages across multiple npm scopes: 28 packages in the @EmilGroup scope, 16 packages in the @opengov scope, plus @teale.io/eslint-config, @airtm/uuid-base32, and @pypestream/floating-ui-dom.

Socket later confirmed the attack expanded to 141 malicious package artifacts spanning 66+ unique packages.

What You Should Do Right Now

Don't panic, but don't ignore this either. Here's a practical checklist.

Immediate (next 30 minutes)

Run npm audit on all your active projects. Check if any of the affected scopes (@EmilGroup, @opengov, @teale.io, @airtm, @pypestream) appear in your package-lock.json. Look for unexpected postinstall scripts in your dependencies.

# Quick audit
npm audit

# Check for affected scopes
grep -rn "@EmilGroup\|@opengov\|@teale.io\|@airtm\|@pypestream" package-lock.json

# List all postinstall scripts in your dependency tree
npm ls --all 2>/dev/null | head -50

This week

Rotate your npm tokens. If you've run npm install on any project in the last two weeks, assume your tokens could be compromised. Generate new ones from npmjs.com/settings.

Rotate GitHub PATs too — the original Trivy attack harvested these. Add --ignore-scripts to CI pipelines where you don't need postinstall hooks. Enable 2FA on your npm account — this is the single biggest thing you can do. Pin critical dependencies to exact versions instead of using ^ or ~ ranges.

# Disable postinstall scripts globally in CI
npm config set ignore-scripts true

# Or per-install
npm install --ignore-scripts

Ongoing best practices

Wait 24-48 hours before updating to new package versions. Let the community catch malicious updates first. Review changelogs before updating — if a patch version suddenly changes postinstall scripts, that's a red flag.

Use lockfiles religiously. package-lock.json exists for a reason — commit it, review changes to it in PRs. Consider tools like Socket, Snyk, or npm audit signatures to catch supply chain attacks before they hit your codebase. Use read-only tokens in CI/CD where possible.

Why This Attack Is Different

We've seen malicious npm packages before. Typosquatting, dependency confusion — they're nothing new. But CanisterWorm represents an evolution.

First, it's self-spreading. Previous attacks required the attacker to manually publish each malicious package. CanisterWorm automates propagation through stolen tokens.

Second, the C2 is unkillable. Using blockchain infrastructure for command and control means there's no server to take down. The attacker can rotate payloads indefinitely.

Third, it was seeded through a trusted security tool. Trivy is used by security-conscious teams. The attack specifically targeted developers who were already doing the right thing by scanning their code.

Fourth, it targets the entire ecosystem, not individual packages. The worm doesn't care which specific package it infects — it spreads to everything the stolen token has access to.

The Bigger Picture

The Node.js ecosystem's greatest strength — the massive npm registry with millions of packages — is also its biggest vulnerability. We've built an incredible developer experience around npm install, but that convenience comes with implicit trust in every maintainer, every CI pipeline, and every dependency in your tree.

CanisterWorm is a wake-up call. The supply chain is only as strong as its weakest link, and right now there are a lot of weak links.

As someone who builds production Node.js applications, I'm not saying we should abandon npm or stop using open source. That would be absurd. But we need to be more deliberate about what we install, how we manage credentials, and how much trust we extend by default.

Stay sharp out there.