Created
May 12, 2026 11:01
-
-
Save abien/a3f54cc2a4d5fb4d0b036b1e5a96baa5 to your computer and use it in GitHub Desktop.
Read-only Shai-Hulud / Mini Shai-Hulud IOC scanner adapted for Linux/WSL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -u | |
| # Read-only Shai-Hulud / Mini Shai-Hulud IOC scanner for Linux/WSL. | |
| # Adapted from Manuel Kiessling's original macOS scanner: | |
| # https://gist.githubusercontent.com/manuelkiessling/9b3dd90bf673a618630f69a2a334a0c4/raw/0392880be3d9c0a27bef0e76bf4037ea4fc5530a/mini-shai-hulud-ioc-scan-macos.sh | |
| # Exits 1 when confirmed/suspicious IOCs are found, 0 otherwise. | |
| RED=$'\033[0;31m'; GRN=$'\033[0;32m'; YLW=$'\033[0;33m'; BLD=$'\033[1m'; RST=$'\033[0m' | |
| HITS=0 | |
| WARNS=0 | |
| hit() { printf '%s%s[HIT]%s %s\n' "$RED" "$BLD" "$RST" "$*"; HITS=$((HITS+1)); } | |
| warn() { printf '%s[WARN]%s %s\n' "$YLW" "$RST" "$*"; WARNS=$((WARNS+1)); } | |
| ok() { printf '%s[ OK ]%s %s\n' "$GRN" "$RST" "$*"; } | |
| hdr() { printf '\n%s=== %s ===%s\n' "$BLD" "$*" "$RST"; } | |
| have() { command -v "$1" >/dev/null 2>&1; } | |
| sha256_file() { | |
| if have sha256sum; then | |
| sha256sum "$1" 2>/dev/null | awk '{print $1}' | |
| elif have shasum; then | |
| shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' | |
| else | |
| return 1 | |
| fi | |
| } | |
| contains_word() { | |
| local needle=$1 haystack=$2 word | |
| for word in $haystack; do | |
| [[ "$word" == "$needle" ]] && return 0 | |
| done | |
| return 1 | |
| } | |
| SCAN_ROOTS=() | |
| add_root() { | |
| local root=$1 | |
| [[ -n "$root" && -d "$root" ]] || return 0 | |
| local existing | |
| for existing in "${SCAN_ROOTS[@]}"; do | |
| [[ "$existing" == "$root" ]] && return 0 | |
| done | |
| SCAN_ROOTS+=("$root") | |
| } | |
| add_root "$HOME" | |
| add_root /tmp | |
| add_root /var/tmp | |
| if [[ "${SCAN_WINDOWS:-0}" == "1" && -d /mnt/c/Users ]]; then | |
| while IFS= read -r user_home; do | |
| add_root "$user_home" | |
| done < <(find /mnt/c/Users -mindepth 1 -maxdepth 1 -type d 2>/dev/null) | |
| fi | |
| if have npm; then | |
| npm_root=$(npm root -g 2>/dev/null || true) | |
| [[ -n "$npm_root" ]] && add_root "$npm_root" | |
| fi | |
| if [[ -n "${EXTRA_SCAN_ROOTS:-}" ]]; then | |
| IFS=: read -r -a extra_roots <<< "$EXTRA_SCAN_ROOTS" | |
| for root in "${extra_roots[@]}"; do | |
| add_root "$root" | |
| done | |
| fi | |
| FIND_ARGS=() | |
| for root in "${SCAN_ROOTS[@]}"; do | |
| FIND_ARGS+=("$root") | |
| done | |
| # Mini Shai-Hulud / TanStack wave hashes from the macOS gist. | |
| BAD_HASHES=( | |
| ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c | |
| 2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96 | |
| 2258284d65f63829bd67eaba01ef6f1ada2f593f9bbe41678b2df360bd90d3df | |
| a3894003ad1d293ba96d77881ccd2071c264ca7ac40c4c55d9bda0545de14d | |
| ) | |
| # Known-bad package versions from the macOS gist. This is intentionally small; | |
| # package lists move quickly, so payload/workflow IOCs matter more here. | |
| declare -A BAD_NPM_VERS=( | |
| ["@tanstack/react-router"]="1.169.5 1.169.8" | |
| ["@mistralai/mistralai"]="2.2.2 2.2.3 2.2.4" | |
| ) | |
| declare -A BAD_PY_VERS=( | |
| ["guardrails-ai"]="0.10.1" | |
| ["mistralai"]="2.4.6" | |
| ) | |
| C2_RE='83\.142\.209\.194|git-tanstack\.com|getsession\.org|webhook\.site' | |
| TEXT_IOC_RE='git-tanstack|getsession\.org|gh-token-monitor|83\.142\.209\.194|SHA1HULUD|Sha1-Hulud|Shai-Hulud|webhook\.site' | |
| printf '%sMini Shai-Hulud IOC scanner for Linux/WSL%s\n' "$BLD" "$RST" | |
| printf 'Scan roots:\n' | |
| printf ' %s\n' "${SCAN_ROOTS[@]}" | |
| hdr '1. Linux/WSL persistence' | |
| found_persistence=false | |
| for path in \ | |
| "$HOME/.config/systemd/user/gh-token-monitor.service" \ | |
| "$HOME/.config/systemd/user/com.user.gh-token-monitor.service" \ | |
| "$HOME/.config/systemd/user/SHA1HULUD.service" \ | |
| "$HOME/.dev-env/run.sh" \ | |
| "$HOME/.dev-env/config.sh" \ | |
| "$HOME/Library/LaunchAgents/com.user.gh-token-monitor.plist"; do | |
| if [[ -e "$path" ]]; then | |
| hit "Suspicious persistence artifact: $path" | |
| found_persistence=true | |
| fi | |
| done | |
| if [[ -d "$HOME/.dev-env" ]]; then | |
| hit "Suspicious GitHub runner directory: $HOME/.dev-env" | |
| found_persistence=true | |
| fi | |
| if have systemctl; then | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| hit "Suspicious user systemd unit: $line" | |
| found_persistence=true | |
| done < <(systemctl --user list-unit-files --no-pager --no-legend 2>/dev/null | grep -Ei 'gh-token-monitor|SHA1HULUD|shai-hulud' || true) | |
| fi | |
| if have crontab; then | |
| cron=$(crontab -l 2>/dev/null || true) | |
| if grep -Eiq 'gh-token-monitor|SHA1HULUD|shai-hulud|setup_bun|bun_environment|router_init|getsession|git-tanstack' <<< "$cron"; then | |
| hit 'Suspicious IOC string found in user crontab' | |
| grep -Ein 'gh-token-monitor|SHA1HULUD|shai-hulud|setup_bun|bun_environment|router_init|getsession|git-tanstack' <<< "$cron" || true | |
| found_persistence=true | |
| fi | |
| fi | |
| $found_persistence || ok 'No obvious Linux/WSL persistence artifacts found' | |
| hdr '2. Processes' | |
| process_hit=false | |
| if have pgrep; then | |
| for pattern in gh-token-monitor SHA1HULUD setup_bun bun_environment router_init 'Runner.Listener' trufflehog; do | |
| if pids=$(pgrep -f "$pattern" 2>/dev/null); then | |
| hit "Process matching '$pattern' running: $pids" | |
| process_hit=true | |
| fi | |
| done | |
| else | |
| warn 'pgrep unavailable; skipping process checks' | |
| fi | |
| $process_hit || ok 'No known suspicious process names found' | |
| hdr '3. Network / C2 connections' | |
| network_hit=false | |
| if have ss; then | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| hit "Active known/suspicious connection: $line" | |
| network_hit=true | |
| done < <(ss -tunap 2>/dev/null | grep -E "$C2_RE" || true) | |
| elif have lsof; then | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| hit "Active known/suspicious connection: $line" | |
| network_hit=true | |
| done < <(lsof -nP -i 2>/dev/null | grep -E "$C2_RE" || true) | |
| else | |
| warn 'Neither ss nor lsof available; skipping active connection checks' | |
| fi | |
| $network_hit || ok 'No active connections to known C2 strings found' | |
| hdr '4. Payload and exfiltration files' | |
| payload_found=false | |
| while IFS= read -r f; do | |
| [[ -e "$f" ]] || continue | |
| h=$(sha256_file "$f" || true) | |
| confirmed=false | |
| if [[ -n "$h" ]]; then | |
| for bad in "${BAD_HASHES[@]}"; do | |
| if [[ "$h" == "$bad" ]]; then | |
| payload_found=true | |
| hit "Hash-confirmed malicious payload: $f" | |
| confirmed=true | |
| break | |
| fi | |
| done | |
| fi | |
| if ! $confirmed; then | |
| case "$(basename "$f")" in | |
| actionsSecrets.json|truffleSecrets.json|cloud.json|contents.json|environment.json|trufflehog_output.json) | |
| payload_found=true | |
| hit "Likely exfiltration artifact: $f${h:+ [sha256=$h]}" | |
| ;; | |
| setup_bun.js|bun_environment.js|router_init.js|setup.mjs|bun_installer.js|environment_source.js) | |
| payload_found=true | |
| warn "Suspicious payload filename: $f${h:+ [sha256=$h]}" | |
| ;; | |
| bundle.js) | |
| if grep -Eiq 'SHA1HULUD|shai-hulud|setup_bun|bun_environment|webhook\.site|toJSON\(secrets\)|RUNNER_TRACKING_ID|getsession|git-tanstack' "$f" 2>/dev/null; then | |
| payload_found=true | |
| warn "Suspicious bundle.js content: $f${h:+ [sha256=$h]}" | |
| fi | |
| ;; | |
| *) | |
| payload_found=true | |
| warn "Suspicious IOC file: $f${h:+ [sha256=$h]}" | |
| ;; | |
| esac | |
| fi | |
| done < <(find "${FIND_ARGS[@]}" -xdev -maxdepth "${MAX_DEPTH:-8}" \( \ | |
| -name router_init.js -o \ | |
| -name setup.mjs -o \ | |
| -name setup_bun.js -o \ | |
| -name bun_environment.js -o \ | |
| -name bundle.js -o \ | |
| -name bun_installer.js -o \ | |
| -name environment_source.js -o \ | |
| -name actionsSecrets.json -o \ | |
| -name truffleSecrets.json -o \ | |
| -name cloud.json -o \ | |
| -name contents.json -o \ | |
| -name environment.json -o \ | |
| -name trufflehog_output.json \ | |
| \) 2>/dev/null) | |
| $payload_found || ok 'No known payload/exfiltration filenames found' | |
| hdr '5. GitHub workflow IOCs' | |
| workflow_found=false | |
| while IFS= read -r f; do | |
| [[ -e "$f" ]] || continue | |
| workflow_found=true | |
| if grep -Eiq 'SHA1HULUD|shai-hulud|setup_bun|bun_environment|webhook\.site|toJSON\(secrets\)|RUNNER_TRACKING_ID' "$f" 2>/dev/null; then | |
| hit "Suspicious GitHub workflow content: $f" | |
| else | |
| warn "Suspicious workflow filename; verify manually: $f" | |
| fi | |
| done < <(find "${FIND_ARGS[@]}" -xdev -maxdepth "${MAX_DEPTH:-8}" -path '*/.github/workflows/*' \( \ | |
| -iname 'discussion.yml' -o \ | |
| -iname 'discussion.yaml' -o \ | |
| -iname 'formatter_*.yml' -o \ | |
| -iname 'formatter_*.yaml' -o \ | |
| -iname 'shai-hulud*.yml' -o \ | |
| -iname 'shai-hulud*.yaml' \ | |
| \) 2>/dev/null) | |
| $workflow_found || ok 'No suspicious GitHub workflow filenames found' | |
| hdr '6. npm packages and lifecycle hooks' | |
| npm_hit=false | |
| while IFS= read -r pkg; do | |
| [[ -f "$pkg" ]] || continue | |
| name=$(node -e 'const p=require(process.argv[1]); process.stdout.write(String(p.name||""))' "$pkg" 2>/dev/null || true) | |
| version=$(node -e 'const p=require(process.argv[1]); process.stdout.write(String(p.version||""))' "$pkg" 2>/dev/null || true) | |
| scripts=$(node -e 'const p=require(process.argv[1]); process.stdout.write(JSON.stringify(p.scripts||{}))' "$pkg" 2>/dev/null || true) | |
| if [[ -n "$name" && -n "${BAD_NPM_VERS[$name]:-}" ]] && contains_word "$version" "${BAD_NPM_VERS[$name]}"; then | |
| hit "Known-bad npm package version: $name@$version ($pkg)" | |
| npm_hit=true | |
| fi | |
| if grep -Eiq '"(preinstall|postinstall)"[[:space:]]*:[[:space:]]*"[^"]*(setup_bun|bun_environment|router_init|bundle\.js|curl|wget|webhook\.site|getsession|git-tanstack)' "$pkg" 2>/dev/null; then | |
| warn "Suspicious npm lifecycle hook in: $pkg scripts=$scripts" | |
| npm_hit=true | |
| fi | |
| done < <(find "${FIND_ARGS[@]}" -xdev -maxdepth "${MAX_DEPTH:-8}" -name package.json 2>/dev/null) | |
| $npm_hit || ok 'No known-bad npm versions or suspicious lifecycle hooks found' | |
| hdr '7. Python package versions' | |
| py_hit=false | |
| if have python3; then | |
| for pkg in "${!BAD_PY_VERS[@]}"; do | |
| version=$(python3 -m pip show "$pkg" 2>/dev/null | awk -F': ' '/^Version:/ {print $2; exit}' || true) | |
| if [[ -n "$version" ]] && contains_word "$version" "${BAD_PY_VERS[$pkg]}"; then | |
| hit "Known-bad Python package version: $pkg==$version" | |
| py_hit=true | |
| fi | |
| done | |
| else | |
| warn 'python3 unavailable; skipping Python package checks' | |
| fi | |
| $py_hit || ok 'No known-bad Python package versions found in default python3 environment' | |
| hdr '8. Config and text IOC sweep' | |
| text_hit=false | |
| TEXT_TARGETS=() | |
| for path in "$HOME/.claude" "$HOME/.vscode" "$HOME/.npmrc" "$HOME/.env" "$HOME/.gitconfig" "$HOME/.config/gh" "$HOME/.dev-env"; do | |
| [[ -e "$path" ]] && TEXT_TARGETS+=("$path") | |
| done | |
| if ((${#TEXT_TARGETS[@]} > 0)); then | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| hit "IOC string in config/text file: $line" | |
| text_hit=true | |
| done < <(grep -RInE --exclude-dir=transcripts --exclude-dir=logs --exclude-dir=cache --exclude=*.jsonl "$TEXT_IOC_RE" "${TEXT_TARGETS[@]}" 2>/dev/null || true) | |
| else | |
| ok 'No standard config paths present for text sweep' | |
| fi | |
| $text_hit || ok 'No IOC strings found in standard config paths' | |
| hdr 'Summary' | |
| if ((HITS > 0)); then | |
| printf '%s%s%d confirmed/high-confidence hit(s). Treat this WSL environment as potentially compromised.%s\n' "$RED" "$BLD" "$HITS" "$RST" | |
| printf 'Immediate response: disconnect sensitive tokens, revoke GitHub/npm/cloud credentials, inspect GitHub repos/runners, then rebuild/clean.\n' | |
| exit 1 | |
| fi | |
| if ((WARNS > 0)); then | |
| printf '%s%d suspicious warning(s). Review manually; warnings are not always compromise.%s\n' "$YLW" "$WARNS" "$RST" | |
| exit 0 | |
| fi | |
| ok 'No Shai-Hulud / Mini Shai-Hulud IOCs found by this scanner' | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment