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.
- package/README.md +2 -0
- package/dist/analyzer.js +852 -0
- package/dist/contest.js +144 -0
- package/dist/disclosure.js +85 -0
- package/dist/discovery.js +260 -0
- package/dist/index.js +88 -0
- package/dist/notifier.js +51 -0
- package/dist/redis.js +36 -0
- package/dist/scorer.js +33 -0
- package/dist/submission.js +103 -0
- package/dist/test/smoke.js +511 -0
- package/package.json +23 -0
- package/research/bounty-economics.md +145 -0
- package/research/tooling-landscape.md +216 -0
- package/research/vuln-pattern-library.md +401 -0
- package/src/analyzer.ts +974 -0
- package/src/contest.ts +172 -0
- package/src/disclosure.ts +111 -0
- package/src/discovery.ts +297 -0
- package/src/index.ts +105 -0
- package/src/notifier.ts +58 -0
- package/src/redis.ts +31 -0
- package/src/scorer.ts +46 -0
- package/src/submission.ts +124 -0
- package/src/test/smoke.ts +457 -0
- package/system/architecture.md +488 -0
- package/system/scanner-mvp.md +305 -0
- package/targets/active-bounty-programs.md +111 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { tmpdir } from 'os'
|
|
4
|
+
import { getRedis, log, closeRedis } from '../redis'
|
|
5
|
+
import { scoreFindings } from '../scorer'
|
|
6
|
+
import { createDisclosureRecord } from '../disclosure'
|
|
7
|
+
import { pruneNoGithubFromQueue, prioritizeExploitTargets, isExploitTarget } from '../discovery'
|
|
8
|
+
import { resolveCloneUrl, contractPriority, installDeps, detectSolcVersion } from '../analyzer'
|
|
9
|
+
import { generateSubmissionDraft, formatImmunefireport } from '../submission'
|
|
10
|
+
import { parseProtocolName, fetchContestProtocols } from '../contest'
|
|
11
|
+
import type { AnalysisResult } from '../analyzer'
|
|
12
|
+
import type { Protocol } from '../discovery'
|
|
13
|
+
|
|
14
|
+
async function runSmoke(): Promise<void> {
|
|
15
|
+
console.log('[smoke] Starting smoke tests...')
|
|
16
|
+
|
|
17
|
+
// Test 1: Redis connection
|
|
18
|
+
const redis = getRedis()
|
|
19
|
+
await redis.ping()
|
|
20
|
+
console.log('[smoke] ✓ Redis connection OK')
|
|
21
|
+
|
|
22
|
+
// Test 2: Logging
|
|
23
|
+
await log('smoke test log entry')
|
|
24
|
+
const logEntry = await redis.lindex('whiteh:log', 0)
|
|
25
|
+
if (!logEntry) throw new Error('Log entry not written')
|
|
26
|
+
const parsed = JSON.parse(logEntry) as { message: string }
|
|
27
|
+
if (!parsed.message.includes('smoke test')) throw new Error('Log entry malformed')
|
|
28
|
+
console.log('[smoke] ✓ Redis logging OK')
|
|
29
|
+
|
|
30
|
+
// Test 3: Scorer — with source code (should alert)
|
|
31
|
+
const mockResultWithCode: AnalysisResult = {
|
|
32
|
+
protocolId: 'test-protocol',
|
|
33
|
+
protocolName: 'Test Protocol',
|
|
34
|
+
chain: 'ethereum',
|
|
35
|
+
tvl: 100_000_000,
|
|
36
|
+
slitherFindings: [
|
|
37
|
+
{ check: 'reentrancy', impact: 'High', confidence: 'High', description: 'test', elements: [] },
|
|
38
|
+
],
|
|
39
|
+
slitherStatus: 'success',
|
|
40
|
+
claudeReview: 'RISK_LEVEL: HIGH\nBOUNTY_ESTIMATE_USD: 50000\nSUMMARY: Test summary',
|
|
41
|
+
riskLevel: 'HIGH',
|
|
42
|
+
estimatedBounty: 50000,
|
|
43
|
+
disclosureSummary: 'Test summary',
|
|
44
|
+
scannedAt: Date.now(),
|
|
45
|
+
sourceAvailable: true,
|
|
46
|
+
}
|
|
47
|
+
const scored = scoreFindings(mockResultWithCode)
|
|
48
|
+
if (!scored.needsAlert) throw new Error('HIGH risk with source should trigger alert')
|
|
49
|
+
if (scored.severity < 7) throw new Error('HIGH risk severity too low')
|
|
50
|
+
if (!scored.sourceAvailable) throw new Error('sourceAvailable should be true')
|
|
51
|
+
console.log('[smoke] ✓ Scorer (with source) OK')
|
|
52
|
+
|
|
53
|
+
// Test 4: Scorer — no source code (should NOT alert, bounty should be 0)
|
|
54
|
+
const mockResultNoCode: AnalysisResult = {
|
|
55
|
+
...mockResultWithCode,
|
|
56
|
+
protocolId: 'test-protocol-nocode',
|
|
57
|
+
sourceAvailable: false,
|
|
58
|
+
estimatedBounty: 0,
|
|
59
|
+
}
|
|
60
|
+
const scoredNoCode = scoreFindings(mockResultNoCode)
|
|
61
|
+
if (scoredNoCode.needsAlert) throw new Error('HIGH risk without source should NOT trigger alert')
|
|
62
|
+
if (scoredNoCode.sourceAvailable) throw new Error('sourceAvailable should be false')
|
|
63
|
+
console.log('[smoke] ✓ Scorer (no source, no alert) OK')
|
|
64
|
+
|
|
65
|
+
// Test 5: No-source finding does NOT create a disclosure record
|
|
66
|
+
const disclosuresBefore = await redis.llen('whiteh:disclosures')
|
|
67
|
+
// Simulate what index.ts does: only call createDisclosureRecord when sourceAvailable
|
|
68
|
+
if (scoredNoCode.sourceAvailable) {
|
|
69
|
+
await createDisclosureRecord(mockResultNoCode, scoredNoCode)
|
|
70
|
+
}
|
|
71
|
+
const disclosuresAfter = await redis.llen('whiteh:disclosures')
|
|
72
|
+
if (disclosuresAfter !== disclosuresBefore) {
|
|
73
|
+
throw new Error('No-source finding should not create a disclosure record')
|
|
74
|
+
}
|
|
75
|
+
console.log('[smoke] ✓ No-source finding skips disclosure record OK')
|
|
76
|
+
|
|
77
|
+
// Test 6: pruneNoGithubFromQueue removes low-TVL no-GitHub entries but keeps high-TVL + github ones
|
|
78
|
+
const testKey = 'whiteh:queue'
|
|
79
|
+
const noGithubLowTvl: Protocol = { id: 'smoke-no-gh-low', name: 'Smoke No GitHub Low TVL', chain: 'eth', tvl: 50_000_000 }
|
|
80
|
+
const noGithubHighTvl: Protocol = { id: 'smoke-no-gh-high', name: 'Smoke No GitHub High TVL', chain: 'eth', tvl: 600_000_000 }
|
|
81
|
+
const withGithub: Protocol = { id: 'smoke-with-gh', name: 'Smoke With GitHub', github: 'https://github.com/test', chain: 'eth', tvl: 50_000_000 }
|
|
82
|
+
// Ensure clean state
|
|
83
|
+
await redis.lrem(testKey, 0, JSON.stringify(noGithubLowTvl))
|
|
84
|
+
await redis.lrem(testKey, 0, JSON.stringify(noGithubHighTvl))
|
|
85
|
+
await redis.lrem(testKey, 0, JSON.stringify(withGithub))
|
|
86
|
+
await redis.rpush(testKey, JSON.stringify(noGithubLowTvl))
|
|
87
|
+
await redis.rpush(testKey, JSON.stringify(noGithubHighTvl))
|
|
88
|
+
await redis.rpush(testKey, JSON.stringify(withGithub))
|
|
89
|
+
await pruneNoGithubFromQueue()
|
|
90
|
+
// noGithubLowTvl should be gone
|
|
91
|
+
const lowTvlPos = await redis.lpos(testKey, JSON.stringify(noGithubLowTvl))
|
|
92
|
+
if (lowTvlPos !== null) throw new Error('Low-TVL no-GitHub entry should have been pruned')
|
|
93
|
+
// high-TVL no-GitHub should remain
|
|
94
|
+
const highTvlPos = await redis.lpos(testKey, JSON.stringify(noGithubHighTvl))
|
|
95
|
+
if (highTvlPos === null) throw new Error('High-TVL no-GitHub entry should NOT have been pruned')
|
|
96
|
+
// with-GitHub should remain
|
|
97
|
+
const githubPos = await redis.lpos(testKey, JSON.stringify(withGithub))
|
|
98
|
+
if (githubPos === null) throw new Error('GitHub-available entry should NOT have been pruned')
|
|
99
|
+
// clean up test entries
|
|
100
|
+
await redis.lrem(testKey, 0, JSON.stringify(noGithubHighTvl))
|
|
101
|
+
await redis.lrem(testKey, 0, JSON.stringify(withGithub))
|
|
102
|
+
console.log('[smoke] ✓ pruneNoGithubFromQueue removes low-TVL no-GitHub entries OK')
|
|
103
|
+
|
|
104
|
+
// Test 7: resolveCloneUrl passthrough for already-URL values
|
|
105
|
+
const fullUrl = 'https://github.com/aave/aave-v3-core'
|
|
106
|
+
const resolved = await resolveCloneUrl(fullUrl)
|
|
107
|
+
if (resolved !== fullUrl) throw new Error(`resolveCloneUrl should pass through full URLs unchanged, got: ${resolved}`)
|
|
108
|
+
console.log('[smoke] ✓ resolveCloneUrl passthrough OK')
|
|
109
|
+
|
|
110
|
+
// Test 8: resolveCloneUrl resolves org name to a valid https URL
|
|
111
|
+
const orgUrl = await resolveCloneUrl('uniswap')
|
|
112
|
+
if (!orgUrl || !orgUrl.startsWith('https://github.com/uniswap/')) {
|
|
113
|
+
throw new Error(`resolveCloneUrl should resolve org to https URL, got: ${orgUrl}`)
|
|
114
|
+
}
|
|
115
|
+
console.log(`[smoke] ✓ resolveCloneUrl org resolution OK (${orgUrl})`)
|
|
116
|
+
|
|
117
|
+
// Test 9: Alert payload uses 'text' field (not 'message') for Telegram compatibility
|
|
118
|
+
const { sendAlert } = await import('../notifier')
|
|
119
|
+
// Use a unique name to avoid dedup from prior smoke runs
|
|
120
|
+
const testFinding = { ...scored, protocolName: `Smoke-Test-${Date.now()}` }
|
|
121
|
+
// Pre-clear any prior dedup entry for this name just in case
|
|
122
|
+
const alertsBefore = await redis.llen('cca:notify:money-brain')
|
|
123
|
+
await sendAlert(testFinding)
|
|
124
|
+
const alertsAfter = await redis.llen('cca:notify:money-brain')
|
|
125
|
+
if (alertsAfter <= alertsBefore) throw new Error('Alert should have been pushed to notification channel')
|
|
126
|
+
const rawAlert = await redis.lindex('cca:notify:money-brain', 0)
|
|
127
|
+
if (!rawAlert) throw new Error('Alert not found in channel')
|
|
128
|
+
const alertPayload = JSON.parse(rawAlert) as Record<string, unknown>
|
|
129
|
+
if (!alertPayload.text) throw new Error(`Alert payload missing 'text' field (got keys: ${Object.keys(alertPayload).join(', ')})`)
|
|
130
|
+
if (alertPayload.message) throw new Error(`Alert payload should not have 'message' field (Telegram compatibility)`)
|
|
131
|
+
// clean up test alert
|
|
132
|
+
await redis.lrem('cca:notify:money-brain', 1, rawAlert)
|
|
133
|
+
await redis.lrem('whiteh:alerts', 1, JSON.stringify({ ts: Date.now() }))
|
|
134
|
+
console.log('[smoke] ✓ Alert payload uses text field (Telegram-compatible) OK')
|
|
135
|
+
|
|
136
|
+
// Test 10: contractPriority filters out test/mock/interface files
|
|
137
|
+
// Test files should be filtered out (priority = 0)
|
|
138
|
+
if (contractPriority('/repo/test/PoolTest.sol') !== 0) throw new Error('test/ dir should be filtered')
|
|
139
|
+
if (contractPriority('/repo/contracts/MockToken.sol') !== 0) throw new Error('Mock*.sol should be filtered')
|
|
140
|
+
if (contractPriority('/repo/contracts/IPool.sol') !== 0) throw new Error('ICapitalized.sol should be filtered')
|
|
141
|
+
if (contractPriority('/repo/contracts/PoolTest.sol') !== 0) throw new Error('*Test.sol should be filtered')
|
|
142
|
+
if (contractPriority('/repo/contracts/Pool.t.sol') !== 0) throw new Error('*.t.sol should be filtered')
|
|
143
|
+
// Core contracts should have boosted priority
|
|
144
|
+
if (contractPriority('/repo/contracts/Pool.sol') <= 0) throw new Error('Pool.sol should have positive priority')
|
|
145
|
+
if (contractPriority('/repo/contracts/Vault.sol') < contractPriority('/repo/contracts/Library.sol')) {
|
|
146
|
+
throw new Error('Vault.sol should have higher priority than Library.sol')
|
|
147
|
+
}
|
|
148
|
+
// Regular contracts have default priority
|
|
149
|
+
if (contractPriority('/repo/contracts/MyProtocol.sol') <= 0) throw new Error('MyProtocol.sol should have positive priority')
|
|
150
|
+
console.log('[smoke] ✓ contractPriority filters test/mock/interface files OK')
|
|
151
|
+
|
|
152
|
+
// Test 11: CRITICAL without Slither should NOT alert (Claude-only CRITICAL = noisy)
|
|
153
|
+
const mockCriticalNoSlither: AnalysisResult = {
|
|
154
|
+
...mockResultWithCode,
|
|
155
|
+
protocolId: 'test-critical-no-slither',
|
|
156
|
+
protocolName: 'Test Critical No Slither',
|
|
157
|
+
slitherFindings: [],
|
|
158
|
+
slitherStatus: 'compilation_error',
|
|
159
|
+
riskLevel: 'CRITICAL',
|
|
160
|
+
estimatedBounty: 1000000,
|
|
161
|
+
}
|
|
162
|
+
const scoredCriticalNoSlither = scoreFindings(mockCriticalNoSlither)
|
|
163
|
+
if (scoredCriticalNoSlither.needsAlert) {
|
|
164
|
+
throw new Error('CRITICAL without Slither should NOT trigger alert (too noisy)')
|
|
165
|
+
}
|
|
166
|
+
console.log('[smoke] ✓ CRITICAL without Slither = no alert OK')
|
|
167
|
+
|
|
168
|
+
// Test 12: CRITICAL WITH Slither SHOULD alert
|
|
169
|
+
const mockCriticalWithSlither: AnalysisResult = {
|
|
170
|
+
...mockResultWithCode,
|
|
171
|
+
protocolId: 'test-critical-with-slither',
|
|
172
|
+
protocolName: 'Test Critical With Slither',
|
|
173
|
+
slitherFindings: [
|
|
174
|
+
{ check: 'arbitrary-send', impact: 'Critical', confidence: 'High', description: 'test', elements: [] },
|
|
175
|
+
],
|
|
176
|
+
riskLevel: 'CRITICAL',
|
|
177
|
+
estimatedBounty: 500000,
|
|
178
|
+
}
|
|
179
|
+
const scoredCriticalWithSlither = scoreFindings(mockCriticalWithSlither)
|
|
180
|
+
if (!scoredCriticalWithSlither.needsAlert) {
|
|
181
|
+
throw new Error('CRITICAL with Slither findings SHOULD trigger alert')
|
|
182
|
+
}
|
|
183
|
+
console.log('[smoke] ✓ CRITICAL with Slither findings = alert OK')
|
|
184
|
+
|
|
185
|
+
// Test 13: formatImmunefireport generates valid Immunefi-style report
|
|
186
|
+
const scoredForReport = scoreFindings(mockResultWithCode)
|
|
187
|
+
const report = formatImmunefireport(mockResultWithCode, scoredForReport)
|
|
188
|
+
if (!report.includes('# Immunefi Bug Report Draft')) throw new Error('Report missing header')
|
|
189
|
+
if (!report.includes('Test Protocol')) throw new Error('Report missing protocol name')
|
|
190
|
+
if (!report.includes('High')) throw new Error('Report missing severity')
|
|
191
|
+
if (!report.includes('AUTO-GENERATED DRAFT')) throw new Error('Report missing review warning')
|
|
192
|
+
console.log('[smoke] ✓ formatImmunefireport generates valid report structure OK')
|
|
193
|
+
|
|
194
|
+
// Test 14: generateSubmissionDraft creates Redis entry and Telegram notification for qualifying finding
|
|
195
|
+
const submissionsBefore = await redis.llen('whiteh:submissions')
|
|
196
|
+
const notifyBefore = await redis.llen('cca:notify:money-brain')
|
|
197
|
+
await generateSubmissionDraft(mockResultWithCode, scoredForReport)
|
|
198
|
+
const submissionsAfter = await redis.llen('whiteh:submissions')
|
|
199
|
+
const notifyAfter = await redis.llen('cca:notify:money-brain')
|
|
200
|
+
if (submissionsAfter !== submissionsBefore + 1) throw new Error('generateSubmissionDraft should push to whiteh:submissions')
|
|
201
|
+
if (notifyAfter !== notifyBefore + 1) throw new Error('generateSubmissionDraft should push Telegram notification')
|
|
202
|
+
// Verify the notification has the right format — rpush appends to the right end (index -1)
|
|
203
|
+
const rawNotify = await redis.lindex('cca:notify:money-brain', -1)
|
|
204
|
+
if (!rawNotify) throw new Error('Submission notification not found')
|
|
205
|
+
const notifyPayload = JSON.parse(rawNotify) as Record<string, unknown>
|
|
206
|
+
if (!notifyPayload.text || !(notifyPayload.text as string).includes('IMMUNEFI DRAFT READY')) {
|
|
207
|
+
throw new Error(`Submission notification missing expected text (got: ${JSON.stringify(notifyPayload)})`)
|
|
208
|
+
}
|
|
209
|
+
// clean up test entries
|
|
210
|
+
const rawSub = await redis.lindex('whiteh:submissions', 0)
|
|
211
|
+
if (rawSub) await redis.lrem('whiteh:submissions', 1, rawSub)
|
|
212
|
+
await redis.lrem('cca:notify:money-brain', 1, rawNotify)
|
|
213
|
+
console.log('[smoke] ✓ generateSubmissionDraft creates submission + notification OK')
|
|
214
|
+
|
|
215
|
+
// Test 15: generateSubmissionDraft skips low-bounty findings
|
|
216
|
+
const lowBountyResult: AnalysisResult = { ...mockResultWithCode, estimatedBounty: 5_000 }
|
|
217
|
+
const scoredLowBounty = scoreFindings(lowBountyResult)
|
|
218
|
+
const subsBefore = await redis.llen('whiteh:submissions')
|
|
219
|
+
await generateSubmissionDraft(lowBountyResult, scoredLowBounty)
|
|
220
|
+
const subsAfter = await redis.llen('whiteh:submissions')
|
|
221
|
+
if (subsAfter !== subsBefore) throw new Error('generateSubmissionDraft should skip bounty < $10k')
|
|
222
|
+
console.log('[smoke] ✓ generateSubmissionDraft skips low-bounty findings OK')
|
|
223
|
+
|
|
224
|
+
// Test 16: isExploitTarget matches known exploit targets
|
|
225
|
+
if (!isExploitTarget('Compound Finance')) throw new Error('Compound should be an exploit target')
|
|
226
|
+
if (!isExploitTarget('Ronin Network')) throw new Error('Ronin should be an exploit target')
|
|
227
|
+
if (!isExploitTarget('Curve Finance')) throw new Error('Curve should be an exploit target')
|
|
228
|
+
if (!isExploitTarget('BadgerDAO')) throw new Error('Badger should be an exploit target')
|
|
229
|
+
if (isExploitTarget('MyRandomProtocol')) throw new Error('Random protocol should NOT be an exploit target')
|
|
230
|
+
if (isExploitTarget('SafeToken')) throw new Error('SafeToken should NOT be an exploit target')
|
|
231
|
+
console.log('[smoke] ✓ isExploitTarget matches known exploit history protocols OK')
|
|
232
|
+
|
|
233
|
+
// Test 17: prioritizeExploitTargets moves exploit-history entries before normal entries.
|
|
234
|
+
// Append both to the real queue (normal first, exploit second — reversed from desired order),
|
|
235
|
+
// then verify that after reorder the exploit entry has a lower index than the normal one.
|
|
236
|
+
const exploitProto: Protocol = { id: 'smoke-exploit-t17', name: 'Compound Fork Protocol', github: 'https://github.com/test/compound-fork', chain: 'eth', tvl: 50_000_000 }
|
|
237
|
+
const normalProto: Protocol = { id: 'smoke-normal-t17', name: 'Some Normal Protocol T17', github: 'https://github.com/test/normal', chain: 'eth', tvl: 50_000_000 }
|
|
238
|
+
// Clean any leftover test entries
|
|
239
|
+
await redis.lrem('whiteh:queue', 0, JSON.stringify(exploitProto))
|
|
240
|
+
await redis.lrem('whiteh:queue', 0, JSON.stringify(normalProto))
|
|
241
|
+
// Insert normal first, then exploit — wrong order intentionally
|
|
242
|
+
await redis.rpush('whiteh:queue', JSON.stringify(normalProto))
|
|
243
|
+
await redis.rpush('whiteh:queue', JSON.stringify(exploitProto))
|
|
244
|
+
// Run reorder
|
|
245
|
+
await prioritizeExploitTargets()
|
|
246
|
+
// Check: exploit entry must appear before normal entry
|
|
247
|
+
const reorderedAll = await redis.lrange('whiteh:queue', 0, -1)
|
|
248
|
+
const exploitIdx = reorderedAll.findIndex((r) => { try { return (JSON.parse(r) as Protocol).id === 'smoke-exploit-t17' } catch { return false } })
|
|
249
|
+
const normalIdx = reorderedAll.findIndex((r) => { try { return (JSON.parse(r) as Protocol).id === 'smoke-normal-t17' } catch { return false } })
|
|
250
|
+
if (exploitIdx === -1 || normalIdx === -1) throw new Error(`Test entries missing after reorder (exploit: ${exploitIdx}, normal: ${normalIdx})`)
|
|
251
|
+
if (exploitIdx >= normalIdx) throw new Error(`Exploit target (idx ${exploitIdx}) should come before normal (idx ${normalIdx})`)
|
|
252
|
+
// Clean up
|
|
253
|
+
await redis.lrem('whiteh:queue', 0, JSON.stringify(exploitProto))
|
|
254
|
+
await redis.lrem('whiteh:queue', 0, JSON.stringify(normalProto))
|
|
255
|
+
console.log('[smoke] ✓ prioritizeExploitTargets moves exploit-history targets to queue front OK')
|
|
256
|
+
|
|
257
|
+
// Test 18: resolveCloneUrl handles GitHub org URLs (https://github.com/OrgName with no repo)
|
|
258
|
+
// DeFiLlama sometimes returns org URLs rather than plain org names — these were previously
|
|
259
|
+
// passed through as-is, causing git clone to fail on a URL that has no repo path.
|
|
260
|
+
const orgUrlResolved = await resolveCloneUrl('https://github.com/uniswap')
|
|
261
|
+
if (!orgUrlResolved || !orgUrlResolved.startsWith('https://github.com/uniswap/')) {
|
|
262
|
+
throw new Error(`resolveCloneUrl should resolve org URL to repo URL, got: ${orgUrlResolved}`)
|
|
263
|
+
}
|
|
264
|
+
// Full repo URLs with 2 path segments must still pass through unchanged
|
|
265
|
+
const fullRepoUrl = 'https://github.com/aave/aave-v3-core'
|
|
266
|
+
const fullResolved = await resolveCloneUrl(fullRepoUrl)
|
|
267
|
+
if (fullResolved !== fullRepoUrl) {
|
|
268
|
+
throw new Error(`resolveCloneUrl should not modify full repo URLs, got: ${fullResolved}`)
|
|
269
|
+
}
|
|
270
|
+
console.log(`[smoke] ✓ resolveCloneUrl org URL resolution OK (${orgUrlResolved})`)
|
|
271
|
+
|
|
272
|
+
// Test 19: queueNewProtocols uses whiteh:queued set — does NOT re-queue protocols already in set
|
|
273
|
+
const { queueNewProtocols, dequeueProtocol } = await import('../discovery')
|
|
274
|
+
const dedupeProto: Protocol = {
|
|
275
|
+
id: `smoke-dedup-${Date.now()}`,
|
|
276
|
+
name: 'Smoke Dedup Test Protocol',
|
|
277
|
+
github: 'https://github.com/test/dedup-proto',
|
|
278
|
+
chain: 'eth',
|
|
279
|
+
tvl: 50_000_000,
|
|
280
|
+
}
|
|
281
|
+
// Ensure protocol is NOT already scanned
|
|
282
|
+
await redis.srem('whiteh:scanned', dedupeProto.id)
|
|
283
|
+
// Simulate it already being in the queue (add to whiteh:queued set, not the list)
|
|
284
|
+
await redis.sadd('whiteh:queued', dedupeProto.id)
|
|
285
|
+
const queueLenBefore = await redis.llen('whiteh:queue')
|
|
286
|
+
await queueNewProtocols([dedupeProto])
|
|
287
|
+
const queueLenAfter = await redis.llen('whiteh:queue')
|
|
288
|
+
if (queueLenAfter !== queueLenBefore) {
|
|
289
|
+
throw new Error(`queueNewProtocols should skip protocols already in whiteh:queued (queue grew by ${queueLenAfter - queueLenBefore})`)
|
|
290
|
+
}
|
|
291
|
+
await redis.srem('whiteh:queued', dedupeProto.id)
|
|
292
|
+
console.log('[smoke] ✓ queueNewProtocols skips protocols in whiteh:queued set OK')
|
|
293
|
+
|
|
294
|
+
// Test 20: dequeueProtocol skips already-scanned entries (duplicate cleanup)
|
|
295
|
+
const skipProto: Protocol = {
|
|
296
|
+
id: `smoke-skip-${Date.now()}`,
|
|
297
|
+
name: 'Smoke Skip Already Scanned',
|
|
298
|
+
github: 'https://github.com/test/skip-proto',
|
|
299
|
+
chain: 'eth',
|
|
300
|
+
tvl: 50_000_000,
|
|
301
|
+
}
|
|
302
|
+
const keepProto: Protocol = {
|
|
303
|
+
id: `smoke-keep-${Date.now()}`,
|
|
304
|
+
name: 'Smoke Keep Unscanned',
|
|
305
|
+
github: 'https://github.com/test/keep-proto',
|
|
306
|
+
chain: 'eth',
|
|
307
|
+
tvl: 50_000_000,
|
|
308
|
+
}
|
|
309
|
+
// Mark skipProto as already scanned, keepProto is fresh
|
|
310
|
+
await redis.sadd('whiteh:scanned', skipProto.id)
|
|
311
|
+
await redis.srem('whiteh:scanned', keepProto.id)
|
|
312
|
+
// Push skipProto at front, keepProto behind it (skipProto is the "duplicate")
|
|
313
|
+
await redis.lpush('whiteh:queue', JSON.stringify(keepProto))
|
|
314
|
+
await redis.lpush('whiteh:queue', JSON.stringify(skipProto))
|
|
315
|
+
const dequeued = await dequeueProtocol()
|
|
316
|
+
if (!dequeued) throw new Error('dequeueProtocol returned null — should have skipped skipProto and returned keepProto')
|
|
317
|
+
if (dequeued.id !== keepProto.id) {
|
|
318
|
+
throw new Error(`dequeueProtocol should skip already-scanned entry and return next, got id=${dequeued.id}`)
|
|
319
|
+
}
|
|
320
|
+
// Cleanup
|
|
321
|
+
await redis.lrem('whiteh:queue', 0, JSON.stringify(keepProto))
|
|
322
|
+
await redis.lrem('whiteh:queue', 0, JSON.stringify(skipProto))
|
|
323
|
+
await redis.srem('whiteh:scanned', skipProto.id)
|
|
324
|
+
console.log('[smoke] ✓ dequeueProtocol skips already-scanned entries OK')
|
|
325
|
+
|
|
326
|
+
// Test 21: parseProtocolName — Code4rena repo naming convention
|
|
327
|
+
if (parseProtocolName('code-423n4', '2024-01-myprotocol') !== 'Myprotocol (C4)') {
|
|
328
|
+
throw new Error(`parseProtocolName C4 basic failed: got "${parseProtocolName('code-423n4', '2024-01-myprotocol')}"`)
|
|
329
|
+
}
|
|
330
|
+
if (parseProtocolName('code-423n4', '2024-01-uniswap-v4') !== 'Uniswap V4 (C4)') {
|
|
331
|
+
throw new Error(`parseProtocolName C4 multi-word failed: got "${parseProtocolName('code-423n4', '2024-01-uniswap-v4')}"`)
|
|
332
|
+
}
|
|
333
|
+
console.log('[smoke] ✓ parseProtocolName Code4rena convention OK')
|
|
334
|
+
|
|
335
|
+
// Test 22: parseProtocolName — Sherlock repo naming convention
|
|
336
|
+
if (parseProtocolName('sherlock-audit', '2024-curve-audit') !== 'Curve (Sherlock)') {
|
|
337
|
+
throw new Error(`parseProtocolName Sherlock basic failed: got "${parseProtocolName('sherlock-audit', '2024-curve-audit')}"`)
|
|
338
|
+
}
|
|
339
|
+
if (parseProtocolName('sherlock-audit', '2024-aave-v3-audit') !== 'Aave V3 (Sherlock)') {
|
|
340
|
+
throw new Error(`parseProtocolName Sherlock multi-word failed: got "${parseProtocolName('sherlock-audit', '2024-aave-v3-audit')}"`)
|
|
341
|
+
}
|
|
342
|
+
console.log('[smoke] ✓ parseProtocolName Sherlock convention OK')
|
|
343
|
+
|
|
344
|
+
// Test 23: fetchContestProtocols — returns Protocol objects with correct shape
|
|
345
|
+
// (live network call; only verify shape, not exact count)
|
|
346
|
+
const contestProtocols = await fetchContestProtocols()
|
|
347
|
+
if (!Array.isArray(contestProtocols)) throw new Error('fetchContestProtocols should return an array')
|
|
348
|
+
for (const p of contestProtocols) {
|
|
349
|
+
if (!p.id.startsWith('contest-')) throw new Error(`Contest protocol id should start with 'contest-': ${p.id}`)
|
|
350
|
+
if (!p.github || !p.github.startsWith('https://github.com/')) {
|
|
351
|
+
throw new Error(`Contest protocol github should be a full GitHub URL: ${p.github}`)
|
|
352
|
+
}
|
|
353
|
+
if (p.tvl <= 0) throw new Error(`Contest protocol TVL should be positive: ${p.tvl}`)
|
|
354
|
+
if (!p.name) throw new Error(`Contest protocol name should be non-empty`)
|
|
355
|
+
}
|
|
356
|
+
console.log(`[smoke] ✓ fetchContestProtocols returns valid Protocol objects (${contestProtocols.length} found) OK`)
|
|
357
|
+
|
|
358
|
+
// Test 24: installDeps — no package manager files → 'no-pkg-manager'
|
|
359
|
+
const tmpEmpty = join(tmpdir(), `smoke-empty-${Date.now()}`)
|
|
360
|
+
mkdirSync(tmpEmpty, { recursive: true })
|
|
361
|
+
try {
|
|
362
|
+
const status24 = installDeps(tmpEmpty)
|
|
363
|
+
if (status24 !== 'no-pkg-manager') throw new Error(`Expected 'no-pkg-manager', got '${status24}'`)
|
|
364
|
+
console.log('[smoke] ✓ installDeps no-pkg-manager OK')
|
|
365
|
+
} finally {
|
|
366
|
+
rmSync(tmpEmpty, { recursive: true, force: true })
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Test 25: installDeps — foundry.toml + non-empty lib/ → 'foundry-lib-present'
|
|
370
|
+
// lib/ must have actual content (a subdirectory) to be considered populated.
|
|
371
|
+
// An empty lib/ means submodules weren't fetched and forge install should run.
|
|
372
|
+
const tmpFoundry = join(tmpdir(), `smoke-foundry-${Date.now()}`)
|
|
373
|
+
mkdirSync(join(tmpFoundry, 'lib', 'forge-std'), { recursive: true })
|
|
374
|
+
writeFileSync(join(tmpFoundry, 'foundry.toml'), '[profile.default]\n')
|
|
375
|
+
try {
|
|
376
|
+
const status25 = installDeps(tmpFoundry)
|
|
377
|
+
if (status25 !== 'foundry-lib-present') throw new Error(`Expected 'foundry-lib-present', got '${status25}'`)
|
|
378
|
+
console.log('[smoke] ✓ installDeps foundry-lib-present OK')
|
|
379
|
+
} finally {
|
|
380
|
+
rmSync(tmpFoundry, { recursive: true, force: true })
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Test 26: installDeps — package.json present → should run npm and return 'npm'
|
|
384
|
+
const tmpNpm = join(tmpdir(), `smoke-npm-${Date.now()}`)
|
|
385
|
+
mkdirSync(tmpNpm, { recursive: true })
|
|
386
|
+
writeFileSync(join(tmpNpm, 'package.json'), JSON.stringify({ name: 'test', version: '1.0.0', dependencies: {} }))
|
|
387
|
+
try {
|
|
388
|
+
const status26 = installDeps(tmpNpm)
|
|
389
|
+
if (status26 !== 'npm') throw new Error(`Expected 'npm', got '${status26}'`)
|
|
390
|
+
console.log('[smoke] ✓ installDeps npm install OK')
|
|
391
|
+
} finally {
|
|
392
|
+
rmSync(tmpNpm, { recursive: true, force: true })
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Test 27: slitherStatus in Immunefi report — compilation_error shows correct note
|
|
396
|
+
const compilationErrResult: AnalysisResult = {
|
|
397
|
+
...mockResultWithCode,
|
|
398
|
+
protocolId: 'test-compilation-err',
|
|
399
|
+
protocolName: 'Test Compilation Error Protocol',
|
|
400
|
+
slitherFindings: [],
|
|
401
|
+
slitherStatus: 'compilation_error',
|
|
402
|
+
}
|
|
403
|
+
const scoredCompErr = scoreFindings(compilationErrResult)
|
|
404
|
+
const compilationErrReport = formatImmunefireport(compilationErrResult, scoredCompErr)
|
|
405
|
+
if (!compilationErrReport.includes('could not compile')) {
|
|
406
|
+
throw new Error(`Compilation error report should mention compilation failure, got: ${compilationErrReport.slice(0, 300)}`)
|
|
407
|
+
}
|
|
408
|
+
console.log('[smoke] ✓ slitherStatus compilation_error report note OK')
|
|
409
|
+
|
|
410
|
+
// Test 28: slitherStatus 'success' with 0 findings shows correct "ran but found nothing" note
|
|
411
|
+
const successZeroResult: AnalysisResult = {
|
|
412
|
+
...mockResultWithCode,
|
|
413
|
+
protocolId: 'test-success-zero',
|
|
414
|
+
protocolName: 'Test Clean Slither Protocol',
|
|
415
|
+
slitherFindings: [],
|
|
416
|
+
slitherStatus: 'success',
|
|
417
|
+
}
|
|
418
|
+
const scoredSuccessZero = scoreFindings(successZeroResult)
|
|
419
|
+
const successZeroReport = formatImmunefireport(successZeroResult, scoredSuccessZero)
|
|
420
|
+
if (!successZeroReport.includes('ran successfully but found no')) {
|
|
421
|
+
throw new Error(`Success+0 findings report should mention "ran successfully", got: ${successZeroReport.slice(0, 300)}`)
|
|
422
|
+
}
|
|
423
|
+
console.log('[smoke] ✓ slitherStatus success+0 findings report note OK')
|
|
424
|
+
|
|
425
|
+
// Test 29: detectSolcVersion — parses pragma from a temp directory
|
|
426
|
+
const solcTestDir = join(tmpdir(), `smoke-solc-${Date.now()}`)
|
|
427
|
+
mkdirSync(solcTestDir, { recursive: true })
|
|
428
|
+
try {
|
|
429
|
+
writeFileSync(join(solcTestDir, 'Token.sol'), `// SPDX-License-Identifier: MIT\npragma solidity =0.7.6;\ncontract Token {}\n`)
|
|
430
|
+
writeFileSync(join(solcTestDir, 'Pool.sol'), `// SPDX-License-Identifier: MIT\npragma solidity =0.7.6;\ncontract Pool {}\n`)
|
|
431
|
+
const detected = detectSolcVersion(solcTestDir)
|
|
432
|
+
if (detected !== '0.7.6') throw new Error(`detectSolcVersion expected 0.7.6, got ${detected}`)
|
|
433
|
+
console.log('[smoke] ✓ detectSolcVersion parses pinned pragma OK')
|
|
434
|
+
} finally {
|
|
435
|
+
rmSync(solcTestDir, { recursive: true, force: true })
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Test 30: detectSolcVersion with caret pragma
|
|
439
|
+
const solcCaretDir = join(tmpdir(), `smoke-solc-caret-${Date.now()}`)
|
|
440
|
+
mkdirSync(solcCaretDir, { recursive: true })
|
|
441
|
+
try {
|
|
442
|
+
writeFileSync(join(solcCaretDir, 'A.sol'), `pragma solidity ^0.8.17;\ncontract A {}\n`)
|
|
443
|
+
const detected2 = detectSolcVersion(solcCaretDir)
|
|
444
|
+
if (detected2 !== '0.8.17') throw new Error(`detectSolcVersion caret expected 0.8.17, got ${detected2}`)
|
|
445
|
+
console.log('[smoke] ✓ detectSolcVersion parses caret pragma OK')
|
|
446
|
+
} finally {
|
|
447
|
+
rmSync(solcCaretDir, { recursive: true, force: true })
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
console.log('[smoke] All smoke tests passed (30/30) ✓')
|
|
451
|
+
await closeRedis()
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
runSmoke().catch((err) => {
|
|
455
|
+
console.error('[smoke] FAILED:', err)
|
|
456
|
+
process.exit(1)
|
|
457
|
+
})
|