╔══════════════════════════════════════════════════╗
║ n p m - s h i e l d v0.1.1 ║
║ Shai-Hulud / ChainDrop worm detector ║
╚══════════════▲═══════════════════════════════════╝
│
project ────────┤──────── system ──────── lockfile
│
┌─────────────────┴──────────────────────────┐
│ 7 detection stages · offline IOC data │
│ Linux · macOS · Windows · CI │
└────────────────────────────────────────────┘
Detects the Shai-Hulud / ChainDrop npm worm on developer machines, projects, CI and lockfiles.
A defensive, cross-platform security scanner that checks projects, lockfiles,
node_modules trees, IDE hooks, GitHub Actions workflows, host persistence,
running processes and credential exposure against verified IOC data from
a large npm supply-chain worm campaign.
This is a defensive tool. It detects malware; it does not contain, ship, or execute any malicious code.
The Shai-Hulud / ChainDrop worm ripped through npm — 456 packages, 2,244 poisoned versions (JFrog; wave ongoing) across a dozen+ organisations (keyv/cacheable family, @servicetitan/* ×141, @qlik, @picsart, @deliveroo, @nebula.js and more). 11 verified worm carriers (keyv@6.0.0 alone: 153M weekly downloads). All 11 carrier tarballs were pulled from the registry, but the campaign is not contained — second-wave republishing continues via stolen bypass-2FA tokens.
npm-shield answers one question on every machine it runs on: "is this project — or this developer — already hit?"
┌─────────────────────────────┐
│ scan target │
└──────┬───────┬───────┬──────┘
│ │ │
project dir ─────┘ │ └──── single file
▼ ▼ (lockfile / artifact)
┌────────────────────────┐
│ 1 lockfiles │ package-lock v1/v2/v3
│ poisoned versions │ yarn.lock, pnpm-lock.yaml
├────────────────────────┤ npm: aliases resolved
│ 2 node_modules walk │ threaded; JS/TS hashed +
│ hooks · hashes │ content-scanned
├────────────────────────┤
│ 3 manifests │ decoded lifecycle scripts,
│ hooks + aliases │ poisoned name@version
├────────────────────────┤
│ 4 signal files │ setup.mjs / Math_Symbol.js /
│ hash·size·name │ math_init.js at any depth
├────────────────────────┤
│ 5 IDE hooks │ .claude/settings.json,
│ │ .vscode/tasks.json (+nested)
├────────────────────────┤
│ 6 CI workflows/actions │ token-relay + Bun markers
└───────────┬────────────┘
▼
findings → risk score → verdict
| Layer | Detection | Severity |
|---|---|---|
| Poisoned packages | 416 packages / 1,123 version entries — 269 exact-version + 147 any-version | 🔴 critical |
| File hashes | 3 SHA-256 signatures: setup.mjs loaders A/B, Math_Symbol.js/math_init.js stage-2 harvester |
🔴 critical |
| Install hooks | lifecycle scripts referencing setup.mjs, Math_Symbol, gh-token-monitor — checked on the JSON-decoded values, so unicode escapes, casing and shell chaining don't help; every install-time hook covered |
🔴 critical |
| Content markers | token-relay marker (IfYouBlockThisAPIKeyItWillCrash…), live exfil domain (npm-cache.com), Bun loader (bun-v1.3.13), Ethereum C2 fallbacks (thebeautifulmarchoftime/…snadsoftime) — catches renamed variants in nested scripts and TypeScript payloads too |
🔴/🟡 |
| IDE hooks | .claude/settings.json SessionStart + .vscode/tasks.json folderOpen (cross-wired), incl. monorepo subpackage plants |
🔴 critical |
| Persistence | ~/.config/gh-token-monitor/ dead-man's switch (Linux), com.user.gh-token-monitor LaunchAgent (macOS) |
🔴 critical |
| Processes | gh-token-monitor, setup.mjs, Math_Symbol, bun.*runner launchers |
🔴 critical |
| Workflows | GitHub Actions workflows and composite actions carrying the marker strings | 🔴/🟡 |
| Temp artifacts | bun-dl-* staging dirs under the platform temp locations |
🟡 medium |
| Credentials | npm tokens, ~/.git-credentials, env secrets — names only, values never printed |
🔴/🟡 |
Evasion resistance is built into each layer: npm alias specs resolve to the real target everywhere, directory walks refuse symlink/junction cycles, an unreadable subtree fails the scan loudly instead of reporting CLEAN, and oversized/adversarial inputs are handled with bounded reads.
flowchart LR
T["target\nproject dir · lockfile · system"] --> D{"what is it?"}
D -->|"directory"| P["scan_project\n7 stages"]
D -->|"lockfile"| L["lockfile parser\nnpm / yarn / pnpm"]
D -->|"artifact"| M["hash · name ·\ncontent markers"]
P --> S["SignatureMatcher"]
L --> S
M --> S
S --> F["findings\n(deduplicated)"]
F --> R["risk score\n0 – 100"]
R --> V{"verdict"}
V -->|"clean"| OK["exit 0"]
V -->|"findings"| BAD["exit 1"]
V -->|"incomplete / error"| ERR["exit 2"]
R --> O["reporter\nterminal · JSON · SARIF · HTML"]
flowchart TD
F["a file on disk"] --> REG{"regular file?"}
REG -->|"no — FIFO/dir/device"| SKIP["skip safely\nno hang possible"]
REG -->|"yes"| SZ{"size ≤ 2 MiB and\nsize is a known payload size?"}
SZ -->|"yes"| H{"SHA-256 matches a\nverified worm hash?"}
H -->|"yes"| C1["🔴 critical — file_hash"]
H -->|"no"| NM{"signal filename?\n(setup.mjs …)"}
SZ -->|"no"| NM
NM -->|"yes + size match"| C2["🔴 critical — name_size"]
NM -->|"yes"| HI["🟠 high — name signal"]
NM -->|"no"| MK{"campaign content\nmarkers inside?"}
MK -->|"token-relay / C2 string"| C3["🔴 critical"]
MK -->|"exfil domain"| HI2["🟠 high"]
MK -->|"Bun version"| ME["🟡 medium"]
MK -->|"none"| CL["✅ clean"]
| Platform | Support |
|---|---|
| Linux (Kali/Ubuntu/Debian) | ✅ Full — systemd user-service check, ps aux fallback |
| macOS | ✅ Full — LaunchAgent detection, case-insensitive FS matching |
| Windows | ✅ Full — junction-safe walks, .cmd shims, VT colors, cp1252-safe output |
| CI/CD (GitHub Actions, GitLab) | ✅ --json / --sarif, exit codes 0/1/2 |
Python 3.9 – 3.13, zero mandatory dependencies. Everything ships offline — the verified IOC data lives inside the package.
# from the project directory (dev)
cd npm-shield
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -e .
# or install anywhere from source
pip install /path/to/npm-shieldRequirements: Python 3.9+ · zero mandatory deps.
Optional extras: pip install ".[psutil]" (faster process scans),
".[ijson]" (streaming parse of >50 MB lockfiles), ".[dev]" (pytest).
npm-shield scan ./my-project # full 6-stage project scan
npm-shield scan ./package-lock.json # single lockfile
npm-shield scan ./setup.mjs # single suspicious file
python -m npm_shield scan . # same thing via module invocationnpm-shield system # add --ignore-scripts-check to skip the npm probenpm-shield feed-update # refresh cache; exits 2 when offlinefrom npm_shield.watcher import Watcher
Watcher().run_forever("./my-project", interval=5)npm-shield scan ./my-project --json # machine-readable (CI)
npm-shield scan ./my-project --sarif # SARIF 2.1.0 for code scanning
npm-shield scan ./my-project --html -o report.html # shareable HTML report
npm-shield scan ./my-project --lang hi # Hinglish status messages
npm-shield scan ./my-project --no-colors # plain text
npm-shield scan ./my-project --threads 8 # parallel node_modules walk| Code | Meaning |
|---|---|
0 |
Clean — no actionable findings |
1 |
Affected — findings detected (blocks CI) |
2 |
Error — bad usage, missing path, unreadable/incomplete scan, engine failure |
An incomplete scan can never masquerade as CLEAN: if any subtree could not be
read (permissions, hostile artifacts), the result carries the error and the
CLI exits 2.
┌─ npm-shield v0.1.1 ───────────────┐
│ Scan: 214 packages checked │
│ ✅ 212 safe ⚠️ 2 affected │
└───────────────────────────────────┘
⚠️ AFFECTED FOUND!
🔴 2 CRITICAL
━━ Findings ━━
1. 🔴 CRITICAL package-lock.json — Package keyv is on the Shai-Hulud
affected list (verified IOC).
type: poisoned_package
2. 🔴 CRITICAL node_modules/keyv/package.json — preinstall hook executes
a Shai-Hulud payload (setup.mjs found in script value)
type: package_json_pattern
━━ Fix suggestions ━━
• Remove the package and its lockfile entries; upgrade to the latest
clean version.
• Remove the malicious install script and reinstall from a clean lockfile.
python -m pytest tests/ -q
# 262 tests across 25 files — POSIX-only tests auto-skip on WindowsThe suite pins parsers, every detection layer, hostile inputs (malformed manifests, FIFOs, oversized files, junction cycles, unreadable subtrees), alias evasion, thread safety and the CLI exit-code contract.
The Shai-Hulud payload installs a watcher that polls api.github.com/user
every 60 seconds with a stolen GitHub token and executes a handler when the
token is revoked (24h TTL).
If you find the persistence artifact (gh-token-monitor), do NOT rotate
tokens first. Remove the watcher, then rotate. npm-shield reports this
ordering in every relevant finding's fix guidance.
All IOC data is verified against multiple independent reports — the file hashes are byte-identical across vendors:
- Semgrep — Chaindrop analysis (SHA-256 hashes, package list)
- StepSecurity — ChainDrop npm Worm (444 pkgs / 2,212 versions, 11 carriers, EtherHiding C2)
- Aikido — Keyv & friends compromised (identical hashes, exfil endpoint)
- SafeDep — npm Worm Poisons 400+ Packages (dead-man's switch analysis)
- Socket — keyv/cacheable compromise (live tracking)
- O3 Security — Shai-Hulud IOCs (staging caveat, token marker)
Caveat: the 546–1,300 public GitHub repos carrying the "Shai-Hulud: Here We Go Again" description are staging artifacts, not victims.
Defense-in-depth against install-script worms:
# block install scripts by default (npm v12+ does this natively)
npm config set ignore-scripts true
# always audit
npm audit --audit-level=high
# pin everything; commit lockfiles
# prefer a package manager that blocks install scripts by default (pnpm, bun)MIT — free to use, modify, and share. See LICENSE.
Deep-dive into the internals: ARCHITECTURE.md.
v0.1.1 — current release.