Skip to content

Repository files navigation

npm-shield

      ╔══════════════════════════════════════════════════╗
      ║   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 threat, in one paragraph

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?"


What it scans

                        ┌─────────────────────────────┐
                        │        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.


Scan pipeline

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"]
Loading
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"]
Loading

Cross-platform

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.


Installation

# 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-shield

Requirements: Python 3.9+ · zero mandatory deps. Optional extras: pip install ".[psutil]" (faster process scans), ".[ijson]" (streaming parse of >50 MB lockfiles), ".[dev]" (pytest).


Usage

Scan a project

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 invocation

Full system scan (persistence + processes + credentials)

npm-shield system                     # add --ignore-scripts-check to skip the npm probe

Threat feed & watch mode

npm-shield feed-update                # refresh cache; exits 2 when offline
from npm_shield.watcher import Watcher
Watcher().run_forever("./my-project", interval=5)

Output formats

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

Exit codes

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.


Example output

┌─ 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.

Testing

python -m pytest tests/ -q
# 262 tests across 25 files — POSIX-only tests auto-skip on Windows

The 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.


⚠️ Dead-man's switch warning

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.


Data sources (verified & cross-checked)

All IOC data is verified against multiple independent reports — the file hashes are byte-identical across vendors:

Caveat: the 546–1,300 public GitHub repos carrying the "Shai-Hulud: Here We Go Again" description are staging artifacts, not victims.


Hardening recommendations

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)

License

MIT — free to use, modify, and share. See LICENSE.

Deep-dive into the internals: ARCHITECTURE.md.

Version

v0.1.1 — current release.

About

Defensive scanner that detects the Shai-Hulud npm worm (Chaindrop) - poisoned packages, file hashes, install/IDE hooks and persistence artifacts. Python 3.9+, zero dependencies.

Topics

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages