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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +75 -0
- data/lib/vtk/commands/scan/README.md +102 -0
- data/lib/vtk/commands/scan/credentials.rb +59 -0
- data/lib/vtk/commands/scan/repo.rb +77 -0
- data/lib/vtk/commands/scan.rb +41 -3
- data/lib/vtk/version.rb +1 -1
- data/scripts/credential-audit.ps1 +620 -0
- data/scripts/credential-audit.sh +535 -0
- data/scripts/shai-hulud-machine-check.ps1 +625 -0
- data/scripts/shai-hulud-repo-check.ps1 +615 -0
- data/scripts/shai-hulud-repo-check.sh +849 -0
- metadata +10 -2
|
@@ -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
|
+
}
|