Cobenian/shai-hulud-detect

A simple project to detect the Shai-Hulud npm supply chain attack

Shell

410

268 commits

updated Oct 12, 2026

See the code

README

Shai-Hulud Supply Chain Attack Detector

License Shell Status Tests Packages Type Contributions Last Commit

sshd

A Bash script that scans a project — or many projects at once — for known traces of the September 2025 → October 2026 npm, PyPI, Composer, Crates, Go, Hex, and RubyGems supply-chain attacks. Cross-checks 5,791 confirmed bad package versions and a library of content-pattern IoCs (file hashes, C2 domains, dead-man's-switch artifacts, wipe-threat strings, AI-assistant config droppers, etc.).

Quick Start

git clone https://github.com/Cobenian/shai-hulud-detect
cd shai-hulud-detect
chmod +x shai-hulud-detector.sh

# Scan one project
./shai-hulud-detector.sh /path/to/your/project

# Scan every project under one or more parent dirs and get one aggregate report
./shai-hulud-detector.sh --bulk ~/dev ~/work

# Save findings to a file (same format as CI-friendly logs)
./shai-hulud-detector.sh --save-log report.log /path/to/project

Exit codes (drop straight into CI): 0 clean · 1 high-risk · 2 medium-risk · 3 per-project scan errored (bulk mode only).

What it catches

The detector looks for two kinds of evidence on disk:

  1. Compromised package versions — every package.json, lockfile, pyproject.toml, requirements.txt, Pipfile, poetry.lock, uv.lock, etc. is parsed and the resolved versions are checked against the 5,791-entry list in compromised-packages.txt. Transitive deps inside node_modules/ are checked too, not skipped.
  2. Content-pattern IoCs — known-malicious file hashes, payload filenames, C2 domains, dead-man's-switch artifacts, marker repo names, malicious workflow files, forged orphan-commit references, suspicious lifecycle hooks, and threat-actor publisher fingerprints. These don't depend on the package list and fire even if the bad package has been uninstalled but the dropper traces remain.
WaveDateScope
Chalk/Debug crypto theft2025-09-0818+ packages, ~2B weekly downloads
Shai-Hulud worm2025-09-14517+ packages (@ctrl, @crowdstrike, …)
Shai-Hulud "Second Coming" (fake Bun)2025-11-241,100+ packages
Golden Path variant2025-12-28renamed Bun-attack files
SANDWORM_MODE workflow poisoning2026-02-1719 packages + GitHub Action
Axios RAT compromise2026-03-31[email protected]/0.30.4 + plain-crypto-js
Mini Shai-Hulud / TanStack2026-05-11400+ versions, dead-man's-switch
Megalodon (GitHub-repo backdooring)2026-05-185,561 repos via stolen PATs; @tiledesk/[email protected] as npm fallout
Mini Shai-Hulud / AntV (atool)2026-05-19643 versions, 323 packages
Web3 / DeFi MCP-server typosquat2026-05-2010 packages (chain-key-validator, defi-threat-scanner, …), exfiltrates SSH + wallet keys
Polymarket wallet drainer2026-05-219 packages from polymarketdev (polymarket-bot, polymarket-trader, …), fake wallet-onboarding prompt captures private keys
Bitwarden CLI ("Third Coming")2026-04-22@bitwarden/[email protected] via Checkmarx ast-github-action breach; bw1.js exfil to audit.checkmarx.cx
node-ipc backdoor2026-05-14[email protected]/9.2.3/12.0.1; IIFE in node-ipc.cjs, DNS exfil to sh.azurestaticprovider.net
Nx Console VS Code ext2026-05-18[email protected]; payload from orphan commit 558b09d7 in nrwl/nx, targets ~/.claude/settings.json (TeamPCP / GitHub breach)
TrapDoor (TeamPCP)2026-05-22→2534 packages / 384+ versions across npm + PyPI + Crates; plants .cursorrules/CLAUDE.md AI-assistant droppers
Laravel-Lang tag-rewrite2026-05-22700+ Composer tags force-rewritten (laravel-lang/lang, …); RCE on autoload, DebugElevator stealer
mouse5212 "Malware-Slop"2026-05-26mouse5212-super-formatter exfils Claude's /mnt/user-data via embedded GitHub PAT (unplowed3584)
art-template npm hijack2025-03 → 2026-054 versions ([email protected]), iOS browser exploit kit (UNC6691)
sl4x0 dependency confusion2025-06 → 2026-0392+ packages across 32 *poc accounts, DNS exfil to oob.sl4x0.xyz (likely security research)
durabletask PyPI worm2026-05-19pypi:durabletask:1.4.1-1.4.3, multi-cloud credential stealer + AWS SSM / k8s lateral movement
PyPI cross-spread (TeamPCP)2026-03 → 05litellm, telnyx, xinference, lightning, mistralai, guardrails-ai
Miasma "Phantom Gyp" worm2026-06-0357 packages / 286 versions (@vapi-ai/server-sdk, ai-sdk-ollama, autotel-*, awaitly-*, executable-stories-*); novel binding.gyp command-substitution trigger bypasses preinstall-script monitors
Miasma "Hades" PyPI wave2026-06-0719 PyPI packages / 37 versions (bramin, magique-ai, pantheon-agents, executor-engine, ufish); novel *-setup.pth Python startup-hook execs an _index.js loader on plain import, no install hook needed; C2 camouflaged under api.anthropic.com
IronWorm ("rustier cousin")2026-06-0337 npm packages from the asteroiddao account (weavedb-*, arnext, cwao, wao, zkjson); Rust infostealer + eBPF rootkit via preinstall ELF hook, Exodus-wallet theft, leaked operator wallet 0x7e28…a4d6
easy-day-js / Mastra AI2026-06-17141 @mastra/* packages + mastra/create-mastra republished with an injected easy-day-js (dayjs typosquat) dependency; postinstall dropper disables TLS, pulls a cross-platform infostealer from C2 23.254.164.92/.123; North-Korea-attributed (Sapphire Sleet / BlueNoroff)
Miasma LeoPlatform / RStreams2026-06-2523 npm packages (leo-*, rstreams-*, serverless-leo, …); Miasma worm reaches the AWS data-streaming ecosystem via the binding.gyp trigger + editor/CI auto-run hooks; new markers RevokeAndItGoesKaboom, thebeautifulmarchoftime
Shai-Hulud "Here We Go Again" (keyv / cacheable) — also tracked as ChainDrop (Microsoft, Elastic)2026-08-04Largest wave by install volume: 444 npm packages / 2,236 versions across 15 scopes, plus 3 Go modules / 82 versions, >2B monthly installs (keyv, flat-cache, file-entry-cache, cacheable-request, cacheable, cache-manager, @servicetitan/*, @ornikar/*, @onereach/*, @or-sdk/*, @qlik/*, @nebula.js/*, @umacloud/*); "preinstall": "node setup.mjs" bootstraps Bun 1.3.13 → 727KB Math_Symbol.js; primary exfil is to GitHub dead-drop repos described Shai-Hulud: Here We Go Again, with no hard-coded C2 — the payload reads its live domain from Ethereum contract 0xE1f2395e… at run time, so hosts rotate without republishing (npm-cache[.]com → awqhnjewqjkl[.]icu on Aug 4); poisoned releases carry valid npm OIDC provenance / SLSA attestation
Mini Shai-Hulud resurfacing2026-09-074 npm packages / 4 versions (blueai-cli, bmc-i18n-extract-cli, bmc-translate-utils, feishu-docx-mcp), all published by one account within an hour; the byte-identical May 19 @antv payload (index.js, hash in MALICIOUS_HASHLIST) via "preinstall": "bun run index.js", 111 days after it was last seen
"supplychain.local" Go-binary worm2026-09-23A new family, not Shai-Hulud code: @memtensor/[email protected]/0.1.23/0.1.25 (the even versions in between are clean maintainer republishes) and pypi:[email protected]. A multiplatform Go sckit binary (6 hashes in MALICIOUS_HASHLIST) steals tokens and republishes through npm, PyPI and a planted GitHub Actions workflow. It runs on invocation, not at install
tensorlake (ChainDrop family)2026-10-08[email protected], published by the project's own release workflow with provenance after an attacker commit to main; "preinstall": "node lib/setup.mjs" → Bun → lib/Math_Symbol.js (both hashed). Same payload family as the keyv wave but new infrastructure: C2 iseekaigogo[.]com, rotation contract 0xb614155F…, operator wallet 0x779f83aE…, and the WORMTAG infection-tag variable, all matched

For per-wave IoC inventories, payload hashes, source advisories, and version-by-version lists, see CHANGELOG.md.

⚠️ Dead-man's-switch warning

Two waves (May 11 and May 19, 2026) install a persistence daemon (gh-token-monitor / kitty-monitor) that wipes the host if its monitored GitHub token is revoked. Run with --check-host to detect the persistence artifacts. If they're present, stop and remove the service BEFORE rotating any tokens. The console summary and aggregate report both print a safe remediation order.

Ecosystems

EcosystemAuto-detected viaStatus
npmpackage.json, package-lock.json, yarn.lock, pnpm-lock.yamlFull support
PyPIpyproject.toml, requirements*.txt, Pipfile, Pipfile.lock, poetry.lock, uv.lock, setup.py, setup.cfgFull support (pure-bash awk parsers, no Python required)
Composercomposer.json, composer.lockFull support (PHP / Packagist; added for the Laravel-Lang wave)
CratesCargo.toml, Cargo.lockFull support (Rust / crates.io; added for the TrapDoor wave)
Gogo.mod, go.sumFull support (Go modules; added for the Miasma LeoPlatform wave. Versions keep their canonical leading v)
Hexmix.exs, mix.lockFull support (Elixir / Hex.pm; mix.lock is authoritative, mix.exs requirements are best-effort)
GemGemfile, Gemfile.lockFull support (RubyGems / Bundler; Gemfile.lock specs are authoritative, Gemfile requirements are best-effort)

Auto-detection looks for marker files in your tree, skipping node_modules/, vendor/, .venv/, venv/, .tox/, site-packages/, dist/, build/, and similar trees. That exclusion only decides which checks to run — content inside node_modules/ is still fully scanned for compromised versions and malware indicators. Override auto-detection with --ecosystem=npm, --ecosystem=pypi, --ecosystem=all, or a comma-separated list.

CI/CD

- name: Shai-Hulud scan
  run: |
    chmod +x ./shai-hulud-detector.sh
    ./shai-hulud-detector.sh --save-log shai-hulud-report.log .
  # Job fails on exit 1 (high-risk) or 2 (medium-risk)
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: shai-hulud-report
    path: shai-hulud-report.log

For GitLab CI, Jenkins, and a custom exit-code handler in shell, see docs/ci-examples.md — or just call the script and switch on $?, the exit-code contract is the same everywhere.

Common scenarios

Scan many projects at once (--bulk)

./shai-hulud-detector.sh --bulk ~/dev ~/work                 # auto-discover all projects
./shai-hulud-detector.sh --bulk --paranoid --bulk-output ./audit ~/dev
./shai-hulud-detector.sh --bulk --bulk-list ~/dev            # dry-run: print what would be scanned

Project discovery is content-aware: a directory with .git, a package.json, a pyproject.toml, Cargo.toml, go.mod, etc. is one scan unit (monorepos stay whole). "Bucket" folders like ~/dev/clients/<client>/<project> are descended into. node_modules, vendor, dist, build dirs, and hidden dirs are never entered. The detector's own repo is skipped automatically. Unreadable directories are reported instead of silently skipped.

Output goes to ./shai-hulud-bulk-report-<timestamp>/ (or --bulk-output DIR):

shai-hulud-bulk-report-<timestamp>/
├── aggregate-report.md      # summary tables + per-project results + remediation
└── per-repo/
    ├── <project>.findings.log    # severity-grouped file paths
    └── <project>.console.txt     # full scan output, ANSI-stripped

Paranoid mode (--paranoid)

Adds typosquatting detection and network-exfiltration heuristics on top of the core checks. These are general-purpose security signals, not Shai-Hulud-specific, and produce more false positives — useful for audits, not recommended for CI gating.

Host-level persistence scan (--check-host)

Walks $HOME for the May 2026 dead-man's-switch artifacts (gh-token-monitor, kitty-monitor). Off by default. See the warning above before remediating any findings.

Save findings to a log file (--save-log FILE)

Writes flagged file paths grouped by severity, in a format friendly to grep and CI artifact uploads:

# HIGH
/path/to/router_init.js
/path/to/package.json
# MEDIUM
/path/to/suspicious-content.js
# LOW
/path/to/namespace-warning.json

Machine-readable JSON output (--json FILE)

For CI gates and tooling that consume findings as data (rather than parsing console text), --json writes a structured report. It uses the same severity tiers as --save-log but keeps the reason for each finding (which --save-log drops):

./shai-hulud-detector.sh --json report.json /path/to/project
{
  "schema_version": "1.0",
  "tool": "shai-hulud-detector",
  "tool_version": "3.9.0",
  "generated_at": "2026-06-26T00:00:00Z",
  "scan_path": "/path/to/project",
  "summary": { "high": 7, "medium": 0, "low": 0 },
  "risk_level": "high",
  "findings": [
    { "severity": "HIGH", "file": "package.json", "line": 6, "message": "[email protected]" }
  ]
}

line is a best-effort pointer at the dependency line in the manifest for package-shaped findings (name@version, @scope/name@version); it is null for prose findings or when no match is found. The exit-code contract (0/1/2) is unchanged and remains the authoritative CI signal — --json and --save-log can be combined.

--json is the only mode that requires jq; it fails fast with a clear message if jq is missing. The default text output keeps the tool's zero-runtime-dependency guarantee.

Other flags

FlagEffect
--json FILEWrite findings as structured JSON (severity/file/line/message + summary). Requires jq.
--check-semver-rangesFlag ^/~ ranges that could resolve to compromised versions (informational, LOW risk).
--ecosystem LISTRestrict checks to npm, pypi, all, or a comma-separated list. Default: auto-detect.
--parallelism NThreads for parallelisable steps. Defaults to your CPU count.
--use-git-grep / --use-ripgrep / --use-grepForce a specific grep tool. Default: auto-select fastest available.
--bulk-depth NDepth cap for bulk discovery (default 3). --bulk-depth 1 = flat.
--bulk-listWith --bulk: print what would be scanned and exit.
--bulk-output DIRWhere to write the bulk report (default ./shai-hulud-bulk-report-<timestamp>/).

How it works

  1. Collect the file inventory (one find pass, categorized by extension).
  2. Detect ecosystems from marker files, decide which package-level checks to run.
  3. Match every resolved package version against compromised-packages.txt via a sorted set-intersection (comm -12).
  4. Hash priority files (bundle.js, setup_bun.js, router_init.js, tanstack_runner.js, cat.py, node-ipc.cjs, etc.) and compare against 20 known-malicious SHA-256s.
  5. Grep for content-pattern IoCs: C2 domains, threat-actor accounts, dead-man's-switch service names, wipe-threat strings, malicious commit SHAs, beacon strings, payload filenames, orphan-commit optionalDependencies patterns.
  6. (Opt-in) scan $HOME for persistence artifacts (--check-host); run typosquatting + network-exfil heuristics (--paranoid); flag latent semver-range risk (--check-semver-ranges).
  7. Report in three severity tiers (HIGH/MEDIUM/LOW), with remediation order for the safety-critical findings.

Detection is read-only. The script never modifies, deletes, or quarantines anything — manual review and remediation are on you.

Output

✅ No indicators of Shai-Hulud compromise detected.   ← clean, exit 0
🚨 HIGH RISK: ...                                     ← exit 1, immediate action
⚠️  MEDIUM RISK: ...                                  ← exit 2, manual review

HIGH: definitive indicators — compromised package version, known-malicious file hash, malicious workflow, dead-man's-switch artifact. Stop and remediate.

MEDIUM: suspicious patterns that warrant a look — semver range that could match a compromised version, references to webhook redirector domains, suspicious git branches.

LOW: informational notes — namespace warnings (packages from affected namespaces at safe versions), legitimate-looking patterns that share shape with attack techniques.

Requirements

  • Bash 5.0+ (associative arrays, mapfile). macOS ships 3.x; install with brew install bash and invoke via /opt/homebrew/bin/bash.
  • Standard Unix tools: find, grep, awk, sed, sort, comm, shasum (or sha256sum), xargs. All POSIX-portable.
  • Tested on macOS (Bash 5), Linux, and Git Bash for Windows.

The script auto-selects the fastest available grep tool (git grep > ripgrep > grep). No runtime dependencies on Python, Node, or anything else.

Limitations

  • Hash detection only catches exact SHA-256 matches against the 20 known-malicious hashes.
  • Compromised-package detection requires the version to be in compromised-packages.txt — new variants need a list update.
  • Paranoid-mode heuristics produce false positives on legitimate code.
  • The detector reads filesystem state; it doesn't query npm/PyPI registries for live data.

Updating the compromised-packages list

compromised-packages.txt is a flat text file:

# Comments start with #
axios:1.14.1                            # bare entry = npm (back-compat)
npm:@tanstack/react-router:1.169.5      # explicit npm prefix
pypi:mistralai:2.4.6                    # PyPI entry
composer:laravel-lang/lang:15.29.5      # Composer / Packagist entry
crates:sui-move-build-helper:0.1.0      # Crates.io / Cargo entry
go:github.com/verana-labs/verana-blockchain:v0.10.1-dev.20  # Go module (version keeps its leading "v")
hex:some_pkg:1.2.3                      # Elixir / Hex.pm entry
gem:some_gem:1.2.3                      # RubyGems entry

For campaigns where every version of a package is malicious (e.g. TrapDoor, Laravel-Lang's tag rewrite), per-version entries can't keep up — detection is done version-agnostically by a dedicated check_*_indicators function that name-matches the dependency in any manifest.

To add new packages from a fresh advisory: append entries in that format, run ./run-tests.sh, open a PR. Source the additions from a reputable security firm (Socket, StepSecurity, Aikido, Snyk, JFrog, Wiz, Semgrep, SafeDep, GitGuardian, OX Security) and cite them.

Testing

./run-tests.sh                          # full suite, 296 checks
./shai-hulud-detector.sh test-cases/<fixture-name>   # run one fixture manually

Each subdirectory of test-cases/ is a self-contained fixture (clean projects, infected projects with the various attack signatures, ecosystem-specific tests, paired clean/dirty pairs). Run ls test-cases/ to see them all.

Contributing

PRs welcome — especially new compromised-package entries as fresh waves are disclosed.

  1. Fork, branch, edit.
  2. For new packages: append to compromised-packages.txt, add a fixture under test-cases/, register it in run-tests.sh.
  3. For new content-pattern IoCs: extend the relevant check_* function in shai-hulud-detector.sh, add an assertion in run-tests.sh.
  4. ./run-tests.sh must pass.
  5. Cite your sources (security advisories, vendor blog posts) in the PR description.

Don't include actual malware in test fixtures — inert string constants and synthetic files only.

Security note

Detection only. The script does not remove malicious code, downgrade packages, or block installs. Verify findings, then remediate manually.

For the dead-man's-switch waves (May 11 + May 19, 2026), follow the remediation order printed by --check-host: stop the persistence service first, delete its files, then rotate credentials. Revoking a monitored token before stopping the service is designed to trigger a destructive wipe.

References

For per-wave source advisories with IoC enumerations, see the ### Security section of each release entry in CHANGELOG.md. The original Shai-Hulud disclosures live at:

Sources we monitor for new waves

When a new wave lands, check these first. Primary research teams publish the per-package version lists, file hashes, and C2 IoCs we add to this repo — always source additions from one of these and cite it in the PR. News aggregators are where a wave usually surfaces first; use them to learn a wave exists, then pull the actual IoCs from a primary source.

Primary threat-research teams (IoC enumerations):

News aggregators (early awareness):

License

MIT — see LICENSE.

Cobenian/shai-hulud-detect

A simple project to detect the Shai-Hulud npm supply chain attack

Shell

410

268 commits

updated Oct 12, 2026

See the code

README

Shai-Hulud Supply Chain Attack Detector

License Shell Status Tests Packages Type Contributions Last Commit

sshd

A Bash script that scans a project — or many projects at once — for known traces of the September 2025 → October 2026 npm, PyPI, Composer, Crates, Go, Hex, and RubyGems supply-chain attacks. Cross-checks 5,791 confirmed bad package versions and a library of content-pattern IoCs (file hashes, C2 domains, dead-man's-switch artifacts, wipe-threat strings, AI-assistant config droppers, etc.).

Quick Start

git clone https://github.com/Cobenian/shai-hulud-detect
cd shai-hulud-detect
chmod +x shai-hulud-detector.sh

# Scan one project
./shai-hulud-detector.sh /path/to/your/project

# Scan every project under one or more parent dirs and get one aggregate report
./shai-hulud-detector.sh --bulk ~/dev ~/work

# Save findings to a file (same format as CI-friendly logs)
./shai-hulud-detector.sh --save-log report.log /path/to/project

Exit codes (drop straight into CI): 0 clean · 1 high-risk · 2 medium-risk · 3 per-project scan errored (bulk mode only).

What it catches

The detector looks for two kinds of evidence on disk:

  1. Compromised package versions — every package.json, lockfile, pyproject.toml, requirements.txt, Pipfile, poetry.lock, uv.lock, etc. is parsed and the resolved versions are checked against the 5,791-entry list in compromised-packages.txt. Transitive deps inside node_modules/ are checked too, not skipped.
  2. Content-pattern IoCs — known-malicious file hashes, payload filenames, C2 domains, dead-man's-switch artifacts, marker repo names, malicious workflow files, forged orphan-commit references, suspicious lifecycle hooks, and threat-actor publisher fingerprints. These don't depend on the package list and fire even if the bad package has been uninstalled but the dropper traces remain.
WaveDateScope
Chalk/Debug crypto theft2025-09-0818+ packages, ~2B weekly downloads
Shai-Hulud worm2025-09-14517+ packages (@ctrl, @crowdstrike, …)
Shai-Hulud "Second Coming" (fake Bun)2025-11-241,100+ packages
Golden Path variant2025-12-28renamed Bun-attack files
SANDWORM_MODE workflow poisoning2026-02-1719 packages + GitHub Action
Axios RAT compromise2026-03-31[email protected]/0.30.4 + plain-crypto-js
Mini Shai-Hulud / TanStack2026-05-11400+ versions, dead-man's-switch
Megalodon (GitHub-repo backdooring)2026-05-185,561 repos via stolen PATs; @tiledesk/[email protected] as npm fallout
Mini Shai-Hulud / AntV (atool)2026-05-19643 versions, 323 packages
Web3 / DeFi MCP-server typosquat2026-05-2010 packages (chain-key-validator, defi-threat-scanner, …), exfiltrates SSH + wallet keys
Polymarket wallet drainer2026-05-219 packages from polymarketdev (polymarket-bot, polymarket-trader, …), fake wallet-onboarding prompt captures private keys
Bitwarden CLI ("Third Coming")2026-04-22@bitwarden/[email protected] via Checkmarx ast-github-action breach; bw1.js exfil to audit.checkmarx.cx
node-ipc backdoor2026-05-14[email protected]/9.2.3/12.0.1; IIFE in node-ipc.cjs, DNS exfil to sh.azurestaticprovider.net
Nx Console VS Code ext2026-05-18[email protected]; payload from orphan commit 558b09d7 in nrwl/nx, targets ~/.claude/settings.json (TeamPCP / GitHub breach)
TrapDoor (TeamPCP)2026-05-22→2534 packages / 384+ versions across npm + PyPI + Crates; plants .cursorrules/CLAUDE.md AI-assistant droppers
Laravel-Lang tag-rewrite2026-05-22700+ Composer tags force-rewritten (laravel-lang/lang, …); RCE on autoload, DebugElevator stealer
mouse5212 "Malware-Slop"2026-05-26mouse5212-super-formatter exfils Claude's /mnt/user-data via embedded GitHub PAT (unplowed3584)
art-template npm hijack2025-03 → 2026-054 versions ([email protected]), iOS browser exploit kit (UNC6691)
sl4x0 dependency confusion2025-06 → 2026-0392+ packages across 32 *poc accounts, DNS exfil to oob.sl4x0.xyz (likely security research)
durabletask PyPI worm2026-05-19pypi:durabletask:1.4.1-1.4.3, multi-cloud credential stealer + AWS SSM / k8s lateral movement
PyPI cross-spread (TeamPCP)2026-03 → 05litellm, telnyx, xinference, lightning, mistralai, guardrails-ai
Miasma "Phantom Gyp" worm2026-06-0357 packages / 286 versions (@vapi-ai/server-sdk, ai-sdk-ollama, autotel-*, awaitly-*, executable-stories-*); novel binding.gyp command-substitution trigger bypasses preinstall-script monitors
Miasma "Hades" PyPI wave2026-06-0719 PyPI packages / 37 versions (bramin, magique-ai, pantheon-agents, executor-engine, ufish); novel *-setup.pth Python startup-hook execs an _index.js loader on plain import, no install hook needed; C2 camouflaged under api.anthropic.com
IronWorm ("rustier cousin")2026-06-0337 npm packages from the asteroiddao account (weavedb-*, arnext, cwao, wao, zkjson); Rust infostealer + eBPF rootkit via preinstall ELF hook, Exodus-wallet theft, leaked operator wallet 0x7e28…a4d6
easy-day-js / Mastra AI2026-06-17141 @mastra/* packages + mastra/create-mastra republished with an injected easy-day-js (dayjs typosquat) dependency; postinstall dropper disables TLS, pulls a cross-platform infostealer from C2 23.254.164.92/.123; North-Korea-attributed (Sapphire Sleet / BlueNoroff)
Miasma LeoPlatform / RStreams2026-06-2523 npm packages (leo-*, rstreams-*, serverless-leo, …); Miasma worm reaches the AWS data-streaming ecosystem via the binding.gyp trigger + editor/CI auto-run hooks; new markers RevokeAndItGoesKaboom, thebeautifulmarchoftime
Shai-Hulud "Here We Go Again" (keyv / cacheable) — also tracked as ChainDrop (Microsoft, Elastic)2026-08-04Largest wave by install volume: 444 npm packages / 2,236 versions across 15 scopes, plus 3 Go modules / 82 versions, >2B monthly installs (keyv, flat-cache, file-entry-cache, cacheable-request, cacheable, cache-manager, @servicetitan/*, @ornikar/*, @onereach/*, @or-sdk/*, @qlik/*, @nebula.js/*, @umacloud/*); "preinstall": "node setup.mjs" bootstraps Bun 1.3.13 → 727KB Math_Symbol.js; primary exfil is to GitHub dead-drop repos described Shai-Hulud: Here We Go Again, with no hard-coded C2 — the payload reads its live domain from Ethereum contract 0xE1f2395e… at run time, so hosts rotate without republishing (npm-cache[.]com → awqhnjewqjkl[.]icu on Aug 4); poisoned releases carry valid npm OIDC provenance / SLSA attestation
Mini Shai-Hulud resurfacing2026-09-074 npm packages / 4 versions (blueai-cli, bmc-i18n-extract-cli, bmc-translate-utils, feishu-docx-mcp), all published by one account within an hour; the byte-identical May 19 @antv payload (index.js, hash in MALICIOUS_HASHLIST) via "preinstall": "bun run index.js", 111 days after it was last seen
"supplychain.local" Go-binary worm2026-09-23A new family, not Shai-Hulud code: @memtensor/[email protected]/0.1.23/0.1.25 (the even versions in between are clean maintainer republishes) and pypi:[email protected]. A multiplatform Go sckit binary (6 hashes in MALICIOUS_HASHLIST) steals tokens and republishes through npm, PyPI and a planted GitHub Actions workflow. It runs on invocation, not at install
tensorlake (ChainDrop family)2026-10-08[email protected], published by the project's own release workflow with provenance after an attacker commit to main; "preinstall": "node lib/setup.mjs" → Bun → lib/Math_Symbol.js (both hashed). Same payload family as the keyv wave but new infrastructure: C2 iseekaigogo[.]com, rotation contract 0xb614155F…, operator wallet 0x779f83aE…, and the WORMTAG infection-tag variable, all matched

For per-wave IoC inventories, payload hashes, source advisories, and version-by-version lists, see CHANGELOG.md.

⚠️ Dead-man's-switch warning

Two waves (May 11 and May 19, 2026) install a persistence daemon (gh-token-monitor / kitty-monitor) that wipes the host if its monitored GitHub token is revoked. Run with --check-host to detect the persistence artifacts. If they're present, stop and remove the service BEFORE rotating any tokens. The console summary and aggregate report both print a safe remediation order.

Ecosystems

EcosystemAuto-detected viaStatus
npmpackage.json, package-lock.json, yarn.lock, pnpm-lock.yamlFull support
PyPIpyproject.toml, requirements*.txt, Pipfile, Pipfile.lock, poetry.lock, uv.lock, setup.py, setup.cfgFull support (pure-bash awk parsers, no Python required)
Composercomposer.json, composer.lockFull support (PHP / Packagist; added for the Laravel-Lang wave)
CratesCargo.toml, Cargo.lockFull support (Rust / crates.io; added for the TrapDoor wave)
Gogo.mod, go.sumFull support (Go modules; added for the Miasma LeoPlatform wave. Versions keep their canonical leading v)
Hexmix.exs, mix.lockFull support (Elixir / Hex.pm; mix.lock is authoritative, mix.exs requirements are best-effort)
GemGemfile, Gemfile.lockFull support (RubyGems / Bundler; Gemfile.lock specs are authoritative, Gemfile requirements are best-effort)

Auto-detection looks for marker files in your tree, skipping node_modules/, vendor/, .venv/, venv/, .tox/, site-packages/, dist/, build/, and similar trees. That exclusion only decides which checks to run — content inside node_modules/ is still fully scanned for compromised versions and malware indicators. Override auto-detection with --ecosystem=npm, --ecosystem=pypi, --ecosystem=all, or a comma-separated list.

CI/CD

- name: Shai-Hulud scan
  run: |
    chmod +x ./shai-hulud-detector.sh
    ./shai-hulud-detector.sh --save-log shai-hulud-report.log .
  # Job fails on exit 1 (high-risk) or 2 (medium-risk)
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: shai-hulud-report
    path: shai-hulud-report.log

For GitLab CI, Jenkins, and a custom exit-code handler in shell, see docs/ci-examples.md — or just call the script and switch on $?, the exit-code contract is the same everywhere.

Common scenarios

Scan many projects at once (--bulk)

./shai-hulud-detector.sh --bulk ~/dev ~/work                 # auto-discover all projects
./shai-hulud-detector.sh --bulk --paranoid --bulk-output ./audit ~/dev
./shai-hulud-detector.sh --bulk --bulk-list ~/dev            # dry-run: print what would be scanned

Project discovery is content-aware: a directory with .git, a package.json, a pyproject.toml, Cargo.toml, go.mod, etc. is one scan unit (monorepos stay whole). "Bucket" folders like ~/dev/clients/<client>/<project> are descended into. node_modules, vendor, dist, build dirs, and hidden dirs are never entered. The detector's own repo is skipped automatically. Unreadable directories are reported instead of silently skipped.

Output goes to ./shai-hulud-bulk-report-<timestamp>/ (or --bulk-output DIR):

shai-hulud-bulk-report-<timestamp>/
├── aggregate-report.md      # summary tables + per-project results + remediation
└── per-repo/
    ├── <project>.findings.log    # severity-grouped file paths
    └── <project>.console.txt     # full scan output, ANSI-stripped

Paranoid mode (--paranoid)

Adds typosquatting detection and network-exfiltration heuristics on top of the core checks. These are general-purpose security signals, not Shai-Hulud-specific, and produce more false positives — useful for audits, not recommended for CI gating.

Host-level persistence scan (--check-host)

Walks $HOME for the May 2026 dead-man's-switch artifacts (gh-token-monitor, kitty-monitor). Off by default. See the warning above before remediating any findings.

Save findings to a log file (--save-log FILE)

Writes flagged file paths grouped by severity, in a format friendly to grep and CI artifact uploads:

# HIGH
/path/to/router_init.js
/path/to/package.json
# MEDIUM
/path/to/suspicious-content.js
# LOW
/path/to/namespace-warning.json

Machine-readable JSON output (--json FILE)

For CI gates and tooling that consume findings as data (rather than parsing console text), --json writes a structured report. It uses the same severity tiers as --save-log but keeps the reason for each finding (which --save-log drops):

./shai-hulud-detector.sh --json report.json /path/to/project
{
  "schema_version": "1.0",
  "tool": "shai-hulud-detector",
  "tool_version": "3.9.0",
  "generated_at": "2026-06-26T00:00:00Z",
  "scan_path": "/path/to/project",
  "summary": { "high": 7, "medium": 0, "low": 0 },
  "risk_level": "high",
  "findings": [
    { "severity": "HIGH", "file": "package.json", "line": 6, "message": "[email protected]" }
  ]
}

line is a best-effort pointer at the dependency line in the manifest for package-shaped findings (name@version, @scope/name@version); it is null for prose findings or when no match is found. The exit-code contract (0/1/2) is unchanged and remains the authoritative CI signal — --json and --save-log can be combined.

--json is the only mode that requires jq; it fails fast with a clear message if jq is missing. The default text output keeps the tool's zero-runtime-dependency guarantee.

Other flags

FlagEffect
--json FILEWrite findings as structured JSON (severity/file/line/message + summary). Requires jq.
--check-semver-rangesFlag ^/~ ranges that could resolve to compromised versions (informational, LOW risk).
--ecosystem LISTRestrict checks to npm, pypi, all, or a comma-separated list. Default: auto-detect.
--parallelism NThreads for parallelisable steps. Defaults to your CPU count.
--use-git-grep / --use-ripgrep / --use-grepForce a specific grep tool. Default: auto-select fastest available.
--bulk-depth NDepth cap for bulk discovery (default 3). --bulk-depth 1 = flat.
--bulk-listWith --bulk: print what would be scanned and exit.
--bulk-output DIRWhere to write the bulk report (default ./shai-hulud-bulk-report-<timestamp>/).

How it works

  1. Collect the file inventory (one find pass, categorized by extension).
  2. Detect ecosystems from marker files, decide which package-level checks to run.
  3. Match every resolved package version against compromised-packages.txt via a sorted set-intersection (comm -12).
  4. Hash priority files (bundle.js, setup_bun.js, router_init.js, tanstack_runner.js, cat.py, node-ipc.cjs, etc.) and compare against 20 known-malicious SHA-256s.
  5. Grep for content-pattern IoCs: C2 domains, threat-actor accounts, dead-man's-switch service names, wipe-threat strings, malicious commit SHAs, beacon strings, payload filenames, orphan-commit optionalDependencies patterns.
  6. (Opt-in) scan $HOME for persistence artifacts (--check-host); run typosquatting + network-exfil heuristics (--paranoid); flag latent semver-range risk (--check-semver-ranges).
  7. Report in three severity tiers (HIGH/MEDIUM/LOW), with remediation order for the safety-critical findings.

Detection is read-only. The script never modifies, deletes, or quarantines anything — manual review and remediation are on you.

Output

✅ No indicators of Shai-Hulud compromise detected.   ← clean, exit 0
🚨 HIGH RISK: ...                                     ← exit 1, immediate action
⚠️  MEDIUM RISK: ...                                  ← exit 2, manual review

HIGH: definitive indicators — compromised package version, known-malicious file hash, malicious workflow, dead-man's-switch artifact. Stop and remediate.

MEDIUM: suspicious patterns that warrant a look — semver range that could match a compromised version, references to webhook redirector domains, suspicious git branches.

LOW: informational notes — namespace warnings (packages from affected namespaces at safe versions), legitimate-looking patterns that share shape with attack techniques.

Requirements

  • Bash 5.0+ (associative arrays, mapfile). macOS ships 3.x; install with brew install bash and invoke via /opt/homebrew/bin/bash.
  • Standard Unix tools: find, grep, awk, sed, sort, comm, shasum (or sha256sum), xargs. All POSIX-portable.
  • Tested on macOS (Bash 5), Linux, and Git Bash for Windows.

The script auto-selects the fastest available grep tool (git grep > ripgrep > grep). No runtime dependencies on Python, Node, or anything else.

Limitations

  • Hash detection only catches exact SHA-256 matches against the 20 known-malicious hashes.
  • Compromised-package detection requires the version to be in compromised-packages.txt — new variants need a list update.
  • Paranoid-mode heuristics produce false positives on legitimate code.
  • The detector reads filesystem state; it doesn't query npm/PyPI registries for live data.

Updating the compromised-packages list

compromised-packages.txt is a flat text file:

# Comments start with #
axios:1.14.1                            # bare entry = npm (back-compat)
npm:@tanstack/react-router:1.169.5      # explicit npm prefix
pypi:mistralai:2.4.6                    # PyPI entry
composer:laravel-lang/lang:15.29.5      # Composer / Packagist entry
crates:sui-move-build-helper:0.1.0      # Crates.io / Cargo entry
go:github.com/verana-labs/verana-blockchain:v0.10.1-dev.20  # Go module (version keeps its leading "v")
hex:some_pkg:1.2.3                      # Elixir / Hex.pm entry
gem:some_gem:1.2.3                      # RubyGems entry

For campaigns where every version of a package is malicious (e.g. TrapDoor, Laravel-Lang's tag rewrite), per-version entries can't keep up — detection is done version-agnostically by a dedicated check_*_indicators function that name-matches the dependency in any manifest.

To add new packages from a fresh advisory: append entries in that format, run ./run-tests.sh, open a PR. Source the additions from a reputable security firm (Socket, StepSecurity, Aikido, Snyk, JFrog, Wiz, Semgrep, SafeDep, GitGuardian, OX Security) and cite them.

Testing

./run-tests.sh                          # full suite, 296 checks
./shai-hulud-detector.sh test-cases/<fixture-name>   # run one fixture manually

Each subdirectory of test-cases/ is a self-contained fixture (clean projects, infected projects with the various attack signatures, ecosystem-specific tests, paired clean/dirty pairs). Run ls test-cases/ to see them all.

Contributing

PRs welcome — especially new compromised-package entries as fresh waves are disclosed.

  1. Fork, branch, edit.
  2. For new packages: append to compromised-packages.txt, add a fixture under test-cases/, register it in run-tests.sh.
  3. For new content-pattern IoCs: extend the relevant check_* function in shai-hulud-detector.sh, add an assertion in run-tests.sh.
  4. ./run-tests.sh must pass.
  5. Cite your sources (security advisories, vendor blog posts) in the PR description.

Don't include actual malware in test fixtures — inert string constants and synthetic files only.

Security note

Detection only. The script does not remove malicious code, downgrade packages, or block installs. Verify findings, then remediate manually.

For the dead-man's-switch waves (May 11 + May 19, 2026), follow the remediation order printed by --check-host: stop the persistence service first, delete its files, then rotate credentials. Revoking a monitored token before stopping the service is designed to trigger a destructive wipe.

References

For per-wave source advisories with IoC enumerations, see the ### Security section of each release entry in CHANGELOG.md. The original Shai-Hulud disclosures live at:

Sources we monitor for new waves

When a new wave lands, check these first. Primary research teams publish the per-package version lists, file hashes, and C2 IoCs we add to this repo — always source additions from one of these and cite it in the PR. News aggregators are where a wave usually surfaces first; use them to learn a wave exists, then pull the actual IoCs from a primary source.

Primary threat-research teams (IoC enumerations):

News aggregators (early awareness):

License

MIT — see LICENSE.

Significant stargazers

Tim Kevin Oxley

1,396 followers · starred Nov 2025

Aileen Booker

113 followers · starred Nov 2025

John Vajda

65 followers · starred Nov 2025

Rick Staa

509 followers · starred Nov 2025