Last active
November 25, 2025 15:02
-
-
Save xpicio/78c5dd859c0c7621c268929b114ebb78 to your computer and use it in GitHub Desktop.
Scans Node.js projects for potentially compromised packages from the Shai-Hulud 2.0 attack
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 | |
| # ============================================================================= | |
| # Shai-Hulud 2.0 Supply Chain Attack Scanner | |
| # ============================================================================= | |
| # Scans Node.js projects for potentially compromised packages from the | |
| # Shai-Hulud 2.0 attack documented at: | |
| # https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack | |
| # | |
| # Uses Trivy (via Docker) to parse lockfiles and generate SBOM | |
| # | |
| # Usage: ./check-shai-hulud.sh <root_folder> [--output-prefix <prefix>] [--exclude <pattern>] | |
| # | |
| # Generates two CSV reports: | |
| # 1. <prefix>-compromised.csv - Packages matching vulnerable versions | |
| # 2. <prefix>-found.csv - All Wiz IOC packages found in lockfiles (any version) | |
| # ============================================================================= | |
| set -e | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| NC='\033[0m' # No Color | |
| # Script identity | |
| SCRIPT_NAME="ShaiHulud-Lockfile-Scanner" | |
| SCRIPT_VERSION="2.2" | |
| TRIVY_IMAGE="aquasec/trivy:0.67.2" | |
| # Default values | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| IOC_CSV_URL="https://raw.githubusercontent.com/wiz-sec-public/wiz-research-iocs/refs/heads/main/reports/shai-hulud-2-packages.csv" | |
| IOC_FILE="${SCRIPT_DIR}/.shai-hulud-ioc.csv" | |
| OUTPUT_PREFIX="" | |
| EXCLUDE_PATTERNS=() | |
| GIT_PULL_ENABLED=true | |
| TEMP_DIR=$(mktemp -d) | |
| IOC_PACKAGES_FILE="${TEMP_DIR}/ioc_packages.txt" | |
| IOC_VERSIONS_FILE="${TEMP_DIR}/ioc_versions.txt" | |
| # Counters | |
| TOTAL_PROJECTS=0 | |
| CLEAN_PROJECTS=0 | |
| COMPROMISED_PROJECTS=0 | |
| TOTAL_FOUND=0 | |
| TOTAL_COMPROMISED=0 | |
| # Cleanup on exit | |
| trap "rm -rf ${TEMP_DIR}" EXIT | |
| # Parse arguments | |
| ROOT_FOLDER="" | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| --output-prefix|-o) | |
| OUTPUT_PREFIX="$2" | |
| shift 2 | |
| ;; | |
| --exclude|-e) | |
| EXCLUDE_PATTERNS+=("$2") | |
| shift 2 | |
| ;; | |
| --no-git-pull) | |
| GIT_PULL_ENABLED=false | |
| shift | |
| ;; | |
| -h|--help) | |
| echo "Usage: $0 <root_folder> [--output-prefix <prefix>] [--exclude <pattern>] [--no-git-pull]" | |
| echo "" | |
| echo "Scans Node.js projects for compromised Shai-Hulud 2.0 packages" | |
| echo "Uses Trivy (Docker) to parse lockfiles" | |
| echo "" | |
| echo "Arguments:" | |
| echo " root_folder Root folder to scan recursively" | |
| echo " --output-prefix, -o Output CSV files prefix (default: shai-hulud-<timestamp>)" | |
| echo " Generates: <prefix>-compromised.csv and <prefix>-found.csv" | |
| echo " --exclude, -e Exclude folders matching pattern (can be used multiple times)" | |
| echo " Example: --exclude node_modules --exclude .git" | |
| echo " --no-git-pull Disable automatic git pull before scanning" | |
| echo " -h, --help Show this help message" | |
| echo "" | |
| echo "Git Integration:" | |
| echo " By default, for each git repository found (not inside node_modules):" | |
| echo " - Checks for pending changes (skips pull if found)" | |
| echo " - Switches to develop/master/main branch (in that priority order)" | |
| echo " - Pulls latest changes from remote" | |
| echo "" | |
| echo "Requirements:" | |
| echo " - Docker must be installed and running" | |
| echo " - jq must be installed" | |
| echo " - git (optional, for auto-pull feature)" | |
| exit 0 | |
| ;; | |
| *) | |
| if [[ -z "$ROOT_FOLDER" ]]; then | |
| ROOT_FOLDER="$1" | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| if [[ -z "$ROOT_FOLDER" ]]; then | |
| echo -e "${RED}Error: Root folder is required${NC}" | |
| echo "Usage: $0 <root_folder> [--output-prefix <prefix>]" | |
| exit 1 | |
| fi | |
| if [[ ! -d "$ROOT_FOLDER" ]]; then | |
| echo -e "${RED}Error: '$ROOT_FOLDER' is not a valid directory${NC}" | |
| exit 1 | |
| fi | |
| # Check dependencies | |
| if ! command -v docker &> /dev/null; then | |
| echo -e "${RED}Error: Docker is required but not installed${NC}" | |
| exit 1 | |
| fi | |
| if ! command -v jq &> /dev/null; then | |
| echo -e "${RED}Error: jq is required but not installed${NC}" | |
| exit 1 | |
| fi | |
| # Check Docker is running | |
| if ! docker info &> /dev/null; then | |
| echo -e "${RED}Error: Docker is not running${NC}" | |
| exit 1 | |
| fi | |
| ROOT_FOLDER="$(cd "$ROOT_FOLDER" && pwd)" | |
| # Set default output prefix if not specified | |
| if [[ -z "$OUTPUT_PREFIX" ]]; then | |
| OUTPUT_PREFIX="${SCRIPT_DIR}/shai-hulud-$(date +%Y%m%d_%H%M%S)" | |
| fi | |
| OUTPUT_COMPROMISED="${OUTPUT_PREFIX}-compromised.csv" | |
| OUTPUT_FOUND="${OUTPUT_PREFIX}-found.csv" | |
| echo -e "${BLUE}========================================${NC}" | |
| echo -e "${BLUE} Shai-Hulud 2.0 Supply Chain Scanner ${NC}" | |
| echo -e "${BLUE}========================================${NC}" | |
| echo -e "${BLUE} ${SCRIPT_NAME} v${SCRIPT_VERSION}${NC}" | |
| echo -e "${BLUE} Using Trivy via Docker${NC}" | |
| echo "" | |
| # Download or update IOC file | |
| echo -e "${YELLOW}[*] Downloading IOC list from Wiz...${NC}" | |
| if curl -sS -o "${IOC_FILE}.tmp" "$IOC_CSV_URL" 2>/dev/null; then | |
| mv "${IOC_FILE}.tmp" "$IOC_FILE" | |
| echo -e "${GREEN}[✓] IOC list updated${NC}" | |
| else | |
| if [[ -f "$IOC_FILE" ]]; then | |
| echo -e "${YELLOW}[!] Could not download IOC list, using cached version${NC}" | |
| else | |
| echo -e "${RED}[✗] Failed to download IOC list and no cache available${NC}" | |
| exit 1 | |
| fi | |
| fi | |
| # Parse IOC file into lookup formats | |
| echo -e "${YELLOW}[*] Parsing IOC data...${NC}" | |
| # Create versions file (package|version) - one line per vulnerable version | |
| tail -n +2 "$IOC_FILE" | while IFS=',' read -r package versions; do | |
| package=$(echo "$package" | tr -d '"' | xargs) | |
| echo "$versions" | tr -d '"' | sed 's/ || /\n/g' | while read -r single_version; do | |
| single_version=$(echo "$single_version" | sed 's/^= //' | xargs) | |
| if [[ -n "$package" && -n "$single_version" ]]; then | |
| echo "${package}|${single_version}" | |
| fi | |
| done | |
| done > "$IOC_VERSIONS_FILE" | |
| # Create unique packages file (just package names) | |
| tail -n +2 "$IOC_FILE" | while IFS=',' read -r package versions; do | |
| package=$(echo "$package" | tr -d '"' | xargs) | |
| [[ -n "$package" ]] && echo "$package" | |
| done | sort -u > "$IOC_PACKAGES_FILE" | |
| TOTAL_VERSIONS=$(wc -l < "$IOC_VERSIONS_FILE" | tr -d ' ') | |
| TOTAL_PACKAGES=$(wc -l < "$IOC_PACKAGES_FILE" | tr -d ' ') | |
| echo -e "${GREEN}[✓] Loaded ${TOTAL_PACKAGES} unique packages (${TOTAL_VERSIONS} vulnerable versions)${NC}" | |
| echo "" | |
| # Pull Trivy image if needed | |
| echo -e "${YELLOW}[*] Checking Trivy Docker image...${NC}" | |
| if ! docker image inspect "$TRIVY_IMAGE" &> /dev/null; then | |
| echo -e "${YELLOW}[*] Pulling Trivy image...${NC}" | |
| docker pull "$TRIVY_IMAGE" | |
| fi | |
| echo -e "${GREEN}[✓] Trivy image ready${NC}" | |
| echo "" | |
| # Initialize CSV files | |
| echo "Folder,Lock Type,Package,Version,Status" > "$OUTPUT_FOUND" | |
| echo "Folder,Lock Type,Package,Version,Status" > "$OUTPUT_COMPROMISED" | |
| # Function to check if path should be excluded | |
| should_exclude() { | |
| local path="$1" | |
| for pattern in "${EXCLUDE_PATTERNS[@]}"; do | |
| if [[ "$path" == *"$pattern"* ]]; then | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| # Function to detect lock type | |
| detect_lock_type() { | |
| local folder="$1" | |
| if [[ -f "${folder}/pnpm-lock.yaml" ]]; then | |
| echo "pnpm" | |
| elif [[ -f "${folder}/yarn.lock" ]]; then | |
| echo "yarn" | |
| elif [[ -f "${folder}/package-lock.json" ]]; then | |
| echo "npm" | |
| else | |
| echo "" | |
| fi | |
| } | |
| # Function to find git root for a folder (returns empty if not a git repo or inside node_modules) | |
| find_git_root() { | |
| local folder="$1" | |
| # Skip if folder is inside node_modules | |
| if [[ "$folder" == *"/node_modules/"* ]]; then | |
| echo "" | |
| return | |
| fi | |
| # Find git root | |
| local git_root | |
| git_root=$(git -C "$folder" rev-parse --show-toplevel 2>/dev/null || echo "") | |
| echo "$git_root" | |
| } | |
| # Function to update git repo before scanning | |
| # Returns: "updated", "skipped", "warning", or "not-git" | |
| update_git_repo() { | |
| local git_root="$1" | |
| local relative_path="$2" | |
| if [[ -z "$git_root" ]]; then | |
| echo "not-git" | |
| return | |
| fi | |
| # Check if we already processed this git root | |
| if [[ " ${PROCESSED_GIT_ROOTS[*]} " =~ " ${git_root} " ]]; then | |
| echo "already-processed" | |
| return | |
| fi | |
| PROCESSED_GIT_ROOTS+=("$git_root") | |
| # Check for pending changes | |
| local git_status | |
| git_status=$(git -C "$git_root" status --porcelain 2>/dev/null) | |
| if [[ -n "$git_status" ]]; then | |
| echo -e "${YELLOW}[!] Warning: ${git_root} has pending changes, skipping git pull${NC}" >&2 | |
| echo "warning" | |
| return | |
| fi | |
| # Get current branch | |
| local current_branch | |
| current_branch=$(git -C "$git_root" rev-parse --abbrev-ref HEAD 2>/dev/null) | |
| # Determine target branch: develop > master > main | |
| local target_branch="" | |
| if git -C "$git_root" show-ref --verify --quiet refs/heads/develop 2>/dev/null; then | |
| target_branch="develop" | |
| elif git -C "$git_root" show-ref --verify --quiet refs/heads/master 2>/dev/null; then | |
| target_branch="master" | |
| elif git -C "$git_root" show-ref --verify --quiet refs/heads/main 2>/dev/null; then | |
| target_branch="main" | |
| fi | |
| if [[ -z "$target_branch" ]]; then | |
| echo -e "${YELLOW}[!] Warning: ${git_root} has no develop/master/main branch${NC}" >&2 | |
| echo "warning" | |
| return | |
| fi | |
| # Checkout target branch if needed | |
| if [[ "$current_branch" != "$target_branch" ]]; then | |
| echo -e "${BLUE}[git]${NC} Switching ${git_root} from ${current_branch} to ${target_branch}..." >&2 | |
| if ! git -C "$git_root" checkout "$target_branch" --quiet 2>/dev/null; then | |
| echo -e "${YELLOW}[!] Warning: Failed to checkout ${target_branch} in ${git_root}${NC}" >&2 | |
| echo "warning" | |
| return | |
| fi | |
| fi | |
| # Pull latest changes | |
| echo -e "${BLUE}[git]${NC} Pulling latest changes in ${git_root} (${target_branch})..." >&2 | |
| if git -C "$git_root" pull --quiet 2>/dev/null; then | |
| echo "updated" | |
| else | |
| echo -e "${YELLOW}[!] Warning: Failed to pull in ${git_root}${NC}" >&2 | |
| echo "warning" | |
| fi | |
| } | |
| # Array to track processed git roots (avoid pulling same repo multiple times) | |
| PROCESSED_GIT_ROOTS=() | |
| # Function to scan a single project with Trivy | |
| scan_project() { | |
| local project_folder="$1" | |
| local lock_type="$2" | |
| local sbom_file="${TEMP_DIR}/sbom_$(date +%s%N).json" | |
| local project_found=0 | |
| local project_compromised=0 | |
| # Run Trivy on the project folder | |
| docker run --rm \ | |
| -v "${project_folder}:/scan:ro" \ | |
| -v "${TEMP_DIR}:/output" \ | |
| "$TRIVY_IMAGE" \ | |
| filesystem /scan \ | |
| --format cyclonedx \ | |
| --output "/output/$(basename "$sbom_file")" \ | |
| --list-all-pkgs \ | |
| --quiet 2>/dev/null | |
| sbom_file="${TEMP_DIR}/$(basename "$sbom_file")" | |
| if [[ ! -f "$sbom_file" ]]; then | |
| echo -e "${RED}[✗] Failed to generate SBOM${NC}" | |
| return | |
| fi | |
| # Extract npm packages from SBOM (purl contains full scoped name) | |
| local packages | |
| packages=$(jq -r ' | |
| .components[]? | | |
| select(.purl != null) | | |
| select(.purl | startswith("pkg:npm/")) | | |
| .purl | | |
| sub("^pkg:npm/"; "") | | |
| gsub("%40"; "@") | |
| ' "$sbom_file" 2>/dev/null | \ | |
| sed -E 's/^(@[^@]+)@(.+)$/\1|\2/' | \ | |
| sed -E 's/^([^@|]+)@(.+)$/\1|\2/' | \ | |
| sort -u) | |
| # Check each package against IOC list | |
| while IFS='|' read -r pkg_name pkg_version; do | |
| [[ -z "$pkg_name" || -z "$pkg_version" ]] && continue | |
| # Check if package is in IOC list | |
| if grep -qx "$pkg_name" "$IOC_PACKAGES_FILE" 2>/dev/null; then | |
| project_found=$((project_found + 1)) | |
| TOTAL_FOUND=$((TOTAL_FOUND + 1)) | |
| # Check if specific version is compromised | |
| if grep -qx "${pkg_name}|${pkg_version}" "$IOC_VERSIONS_FILE" 2>/dev/null; then | |
| echo "\"${project_folder}\",\"${lock_type}\",\"${pkg_name}\",\"${pkg_version}\",\"VULNERABLE\"" >> "$OUTPUT_COMPROMISED" | |
| echo "\"${project_folder}\",\"${lock_type}\",\"${pkg_name}\",\"${pkg_version}\",\"VULNERABLE\"" >> "$OUTPUT_FOUND" | |
| project_compromised=$((project_compromised + 1)) | |
| TOTAL_COMPROMISED=$((TOTAL_COMPROMISED + 1)) | |
| else | |
| echo "\"${project_folder}\",\"${lock_type}\",\"${pkg_name}\",\"${pkg_version}\",\"SAFE\"" >> "$OUTPUT_FOUND" | |
| fi | |
| fi | |
| done <<< "$packages" | |
| # Cleanup | |
| rm -f "$sbom_file" | |
| # Return status | |
| if [[ $project_compromised -gt 0 ]]; then | |
| echo -e "${RED}KO${NC} (${project_found} IOC pkgs, ${project_compromised} vulnerable)" | |
| COMPROMISED_PROJECTS=$((COMPROMISED_PROJECTS + 1)) | |
| elif [[ $project_found -gt 0 ]]; then | |
| echo -e "${GREEN}OK${NC} (${project_found} IOC pkgs, safe versions)" | |
| CLEAN_PROJECTS=$((CLEAN_PROJECTS + 1)) | |
| else | |
| echo -e "${GREEN}OK${NC} (no IOC pkgs)" | |
| CLEAN_PROJECTS=$((CLEAN_PROJECTS + 1)) | |
| fi | |
| } | |
| # Find and scan all Node.js projects | |
| echo -e "${YELLOW}[*] Scanning folder: ${ROOT_FOLDER}${NC}" | |
| echo "" | |
| # Find all directories containing lockfiles | |
| while IFS= read -r lockfile; do | |
| project_folder="$(dirname "$lockfile")" | |
| # Skip excluded paths | |
| if should_exclude "$project_folder"; then | |
| continue | |
| fi | |
| # Detect lock type | |
| lock_type=$(detect_lock_type "$project_folder") | |
| if [[ -z "$lock_type" ]]; then | |
| continue | |
| fi | |
| # Get relative path for display | |
| relative_path="${project_folder#$ROOT_FOLDER}" | |
| [[ -z "$relative_path" ]] && relative_path="/" | |
| # Update git repo if applicable (only for project roots, not node_modules) | |
| if [[ "$GIT_PULL_ENABLED" == "true" ]]; then | |
| git_root=$(find_git_root "$project_folder") | |
| if [[ -n "$git_root" ]]; then | |
| update_git_repo "$git_root" "$relative_path" > /dev/null | |
| fi | |
| fi | |
| TOTAL_PROJECTS=$((TOTAL_PROJECTS + 1)) | |
| echo -ne "${BLUE}[scanning]${NC} ${relative_path} (${lock_type})... " | |
| scan_project "$project_folder" "$lock_type" | |
| done < <(find "$ROOT_FOLDER" -type f \( -name "package-lock.json" -o -name "pnpm-lock.yaml" -o -name "yarn.lock" \) 2>/dev/null | sort -u) | |
| echo "" | |
| echo -e "${BLUE}========================================${NC}" | |
| echo -e "${BLUE} SUMMARY ${NC}" | |
| echo -e "${BLUE}========================================${NC}" | |
| echo "" | |
| echo -e "Root folder: ${ROOT_FOLDER}" | |
| echo -e "Projects scanned: ${TOTAL_PROJECTS}" | |
| echo -e "${GREEN}Clean (OK): ${CLEAN_PROJECTS}${NC}" | |
| echo -e "${RED}Compromised (KO): ${COMPROMISED_PROJECTS}${NC}" | |
| echo -e "${YELLOW}Total Wiz IOC packages found: ${TOTAL_FOUND}${NC}" | |
| echo "" | |
| if [[ $TOTAL_COMPROMISED -gt 0 ]]; then | |
| echo -e "${RED}========================================${NC}" | |
| echo -e "${RED} COMPROMISED PACKAGES DETECTED! ${NC}" | |
| echo -e "${RED}========================================${NC}" | |
| echo "" | |
| # Print remediation instructions | |
| echo -e "${YELLOW}========================================${NC}" | |
| echo -e "${YELLOW} REMEDIATION INSTRUCTIONS ${NC}" | |
| echo -e "${YELLOW}========================================${NC}" | |
| echo "" | |
| echo -e "${YELLOW}⚠️ IMPORTANT: The compromised packages listed above contain malicious code.${NC}" | |
| echo -e "${YELLOW} These packages were published as part of the Shai-Hulud 2.0 supply chain attack.${NC}" | |
| echo "" | |
| echo -e "${BLUE}1. REVERT TO SAFE VERSIONS${NC}" | |
| echo " Update your package.json to use a version BEFORE or AFTER the compromised ones." | |
| echo " Check the package's npm page or changelog to identify safe versions." | |
| echo " Example: if 2.0.9 is compromised, try 2.0.8 (older) or 2.0.10+ (newer if available)." | |
| echo "" | |
| echo -e "${BLUE}2. CLEAR PACKAGE MANAGER CACHE${NC}" | |
| echo " Run the appropriate command for your package manager:" | |
| echo "" | |
| echo -e " ${GREEN}npm:${NC}" | |
| echo " npm cache clean --force" | |
| echo "" | |
| echo -e " ${GREEN}pnpm:${NC}" | |
| echo " pnpm store prune" | |
| echo " pnpm cache delete" | |
| echo "" | |
| echo -e " ${GREEN}yarn (v1):${NC}" | |
| echo " yarn cache clean" | |
| echo "" | |
| echo -e " ${GREEN}yarn (v2+/berry):${NC}" | |
| echo " yarn cache clean --all" | |
| echo "" | |
| echo -e "${BLUE}3. REMOVE node_modules FROM AFFECTED PROJECTS${NC}" | |
| echo " Remove the node_modules folder from each affected project." | |
| echo "" | |
| echo -e "${BLUE}4. REINSTALL DEPENDENCIES${NC}" | |
| echo " After updating package.json and clearing caches:" | |
| echo "" | |
| echo -e " ${GREEN}npm:${NC} npm install" | |
| echo -e " ${GREEN}pnpm:${NC} pnpm install" | |
| echo -e " ${GREEN}yarn:${NC} yarn install" | |
| echo "" | |
| else | |
| echo -e "${GREEN}No compromised packages found! ✓${NC}" | |
| echo "" | |
| fi | |
| echo -e "${BLUE}CSV reports saved to:${NC}" | |
| echo -e " ${BLUE}Found packages:${NC} ${OUTPUT_FOUND}" | |
| echo -e " ${BLUE}Compromised packages:${NC} ${OUTPUT_COMPROMISED}" | |
| echo "" | |
| # Exit with error code if compromised packages found | |
| if [[ $TOTAL_COMPROMISED -gt 0 ]]; then | |
| exit 1 | |
| else | |
| exit 0 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment