Last active
November 26, 2025 21:28
-
-
Save ericboehs/dfb10ea79a4083f79930d8f467a50846 to your computer and use it in GitHub Desktop.
Shai-Hulud 2.0 Full Machine Scanner - Scans for npm supply chain attack indicators
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
| #!/bin/bash | |
| # | |
| # Shai-Hulud 2.0 Full Machine Scanner | |
| # ==================================== | |
| # | |
| # Scans your machine for signs of the Shai-Hulud npm supply chain attack | |
| # (November 2025). This attack compromised 796+ npm packages affecting | |
| # 20+ million weekly downloads. | |
| # | |
| # WHAT THIS SCRIPT DOES: | |
| # | |
| # Phase 1 - Quick Critical Checks (fast, excludes node_modules): | |
| # - Malware files: setup_bun.js, bun_environment.js | |
| # - Backdoor workflows: .github/workflows/discussion.yaml | |
| # - Exfiltration artifacts: cloud.json, truffleSecrets.json | |
| # - Unexpected Bun runtime installation | |
| # - Unexpected Trufflehog installation | |
| # | |
| # Phase 2 - Find Node.js Projects: | |
| # - Searches for package.json files | |
| # - Excludes node_modules, .git, .cache | |
| # | |
| # Phase 3 - Full Project Scans (checks node_modules via lockfiles): | |
| # - Runs shai-hulud-detector on each project | |
| # - Checks lockfiles (package-lock.json, yarn.lock) for 1600+ | |
| # compromised package versions - this catches malware in | |
| # node_modules without scanning every file | |
| # - Reports HIGH/MEDIUM/CLEAN for each | |
| # | |
| # USAGE: | |
| # | |
| # ./scan-machine-shai-hulud.sh | |
| # | |
| # The script will prompt to download the detector if not found. | |
| # | |
| # REQUIREMENTS: | |
| # | |
| # - bash or zsh | |
| # - curl (for downloading detector) | |
| # - Standard Unix tools (find, grep, shasum) | |
| # | |
| # EXIT CODES: | |
| # | |
| # 0 - All clean | |
| # 1 - High risk findings (likely compromise) | |
| # 2 - Medium risk findings (review recommended, often false positives) | |
| # | |
| # CUSTOMIZATION: | |
| # | |
| # Edit SCAN_DIRS below to add/remove directories to scan. | |
| # | |
| # MORE INFO: | |
| # | |
| # - Wiz.io writeup: https://www.wiz.io/blog/shai-hulud-npm-supply-chain-attack | |
| # - Datadog writeup: https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/ | |
| # - Detector repo: https://github.com/Cobenian/shai-hulud-detect | |
| # | |
| # Author: Eric Boehs (with Claude Code) | |
| # Date: November 2025 | |
| # | |
| set -e | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| DETECTOR="$SCRIPT_DIR/shai-hulud-detector" | |
| RESULTS_DIR="$SCRIPT_DIR/shai-hulud-results" | |
| TIMESTAMP=$(date +%Y%m%d_%H%M%S) | |
| RESULTS_FILE="$RESULTS_DIR/scan-$TIMESTAMP.txt" | |
| # Detector version - update these to use a newer version | |
| # Get latest from: https://github.com/Cobenian/shai-hulud-detect/commits/main | |
| DETECTOR_COMMIT="e78331a301bc2fd958e0092c6279c7ad561dbdd4" | |
| DETECTOR_SHA256="8d799bb265827ed5f0e9b5883024a38eccdc16355727817ea461d9c1567ef2a2" | |
| # Directories to scan (add more as needed) | |
| SCAN_DIRS=( | |
| "$HOME/Code" | |
| "$HOME/Projects" | |
| "$HOME/Developer" | |
| "$HOME/dev" | |
| "$HOME/src" | |
| "$HOME/work" | |
| "$HOME/repos" | |
| ) | |
| # Directories to skip | |
| SKIP_PATTERNS=( | |
| "node_modules" | |
| ".git" | |
| ".cache" | |
| "vendor" | |
| ".npm" | |
| ".yarn/cache" | |
| "dist" | |
| "build" | |
| ".next" | |
| ) | |
| echo "==========================================" | |
| echo " Shai-Hulud Full Machine Scanner" | |
| echo " $(date)" | |
| echo "==========================================" | |
| echo "" | |
| # Create results directory | |
| mkdir -p "$RESULTS_DIR" | |
| # Check if detector exists, offer to download if not | |
| if [ ! -f "$DETECTOR" ]; then | |
| echo "Detector not found at $DETECTOR" | |
| echo "" | |
| read -p "Download from Cobenian/shai-hulud-detect? [y/N] " -n 1 -r | |
| echo "" | |
| if [[ $REPLY =~ ^[Yy]$ ]]; then | |
| echo "Downloading detector..." | |
| # Download to temp files first | |
| TEMP_DETECTOR=$(mktemp) | |
| TEMP_PACKAGES=$(mktemp) | |
| # Detector: pinned to specific commit for security | |
| curl -sL "https://raw.githubusercontent.com/Cobenian/shai-hulud-detect/${DETECTOR_COMMIT}/shai-hulud-detector.sh" -o "$TEMP_DETECTOR" | |
| # Package list: always get latest (new compromised packages are added frequently) | |
| curl -sL "https://raw.githubusercontent.com/Cobenian/shai-hulud-detect/main/compromised-packages.txt" -o "$TEMP_PACKAGES" | |
| # Verify detector checksum | |
| ACTUAL_SHA256=$(shasum -a 256 "$TEMP_DETECTOR" | cut -d' ' -f1) | |
| if [ "$ACTUAL_SHA256" != "$DETECTOR_SHA256" ]; then | |
| echo "ERROR: Detector checksum mismatch!" | |
| echo " Expected: $DETECTOR_SHA256" | |
| echo " Got: $ACTUAL_SHA256" | |
| echo " This could indicate tampering. Aborting." | |
| rm -f "$TEMP_DETECTOR" "$TEMP_PACKAGES" | |
| exit 1 | |
| fi | |
| echo " Detector checksum verified: $DETECTOR_SHA256" | |
| # Verify package list looks legitimate (can't checksum - it changes frequently) | |
| if ! grep -q "^# Shai-Hulud NPM Supply Chain Attack" "$TEMP_PACKAGES"; then | |
| echo "ERROR: Downloaded package list missing expected header" | |
| rm -f "$TEMP_DETECTOR" "$TEMP_PACKAGES" | |
| exit 1 | |
| fi | |
| PKG_COUNT=$(grep -v "^#" "$TEMP_PACKAGES" | grep -c ":" || echo "0") | |
| if [ "$PKG_COUNT" -lt 500 ]; then | |
| echo "ERROR: Package list seems too small ($PKG_COUNT packages, expected 500+)" | |
| rm -f "$TEMP_DETECTOR" "$TEMP_PACKAGES" | |
| exit 1 | |
| fi | |
| echo " Package list verified ($PKG_COUNT compromised packages)" | |
| # All checks passed, install | |
| mv "$TEMP_DETECTOR" "$SCRIPT_DIR/shai-hulud-detector" | |
| mv "$TEMP_PACKAGES" "$SCRIPT_DIR/compromised-packages.txt" | |
| chmod +x "$SCRIPT_DIR/shai-hulud-detector" | |
| echo "" | |
| echo "Downloaded successfully!" | |
| echo " Detector: ./shai-hulud-detector (locked to commit $DETECTOR_COMMIT)" | |
| echo " Packages: ./compromised-packages.txt ($PKG_COUNT packages)" | |
| echo "" | |
| else | |
| echo "Aborted. Install manually from: https://github.com/Cobenian/shai-hulud-detect" | |
| exit 1 | |
| fi | |
| fi | |
| # Build find exclusion pattern | |
| EXCLUDES="" | |
| for pattern in "${SKIP_PATTERNS[@]}"; do | |
| EXCLUDES="$EXCLUDES -path '*/$pattern' -prune -o -path '*/$pattern/*' -prune -o" | |
| done | |
| # Initialize results | |
| echo "Shai-Hulud Full Machine Scan - $(date)" > "$RESULTS_FILE" | |
| echo "==========================================" >> "$RESULTS_FILE" | |
| echo "" >> "$RESULTS_FILE" | |
| CRITICAL_FINDINGS=() | |
| HIGH_RISK_PROJECTS=() | |
| MEDIUM_RISK_PROJECTS=() | |
| CLEAN_PROJECTS=() | |
| ########################################### | |
| # PHASE 1: Quick Critical Checks | |
| ########################################### | |
| echo "=== PHASE 1: Quick Critical Checks (Machine-Wide) ===" | |
| echo "" | |
| # Find all directories that exist from our scan list | |
| EXISTING_DIRS=() | |
| for dir in "${SCAN_DIRS[@]}"; do | |
| if [ -d "$dir" ]; then | |
| EXISTING_DIRS+=("$dir") | |
| fi | |
| done | |
| if [ ${#EXISTING_DIRS[@]} -eq 0 ]; then | |
| echo "No standard code directories found. Scanning $HOME..." | |
| EXISTING_DIRS=("$HOME") | |
| fi | |
| echo "Scanning directories:" | |
| for dir in "${EXISTING_DIRS[@]}"; do echo " - $dir"; done | |
| echo "" | |
| # 1. Check for malware payload files | |
| echo "[1/5] Checking for malware files (setup_bun.js, bun_environment.js)..." | |
| MALWARE_FILES="" | |
| for dir in "${EXISTING_DIRS[@]}"; do | |
| FOUND=$(find "$dir" -type d -name "node_modules" -prune -o -type d -name ".git" -prune -o \ | |
| \( -name "setup_bun.js" -o -name "bun_environment.js" \) -type f -print 2>/dev/null || true) | |
| if [ -n "$FOUND" ]; then | |
| MALWARE_FILES="$MALWARE_FILES$FOUND"$'\n' | |
| fi | |
| done | |
| if [ -n "$MALWARE_FILES" ]; then | |
| echo "" | |
| echo " !!! CRITICAL: MALWARE FILES FOUND !!!" | |
| echo "$MALWARE_FILES" | grep -v '^$' | while read -r f; do echo " $f"; done | |
| CRITICAL_FINDINGS+=("MALWARE FILES: $MALWARE_FILES") | |
| echo "CRITICAL - Malware files:" >> "$RESULTS_FILE" | |
| echo "$MALWARE_FILES" >> "$RESULTS_FILE" | |
| else | |
| echo " None found" | |
| fi | |
| # 2. Check for backdoor workflows | |
| echo "[2/5] Checking for backdoor workflows (discussion.yaml in .github/workflows)..." | |
| BACKDOOR_FILES="" | |
| for dir in "${EXISTING_DIRS[@]}"; do | |
| FOUND=$(find "$dir" -type d -name "node_modules" -prune -o \ | |
| -path "*/.github/workflows/discussion.yaml" -print -o \ | |
| -path "*/.github/workflows/discussion.yml" -print 2>/dev/null || true) | |
| if [ -n "$FOUND" ]; then | |
| BACKDOOR_FILES="$BACKDOOR_FILES$FOUND"$'\n' | |
| fi | |
| done | |
| if [ -n "$BACKDOOR_FILES" ]; then | |
| echo "" | |
| echo " !!! CRITICAL: BACKDOOR WORKFLOWS FOUND !!!" | |
| echo "$BACKDOOR_FILES" | grep -v '^$' | while read -r f; do echo " $f"; done | |
| CRITICAL_FINDINGS+=("BACKDOOR WORKFLOWS: $BACKDOOR_FILES") | |
| echo "CRITICAL - Backdoor workflows:" >> "$RESULTS_FILE" | |
| echo "$BACKDOOR_FILES" >> "$RESULTS_FILE" | |
| else | |
| echo " None found" | |
| fi | |
| # 3. Check for exfiltration artifacts | |
| echo "[3/5] Checking for exfiltration artifacts (cloud.json, truffleSecrets.json)..." | |
| EXFIL_FILES="" | |
| for dir in "${EXISTING_DIRS[@]}"; do | |
| FOUND=$(find "$dir" -type d -name "node_modules" -prune -o -type d -name ".git" -prune -o \ | |
| \( -name "cloud.json" -o -name "truffleSecrets.json" -o -name "environment.json" \) \ | |
| -type f -print 2>/dev/null | grep -v "node_modules" || true) | |
| if [ -n "$FOUND" ]; then | |
| EXFIL_FILES="$EXFIL_FILES$FOUND"$'\n' | |
| fi | |
| done | |
| if [ -n "$EXFIL_FILES" ]; then | |
| echo "" | |
| echo " !!! WARNING: Potential exfiltration files found !!!" | |
| echo "$EXFIL_FILES" | grep -v '^$' | while read -r f; do echo " $f"; done | |
| echo " (Review these - could be legitimate config files)" | |
| echo "POTENTIAL - Exfiltration artifacts:" >> "$RESULTS_FILE" | |
| echo "$EXFIL_FILES" >> "$RESULTS_FILE" | |
| else | |
| echo " None found" | |
| fi | |
| # 4. Check for unexpected Bun installation | |
| echo "[4/5] Checking for unexpected Bun runtime..." | |
| if command -v bun &> /dev/null; then | |
| BUN_PATH=$(which bun) | |
| echo " WARNING: Bun is installed at $BUN_PATH" | |
| echo " If you didn't install this intentionally, investigate!" | |
| echo "WARNING - Bun installed at: $BUN_PATH" >> "$RESULTS_FILE" | |
| else | |
| echo " Bun not found in PATH (good)" | |
| fi | |
| # 5. Check for trufflehog (malware downloads this) | |
| echo "[5/5] Checking for unexpected Trufflehog installation..." | |
| if command -v trufflehog &> /dev/null; then | |
| TH_PATH=$(which trufflehog) | |
| echo " WARNING: Trufflehog is installed at $TH_PATH" | |
| echo " If you didn't install this intentionally, investigate!" | |
| echo "WARNING - Trufflehog installed at: $TH_PATH" >> "$RESULTS_FILE" | |
| else | |
| echo " Trufflehog not found in PATH (good)" | |
| fi | |
| echo "" | |
| # Early exit if critical findings | |
| if [ ${#CRITICAL_FINDINGS[@]} -gt 0 ]; then | |
| echo "==========================================" | |
| echo " !!! CRITICAL FINDINGS - STOP HERE !!!" | |
| echo "==========================================" | |
| echo "" | |
| echo "Critical malware indicators were found on your machine." | |
| echo "DISCONNECT FROM NETWORK and investigate immediately." | |
| echo "" | |
| echo "Files to investigate:" | |
| for finding in "${CRITICAL_FINDINGS[@]}"; do | |
| echo " $finding" | |
| done | |
| echo "" | |
| echo "Results saved to: $RESULTS_FILE" | |
| exit 1 | |
| fi | |
| ########################################### | |
| # PHASE 2: Find Node.js Projects | |
| ########################################### | |
| echo "=== PHASE 2: Finding Node.js Projects ===" | |
| echo "" | |
| echo "Searching for package.json files (excluding node_modules)..." | |
| PROJECT_DIRS=() | |
| for dir in "${EXISTING_DIRS[@]}"; do | |
| while IFS= read -r pkg_json; do | |
| if [ -n "$pkg_json" ]; then | |
| PROJECT_DIR=$(dirname "$pkg_json") | |
| # Skip if it's inside node_modules or other excluded dirs | |
| if [[ ! "$PROJECT_DIR" =~ node_modules ]] && \ | |
| [[ ! "$PROJECT_DIR" =~ \.git ]] && \ | |
| [[ ! "$PROJECT_DIR" =~ \.cache ]]; then | |
| PROJECT_DIRS+=("$PROJECT_DIR") | |
| fi | |
| fi | |
| done < <(find "$dir" -type d -name "node_modules" -prune -o \ | |
| -type d -name ".git" -prune -o \ | |
| -type d -name ".cache" -prune -o \ | |
| -name "package.json" -type f -print 2>/dev/null) | |
| done | |
| # Remove duplicates and sort (portable - works with zsh and bash) | |
| UNIQUE_DIRS=$(printf '%s\n' "${PROJECT_DIRS[@]}" | sort -u) | |
| PROJECT_DIRS=() | |
| while IFS= read -r dir; do | |
| [ -n "$dir" ] && PROJECT_DIRS+=("$dir") | |
| done <<< "$UNIQUE_DIRS" | |
| echo "Found ${#PROJECT_DIRS[@]} Node.js projects" | |
| echo "" | |
| if [ ${#PROJECT_DIRS[@]} -eq 0 ]; then | |
| echo "No Node.js projects found to scan." | |
| echo "All quick checks passed!" | |
| exit 0 | |
| fi | |
| ########################################### | |
| # PHASE 3: Full Project Scans | |
| ########################################### | |
| echo "=== PHASE 3: Full Project Scans ===" | |
| echo "" | |
| echo "This may take a while depending on project count..." | |
| echo "" | |
| SCANNED=0 | |
| TOTAL=${#PROJECT_DIRS[@]} | |
| for project in "${PROJECT_DIRS[@]}"; do | |
| SCANNED=$((SCANNED + 1)) | |
| PROJECT_NAME=$(basename "$project") | |
| printf "[%d/%d] Scanning: %s... " "$SCANNED" "$TOTAL" "$PROJECT_NAME" | |
| # Run detector and capture output | |
| OUTPUT=$("$DETECTOR" "$project" 2>&1 || true) | |
| # Determine result | |
| if echo "$OUTPUT" | grep -q "HIGH RISK"; then | |
| echo "!!! HIGH RISK !!!" | |
| HIGH_RISK_PROJECTS+=("$project") | |
| echo "" >> "$RESULTS_FILE" | |
| echo "HIGH RISK: $project" >> "$RESULTS_FILE" | |
| echo "$OUTPUT" | grep -E "(HIGH RISK|CRITICAL|🚨)" >> "$RESULTS_FILE" || true | |
| elif echo "$OUTPUT" | grep -q "MEDIUM RISK"; then | |
| echo "MEDIUM RISK" | |
| MEDIUM_RISK_PROJECTS+=("$project") | |
| echo "" >> "$RESULTS_FILE" | |
| echo "MEDIUM RISK: $project" >> "$RESULTS_FILE" | |
| echo "$OUTPUT" | grep -E "(MEDIUM RISK|⚠️)" >> "$RESULTS_FILE" || true | |
| elif echo "$OUTPUT" | grep -q "No indicators"; then | |
| echo "CLEAN" | |
| CLEAN_PROJECTS+=("$project") | |
| else | |
| echo "CLEAN" | |
| CLEAN_PROJECTS+=("$project") | |
| fi | |
| done | |
| echo "" | |
| ########################################### | |
| # SUMMARY | |
| ########################################### | |
| echo "==========================================" | |
| echo " SCAN COMPLETE - SUMMARY" | |
| echo "==========================================" | |
| echo "" | |
| echo "Scanned ${#PROJECT_DIRS[@]} Node.js projects" | |
| echo "" | |
| if [ ${#HIGH_RISK_PROJECTS[@]} -gt 0 ]; then | |
| echo "!!! HIGH RISK PROJECTS (${#HIGH_RISK_PROJECTS[@]}) !!!" | |
| for project in "${HIGH_RISK_PROJECTS[@]}"; do | |
| echo " - $project" | |
| done | |
| echo "" | |
| fi | |
| if [ ${#MEDIUM_RISK_PROJECTS[@]} -gt 0 ]; then | |
| echo "MEDIUM RISK PROJECTS (${#MEDIUM_RISK_PROJECTS[@]}):" | |
| for project in "${MEDIUM_RISK_PROJECTS[@]}"; do | |
| echo " - $project" | |
| done | |
| echo "" | |
| fi | |
| echo "CLEAN PROJECTS: ${#CLEAN_PROJECTS[@]}" | |
| echo "" | |
| # Summary to file | |
| echo "" >> "$RESULTS_FILE" | |
| echo "==========================================" >> "$RESULTS_FILE" | |
| echo "SUMMARY" >> "$RESULTS_FILE" | |
| echo "==========================================" >> "$RESULTS_FILE" | |
| echo "Total projects scanned: ${#PROJECT_DIRS[@]}" >> "$RESULTS_FILE" | |
| echo "High risk: ${#HIGH_RISK_PROJECTS[@]}" >> "$RESULTS_FILE" | |
| echo "Medium risk: ${#MEDIUM_RISK_PROJECTS[@]}" >> "$RESULTS_FILE" | |
| echo "Clean: ${#CLEAN_PROJECTS[@]}" >> "$RESULTS_FILE" | |
| echo "Full results saved to: $RESULTS_FILE" | |
| echo "" | |
| # Exit code | |
| if [ ${#HIGH_RISK_PROJECTS[@]} -gt 0 ]; then | |
| echo "!!! ACTION REQUIRED: High risk projects detected !!!" | |
| exit 1 | |
| elif [ ${#MEDIUM_RISK_PROJECTS[@]} -gt 0 ]; then | |
| echo "Review recommended for medium risk projects" | |
| echo "(Medium risk often includes false positives from legitimate tools)" | |
| exit 2 | |
| else | |
| echo "All projects appear clean from Shai-Hulud indicators" | |
| exit 0 | |
| fi |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Shai-Hulud 2.0 Full Machine Scanner
Scans your machine for signs of the Shai-Hulud npm supply chain attack (November 2025). This attack compromised 796+ npm packages affecting 20+ million weekly downloads.
Quick Start
The script will prompt to download the shai-hulud-detector if not found.
What It Does
Runtime Warning
This script can take a long time depending on how many Node.js projects you have.
Phase 1 and 2 are fast. Phase 3 (full project scans) takes ~1-2 minutes per project (or longer for large repos with multiple
package.jsonfiles).Consider running overnight or in a background terminal:
Exit Codes
0- All clean1- High risk (likely compromise)2- Medium risk (review recommended, often false positives)More Info