Skip to content

Instantly share code, notes, and snippets.

@abien
Created May 12, 2026 11:01
Show Gist options
  • Select an option

  • Save abien/a3f54cc2a4d5fb4d0b036b1e5a96baa5 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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