white-hat-scanner 1.0.1

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,974 @@
1
+ import { execSync, spawnSync } from 'child_process'
2
+ import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { tmpdir } from 'os'
5
+ import { log } from './redis'
6
+ import type { Protocol } from './discovery'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // solc-select helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function findSolcSelectBin(): string | null {
13
+ const candidates = [
14
+ '/opt/homebrew/bin/solc-select',
15
+ '/usr/local/bin/solc-select',
16
+ '/usr/bin/solc-select',
17
+ ]
18
+ for (const p of candidates) {
19
+ if (existsSync(p)) return p
20
+ }
21
+ try {
22
+ const fromWhich = execSync('which solc-select 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }).trim()
23
+ if (fromWhich) return fromWhich
24
+ } catch {}
25
+ return null
26
+ }
27
+
28
+ /**
29
+ * Parse pragma solidity version constraints from .sol files in repoDir.
30
+ * Returns the most commonly required version string (e.g. "0.7.6", "0.8.17").
31
+ * Returns null if no pragmas found or version can't be resolved.
32
+ */
33
+ export function detectSolcVersion(repoDir: string): string | null {
34
+ try {
35
+ const result = spawnSync(
36
+ 'grep',
37
+ ['-rh', '--include=*.sol', 'pragma solidity', repoDir],
38
+ { encoding: 'utf8', timeout: 10_000 }
39
+ )
40
+ if (result.status !== 0 && !result.stdout) return null
41
+
42
+ const lines = result.stdout.trim().split('\n').filter(Boolean)
43
+ // Extract version strings, e.g. "=0.7.6", "^0.8.17", ">=0.8.0 <0.9.0"
44
+ const versionCounts: Record<string, number> = {}
45
+ for (const line of lines) {
46
+ // Match pinned versions first (=0.x.y), then caret/tilde, then bare ranges
47
+ const pinned = line.match(/pragma solidity\s*=\s*(0\.\d+\.\d+)/)
48
+ if (pinned) {
49
+ versionCounts[pinned[1]] = (versionCounts[pinned[1]] ?? 0) + 1
50
+ continue
51
+ }
52
+ const caretOrTilde = line.match(/pragma solidity\s*[\^~]\s*(0\.\d+\.\d+)/)
53
+ if (caretOrTilde) {
54
+ versionCounts[caretOrTilde[1]] = (versionCounts[caretOrTilde[1]] ?? 0) + 1
55
+ continue
56
+ }
57
+ // >=0.x.y or just 0.x.y
58
+ const any = line.match(/pragma solidity[^;]*?(0\.\d+\.\d+)/)
59
+ if (any) {
60
+ versionCounts[any[1]] = (versionCounts[any[1]] ?? 0) + 1
61
+ }
62
+ }
63
+
64
+ if (Object.keys(versionCounts).length === 0) return null
65
+
66
+ // Return the most common version
67
+ return Object.entries(versionCounts).sort((a, b) => b[1] - a[1])[0][0]
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ // Known good fallback versions installed on this machine (by minor series)
74
+ const FALLBACK_VERSIONS: Record<string, string> = {
75
+ '0.4': '0.4.26',
76
+ '0.5': '0.5.17',
77
+ '0.6': '0.6.12',
78
+ '0.7': '0.7.6',
79
+ '0.8': '0.8.20',
80
+ }
81
+
82
+ /**
83
+ * Ensure a specific solc version is installed and active via solc-select.
84
+ * Returns the version that was set, or null on failure.
85
+ */
86
+ function setSolcVersion(version: string): string | null {
87
+ const bin = findSolcSelectBin()
88
+ if (!bin) return null
89
+
90
+ const toolEnv = {
91
+ ...process.env,
92
+ PATH: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH].filter(Boolean).join(':'),
93
+ }
94
+
95
+ // Resolve to a known stable patch version if only major.minor is given
96
+ const minor = version.match(/^(0\.\d+)/)
97
+ const resolvedVersion = FALLBACK_VERSIONS[minor?.[1] ?? ''] ?? version
98
+
99
+ try {
100
+ // Try to use directly first (already installed)
101
+ const useResult = spawnSync(bin, ['use', resolvedVersion], {
102
+ encoding: 'utf8', timeout: 15_000, stdio: 'pipe', env: toolEnv,
103
+ })
104
+ if (useResult.status === 0) return resolvedVersion
105
+ } catch {}
106
+
107
+ // Not installed — install it
108
+ try {
109
+ spawnSync(bin, ['install', resolvedVersion], {
110
+ encoding: 'utf8', timeout: 120_000, stdio: 'pipe', env: toolEnv,
111
+ })
112
+ const useResult2 = spawnSync(bin, ['use', resolvedVersion], {
113
+ encoding: 'utf8', timeout: 15_000, stdio: 'pipe', env: toolEnv,
114
+ })
115
+ if (useResult2.status === 0) return resolvedVersion
116
+ } catch {}
117
+
118
+ return null
119
+ }
120
+
121
+ /**
122
+ * Detect import remappings from remappings.txt or foundry.toml.
123
+ * Returns array of remapping strings like ["@openzeppelin/=lib/openzeppelin/"].
124
+ */
125
+ function detectRemappings(repoDir: string): string[] {
126
+ // remappings.txt (one per line)
127
+ const remappingsTxt = join(repoDir, 'remappings.txt')
128
+ if (existsSync(remappingsTxt)) {
129
+ try {
130
+ return readFileSync(remappingsTxt, 'utf8')
131
+ .split('\n')
132
+ .map((l) => l.trim())
133
+ .filter(Boolean)
134
+ } catch {}
135
+ }
136
+
137
+ // foundry.toml — extract remappings array
138
+ const foundryToml = join(repoDir, 'foundry.toml')
139
+ if (existsSync(foundryToml)) {
140
+ try {
141
+ const content = readFileSync(foundryToml, 'utf8')
142
+ const remappingsMatch = content.match(/remappings\s*=\s*\[([\s\S]*?)\]/)
143
+ if (remappingsMatch) {
144
+ return remappingsMatch[1]
145
+ .split('\n')
146
+ .map((l) => l.replace(/["',]/g, '').trim())
147
+ .filter(Boolean)
148
+ }
149
+ } catch {}
150
+ }
151
+
152
+ return []
153
+ }
154
+
155
+ /**
156
+ * Docker-based Slither fallback using trailofbits/eth-security-toolbox.
157
+ * Only attempted if Docker is available and image is present (no auto-pull to avoid long waits).
158
+ */
159
+ function runSlitherDocker(repoDir: string, solcVersion: string | null): SlitherResult {
160
+ try {
161
+ // Check docker is available
162
+ const dockerCheck = spawnSync('docker', ['info'], { encoding: 'utf8', timeout: 5_000, stdio: 'pipe' })
163
+ if (dockerCheck.status !== 0) return { findings: [], status: 'compilation_error' }
164
+
165
+ // Check image is already pulled (don't auto-pull — too slow and bandwidth-heavy)
166
+ const imageCheck = spawnSync(
167
+ 'docker', ['image', 'inspect', 'trailofbits/eth-security-toolbox'],
168
+ { encoding: 'utf8', timeout: 5_000, stdio: 'pipe' }
169
+ )
170
+ if (imageCheck.status !== 0) return { findings: [], status: 'compilation_error' }
171
+
172
+ const outputPath = '/tmp/slither-docker-out.json'
173
+ const versionCmd = solcVersion
174
+ ? `solc-select install ${solcVersion} 2>/dev/null; solc-select use ${solcVersion} 2>/dev/null; `
175
+ : ''
176
+ const dockerResult = spawnSync(
177
+ 'docker',
178
+ [
179
+ 'run', '--rm',
180
+ '-v', `${repoDir}:/src`,
181
+ 'trailofbits/eth-security-toolbox',
182
+ 'bash', '-c',
183
+ `cd /src && ${versionCmd}slither . --json ${outputPath} --no-fail-pedantic 2>/dev/null; cat ${outputPath} 2>/dev/null`,
184
+ ],
185
+ { encoding: 'utf8', timeout: 240_000, stdio: 'pipe' }
186
+ )
187
+
188
+ if (!dockerResult.stdout) return { findings: [], status: 'compilation_error' }
189
+
190
+ // Output may have extra lines before the JSON
191
+ const jsonStart = dockerResult.stdout.indexOf('{')
192
+ if (jsonStart === -1) return { findings: [], status: 'compilation_error' }
193
+
194
+ const parsed = JSON.parse(dockerResult.stdout.slice(jsonStart)) as SlitherOutput
195
+ const detectors = parsed?.results?.detectors || []
196
+ const findings = detectors.filter(
197
+ (d) => d.impact === 'High' || d.impact === 'Medium' || d.impact === 'Critical'
198
+ )
199
+ return { findings, status: 'success' }
200
+ } catch {
201
+ return { findings: [], status: 'compilation_error' }
202
+ }
203
+ }
204
+
205
+ export type SlitherStatus = 'success' | 'compilation_error' | 'unavailable' | 'not_applicable'
206
+
207
+ export interface AnalysisResult {
208
+ protocolId: string
209
+ protocolName: string
210
+ chain: string
211
+ tvl: number
212
+ slitherFindings: SlitherFinding[]
213
+ slitherStatus: SlitherStatus
214
+ claudeReview: string
215
+ riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'
216
+ estimatedBounty: number
217
+ disclosureSummary: string
218
+ scannedAt: number
219
+ sourceAvailable: boolean
220
+ error?: string
221
+ }
222
+
223
+ interface SlitherFinding {
224
+ check: string
225
+ impact: string
226
+ confidence: string
227
+ description: string
228
+ elements: unknown[]
229
+ }
230
+
231
+ interface SlitherOutput {
232
+ results?: {
233
+ detectors?: SlitherFinding[]
234
+ }
235
+ }
236
+
237
+ function findClaudeBin(): string {
238
+ const candidates = [
239
+ process.env.CLAUDE_BIN,
240
+ '/Users/feral/.npm-global/bin/claude',
241
+ '/usr/local/bin/claude',
242
+ '/opt/homebrew/bin/claude',
243
+ ].filter(Boolean) as string[]
244
+
245
+ try {
246
+ const fromWhich = execSync('which claude 2>/dev/null', { encoding: 'utf8' }).trim()
247
+ if (fromWhich) candidates.unshift(fromWhich)
248
+ } catch {}
249
+
250
+ for (const p of candidates) {
251
+ if (existsSync(p)) return p
252
+ }
253
+ return candidates[0] || 'claude'
254
+ }
255
+
256
+ function ensureSlither(): boolean {
257
+ // Check common explicit paths first (launchd may not have full PATH)
258
+ const slitherPaths = [
259
+ '/opt/homebrew/bin/slither',
260
+ '/usr/local/bin/slither',
261
+ '/usr/bin/slither',
262
+ ]
263
+ for (const p of slitherPaths) {
264
+ if (existsSync(p)) return true
265
+ }
266
+ try {
267
+ const fromWhich = execSync('which slither 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }).trim()
268
+ if (fromWhich) return true
269
+ } catch {}
270
+ console.log('[whiteh] Slither not found, attempting pip3 install...')
271
+ try {
272
+ execSync('pip3 install slither-analyzer --break-system-packages 2>&1', { encoding: 'utf8', timeout: 120_000, stdio: 'pipe' })
273
+ return existsSync('/opt/homebrew/bin/slither') || existsSync('/usr/local/bin/slither')
274
+ } catch (e) {
275
+ return false
276
+ }
277
+ }
278
+
279
+ interface GHRepo {
280
+ name: string
281
+ stargazers_count: number
282
+ language: string | null
283
+ archived: boolean
284
+ fork: boolean
285
+ }
286
+
287
+ /**
288
+ * DeFiLlama's `github` field is either an org name (e.g. "pendle-finance"),
289
+ * a full repo URL (e.g. "https://github.com/pendle-finance/pendle-core"),
290
+ * or an org URL (e.g. "https://github.com/BeltFi").
291
+ * This function resolves it to a clonable https URL by querying the GitHub API for the
292
+ * org's most relevant public repo (preferring Solidity repos, then by star count).
293
+ */
294
+ export async function resolveCloneUrl(orgOrUrl: string): Promise<string | null> {
295
+ if (orgOrUrl.startsWith('https://github.com/')) {
296
+ // Distinguish org URL (1 path segment) from repo URL (2 path segments)
297
+ const path = orgOrUrl.replace('https://github.com/', '').replace(/\/$/, '')
298
+ const parts = path.split('/').filter(Boolean)
299
+ if (parts.length >= 2) {
300
+ // Already a repo URL — return as-is
301
+ return orgOrUrl
302
+ }
303
+ // Org URL — extract org name and resolve via API
304
+ const org = parts[0]
305
+ if (!org) return null
306
+ return resolveOrgToRepo(org)
307
+ }
308
+
309
+ if (orgOrUrl.startsWith('git@')) {
310
+ return orgOrUrl
311
+ }
312
+
313
+ const org = orgOrUrl.trim()
314
+ if (!org) return null
315
+ return resolveOrgToRepo(org)
316
+ }
317
+
318
+ async function resolveOrgToRepo(org: string): Promise<string | null> {
319
+ try {
320
+ const res = await fetch(
321
+ `https://api.github.com/orgs/${org}/repos?sort=stars&per_page=20&type=public`,
322
+ {
323
+ headers: {
324
+ 'User-Agent': 'white-hat-scanner/1.0',
325
+ Accept: 'application/vnd.github.v3+json',
326
+ },
327
+ signal: AbortSignal.timeout(15_000),
328
+ }
329
+ )
330
+
331
+ if (!res.ok) {
332
+ // Might be a user account rather than an org — try /users/:org/repos
333
+ const res2 = await fetch(
334
+ `https://api.github.com/users/${org}/repos?sort=stars&per_page=20&type=public`,
335
+ {
336
+ headers: {
337
+ 'User-Agent': 'white-hat-scanner/1.0',
338
+ Accept: 'application/vnd.github.v3+json',
339
+ },
340
+ signal: AbortSignal.timeout(15_000),
341
+ }
342
+ )
343
+ if (!res2.ok) return null
344
+ const repos = (await res2.json()) as GHRepo[]
345
+ return pickBestRepo(org, repos)
346
+ }
347
+
348
+ const repos = (await res.json()) as GHRepo[]
349
+ return pickBestRepo(org, repos)
350
+ } catch {
351
+ return null
352
+ }
353
+ }
354
+
355
+ // Repo names that are almost certainly upstream library copies, not the protocol's own code.
356
+ const LIBRARY_REPO_PATTERNS = [
357
+ /^openzeppelin/i,
358
+ /^solmate/i,
359
+ /^solady/i,
360
+ /^forge-std/i,
361
+ /^hardhat/i,
362
+ /^foundry/i,
363
+ /war-room/i,
364
+ /^ds-/i,
365
+ /^dapp-/i,
366
+ ]
367
+
368
+ function isLibraryRepo(name: string): boolean {
369
+ return LIBRARY_REPO_PATTERNS.some((p) => p.test(name))
370
+ }
371
+
372
+ /** Score a repo: higher = better candidate for the protocol's core contracts. */
373
+ function repoScore(org: string, r: GHRepo): number {
374
+ const name = r.name.toLowerCase()
375
+ const orgLower = org.toLowerCase()
376
+ let score = 0
377
+
378
+ // Heavily penalise known library copies
379
+ if (isLibraryRepo(r.name)) return -1000
380
+
381
+ // Reward: org name appears in repo name (e.g. tornadocash/tornado-core)
382
+ if (name.includes(orgLower) || name.replace(/-/g, '').includes(orgLower.replace(/-/g, ''))) {
383
+ score += 50
384
+ }
385
+
386
+ // Reward: Solidity (the protocol's actual language)
387
+ if (r.language === 'Solidity') score += 30
388
+
389
+ // Reward: contract / core / protocol / vault / pool keyword in name
390
+ if (/\b(core|protocol|vault|pool|main|primary)\b/.test(name)) score += 20
391
+ if (name.includes('contract')) score += 10
392
+
393
+ // Stars as tiebreaker (capped to avoid inflated library star counts dominating)
394
+ score += Math.min(r.stargazers_count, 200) * 0.01
395
+
396
+ return score
397
+ }
398
+
399
+ function pickBestRepo(org: string, repos: GHRepo[]): string | null {
400
+ if (!repos || repos.length === 0) return null
401
+
402
+ const active = repos.filter((r) => !r.archived && !r.fork)
403
+ const candidates = active.length > 0 ? active : repos
404
+
405
+ const scored = candidates
406
+ .map((r) => ({ r, score: repoScore(org, r) }))
407
+ .filter(({ score }) => score > -1000)
408
+ .sort((a, b) => b.score - a.score)
409
+
410
+ if (scored.length === 0) return null
411
+ const best = scored[0].r
412
+
413
+ return `https://github.com/${org}/${best.name}`
414
+ }
415
+
416
+ function cloneRepo(githubUrl: string, destDir: string): boolean {
417
+ try {
418
+ const result = spawnSync('git', ['clone', '--depth', '1', '--quiet', githubUrl, destDir], {
419
+ timeout: 60_000,
420
+ encoding: 'utf8',
421
+ })
422
+ return result.status === 0
423
+ } catch {
424
+ return false
425
+ }
426
+ }
427
+
428
+ function findSlitherBin(): string {
429
+ const candidates = [
430
+ '/opt/homebrew/bin/slither',
431
+ '/usr/local/bin/slither',
432
+ '/usr/bin/slither',
433
+ ]
434
+ for (const p of candidates) {
435
+ if (existsSync(p)) return p
436
+ }
437
+ try {
438
+ const fromWhich = execSync('which slither 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }).trim()
439
+ if (fromWhich) return fromWhich
440
+ } catch {}
441
+ return 'slither'
442
+ }
443
+
444
+ function findForgeBin(): string | null {
445
+ const candidates = [
446
+ '/Users/feral/.foundry/bin/forge',
447
+ '/opt/homebrew/bin/forge',
448
+ '/usr/local/bin/forge',
449
+ ]
450
+ for (const p of candidates) {
451
+ if (existsSync(p)) return p
452
+ }
453
+ try {
454
+ const fromWhich = execSync('which forge 2>/dev/null', { encoding: 'utf8', stdio: 'pipe' }).trim()
455
+ if (fromWhich) return fromWhich
456
+ } catch {}
457
+ return null
458
+ }
459
+
460
+ /**
461
+ * Install project dependencies so Slither can compile the contracts.
462
+ * Returns a brief status string for logging.
463
+ */
464
+ export function installDeps(repoDir: string): string {
465
+ // Hardhat / Truffle — npm install (most common for DeFi)
466
+ if (existsSync(join(repoDir, 'package.json'))) {
467
+ try {
468
+ spawnSync('npm', ['install', '--ignore-scripts', '--prefer-offline', '--no-audit'], {
469
+ cwd: repoDir,
470
+ timeout: 120_000,
471
+ encoding: 'utf8',
472
+ stdio: 'pipe',
473
+ })
474
+ } catch {
475
+ return 'npm-failed'
476
+ }
477
+ // Best-effort pre-compilation: Hardhat downloads the right solc version and
478
+ // caches compilation artifacts, which dramatically improves Slither's compile rate.
479
+ try {
480
+ const hardhatBin = join(repoDir, 'node_modules', '.bin', 'hardhat')
481
+ if (existsSync(hardhatBin)) {
482
+ spawnSync('node', [hardhatBin, 'compile', '--quiet'], {
483
+ cwd: repoDir,
484
+ timeout: 120_000,
485
+ encoding: 'utf8',
486
+ stdio: 'pipe',
487
+ })
488
+ }
489
+ } catch {}
490
+ return 'npm'
491
+ }
492
+
493
+ // Foundry — forge install + forge build
494
+ if (existsSync(join(repoDir, 'foundry.toml'))) {
495
+ const forgeBin = findForgeBin()
496
+
497
+ const toolEnv = {
498
+ ...process.env,
499
+ PATH: [
500
+ '/opt/homebrew/bin',
501
+ '/usr/local/bin',
502
+ '/usr/bin',
503
+ '/bin',
504
+ '/Users/feral/.foundry/bin',
505
+ process.env.PATH,
506
+ ].filter(Boolean).join(':'),
507
+ }
508
+
509
+ const libDir = join(repoDir, 'lib')
510
+ // A shallow git clone (--depth 1) may create lib/ as an empty directory when
511
+ // submodules are registered but not populated. Check for actual content so we
512
+ // don't skip forge install on a repo that still needs its dependencies fetched.
513
+ let libHasContent = false
514
+ try {
515
+ libHasContent = existsSync(libDir) && readdirSync(libDir).length > 0
516
+ } catch {}
517
+
518
+ if (forgeBin) {
519
+ if (!libHasContent) {
520
+ // Fetch registered dependencies without cloning full git history
521
+ spawnSync(forgeBin, ['install', '--no-git'], {
522
+ cwd: repoDir,
523
+ timeout: 120_000,
524
+ encoding: 'utf8',
525
+ stdio: 'pipe',
526
+ env: toolEnv,
527
+ })
528
+ }
529
+
530
+ // Pre-build so Slither can use Foundry's compilation artifacts (out/).
531
+ // Best-effort: continue even if some contracts fail to compile.
532
+ spawnSync(forgeBin, ['build', '--skip', 'test', 'script'], {
533
+ cwd: repoDir,
534
+ timeout: 180_000,
535
+ encoding: 'utf8',
536
+ stdio: 'pipe',
537
+ env: toolEnv,
538
+ })
539
+ }
540
+
541
+ return libHasContent ? 'foundry-lib-present' : 'forge'
542
+ }
543
+
544
+ return 'no-pkg-manager'
545
+ }
546
+
547
+ /**
548
+ * Count .sol files in a directory (recursive, ignores node_modules).
549
+ * Returns quickly (milliseconds) and is used as a fast Slither gate —
550
+ * no .sol files means no point running Slither.
551
+ */
552
+ function countSolFiles(dir: string): number {
553
+ try {
554
+ const result = spawnSync(
555
+ 'find',
556
+ [dir, '-name', '*.sol', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/.git/*'],
557
+ { encoding: 'utf8', timeout: 10_000 }
558
+ )
559
+ if (result.status !== 0) return 0
560
+ return result.stdout.trim().split('\n').filter(Boolean).length
561
+ } catch {
562
+ return 0
563
+ }
564
+ }
565
+
566
+ interface SlitherResult {
567
+ findings: SlitherFinding[]
568
+ status: 'success' | 'compilation_error'
569
+ }
570
+
571
+ function runSlither(repoDir: string): SlitherResult {
572
+ const outputPath = join(tmpdir(), `slither-${Date.now()}.json`)
573
+ const slitherBin = findSlitherBin()
574
+
575
+ // Install deps so Slither can compile — without this, Hardhat repos always return 0 findings
576
+ installDeps(repoDir)
577
+
578
+ // Detect the required solc version from pragma statements and switch to it
579
+ const detectedVersion = detectSolcVersion(repoDir)
580
+ if (detectedVersion) {
581
+ setSolcVersion(detectedVersion)
582
+ }
583
+
584
+ // Detect import remappings (remappings.txt or foundry.toml)
585
+ const remappings = detectRemappings(repoDir)
586
+
587
+ // Build PATH that includes common tool locations (launchd has minimal PATH)
588
+ const toolEnv = {
589
+ ...process.env,
590
+ PATH: [
591
+ '/opt/homebrew/bin',
592
+ '/usr/local/bin',
593
+ '/usr/bin',
594
+ '/bin',
595
+ '/Users/feral/.foundry/bin',
596
+ process.env.PATH,
597
+ ].filter(Boolean).join(':'),
598
+ }
599
+
600
+ const slitherArgs = [
601
+ '.',
602
+ '--json', outputPath,
603
+ '--disable-color',
604
+ '--no-fail-pedantic', // continue analysis even if some files fail to compile
605
+ ]
606
+
607
+ if (remappings.length > 0) {
608
+ slitherArgs.push('--solc-remaps', remappings.join(' '))
609
+ }
610
+
611
+ // For Foundry projects, pass --foundry-compile-all to get better coverage
612
+ if (existsSync(join(repoDir, 'foundry.toml'))) {
613
+ slitherArgs.push('--foundry-compile-all')
614
+ }
615
+
616
+ try {
617
+ const result = spawnSync(
618
+ slitherBin,
619
+ slitherArgs,
620
+ {
621
+ cwd: repoDir,
622
+ timeout: 180_000,
623
+ encoding: 'utf8',
624
+ stdio: 'pipe',
625
+ env: toolEnv,
626
+ }
627
+ )
628
+
629
+ // Log stderr for debugging (first 500 chars)
630
+ const errSnippet = (result.stderr ?? '').slice(0, 500)
631
+ if (errSnippet && !existsSync(outputPath)) {
632
+ writeFileSync(join(tmpdir(), `slither-err-${Date.now()}.txt`), errSnippet)
633
+ }
634
+
635
+ if (!existsSync(outputPath)) {
636
+ // Native Slither failed — try Docker fallback
637
+ const dockerResult = runSlitherDocker(repoDir, detectedVersion)
638
+ if (dockerResult.status === 'success') return dockerResult
639
+ return { findings: [], status: 'compilation_error' }
640
+ }
641
+
642
+ const raw = readFileSync(outputPath, 'utf8')
643
+ const parsed = JSON.parse(raw) as SlitherOutput
644
+ const detectors = parsed?.results?.detectors || []
645
+
646
+ const findings = detectors.filter(
647
+ (d) => d.impact === 'High' || d.impact === 'Medium' || d.impact === 'Critical'
648
+ )
649
+ return { findings, status: 'success' }
650
+ } catch {
651
+ return { findings: [], status: 'compilation_error' }
652
+ } finally {
653
+ try {
654
+ if (existsSync(outputPath)) rmSync(outputPath)
655
+ } catch {}
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Returns true if a Solidity file is likely a test, mock, or interface file
661
+ * that shouldn't be prioritized for security review.
662
+ */
663
+ function isTestOrMockFile(filePath: string): boolean {
664
+ const base = filePath.split('/').pop() ?? ''
665
+ const baseLower = base.toLowerCase()
666
+ const pathLower = filePath.toLowerCase()
667
+
668
+ // Files in test/mock/interface directories
669
+ if (/\/(test|tests|mock|mocks|interface|interfaces|fixture|fixtures|stub|stubs|spec)\//.test(pathLower)) return true
670
+
671
+ // Filenames starting with test/mock/interface etc. (case-insensitive)
672
+ if (/^(test|mock|interface|fixture|stub|spec)/.test(baseLower)) return true
673
+
674
+ // Interface files: IPool.sol, IERC20.sol (capital I + capital letter)
675
+ if (/^I[A-Z]/.test(base)) return true
676
+
677
+ // Foundry test style: Foo.t.sol
678
+ if (baseLower.endsWith('.t.sol')) return true
679
+
680
+ // Files ending with Test/Mock/Interface (e.g. PoolTest.sol, TokenMock.sol)
681
+ if (/(test|mock|interface|fixture|stub)\.sol$/.test(baseLower)) return true
682
+
683
+ return false
684
+ }
685
+
686
+ /**
687
+ * Returns a priority score for a Solidity file path.
688
+ * Higher = more likely to be a core protocol contract worth auditing.
689
+ */
690
+ export function contractPriority(filePath: string): number {
691
+ const base = filePath.split('/').pop() ?? ''
692
+
693
+ // Deprioritise test/mock/interface files
694
+ if (isTestOrMockFile(filePath)) return 0
695
+
696
+ // Boost core protocol contracts
697
+ if (/^(Pool|Vault|Core|Main|Protocol|Manager|Controller|Factory|Router|Staking|Lending|Borrow|Exchange|Swap|Bridge|Token|Governor|Governance|Treasury|Strategy|Proxy|Upgradeable)/i.test(base)) return 3
698
+ if (/^(Base|Abstract|Lib|Library|Helper|Utils|Math)/i.test(base)) return 1
699
+
700
+ return 2 // default: include, medium priority
701
+ }
702
+
703
+ function summarizeContracts(repoDir: string): string {
704
+ const solidityFiles: string[] = []
705
+
706
+ function findSol(dir: string, depth = 0): void {
707
+ if (depth > 4) return
708
+ try {
709
+ const entries = execSync(`ls -1 "${dir}" 2>/dev/null`, { encoding: 'utf8' })
710
+ .trim()
711
+ .split('\n')
712
+ .filter(Boolean)
713
+ for (const entry of entries) {
714
+ const fullPath = join(dir, entry)
715
+ if (entry.endsWith('.sol')) {
716
+ solidityFiles.push(fullPath)
717
+ } else if (!entry.startsWith('.') && !entry.includes('node_modules')) {
718
+ try {
719
+ const stat = execSync(`stat -f "%HT" "${fullPath}" 2>/dev/null`, { encoding: 'utf8' }).trim()
720
+ if (stat === 'Directory') findSol(fullPath, depth + 1)
721
+ } catch {}
722
+ }
723
+ }
724
+ } catch {}
725
+ }
726
+
727
+ findSol(repoDir)
728
+
729
+ // Sort by priority: core contracts first, test/mock/interface files filtered out
730
+ const prioritized = solidityFiles
731
+ .map((f) => ({ path: f, priority: contractPriority(f) }))
732
+ .filter((f) => f.priority > 0)
733
+ .sort((a, b) => b.priority - a.priority)
734
+ .map((f) => f.path)
735
+
736
+ const snippets: string[] = []
737
+ let totalChars = 0
738
+ const MAX_CHARS = 16000
739
+ const MAX_FILES = 15
740
+
741
+ for (const f of prioritized.slice(0, MAX_FILES)) {
742
+ if (totalChars >= MAX_CHARS) break
743
+ try {
744
+ const content = readFileSync(f, 'utf8')
745
+ const budget = Math.min(1200, MAX_CHARS - totalChars)
746
+ const snippet = content.slice(0, budget)
747
+ snippets.push(`// File: ${f.replace(repoDir, '')}\n${snippet}`)
748
+ totalChars += snippet.length
749
+ } catch {}
750
+ }
751
+
752
+ return snippets.join('\n\n---\n\n') || 'No Solidity files found'
753
+ }
754
+
755
+ async function runClaudeReview(
756
+ protocol: Protocol,
757
+ slitherFindings: SlitherFinding[],
758
+ contractCode: string,
759
+ sourceAvailable: boolean
760
+ ): Promise<{ review: string; riskLevel: AnalysisResult['riskLevel']; bounty: number; summary: string }> {
761
+ const claudeBin = findClaudeBin()
762
+ const claudeToken = process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY || ''
763
+
764
+ const slitherSummary =
765
+ slitherFindings.length > 0
766
+ ? slitherFindings
767
+ .map((f) => `[${f.impact}/${f.confidence}] ${f.check}: ${f.description?.slice(0, 200)}`)
768
+ .join('\n')
769
+ : 'No Slither findings (Slither may not be installed or no Solidity files found)'
770
+
771
+ const noCodeWarning = sourceAvailable
772
+ ? ''
773
+ : `
774
+ IMPORTANT: No contract source code is available for this protocol. Your review MUST be an architecture-level threat model only.
775
+ - Do NOT claim specific vulnerabilities exist without code evidence.
776
+ - Set RISK_LEVEL to MEDIUM at most unless there is documented public evidence of a critical flaw.
777
+ - Set BOUNTY_ESTIMATE_USD to 0 — speculative findings are not bounty-eligible.
778
+ - The SUMMARY must clearly state: "Architecture review only — no source code analyzed."
779
+ `
780
+
781
+ const prompt = `You are a smart contract security auditor. Review this protocol and its contracts for critical vulnerabilities.
782
+
783
+ Protocol: ${protocol.name}
784
+ Chain: ${protocol.chain}
785
+ TVL: $${(protocol.tvl / 1_000_000).toFixed(1)}M
786
+ Source code available: ${sourceAvailable ? 'YES' : 'NO'}
787
+ ${noCodeWarning}
788
+ Focus on:
789
+ - Governance/voting manipulation
790
+ - Flash loan attack vectors
791
+ - Oracle price manipulation
792
+ - Reentrancy
793
+ - Access control issues
794
+ - Economic exploits
795
+
796
+ Slither findings:
797
+ ${slitherSummary}
798
+
799
+ Contract code summary:
800
+ ${contractCode}
801
+
802
+ Respond in this exact format:
803
+ RISK_LEVEL: <CRITICAL|HIGH|MEDIUM|LOW>
804
+ BOUNTY_ESTIMATE_USD: <number>
805
+ SUMMARY: <one paragraph disclosure summary>
806
+ FULL_REVIEW: <detailed analysis>`
807
+
808
+ const promptFile = join(tmpdir(), `whiteh-prompt-${Date.now()}.txt`)
809
+
810
+ try {
811
+ writeFileSync(promptFile, prompt, 'utf8')
812
+
813
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) }
814
+ if (claudeToken) {
815
+ if (claudeToken.startsWith('sk-ant-oat')) {
816
+ env.CLAUDE_CODE_OAUTH_TOKEN = claudeToken
817
+ } else {
818
+ env.ANTHROPIC_API_KEY = claudeToken
819
+ }
820
+ }
821
+
822
+ const result = spawnSync(claudeBin, ['--print', '--dangerously-skip-permissions', '-p', prompt], {
823
+ encoding: 'utf8',
824
+ timeout: 120_000,
825
+ env,
826
+ stdio: 'pipe',
827
+ })
828
+
829
+ const output = result.stdout || ''
830
+ const riskMatch = output.match(/RISK_LEVEL:\s*(CRITICAL|HIGH|MEDIUM|LOW)/i)
831
+ const bountyMatch = output.match(/BOUNTY_ESTIMATE_USD:\s*(\d+)/i)
832
+ const summaryMatch = output.match(/SUMMARY:\s*(.+?)(?=\nFULL_REVIEW:|$)/is)
833
+
834
+ return {
835
+ review: output,
836
+ riskLevel: (riskMatch?.[1]?.toUpperCase() as AnalysisResult['riskLevel']) || 'UNKNOWN',
837
+ bounty: bountyMatch ? parseInt(bountyMatch[1], 10) : 0,
838
+ summary: summaryMatch?.[1]?.trim() || 'No summary generated',
839
+ }
840
+ } catch (err) {
841
+ return {
842
+ review: `Claude review failed: ${(err as Error).message}`,
843
+ riskLevel: 'UNKNOWN',
844
+ bounty: 0,
845
+ summary: 'Review failed',
846
+ }
847
+ } finally {
848
+ try {
849
+ if (existsSync(promptFile)) rmSync(promptFile)
850
+ } catch {}
851
+ }
852
+ }
853
+
854
+ export async function analyzeProtocol(protocol: Protocol): Promise<AnalysisResult> {
855
+ await log(`Analyzing protocol: ${protocol.name} (TVL: $${(protocol.tvl / 1_000_000).toFixed(1)}M)`)
856
+
857
+ const result: AnalysisResult = {
858
+ protocolId: protocol.id,
859
+ protocolName: protocol.name,
860
+ chain: protocol.chain,
861
+ tvl: protocol.tvl,
862
+ slitherFindings: [],
863
+ slitherStatus: 'not_applicable',
864
+ claudeReview: '',
865
+ riskLevel: 'UNKNOWN',
866
+ estimatedBounty: 0,
867
+ disclosureSummary: '',
868
+ scannedAt: Date.now(),
869
+ sourceAvailable: false,
870
+ }
871
+
872
+ if (!protocol.github) {
873
+ await log(`${protocol.name}: no GitHub URL, running Claude-only review`)
874
+ const { review, riskLevel, bounty, summary } = await runClaudeReview(protocol, [], 'No repository available', false)
875
+ result.claudeReview = review
876
+ result.riskLevel = riskLevel
877
+ result.estimatedBounty = bounty
878
+ result.disclosureSummary = summary
879
+ result.sourceAvailable = false
880
+ return result
881
+ }
882
+
883
+ const tempDir = join(tmpdir(), `whiteh-${protocol.id}-${Date.now()}`)
884
+
885
+ try {
886
+ mkdirSync(tempDir, { recursive: true })
887
+
888
+ const cloneUrl = await resolveCloneUrl(protocol.github)
889
+ if (!cloneUrl) {
890
+ await log(`${protocol.name}: could not resolve GitHub org "${protocol.github}" to a clone URL, running Claude-only review`)
891
+ const { review, riskLevel, bounty, summary } = await runClaudeReview(
892
+ protocol,
893
+ [],
894
+ 'Repository URL could not be resolved',
895
+ false
896
+ )
897
+ result.claudeReview = review
898
+ result.riskLevel = riskLevel
899
+ result.estimatedBounty = bounty
900
+ result.disclosureSummary = summary
901
+ result.sourceAvailable = false
902
+ return result
903
+ }
904
+
905
+ await log(`${protocol.name}: cloning ${cloneUrl} (org: ${protocol.github})`)
906
+ const cloned = cloneRepo(cloneUrl, tempDir)
907
+
908
+ if (!cloned) {
909
+ await log(`${protocol.name}: clone failed, running Claude-only review`)
910
+ const { review, riskLevel, bounty, summary } = await runClaudeReview(
911
+ protocol,
912
+ [],
913
+ 'Repository clone failed',
914
+ false
915
+ )
916
+ result.claudeReview = review
917
+ result.riskLevel = riskLevel
918
+ result.estimatedBounty = bounty
919
+ result.disclosureSummary = summary
920
+ result.sourceAvailable = false
921
+ return result
922
+ }
923
+
924
+ result.sourceAvailable = true
925
+
926
+ const solCount = countSolFiles(tempDir)
927
+ if (solCount === 0) {
928
+ result.slitherStatus = 'not_applicable'
929
+ await log(`${protocol.name}: no .sol files found — skipping Slither (non-EVM or frontend repo)`)
930
+ } else {
931
+ await log(`${protocol.name}: found ${solCount} .sol file(s)`)
932
+ const slitherAvailable = ensureSlither()
933
+ if (slitherAvailable) {
934
+ await log(`${protocol.name}: running Slither analysis`)
935
+ const slitherResult = runSlither(tempDir)
936
+ result.slitherFindings = slitherResult.findings
937
+ result.slitherStatus = slitherResult.status
938
+ if (slitherResult.status === 'compilation_error') {
939
+ await log(`${protocol.name}: Slither compilation error — no static findings (contracts may use unsupported compiler or missing deps)`)
940
+ } else {
941
+ await log(`${protocol.name}: Slither found ${result.slitherFindings.length} HIGH/CRITICAL findings`)
942
+ }
943
+ } else {
944
+ result.slitherStatus = 'unavailable'
945
+ await log(`${protocol.name}: Slither unavailable, skipping static analysis`)
946
+ }
947
+ }
948
+
949
+ const contractCode = summarizeContracts(tempDir)
950
+
951
+ await log(`${protocol.name}: running Claude security review`)
952
+ const { review, riskLevel, bounty, summary } = await runClaudeReview(
953
+ protocol,
954
+ result.slitherFindings,
955
+ contractCode,
956
+ true
957
+ )
958
+ result.claudeReview = review
959
+ result.riskLevel = riskLevel
960
+ result.estimatedBounty = bounty
961
+ result.disclosureSummary = summary
962
+
963
+ await log(`${protocol.name}: analysis complete — risk=${riskLevel}, bounty=$${bounty}`)
964
+ } catch (err) {
965
+ result.error = (err as Error).message
966
+ await log(`${protocol.name}: analysis error: ${result.error}`)
967
+ } finally {
968
+ try {
969
+ if (existsSync(tempDir)) rmSync(tempDir, { recursive: true, force: true })
970
+ } catch {}
971
+ }
972
+
973
+ return result
974
+ }