vtk 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,615 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Shai-Hulud Repository Scanner for Windows
4
+
5
+ .DESCRIPTION
6
+ Scans a repository (or directory tree) for compromised npm packages
7
+ and backdoor GitHub workflow files associated with the Shai-Hulud attack.
8
+
9
+ WHAT THIS CHECKS:
10
+
11
+ Lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml):
12
+ - Compares installed packages against known compromised package list
13
+ - Supports recursive scanning for monorepos
14
+
15
+ Backdoor Workflows (.github\workflows\):
16
+ - discussion.yaml: Self-hosted runner with unescaped discussion body
17
+ - formatter_[0-9]*.yml: Timestamp-based secrets extraction workflows
18
+
19
+ EXIT CODES:
20
+ 0 - Clean (no issues found)
21
+ 1 - INFECTED (compromised packages found)
22
+ 2 - WARNING (backdoor workflows found, but no compromised packages)
23
+
24
+ .PARAMETER Path
25
+ Directory to scan (default: current directory)
26
+
27
+ .PARAMETER Recursive
28
+ Recursively scan subdirectories (default depth: 5)
29
+
30
+ .PARAMETER Depth
31
+ Max directory depth for recursive scan (default: 5, 0=unlimited)
32
+
33
+ .PARAMETER Quiet
34
+ Exit code only, no output
35
+
36
+ .PARAMETER Json
37
+ Output results as JSON
38
+
39
+ .PARAMETER Refresh
40
+ Force refresh of compromised packages list
41
+
42
+ .PARAMETER Verbose
43
+ Show each lockfile path as it's scanned
44
+
45
+ .EXAMPLE
46
+ .\shai-hulud-repo-check.ps1
47
+ Scan current directory
48
+
49
+ .EXAMPLE
50
+ .\shai-hulud-repo-check.ps1 -Path C:\Code\my-project
51
+ Scan specific directory
52
+
53
+ .EXAMPLE
54
+ .\shai-hulud-repo-check.ps1 -Recursive
55
+ Recursive scan with default depth
56
+
57
+ .EXAMPLE
58
+ .\shai-hulud-repo-check.ps1 -Json
59
+ JSON output
60
+
61
+ .NOTES
62
+ Author: Eric Boehs / EERT (with Claude Code)
63
+ Version: 1.0.0
64
+ Date: December 2025
65
+ Requires: PowerShell 5.1+
66
+
67
+ References:
68
+ - https://department-of-veterans-affairs.github.io/eert/shai-hulud-dev-machine-cleanup-playbook
69
+ #>
70
+
71
+ [CmdletBinding()]
72
+ param(
73
+ [string]$Path = ".",
74
+ [switch]$Recursive,
75
+ [int]$Depth = 5,
76
+ [switch]$Quiet,
77
+ [switch]$Json,
78
+ [switch]$Refresh,
79
+ [switch]$Help
80
+ )
81
+
82
+ # Handle help
83
+ if ($Help) {
84
+ Get-Help $MyInvocation.MyCommand.Path -Detailed
85
+ exit 0
86
+ }
87
+
88
+ # Configuration
89
+ $CompromisedPackagesUrl = "https://raw.githubusercontent.com/Cobenian/shai-hulud-detect/main/compromised-packages.txt"
90
+ $CacheDir = Join-Path $env:LOCALAPPDATA "vtk"
91
+ $CacheFile = Join-Path $CacheDir "compromised-packages.txt"
92
+ $CacheTTL = 86400 # 24 hours in seconds
93
+ $MinExpectedPackages = 500
94
+ $ExpectedHeader = "Shai-Hulud NPM Supply Chain Attack"
95
+ $PlaybookUrl = "https://department-of-veterans-affairs.github.io/eert/shai-hulud-dev-machine-cleanup-playbook"
96
+
97
+ # Resolve path
98
+ try {
99
+ $ScanPath = (Resolve-Path $Path -ErrorAction Stop).Path
100
+ } catch {
101
+ Write-Error "ERROR: Directory not found: $Path"
102
+ exit 1
103
+ }
104
+
105
+ # Results tracking
106
+ $script:CompromisedFindings = [System.Collections.ArrayList]::new()
107
+ $script:BackdoorFindings = [System.Collections.ArrayList]::new()
108
+ $script:Warnings = [System.Collections.ArrayList]::new()
109
+ $script:LockfilesScanned = [System.Collections.ArrayList]::new()
110
+ $script:TotalPackagesScanned = 0
111
+ $script:CompromisedPackagesList = @()
112
+
113
+ # Color support - SupportsVirtualTerminal doesn't exist in PS 5.1, so check safely
114
+ $UseColors = -not $Quiet -and -not $Json -and (($Host.UI.psobject.Properties.Name -contains 'SupportsVirtualTerminal') -and $Host.UI.SupportsVirtualTerminal)
115
+
116
+ function Write-Log {
117
+ param([string]$Message)
118
+ if (-not $Quiet -and -not $Json) {
119
+ Write-Host $Message
120
+ }
121
+ }
122
+
123
+ function Write-Status {
124
+ param([string]$Message)
125
+ if (-not $Quiet -and -not $Json) {
126
+ Write-Host $Message -NoNewline
127
+ }
128
+ }
129
+
130
+ ###########################################
131
+ # CACHE MANAGEMENT
132
+ ###########################################
133
+
134
+ function Ensure-CacheDir {
135
+ if (-not (Test-Path $CacheDir -PathType Container)) {
136
+ New-Item -Path $CacheDir -ItemType Directory -Force | Out-Null
137
+ }
138
+ }
139
+
140
+ function Test-CacheStale {
141
+ if (-not (Test-Path $CacheFile -PathType Leaf)) {
142
+ return $true
143
+ }
144
+
145
+ $fileInfo = Get-Item $CacheFile
146
+ $fileAge = ((Get-Date) - $fileInfo.LastWriteTime).TotalSeconds
147
+ return $fileAge -gt $CacheTTL
148
+ }
149
+
150
+ function Test-PackageListValid {
151
+ param([string]$Content)
152
+
153
+ # Check for expected header
154
+ if ($Content -notmatch [regex]::Escape($ExpectedHeader)) {
155
+ Write-Warning "Downloaded file missing expected header - possible MITM or corrupted file"
156
+ return $false
157
+ }
158
+
159
+ # Count packages (non-comment lines with colons)
160
+ $packageCount = ($Content -split "`n" | Where-Object { $_ -notmatch "^#" -and $_ -match ":" }).Count
161
+
162
+ if ($packageCount -lt $MinExpectedPackages) {
163
+ Write-Warning "Downloaded file has only $packageCount packages (expected $MinExpectedPackages+)"
164
+ return $false
165
+ }
166
+
167
+ return $true
168
+ }
169
+
170
+ function Get-CompromisedPackages {
171
+ if (-not $Quiet -and -not $Json) {
172
+ Write-Host "Fetching compromised packages list..." -ForegroundColor DarkGray
173
+ }
174
+
175
+ try {
176
+ $content = Invoke-WebRequest -Uri $CompromisedPackagesUrl -UseBasicParsing -UserAgent "vtk-security-scanner" -ErrorAction Stop
177
+ $contentText = $content.Content
178
+
179
+ if (-not (Test-PackageListValid $contentText)) {
180
+ return $false
181
+ }
182
+
183
+ $contentText | Out-File -FilePath $CacheFile -Encoding UTF8 -Force
184
+
185
+ $count = ($contentText -split "`n" | Where-Object { $_ -notmatch "^#" -and $_ -match ":" }).Count
186
+ if (-not $Quiet -and -not $Json) {
187
+ Write-Host "Cached $count compromised packages" -ForegroundColor DarkGray
188
+ }
189
+
190
+ return $true
191
+ } catch {
192
+ Write-Warning "Failed to fetch compromised packages list: $_"
193
+ return $false
194
+ }
195
+ }
196
+
197
+ function Initialize-CompromisedPackages {
198
+ Ensure-CacheDir
199
+
200
+ if ($Refresh -or (Test-CacheStale)) {
201
+ $success = Get-CompromisedPackages
202
+ if (-not $success) {
203
+ if (-not (Test-Path $CacheFile -PathType Leaf)) {
204
+ Write-Error "ERROR: No compromised packages list available. Check your network connection."
205
+ exit 1
206
+ }
207
+ Write-Warning "Using cached version"
208
+ }
209
+ }
210
+
211
+ if (-not (Test-Path $CacheFile -PathType Leaf)) {
212
+ Write-Error "ERROR: No compromised packages list available."
213
+ exit 1
214
+ }
215
+
216
+ # Load into memory for fast lookups
217
+ $script:CompromisedPackagesList = Get-Content $CacheFile |
218
+ Where-Object { $_ -notmatch "^#" -and $_ -match ":" } |
219
+ ForEach-Object { $_.Trim() }
220
+ }
221
+
222
+ function Test-Compromised {
223
+ param([string]$Package)
224
+ return $script:CompromisedPackagesList -contains $Package
225
+ }
226
+
227
+ ###########################################
228
+ # LOCKFILE PARSING
229
+ ###########################################
230
+
231
+ function Find-Lockfiles {
232
+ $lockfiles = @()
233
+
234
+ if ($Recursive) {
235
+ $depthParam = @{}
236
+ if ($Depth -gt 0) {
237
+ $depthParam["Depth"] = $Depth
238
+ }
239
+
240
+ $lockfiles += Get-ChildItem -Path $ScanPath -Include "package-lock.json", "yarn.lock", "pnpm-lock.yaml" -Recurse @depthParam -ErrorAction SilentlyContinue |
241
+ Sort-Object FullName
242
+ } else {
243
+ foreach ($name in @("package-lock.json", "yarn.lock", "pnpm-lock.yaml")) {
244
+ $filePath = Join-Path $ScanPath $name
245
+ if (Test-Path $filePath -PathType Leaf) {
246
+ $lockfiles += Get-Item $filePath
247
+ }
248
+ }
249
+ }
250
+
251
+ return $lockfiles
252
+ }
253
+
254
+ function Parse-PackageLock {
255
+ param([string]$FilePath)
256
+
257
+ $packages = @()
258
+ $content = Get-Content $FilePath -Raw -ErrorAction SilentlyContinue
259
+
260
+ if (-not $content) { return $packages }
261
+
262
+ # Extract packages from v2/v3 format (node_modules/*)
263
+ $matches = [regex]::Matches($content, '"node_modules/([^"]+)":\s*\{[^}]*"version":\s*"([^"]+)"')
264
+ foreach ($match in $matches) {
265
+ $packages += "$($match.Groups[1].Value):$($match.Groups[2].Value)"
266
+ }
267
+
268
+ # Extract from v1 format (dependencies)
269
+ $matches = [regex]::Matches($content, '"([^"]+)":\s*\{\s*"version":\s*"(\d+\.\d+\.\d+[^"]*)"')
270
+ foreach ($match in $matches) {
271
+ $name = $match.Groups[1].Value
272
+ if ($name -notmatch "node_modules") {
273
+ $packages += "$name`:$($match.Groups[2].Value)"
274
+ }
275
+ }
276
+
277
+ return $packages | Select-Object -Unique
278
+ }
279
+
280
+ function Parse-YarnLock {
281
+ param([string]$FilePath)
282
+
283
+ $packages = @()
284
+ $content = Get-Content $FilePath -ErrorAction SilentlyContinue
285
+
286
+ if (-not $content) { return $packages }
287
+
288
+ $currentPkg = ""
289
+ foreach ($line in $content) {
290
+ # Package header line - check scoped packages (@scope/name) FIRST to avoid false matches
291
+ if ($line -match '^"?(@[^/]+/[^@"]+)@' -or $line -match '^"?([^@][^"@]*)@') {
292
+ $currentPkg = $matches[1]
293
+ }
294
+ # Version line
295
+ elseif ($line -match '^\s+version\s+"?([^"]+)"?' -and $currentPkg) {
296
+ $packages += "$currentPkg`:$($matches[1])"
297
+ $currentPkg = ""
298
+ }
299
+ }
300
+
301
+ return $packages | Select-Object -Unique
302
+ }
303
+
304
+ function Parse-PnpmLock {
305
+ param([string]$FilePath)
306
+
307
+ $packages = @()
308
+ $content = Get-Content $FilePath -ErrorAction SilentlyContinue
309
+
310
+ if (-not $content) { return $packages }
311
+
312
+ foreach ($line in $content) {
313
+ # pnpm format: /@scope/pkg@1.2.3: or /pkg@1.2.3:
314
+ if ($line -match "^\s+'?/?(@?[^@:]+)@([^:']+)") {
315
+ $packages += "$($matches[1]):$($matches[2])"
316
+ }
317
+ }
318
+
319
+ return $packages | Select-Object -Unique
320
+ }
321
+
322
+ function Parse-Lockfile {
323
+ param([string]$FilePath)
324
+
325
+ $fileName = Split-Path $FilePath -Leaf
326
+
327
+ switch ($fileName) {
328
+ "package-lock.json" { return Parse-PackageLock $FilePath }
329
+ "yarn.lock" { return Parse-YarnLock $FilePath }
330
+ "pnpm-lock.yaml" { return Parse-PnpmLock $FilePath }
331
+ default { return @() }
332
+ }
333
+ }
334
+
335
+ function Check-Lockfiles {
336
+ $lockfiles = Find-Lockfiles
337
+
338
+ if ($lockfiles.Count -eq 0) {
339
+ [void]$script:Warnings.Add("No lockfiles found (package-lock.json, yarn.lock, or pnpm-lock.yaml)")
340
+ return
341
+ }
342
+
343
+ $total = $lockfiles.Count
344
+ $count = 0
345
+
346
+ foreach ($lockfile in $lockfiles) {
347
+ $count++
348
+ $relPath = $lockfile.FullName.Replace($ScanPath, "").TrimStart([char[]]@('\', '/'))
349
+
350
+ # Progress display
351
+ if (-not $Quiet -and -not $Json) {
352
+ if ($VerbosePreference -eq 'Continue') {
353
+ Write-Host "[$count/$total] $($lockfile.FullName)"
354
+ } else {
355
+ Write-Host "[$count/$total] $relPath"
356
+ }
357
+ }
358
+
359
+ $packages = Parse-Lockfile $lockfile.FullName
360
+ $pkgCount = $packages.Count
361
+
362
+ # Track compromised for this lockfile
363
+ $lockfileCompromised = @()
364
+
365
+ $pkgScanned = 0
366
+ foreach ($pkg in $packages) {
367
+ if (-not $pkg) { continue }
368
+ $pkgScanned++
369
+
370
+ if (Test-Compromised $pkg) {
371
+ [void]$script:CompromisedFindings.Add([PSCustomObject]@{
372
+ Package = $pkg
373
+ Lockfile = $lockfile.FullName
374
+ })
375
+ $lockfileCompromised += $pkg
376
+ }
377
+ }
378
+
379
+ $script:TotalPackagesScanned += $pkgScanned
380
+
381
+ [void]$script:LockfilesScanned.Add([PSCustomObject]@{
382
+ Path = $lockfile.FullName
383
+ PackagesScanned = $pkgScanned
384
+ Compromised = $lockfileCompromised
385
+ })
386
+ }
387
+
388
+ # Summary line
389
+ if (-not $Quiet -and -not $Json) {
390
+ Write-Host "Scanned $total lockfiles ($($script:TotalPackagesScanned) packages)."
391
+ }
392
+ }
393
+
394
+ ###########################################
395
+ # BACKDOOR WORKFLOW DETECTION
396
+ ###########################################
397
+
398
+ function Find-WorkflowDirs {
399
+ if ($Recursive) {
400
+ $depthParam = @{}
401
+ if ($Depth -gt 0) {
402
+ $depthParam["Depth"] = $Depth
403
+ }
404
+ return Get-ChildItem -Path $ScanPath -Directory -Filter "workflows" -Recurse @depthParam -ErrorAction SilentlyContinue |
405
+ Where-Object { $_.Parent.Name -eq ".github" }
406
+ } else {
407
+ $dir = Join-Path $ScanPath ".github\workflows"
408
+ if (Test-Path $dir -PathType Container) {
409
+ return Get-Item $dir
410
+ }
411
+ }
412
+ return @()
413
+ }
414
+
415
+ function Check-DiscussionBackdoor {
416
+ param([string]$WorkflowsDir)
417
+
418
+ foreach ($filename in @("discussion.yaml", "discussion.yml")) {
419
+ $workflowPath = Join-Path $WorkflowsDir $filename
420
+ if (-not (Test-Path $workflowPath -PathType Leaf)) { continue }
421
+
422
+ $content = Get-Content $workflowPath -Raw -ErrorAction SilentlyContinue
423
+
424
+ # Check for malicious pattern
425
+ if ($content -match "discussion" -and $content -match "self-hosted" -and $content -match '\$\{\{\s*github\.event\.discussion\.body\s*\}\}') {
426
+ [void]$script:BackdoorFindings.Add([PSCustomObject]@{
427
+ File = $workflowPath
428
+ Type = "discussion_backdoor"
429
+ })
430
+ }
431
+ }
432
+ }
433
+
434
+ function Check-FormatterBackdoor {
435
+ param([string]$WorkflowsDir)
436
+
437
+ # Match timestamp-based formatter files (formatter_ + digits) per Wiz report
438
+ # This reduces false positives on legitimate files like formatter_config.yml
439
+ $formatterFiles = Get-ChildItem -Path $WorkflowsDir -Filter "formatter_[0-9]*.yml" -ErrorAction SilentlyContinue
440
+
441
+ foreach ($file in $formatterFiles) {
442
+ [void]$script:BackdoorFindings.Add([PSCustomObject]@{
443
+ File = $file.FullName
444
+ Type = "secrets_extraction"
445
+ })
446
+ }
447
+ }
448
+
449
+ function Check-BackdoorWorkflows {
450
+ $workflowDirs = Find-WorkflowDirs
451
+
452
+ foreach ($dir in $workflowDirs) {
453
+ Check-DiscussionBackdoor $dir.FullName
454
+ Check-FormatterBackdoor $dir.FullName
455
+ }
456
+ }
457
+
458
+ ###########################################
459
+ # OUTPUT
460
+ ###########################################
461
+
462
+ function Report-Text {
463
+ Write-Log ""
464
+
465
+ # Report compromised packages
466
+ if ($script:CompromisedFindings.Count -gt 0) {
467
+ if ($UseColors) {
468
+ Write-Host "COMPROMISED PACKAGES FOUND:" -ForegroundColor Red
469
+ } else {
470
+ Write-Log "COMPROMISED PACKAGES FOUND:"
471
+ }
472
+ foreach ($finding in $script:CompromisedFindings) {
473
+ Write-Log " $($finding.Package)"
474
+ Write-Log " in $($finding.Lockfile)"
475
+ }
476
+ Write-Log ""
477
+ }
478
+
479
+ # Report backdoors
480
+ if ($script:BackdoorFindings.Count -gt 0) {
481
+ if ($UseColors) {
482
+ Write-Host "BACKDOOR WORKFLOWS FOUND:" -ForegroundColor Red
483
+ } else {
484
+ Write-Log "BACKDOOR WORKFLOWS FOUND:"
485
+ }
486
+ foreach ($finding in $script:BackdoorFindings) {
487
+ Write-Log " $($finding.File)"
488
+ Write-Log " Type: $($finding.Type)"
489
+ }
490
+ Write-Log ""
491
+ }
492
+
493
+ # Report warnings
494
+ if ($script:CompromisedFindings.Count -eq 0 -and $script:BackdoorFindings.Count -eq 0 -and $script:Warnings.Count -gt 0) {
495
+ foreach ($warning in $script:Warnings) {
496
+ if ($UseColors) {
497
+ Write-Host "WARNING: $warning" -ForegroundColor Yellow
498
+ } else {
499
+ Write-Log "WARNING: $warning"
500
+ }
501
+ }
502
+ Write-Log ""
503
+ }
504
+
505
+ # Status
506
+ if ($script:CompromisedFindings.Count -gt 0) {
507
+ if ($UseColors) {
508
+ Write-Host "Status: INFECTED - Compromised packages found" -ForegroundColor Red
509
+ } else {
510
+ Write-Log "Status: INFECTED - Compromised packages found"
511
+ }
512
+ } elseif ($script:BackdoorFindings.Count -gt 0) {
513
+ if ($UseColors) {
514
+ Write-Host "Status: WARNING - Backdoor workflows found" -ForegroundColor Yellow
515
+ } else {
516
+ Write-Log "Status: WARNING - Backdoor workflows found"
517
+ }
518
+ } else {
519
+ if ($UseColors) {
520
+ Write-Host "Status: CLEAN" -ForegroundColor Green
521
+ } else {
522
+ Write-Log "Status: CLEAN"
523
+ }
524
+ }
525
+
526
+ if ($script:CompromisedFindings.Count -gt 0 -or $script:BackdoorFindings.Count -gt 0) {
527
+ Write-Log ""
528
+ Write-Log "See cleanup playbook:"
529
+ Write-Log " $PlaybookUrl"
530
+ }
531
+ }
532
+
533
+ function Report-Json {
534
+ $status = if ($script:CompromisedFindings.Count -gt 0) { "INFECTED - Compromised packages found" }
535
+ elseif ($script:BackdoorFindings.Count -gt 0) { "WARNING - Backdoor workflows found" }
536
+ else { "CLEAN" }
537
+
538
+ $lockfilesJson = @()
539
+ foreach ($lf in $script:LockfilesScanned) {
540
+ $lockfilesJson += [PSCustomObject]@{
541
+ path = $lf.Path
542
+ packages_scanned = $lf.PackagesScanned
543
+ }
544
+ }
545
+
546
+ $compromisedJson = @()
547
+ foreach ($finding in $script:CompromisedFindings) {
548
+ $compromisedJson += [PSCustomObject]@{
549
+ package = $finding.Package
550
+ lockfile = $finding.Lockfile
551
+ }
552
+ }
553
+
554
+ $backdoorsJson = @()
555
+ foreach ($finding in $script:BackdoorFindings) {
556
+ $backdoorsJson += [PSCustomObject]@{
557
+ file = $finding.File
558
+ type = $finding.Type
559
+ }
560
+ }
561
+
562
+ $output = [ordered]@{
563
+ path = $ScanPath
564
+ status = $status
565
+ packages_scanned = $script:TotalPackagesScanned
566
+ lockfiles_scanned = $lockfilesJson
567
+ compromised_packages = $compromisedJson
568
+ backdoors = $backdoorsJson
569
+ warnings = @($script:Warnings)
570
+ }
571
+
572
+ $output | ConvertTo-Json -Depth 4
573
+ }
574
+
575
+ ###########################################
576
+ # MAIN
577
+ ###########################################
578
+
579
+ # Show scan info
580
+ if (-not $Quiet -and -not $Json) {
581
+ if ($Recursive) {
582
+ if ($Depth -eq 0) {
583
+ Write-Log "Scanning: $ScanPath (recursive, unlimited depth)"
584
+ } else {
585
+ Write-Log "Scanning: $ScanPath (recursive, max depth: $Depth)"
586
+ }
587
+ } else {
588
+ Write-Log "Scanning: $ScanPath"
589
+ }
590
+ }
591
+
592
+ # Load compromised packages
593
+ Initialize-CompromisedPackages
594
+
595
+ # Run checks
596
+ Check-Lockfiles
597
+ Check-BackdoorWorkflows
598
+
599
+ # Output results
600
+ if (-not $Quiet) {
601
+ if ($Json) {
602
+ Report-Json
603
+ } else {
604
+ Report-Text
605
+ }
606
+ }
607
+
608
+ # Exit code
609
+ if ($script:CompromisedFindings.Count -gt 0) {
610
+ exit 1
611
+ } elseif ($script:BackdoorFindings.Count -gt 0) {
612
+ exit 2
613
+ } else {
614
+ exit 0
615
+ }