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 (TypeScript)

  1. 1.Install Bun: https://bun.sh
  2. 2.Download the script to your project folder (same directory as package.json)
  3. 3.Run: bun {scriptPath}
1#!/usr/bin/env bun
2
3/**
4 * Shai-Hulud 2.0 Supply Chain Attack Detector
5 *
6 * Checks your project dependencies against known compromised packages from the
7 * Shai-Hulud 2.0 supply chain attack that targeted npm packages.
8 *
9 * This script checks:
10 * - package.json dependencies
11 * - package-lock.json (npm)
12 * - pnpm-lock.yaml (pnpm)
13 * - yarn.lock (yarn)
14 *
15 * References:
16 * - https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack
17 * - https://socket.dev/blog/supply-chain-attack-shai-hulud-2-0
18 *
19 * Usage:
20 *   ./check-deps.ts
21 *   (Automatically finds package.json in current or parent directories)
22 */
23
24import { readFileSync, existsSync } from 'fs'
25import { join } from 'path'
26
27type PackageEntry = {
28	name: string
29	version: string
30}
31
32type PackageJson = {
33	dependencies?: Record<string, string>
34	devDependencies?: Record<string, string>
35	peerDependencies?: Record<string, string>
36	optionalDependencies?: Record<string, string>
37}
38
39type LockfilePackages = Record<string, { version?: string }>
40
41// Full list of compromised packages from Shai-Hulud 2.0 supply chain attack
42const COMPROMISED_PACKAGES: Array<PackageEntry> = [
43	{ name: '@accordproject/concerto-analysis', version: '3.24.1' },
44	{ name: '@accordproject/concerto-metamodel', version: '3.12.5' },
45	{ name: '@accordproject/concerto-types', version: '3.24.1' },
46	{ name: '@accordproject/markdown-it-cicero', version: '0.16.26' },
47	{ name: '@asyncapi/studio', version: '1.0.3' },
48	{ name: '@asyncapi/studio', version: '1.0.2' },
49	{ name: '@ensdomains/address-encoder', version: '1.1.5' },
50	{ name: '@ensdomains/content-hash', version: '3.0.1' },
51	{ name: '@ensdomains/dnsprovejs', version: '0.5.3' },
52	{ name: '@ensdomains/ens-validation', version: '0.1.1' },
53	{ name: '@ensdomains/ensjs', version: '4.0.3' },
54	{ name: '@ensdomains/eth-ens-namehash', version: '2.0.16' },
55	{ name: '@posthog/agent', version: '1.24.1' },
56	{ name: '@posthog/ai', version: '7.1.2' },
57	{ name: '@posthog/cli', version: '0.5.15' },
58	{ name: '@posthog/clickhouse', version: '1.7.1' },
59	{ name: '@posthog/core', version: '1.5.6' },
60	{ name: '@posthog/hedgehog-mode', version: '0.0.42' },
61	{ name: '@posthog/icons', version: '0.36.1' },
62	{ name: '@posthog/lemon-ui', version: '0.0.1' },
63	{ name: '@posthog/nextjs-config', version: '1.5.1' },
64	{ name: '@posthog/nuxt', version: '1.2.9' },
65	{ name: '@posthog/piscina', version: '3.2.1' },
66	{ name: '@posthog/plugin-contrib', version: '0.0.6' },
67	{ name: '@posthog/react-rrweb-player', version: '1.1.4' },
68	{ name: '@posthog/rrdom', version: '0.0.31' },
69	{ name: '@posthog/rrweb', version: '0.0.31' },
70	{ name: '@posthog/rrweb-player', version: '0.0.31' },
71	{ name: '@posthog/rrweb-record', version: '0.0.31' },
72	{ name: '@posthog/rrweb-replay', version: '0.0.19' },
73	{ name: '@posthog/rrweb-snapshot', version: '0.0.31' },
74	{ name: '@posthog/rrweb-utils', version: '0.0.31' },
75	{ name: '@posthog/siphash', version: '1.1.2' },
76	{ name: '@posthog/wizard', version: '1.18.1' },
77	{ name: '@postman/aether-icons', version: '2.23.3' },
78	{ name: '@postman/aether-icons', version: '2.23.4' },
79	{ name: '@postman/aether-icons', version: '2.23.2' },
80	{ name: '@postman/csv-parse', version: '4.0.5' },
81	{ name: '@postman/csv-parse', version: '4.0.3' },
82	{ name: '@postman/csv-parse', version: '4.0.4' },
83	{ name: '@postman/node-keytar', version: '7.9.5' },
84	{ name: '@postman/node-keytar', version: '7.9.6' },
85	{ name: '@postman/node-keytar', version: '7.9.4' },
86	{ name: '@postman/tunnel-agent', version: '0.6.7' },
87	{ name: '@postman/tunnel-agent', version: '0.6.6' },
88	{ name: '@postman/tunnel-agent', version: '0.6.5' },
89	{ name: '@voiceflow/common', version: '8.9.2' },
90	{ name: '@voiceflow/common', version: '8.9.1' },
91	{ name: '@zapier/ai-actions', version: '0.1.19' },
92	{ name: '@zapier/ai-actions', version: '0.1.20' },
93	{ name: '@zapier/ai-actions', version: '0.1.18' },
94	{ name: '@zapier/babel-preset-zapier', version: '6.4.2' },
95	{ name: '@zapier/babel-preset-zapier', version: '6.4.3' },
96	{ name: '@zapier/babel-preset-zapier', version: '6.4.1' },
97	{ name: '@zapier/browserslist-config-zapier', version: '1.0.4' },
98	{ name: '@zapier/browserslist-config-zapier', version: '1.0.5' },
99	{ name: '@zapier/browserslist-config-zapier', version: '1.0.3' },
100	{ name: '@zapier/secret-scrubber', version: '1.1.5' },
101	{ name: '@zapier/secret-scrubber', version: '1.1.3' },
102	{ name: '@zapier/secret-scrubber', version: '1.1.4' },
103	{ name: 'blob-to-base64', version: '1.0.3' },
104	{ name: 'cpu-instructions', version: '0.0.14' },
105	{ name: 'crypto-addr-codec', version: '0.1.9' },
106	{ name: 'enforce-branch-name', version: '1.1.3' },
107	{ name: 'ethereum-ens', version: '0.8.1' },
108	{ name: 'formik-error-focus', version: '2.0.1' },
109	{ name: 'fuzzy-finder', version: '1.0.5' },
110	{ name: 'fuzzy-finder', version: '1.0.6' },
111	{ name: 'gatsby-plugin-cname', version: '1.0.1' },
112	{ name: 'gatsby-plugin-cname', version: '1.0.2' },
113	{ name: 'get-them-args', version: '1.3.3' },
114	{ name: 'kill-port', version: '2.0.2' },
115	{ name: 'posthog-docusaurus', version: '2.0.6' },
116	{ name: 'posthog-js', version: '1.297.3' },
117	{ name: 'posthog-node', version: '5.13.3' },
118	{ name: 'posthog-node', version: '5.11.3' },
119	{ name: 'posthog-node', version: '4.18.1' },
120	{ name: 'posthog-react-native', version: '4.11.1' },
121	{ name: 'posthog-react-native', version: '4.12.5' },
122	{ name: 'posthog-react-native-session-replay', version: '1.2.2' },
123	{ name: 'react-hook-form-persist', version: '3.0.1' },
124	{ name: 'react-native-email', version: '2.1.2' },
125	{ name: 'react-native-email', version: '2.1.1' },
126	{ name: 'react-native-google-maps-directions', version: '2.1.2' },
127	{ name: 'react-native-phone-call', version: '1.2.2' },
128	{ name: 'react-native-phone-call', version: '1.2.1' },
129	{ name: 'react-native-websocket', version: '1.0.3' },
130	{ name: 'shell-exec', version: '1.1.3' },
131	{ name: 'shell-exec', version: '1.1.4' },
132	{ name: 'sort-by-distance', version: '2.0.1' },
133	{ name: 'template-lib', version: '1.1.3' },
134	{ name: 'template-lib', version: '1.1.4' },
135	{ name: 'tenacious-fetch', version: '2.3.2' },
136	{ name: 'url-encode-decode', version: '1.0.1' },
137	{ name: 'zapier-platform-cli', version: '18.0.4' },
138	{ name: 'zapier-platform-cli', version: '18.0.3' },
139	{ name: 'zapier-platform-cli', version: '18.0.2' },
140	{ name: 'zapier-platform-core', version: '18.0.4' },
141	{ name: 'zapier-platform-core', version: '18.0.3' },
142	{ name: 'zapier-platform-core', version: '18.0.2' },
143	{ name: 'zapier-platform-schema', version: '18.0.4' },
144	{ name: 'zapier-platform-schema', version: '18.0.3' },
145	{ name: 'zapier-platform-schema', version: '18.0.2' }
146]
147
148function findPackageJson(): string | null {
149	let currentDir = process.cwd()
150	const root = '/'
151
152	while (currentDir !== root) {
153		const pkgPath = join(currentDir, 'package.json')
154		if (existsSync(pkgPath)) {
155			return pkgPath
156		}
157		const parentDir = currentDir.split('/').slice(0, -1).join('/')
158		if (!parentDir || parentDir === currentDir) break
159		currentDir = parentDir || root
160	}
161
162	const rootPkgPath = join(root, 'package.json')
163	if (existsSync(rootPkgPath)) {
164		return rootPkgPath
165	}
166
167	return null
168}
169
170function loadPackageJson(): { path: string; content: PackageJson; projectDir: string } {
171	const pkgPath = findPackageJson()
172
173	if (!pkgPath) {
174		console.error('โŒ package.json not found in current directory or any parent directory.')
175		process.exit(1)
176	}
177
178	try {
179		const content = JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson
180		const projectDir = pkgPath.substring(0, pkgPath.lastIndexOf('/'))
181		return { path: pkgPath, content, projectDir }
182	} catch (error) {
183		console.error(`โŒ Failed to parse package.json: ${error}`)
184		process.exit(1)
185	}
186}
187
188function extractDepsFromPackageJson(pkg: PackageJson): Array<PackageEntry> {
189	const deps: Array<PackageEntry> = []
190
191	const sections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const
192	for (const section of sections) {
193		if (pkg[section]) {
194			for (const [name, version] of Object.entries(pkg[section])) {
195				deps.push({ name, version })
196			}
197		}
198	}
199
200	return deps
201}
202
203function parseNpmLockfile(projectDir: string): Array<PackageEntry> {
204	const lockfilePath = join(projectDir, 'package-lock.json')
205	if (!existsSync(lockfilePath)) return []
206
207	try {
208		const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf-8'))
209		const deps: Array<PackageEntry> = []
210
211		// package-lock.json v2/v3 format
212		if (lockfile.packages) {
213			for (const [pkgPath, info] of Object.entries<any>(lockfile.packages)) {
214				if (pkgPath === '') continue // root package
215				const name = pkgPath.startsWith('node_modules/')
216					? pkgPath.replace('node_modules/', '')
217					: pkgPath
218				if (info.version) {
219					deps.push({ name, version: info.version })
220				}
221			}
222		}
223
224		// package-lock.json v1 format fallback
225		if (lockfile.dependencies && deps.length === 0) {
226			for (const [name, info] of Object.entries<any>(lockfile.dependencies)) {
227				if (info.version) {
228					deps.push({ name, version: info.version })
229				}
230			}
231		}
232
233		return deps
234	} catch (error) {
235		console.warn(`โš ๏ธ  Failed to parse package-lock.json: ${error}`)
236		return []
237	}
238}
239
240function parsePnpmLockfile(projectDir: string): Array<PackageEntry> {
241	const lockfilePath = join(projectDir, 'pnpm-lock.yaml')
242	if (!existsSync(lockfilePath)) return []
243
244	try {
245		const content = readFileSync(lockfilePath, 'utf-8')
246		const deps: Array<PackageEntry> = []
247
248		// Simple regex parsing for pnpm lock format
249		// Format: /@scope/package@version or /package@version
250		const packageRegex = /^\s+['"]?([^'":\s]+)['"]?:\s+(['"])?([^'"\s]+)\2/gm
251		let match
252
253		while ((match = packageRegex.exec(content)) !== null) {
254			const fullName = match[1]
255			const versionInfo = match[3]
256
257			// Extract package name and version
258			// Handle scoped packages: @scope/package@version
259			// Handle regular packages: package@version
260			const lastAtIndex = fullName.lastIndexOf('@')
261			if (lastAtIndex > 0) {
262				const name = fullName.substring(0, lastAtIndex)
263				const version = fullName.substring(lastAtIndex + 1)
264				if (version && !version.startsWith('link:') && !version.startsWith('file:')) {
265					// Clean version from any additional info
266					const cleanVersion = version.split('(')[0].trim()
267					deps.push({ name, version: cleanVersion })
268				}
269			}
270		}
271
272		return deps
273	} catch (error) {
274		console.warn(`โš ๏ธ  Failed to parse pnpm-lock.yaml: ${error}`)
275		return []
276	}
277}
278
279function parseYarnLockfile(projectDir: string): Array<PackageEntry> {
280	const lockfilePath = join(projectDir, 'yarn.lock')
281	if (!existsSync(lockfilePath)) return []
282
283	try {
284		const content = readFileSync(lockfilePath, 'utf-8')
285		const deps: Array<PackageEntry> = []
286
287		// Yarn lock format parsing
288		// Format: "package@version", package@version:
289		const lines = content.split('\n')
290		let currentPackage = ''
291
292		for (let i = 0; i < lines.length; i++) {
293			const line = lines[i]
294
295			// Package declaration line (not indented or starts with quote)
296			if (line && !line.startsWith(' ') && !line.startsWith('\t')) {
297				// Extract package name from declaration like: "package@^1.0.0", "@scope/package@^1.0.0":
298				const packageMatch = line.match(/^["']?(@?[^@"']+)@/)
299				if (packageMatch) {
300					currentPackage = packageMatch[1]
301				}
302			}
303
304			// Version line (indented with "version")
305			if (line.trim().startsWith('version ') && currentPackage) {
306				const versionMatch = line.match(/version\s+["']([^"']+)["']/)
307				if (versionMatch) {
308					deps.push({ name: currentPackage, version: versionMatch[1] })
309					currentPackage = ''
310				}
311			}
312		}
313
314		return deps
315	} catch (error) {
316		console.warn(`โš ๏ธ  Failed to parse yarn.lock: ${error}`)
317		return []
318	}
319}
320
321function detectPackageManager(projectDir: string): string {
322	if (existsSync(join(projectDir, 'pnpm-lock.yaml'))) return 'pnpm'
323	if (existsSync(join(projectDir, 'yarn.lock'))) return 'yarn'
324	if (existsSync(join(projectDir, 'package-lock.json'))) return 'npm'
325	return 'unknown'
326}
327
328function main() {
329	const { path: pkgPath, content: pkg, projectDir } = loadPackageJson()
330
331	console.log(`๐Ÿ“ฆ Checking: ${pkgPath}`)
332
333	const packageManager = detectPackageManager(projectDir)
334	console.log(`๐Ÿ“‹ Package manager: ${packageManager}`)
335
336	// Collect dependencies from all sources
337	const allDeps = new Map<string, Set<string>>()
338
339	// Add package.json dependencies
340	const packageJsonDeps = extractDepsFromPackageJson(pkg)
341	for (const dep of packageJsonDeps) {
342		if (!allDeps.has(dep.name)) {
343			allDeps.set(dep.name, new Set())
344		}
345		allDeps.get(dep.name)!.add(dep.version)
346	}
347
348	// Add lockfile dependencies
349	let lockfileDeps: Array<PackageEntry> = []
350	switch (packageManager) {
351		case 'npm':
352			lockfileDeps = parseNpmLockfile(projectDir)
353			break
354		case 'pnpm':
355			lockfileDeps = parsePnpmLockfile(projectDir)
356			break
357		case 'yarn':
358			lockfileDeps = parseYarnLockfile(projectDir)
359			break
360	}
361
362	if (lockfileDeps.length > 0) {
363		console.log(`๐Ÿ”’ Found ${lockfileDeps.length} packages in lockfile`)
364		for (const dep of lockfileDeps) {
365			if (!allDeps.has(dep.name)) {
366				allDeps.set(dep.name, new Set())
367			}
368			allDeps.get(dep.name)!.add(dep.version)
369		}
370	}
371
372	// Check for compromised packages
373	const compromised: Array<{ name: string; version: string; source: string }> = []
374
375	for (const [name, versions] of allDeps.entries()) {
376		for (const version of versions) {
377			const isCompromised = COMPROMISED_PACKAGES.some(
378				(bad) => bad.name === name && bad.version === version
379			)
380			if (isCompromised) {
381				const source = packageJsonDeps.some(d => d.name === name && d.version === version)
382					? 'package.json'
383					: 'lockfile'
384				compromised.push({ name, version, source })
385			}
386		}
387	}
388
389	if (compromised.length === 0) {
390		console.log('โœ… No compromised packages found.')
391		return
392	}
393
394	console.log('\n๐Ÿšจ COMPROMISED PACKAGES DETECTED!')
395	console.log('โ”'.repeat(60))
396	for (const c of compromised) {
397		console.log(`โŒ ${c.name}@${c.version} (found in: ${c.source})`)
398	}
399
400	console.log('\n๐Ÿ”ง Recommended actions:')
401	console.log('1. Remove node_modules directory')
402	console.log('2. Clear package manager cache:')
403	console.log('   - npm: npm cache clean --force')
404	console.log('   - pnpm: pnpm store prune')
405	console.log('   - yarn: yarn cache clean')
406	console.log('3. Pin packages to safe versions in package.json (use exact versions)')
407	console.log('4. Delete lockfile and regenerate it')
408	console.log('5. Reinstall dependencies')
409	console.log('6. Rotate all secrets/tokens (GitHub, CI/CD, cloud providers)')
410	console.log('7. Audit GitHub org for suspicious repos named "Shai-Hulud" or "SHA1-HULUD"')
411	console.log('\n๐Ÿ“š More info: https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack')
412
413	process.exit(1)
414}
415
416main()
417