Skip to content

Instantly share code, notes, and snippets.

@xpicio
Last active November 25, 2025 15:02
Show Gist options
  • Select an option

  • Save xpicio/78c5dd859c0c7621c268929b114ebb78 to your computer and use it in GitHub Desktop.

Select an option

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