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.Install Bun: https://bun.sh
- 2.Download the script to your project folder (same directory as package.json)
- 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