Scripts

shai-hulud-2-detector (NPM Supply Chain Attack)

Validates if your package.json (and lock-file) has any package infected by the Shai-Hulud 2.0 attack (Nov 24, 2025); Works with npm, yarn, and pnpm. Read more about the attack

How to use (Bash)

  1. 1.Download the script to your project folder (same directory as package.json)
  2. 2.Make it executable: chmod +x {scriptPath}
  3. 3.Run: ./{scriptPath}
1#!/usr/bin/env bash
2
3# Shai-Hulud 2.0 Supply Chain Attack Detector
4#
5# Checks your project dependencies against known compromised packages from the
6# Shai-Hulud 2.0 supply chain attack that targeted npm packages.
7#
8# This script checks:
9# - package.json dependencies
10# - package-lock.json (npm)
11# - pnpm-lock.yaml (pnpm)
12# - yarn.lock (yarn)
13#
14# References:
15# - https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack
16# - https://socket.dev/blog/supply-chain-attack-shai-hulud-2-0
17#
18# Usage:
19#   ./check-deps.sh
20#   (Automatically finds package.json in current or parent directories)
21
22set -euo pipefail
23
24# Full list of compromised packages from Shai-Hulud 2.0 supply chain attack
25# Format: "package@version"
26COMPROMISED_LIST=(
27	"@accordproject/concerto-analysis@3.24.1"
28	"@accordproject/concerto-metamodel@3.12.5"
29	"@accordproject/concerto-types@3.24.1"
30	"@accordproject/markdown-it-cicero@0.16.26"
31	"@asyncapi/studio@1.0.3"
32	"@asyncapi/studio@1.0.2"
33	"@ensdomains/address-encoder@1.1.5"
34	"@ensdomains/content-hash@3.0.1"
35	"@ensdomains/dnsprovejs@0.5.3"
36	"@ensdomains/ens-validation@0.1.1"
37	"@ensdomains/ensjs@4.0.3"
38	"@ensdomains/eth-ens-namehash@2.0.16"
39	"@posthog/agent@1.24.1"
40	"@posthog/ai@7.1.2"
41	"@posthog/cli@0.5.15"
42	"@posthog/clickhouse@1.7.1"
43	"@posthog/core@1.5.6"
44	"@posthog/hedgehog-mode@0.0.42"
45	"@posthog/icons@0.36.1"
46	"@posthog/lemon-ui@0.0.1"
47	"@posthog/nextjs-config@1.5.1"
48	"@posthog/nuxt@1.2.9"
49	"@posthog/piscina@3.2.1"
50	"@posthog/plugin-contrib@0.0.6"
51	"@posthog/react-rrweb-player@1.1.4"
52	"@posthog/rrdom@0.0.31"
53	"@posthog/rrweb@0.0.31"
54	"@posthog/rrweb-player@0.0.31"
55	"@posthog/rrweb-record@0.0.31"
56	"@posthog/rrweb-replay@0.0.19"
57	"@posthog/rrweb-snapshot@0.0.31"
58	"@posthog/rrweb-utils@0.0.31"
59	"@posthog/siphash@1.1.2"
60	"@posthog/wizard@1.18.1"
61	"@postman/aether-icons@2.23.3"
62	"@postman/aether-icons@2.23.4"
63	"@postman/aether-icons@2.23.2"
64	"@postman/csv-parse@4.0.5"
65	"@postman/csv-parse@4.0.3"
66	"@postman/csv-parse@4.0.4"
67	"@postman/node-keytar@7.9.5"
68	"@postman/node-keytar@7.9.6"
69	"@postman/node-keytar@7.9.4"
70	"@postman/tunnel-agent@0.6.7"
71	"@postman/tunnel-agent@0.6.6"
72	"@postman/tunnel-agent@0.6.5"
73	"@voiceflow/common@8.9.2"
74	"@voiceflow/common@8.9.1"
75	"@zapier/ai-actions@0.1.19"
76	"@zapier/ai-actions@0.1.20"
77	"@zapier/ai-actions@0.1.18"
78	"@zapier/babel-preset-zapier@6.4.2"
79	"@zapier/babel-preset-zapier@6.4.3"
80	"@zapier/babel-preset-zapier@6.4.1"
81	"@zapier/browserslist-config-zapier@1.0.4"
82	"@zapier/browserslist-config-zapier@1.0.5"
83	"@zapier/browserslist-config-zapier@1.0.3"
84	"@zapier/secret-scrubber@1.1.5"
85	"@zapier/secret-scrubber@1.1.3"
86	"@zapier/secret-scrubber@1.1.4"
87	"blob-to-base64@1.0.3"
88	"cpu-instructions@0.0.14"
89	"crypto-addr-codec@0.1.9"
90	"enforce-branch-name@1.1.3"
91	"ethereum-ens@0.8.1"
92	"formik-error-focus@2.0.1"
93	"fuzzy-finder@1.0.5"
94	"fuzzy-finder@1.0.6"
95	"gatsby-plugin-cname@1.0.1"
96	"gatsby-plugin-cname@1.0.2"
97	"get-them-args@1.3.3"
98	"kill-port@2.0.2"
99	"posthog-docusaurus@2.0.6"
100	"posthog-js@1.297.3"
101	"posthog-node@5.13.3"
102	"posthog-node@5.11.3"
103	"posthog-node@4.18.1"
104	"posthog-react-native@4.11.1"
105	"posthog-react-native@4.12.5"
106	"posthog-react-native-session-replay@1.2.2"
107	"react-hook-form-persist@3.0.1"
108	"react-native-email@2.1.2"
109	"react-native-email@2.1.1"
110	"react-native-google-maps-directions@2.1.2"
111	"react-native-phone-call@1.2.2"
112	"react-native-phone-call@1.2.1"
113	"react-native-websocket@1.0.3"
114	"shell-exec@1.1.3"
115	"shell-exec@1.1.4"
116	"sort-by-distance@2.0.1"
117	"template-lib@1.1.3"
118	"template-lib@1.1.4"
119	"tenacious-fetch@2.3.2"
120	"url-encode-decode@1.0.1"
121	"zapier-platform-cli@18.0.4"
122	"zapier-platform-cli@18.0.3"
123	"zapier-platform-cli@18.0.2"
124	"zapier-platform-core@18.0.4"
125	"zapier-platform-core@18.0.3"
126	"zapier-platform-core@18.0.2"
127	"zapier-platform-schema@18.0.4"
128	"zapier-platform-schema@18.0.3"
129	"zapier-platform-schema@18.0.2"
130)
131
132# Find package.json in current or parent directories
133find_package_json() {
134	local current_dir="$PWD"
135
136	while [[ "$current_dir" != "/" ]]; do
137		if [[ -f "$current_dir/package.json" ]]; then
138			echo "$current_dir/package.json"
139			return 0
140		fi
141		current_dir="$(dirname "$current_dir")"
142	done
143
144	if [[ -f "/package.json" ]]; then
145		echo "/package.json"
146		return 0
147	fi
148
149	return 1
150}
151
152# Check if jq is available for JSON parsing
153has_jq() {
154	command -v jq &> /dev/null
155}
156
157# Parse package.json dependencies
158parse_package_json() {
159	local pkg_file="$1"
160
161	if has_jq; then
162		# Use jq for robust JSON parsing
163		jq -r '
164			(.dependencies // {}) +
165			(.devDependencies // {}) +
166			(.peerDependencies // {}) +
167			(.optionalDependencies // {}) |
168			to_entries[] |
169			"\(.key)@\(.value)"
170		' "$pkg_file" 2>/dev/null
171	else
172		# Fallback to grep/sed
173		grep -E '".*":\s*".*"' "$pkg_file" | \
174			sed -E 's/.*"([^"]+)":\s*"([^"]+)".*/\1@\2/' | \
175			grep '@'
176	fi
177}
178
179# Parse npm lockfile
180parse_npm_lockfile() {
181	local project_dir="$1"
182	local lockfile="$project_dir/package-lock.json"
183
184	[[ ! -f "$lockfile" ]] && return
185
186	if has_jq; then
187		jq -r '
188			(.packages // {}) |
189			to_entries[] |
190			select(.key != "" and .value.version != null) |
191			.key as $path |
192			($path | ltrimstr("node_modules/")) as $name |
193			"\($name)@\(.value.version)"
194		' "$lockfile" 2>/dev/null || return
195	fi
196}
197
198# Parse pnpm lockfile
199parse_pnpm_lockfile() {
200	local project_dir="$1"
201	local lockfile="$project_dir/pnpm-lock.yaml"
202
203	[[ ! -f "$lockfile" ]] && return
204
205	# Extract package@version from pnpm lock format
206	grep -E "^\s+['\"]?[^'\":]+['\"]?:\s+" "$lockfile" | \
207		sed -E "s/^\s+['\"]?([^'\":]+)['\"]?:\s+.*/\1/" | \
208		grep '@' | \
209		grep -v 'link:' | \
210		grep -v 'file:' || return
211}
212
213# Parse yarn lockfile
214parse_yarn_lockfile() {
215	local project_dir="$1"
216	local lockfile="$project_dir/yarn.lock"
217
218	[[ ! -f "$lockfile" ]] && return
219
220	# Parse yarn.lock format
221	local current_package=""
222	while IFS= read -r line; do
223		# Package declaration (not indented)
224		if [[ "$line" =~ ^[\"\'@a-zA-Z] ]]; then
225			if [[ "$line" =~ ^[\"\']*(@?[^@\"\']+)@ ]]; then
226				current_package="${BASH_REMATCH[1]}"
227			fi
228		# Version line (indented)
229		elif [[ "$line" =~ ^[[:space:]]+version[[:space:]]+[\"\']([^\"\']+)[\"\'] ]] && [[ -n "$current_package" ]]; then
230			echo "$current_package@${BASH_REMATCH[1]}"
231			current_package=""
232		fi
233	done < "$lockfile"
234}
235
236# Detect package manager
237detect_package_manager() {
238	local project_dir="$1"
239
240	if [[ -f "$project_dir/pnpm-lock.yaml" ]]; then
241		echo "pnpm"
242	elif [[ -f "$project_dir/yarn.lock" ]]; then
243		echo "yarn"
244	elif [[ -f "$project_dir/package-lock.json" ]]; then
245		echo "npm"
246	else
247		echo "unknown"
248	fi
249}
250
251# Check if package@version is compromised
252is_compromised() {
253	local dep="$1"
254
255	for bad_dep in "${COMPROMISED_LIST[@]}"; do
256		if [[ "$dep" == "$bad_dep" ]]; then
257			return 0
258		fi
259	done
260	return 1
261}
262
263# Main function
264main() {
265	local pkg_path project_dir package_manager
266
267	# Find package.json
268	if ! pkg_path=$(find_package_json); then
269		echo "❌ package.json not found in current directory or any parent directory."
270		exit 1
271	fi
272
273	project_dir="$(dirname "$pkg_path")"
274
275	echo "πŸ“¦ Checking: $pkg_path"
276
277	# Detect package manager
278	package_manager=$(detect_package_manager "$project_dir")
279	echo "πŸ“‹ Package manager: $package_manager"
280
281	# Check for jq
282	if ! has_jq; then
283		echo "⚠️  jq not found. Using fallback parsing (may be less accurate)."
284		echo "   Install jq for better results: brew install jq (macOS) or apt-get install jq (Linux)"
285	fi
286
287	# Collect all dependencies
288	local -a all_deps=()
289	local -a pkg_json_deps=()
290
291	# Parse package.json
292	while IFS= read -r dep; do
293		[[ -z "$dep" ]] && continue
294		all_deps+=("$dep|package.json")
295		pkg_json_deps+=("$dep")
296	done < <(parse_package_json "$pkg_path")
297
298	# Parse lockfile
299	local lockfile_count=0
300	case "$package_manager" in
301		npm)
302			while IFS= read -r dep; do
303				[[ -z "$dep" ]] && continue
304				all_deps+=("$dep|lockfile")
305				((lockfile_count++))
306			done < <(parse_npm_lockfile "$project_dir")
307			;;
308		pnpm)
309			while IFS= read -r dep; do
310				[[ -z "$dep" ]] && continue
311				all_deps+=("$dep|lockfile")
312				((lockfile_count++))
313			done < <(parse_pnpm_lockfile "$project_dir")
314			;;
315		yarn)
316			while IFS= read -r dep; do
317				[[ -z "$dep" ]] && continue
318				all_deps+=("$dep|lockfile")
319				((lockfile_count++))
320			done < <(parse_yarn_lockfile "$project_dir")
321			;;
322	esac
323
324	if [[ $lockfile_count -gt 0 ]]; then
325		echo "πŸ”’ Found $lockfile_count packages in lockfile"
326	fi
327
328	# Check for compromised packages
329	local -a compromised=()
330	local -a seen_deps=()
331
332	for dep_entry in "${all_deps[@]}"; do
333		IFS='|' read -r dep source <<< "$dep_entry"
334
335		# Skip if we've already checked this dep
336		local already_seen=false
337		if [[ ${#seen_deps[@]} -gt 0 ]]; then
338			for seen in "${seen_deps[@]}"; do
339				if [[ "$seen" == "$dep" ]]; then
340					already_seen=true
341					break
342				fi
343			done
344		fi
345
346		if [[ "$already_seen" == "true" ]]; then
347			continue
348		fi
349
350		seen_deps+=("$dep")
351
352		if is_compromised "$dep"; then
353			compromised+=("$dep (found in: $source)")
354		fi
355	done
356
357	if [[ ${#compromised[@]} -eq 0 ]]; then
358		echo "βœ… No compromised packages found."
359		exit 0
360	fi
361
362	# Report compromised packages
363	echo ""
364	echo "🚨 COMPROMISED PACKAGES DETECTED!"
365	printf '━%.0s' {1..60}
366	echo ""
367	for c in "${compromised[@]}"; do
368		echo "❌ $c"
369	done
370
371	echo ""
372	echo "πŸ”§ Recommended actions:"
373	echo "1. Remove node_modules directory"
374	echo "2. Clear package manager cache:"
375	echo "   - npm: npm cache clean --force"
376	echo "   - pnpm: pnpm store prune"
377	echo "   - yarn: yarn cache clean"
378	echo "3. Pin packages to safe versions in package.json (use exact versions)"
379	echo "4. Delete lockfile and regenerate it"
380	echo "5. Reinstall dependencies"
381	echo "6. Rotate all secrets/tokens (GitHub, CI/CD, cloud providers)"
382	echo "7. Audit GitHub org for suspicious repos named \"Shai-Hulud\" or \"SHA1-HULUD\""
383	echo ""
384	echo "πŸ“š More info: https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack"
385
386	exit 1
387}
388
389main "$@"
390