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/src/contest.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Code4rena and Sherlock contest calendar scraper.
3
+ *
4
+ * Fetches recently-created audit contest repos from `code-423n4` and `sherlock-audit`
5
+ * GitHub orgs. Active audit contests are live protocols with known codebases — ideal
6
+ * scan targets because the code is publicly accessible and the protocol is actively
7
+ * seeking security review.
8
+ *
9
+ * The contest repo itself (or the protocol code it mirrors) is used as the github field
10
+ * so our analyzer can clone and run Slither alongside Claude review.
11
+ */
12
+
13
+ import { log } from './redis'
14
+ import type { Protocol } from './discovery'
15
+
16
+ const CONTEST_ORGS = ['code-423n4', 'sherlock-audit']
17
+ const GITHUB_API = 'https://api.github.com'
18
+
19
+ // Default TVL for contest protocols — they lack on-chain TVL data but are
20
+ // vetted protocols worth auditing (otherwise they wouldn't pay for audits).
21
+ const CONTEST_DEFAULT_TVL = 50_000_000
22
+
23
+ // Only fetch repos created within this window (active/recent contests)
24
+ const CONTEST_MAX_AGE_DAYS = 30
25
+
26
+ interface GitHubRepo {
27
+ id: number
28
+ name: string
29
+ full_name: string
30
+ html_url: string
31
+ description: string | null
32
+ created_at: string
33
+ pushed_at: string
34
+ stargazers_count: number
35
+ language: string | null
36
+ fork: boolean
37
+ archived: boolean
38
+ }
39
+
40
+ /**
41
+ * Parse a human-readable protocol name from a contest repo name.
42
+ * Code4rena repos: "2024-01-protocolname" → "protocolname"
43
+ * Sherlock repos: "2024-protocolname-audit" → "protocolname"
44
+ */
45
+ export function parseProtocolName(org: string, repoName: string): string {
46
+ let name = repoName
47
+
48
+ // Strip leading date prefix: YYYY-MM- or YYYY-
49
+ name = name.replace(/^\d{4}-\d{2}-/, '').replace(/^\d{4}-/, '')
50
+
51
+ // Strip trailing "-audit" suffix (Sherlock convention)
52
+ name = name.replace(/-audit$/, '')
53
+
54
+ // Convert hyphens/underscores to spaces and title-case
55
+ name = name
56
+ .split(/[-_]/)
57
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
58
+ .join(' ')
59
+ .trim()
60
+
61
+ if (!name) return repoName
62
+
63
+ const source = org === 'code-423n4' ? 'C4' : 'Sherlock'
64
+ return `${name} (${source})`
65
+ }
66
+
67
+ /**
68
+ * Filter: skip repos that are clearly meta/admin repos, not protocol contest repos.
69
+ */
70
+ function isContestRepo(org: string, repo: GitHubRepo): boolean {
71
+ if (repo.fork) return false
72
+ if (repo.archived) return false
73
+
74
+ const name = repo.name.toLowerCase()
75
+
76
+ // Skip org-level meta repos
77
+ const metaPatterns = [
78
+ /^\.github$/,
79
+ /^org-/,
80
+ /^template/,
81
+ /^docs$/,
82
+ /^website$/,
83
+ /^judging/,
84
+ /^judge/,
85
+ /^findings/,
86
+ /^leaderboard/,
87
+ /^governance/,
88
+ ]
89
+ if (metaPatterns.some((p) => p.test(name))) return false
90
+
91
+ // Code4rena contest repos start with a year
92
+ if (org === 'code-423n4') {
93
+ if (!/^\d{4}-/.test(name)) return false
94
+ }
95
+
96
+ // Sherlock contest repos end with -audit or start with a year
97
+ if (org === 'sherlock-audit') {
98
+ if (!name.includes('audit') && !/^\d{4}-/.test(name)) return false
99
+ }
100
+
101
+ return true
102
+ }
103
+
104
+ async function fetchOrgContests(org: string): Promise<Protocol[]> {
105
+ const cutoff = new Date(Date.now() - CONTEST_MAX_AGE_DAYS * 24 * 60 * 60 * 1000)
106
+ const protocols: Protocol[] = []
107
+ let page = 1
108
+
109
+ while (page <= 3) {
110
+ const url = `${GITHUB_API}/orgs/${org}/repos?type=public&sort=created&direction=desc&per_page=50&page=${page}`
111
+ const res = await fetch(url, {
112
+ headers: {
113
+ 'User-Agent': 'white-hat-scanner/1.0',
114
+ Accept: 'application/vnd.github+json',
115
+ },
116
+ signal: AbortSignal.timeout(20_000),
117
+ })
118
+
119
+ if (!res.ok) {
120
+ if (res.status === 404) break // org might not exist
121
+ throw new Error(`GitHub API ${org} returned ${res.status}`)
122
+ }
123
+
124
+ const repos = (await res.json()) as GitHubRepo[]
125
+ if (repos.length === 0) break
126
+
127
+ for (const repo of repos) {
128
+ const createdAt = new Date(repo.created_at)
129
+ // Since repos are sorted by created desc, stop when we go past the cutoff
130
+ if (createdAt < cutoff) {
131
+ page = 999 // signal outer loop to stop
132
+ break
133
+ }
134
+
135
+ if (!isContestRepo(org, repo)) continue
136
+
137
+ const name = parseProtocolName(org, repo.name)
138
+ protocols.push({
139
+ id: `contest-${org}-${repo.name}`,
140
+ name,
141
+ github: repo.html_url,
142
+ chain: 'ethereum', // most DeFi audit contests are EVM-based
143
+ tvl: CONTEST_DEFAULT_TVL,
144
+ listedAt: createdAt.getTime(),
145
+ })
146
+ }
147
+
148
+ page++
149
+ }
150
+
151
+ return protocols
152
+ }
153
+
154
+ /**
155
+ * Fetch active/recent audit contest protocols from Code4rena and Sherlock.
156
+ * Returns Protocol objects ready to be queued via queueNewProtocols().
157
+ */
158
+ export async function fetchContestProtocols(): Promise<Protocol[]> {
159
+ const all: Protocol[] = []
160
+
161
+ for (const org of CONTEST_ORGS) {
162
+ try {
163
+ const protocols = await fetchOrgContests(org)
164
+ await log(`Contest scraper (${org}): found ${protocols.length} recent contest repos`)
165
+ all.push(...protocols)
166
+ } catch (err) {
167
+ await log(`Contest scraper (${org}) error: ${(err as Error).message}`)
168
+ }
169
+ }
170
+
171
+ return all
172
+ }
@@ -0,0 +1,111 @@
1
+ import { getRedis, log } from './redis'
2
+ import type { AnalysisResult } from './analyzer'
3
+ import type { ScoredFinding } from './scorer'
4
+
5
+ export interface DisclosureRecord {
6
+ id: string
7
+ protocolName: string
8
+ riskLevel: string
9
+ bounty: number
10
+ summary: string
11
+ contactedAt?: number
12
+ deadlineAt?: number
13
+ followUpAt?: number
14
+ publicAt?: number
15
+ status: 'pending' | 'contacted' | 'acknowledged' | 'fixed' | 'public' | 'expired'
16
+ createdAt: number
17
+ }
18
+
19
+ const DISCLOSURE_DEADLINE_MS = 72 * 60 * 60 * 1000
20
+ const FOLLOWUP_MS = 24 * 60 * 60 * 1000
21
+
22
+ export async function createDisclosureRecord(
23
+ result: AnalysisResult,
24
+ scored: ScoredFinding
25
+ ): Promise<DisclosureRecord> {
26
+ const redis = getRedis()
27
+ const id = `${result.protocolId}-${Date.now()}`
28
+
29
+ const record: DisclosureRecord = {
30
+ id,
31
+ protocolName: result.protocolName,
32
+ riskLevel: result.riskLevel,
33
+ bounty: result.estimatedBounty,
34
+ summary: result.disclosureSummary,
35
+ status: 'pending',
36
+ createdAt: Date.now(),
37
+ }
38
+
39
+ await redis.set(`whiteh:findings:${result.protocolId}`, JSON.stringify(result))
40
+ await redis.lpush('whiteh:disclosures', JSON.stringify(record))
41
+ await redis.ltrim('whiteh:disclosures', 0, 499)
42
+ await log(`Disclosure record created for ${result.protocolName} (${result.riskLevel})`)
43
+
44
+ return record
45
+ }
46
+
47
+ export async function checkDisclosureTimelines(): Promise<void> {
48
+ const redis = getRedis()
49
+ const rawRecords = await redis.lrange('whiteh:disclosures', 0, -1)
50
+
51
+ const now = Date.now()
52
+ let warnings = 0
53
+ let expired = 0
54
+
55
+ for (const raw of rawRecords) {
56
+ let record: DisclosureRecord
57
+ try {
58
+ record = JSON.parse(raw) as DisclosureRecord
59
+ } catch {
60
+ continue
61
+ }
62
+
63
+ if (record.status === 'public' || record.status === 'fixed') continue
64
+
65
+ if (record.contactedAt) {
66
+ const deadline = record.contactedAt + DISCLOSURE_DEADLINE_MS
67
+
68
+ if (now > deadline && record.status !== 'expired') {
69
+ await log(
70
+ `⏰ DEADLINE EXPIRED: ${record.protocolName} — 72h disclosure window passed, consider public disclosure`
71
+ )
72
+ expired++
73
+ } else if (deadline - now < FOLLOWUP_MS && record.status === 'contacted') {
74
+ await log(
75
+ `⚠️ FOLLOW-UP DUE: ${record.protocolName} — ${Math.floor((deadline - now) / 3_600_000)}h until deadline`
76
+ )
77
+ warnings++
78
+ }
79
+ }
80
+ }
81
+
82
+ if (warnings > 0 || expired > 0) {
83
+ await log(`Timeline check: ${warnings} follow-ups due, ${expired} deadlines expired`)
84
+ } else {
85
+ await log('Timeline check: all disclosures on track')
86
+ }
87
+ }
88
+
89
+ export function draftDisclosureEmail(record: DisclosureRecord): string {
90
+ return `Subject: Responsible Disclosure — Security Vulnerability in ${record.protocolName}
91
+
92
+ Dear ${record.protocolName} Security Team,
93
+
94
+ I am writing to responsibly disclose a security vulnerability I discovered in your protocol.
95
+
96
+ Risk Level: ${record.riskLevel}
97
+ ${record.bounty > 0 ? `Estimated Bug Bounty: $${record.bounty.toLocaleString()}` : ''}
98
+
99
+ Summary:
100
+ ${record.summary}
101
+
102
+ I am disclosing this under a 72-hour responsible disclosure policy. I will refrain from publishing details until:
103
+ 1. You acknowledge receipt of this report
104
+ 2. A fix is deployed, OR
105
+ 3. 72 hours have elapsed from the timestamp of this email
106
+
107
+ Please respond to confirm receipt.
108
+
109
+ Regards,
110
+ White Hat Security Researcher`
111
+ }
@@ -0,0 +1,297 @@
1
+ import { getRedis, log } from './redis'
2
+ import { fetchContestProtocols } from './contest'
3
+
4
+ export interface Protocol {
5
+ id: string
6
+ name: string
7
+ github?: string
8
+ chain: string
9
+ tvl: number
10
+ listedAt?: number
11
+ }
12
+
13
+ interface DefiLlamaProtocol {
14
+ id: string
15
+ name: string
16
+ url?: string
17
+ description?: string
18
+ chain?: string
19
+ chains?: string[]
20
+ tvl?: number
21
+ listedAt?: number
22
+ github?: string[]
23
+ }
24
+
25
+ const DEFILLAMA_API = 'https://api.llama.fi/protocols'
26
+ const TVL_THRESHOLD = 10_000_000
27
+ const MAX_AGE_DAYS = 7
28
+
29
+ /**
30
+ * Protocols with known exploit history from Rekt.news / public incident records.
31
+ * These are high-priority scan targets: prior exploits indicate architectural
32
+ * weaknesses or recurring vuln classes that may still be present in redeployments
33
+ * or sister protocols. Matched case-insensitively against protocol names.
34
+ *
35
+ * Sources: rekt.news all-time losses, Immunefi incident reports, DeFiHackLabs
36
+ */
37
+ export const EXPLOIT_HISTORY_TARGETS: string[] = [
38
+ // Cross-chain bridges (historically highest losses)
39
+ 'ronin', 'wormhole', 'nomad', 'multichain', 'anyswap', 'thorchain', 'harmony',
40
+ 'poly network', 'polynetwork', 'orbit bridge', 'orbitbridge',
41
+ // Lending / money markets
42
+ 'compound', 'aave', 'cream finance', 'cream', 'beanstalk', 'euler',
43
+ 'inverse finance', 'mango markets', 'venus', 'moola',
44
+ // DEX / AMM
45
+ 'uniswap', 'curve', 'balancer', 'kyber', 'dodo', 'pancakeswap',
46
+ 'saddle finance', 'saddle',
47
+ // Yield / vault
48
+ 'yearn', 'harvest finance', 'harvest', 'badger', 'rari capital',
49
+ 'value defi', 'valuedefi', 'belt finance', 'belt', 'bunny finance', 'bunny',
50
+ // Stablecoin / algo-stable
51
+ 'terra', 'anchor', 'fei', 'iron finance', 'ironfinance',
52
+ 'deus finance', 'deus', 'mirror protocol',
53
+ // Other notable hacks
54
+ 'bzx', 'fulcrum', 'indexed finance', 'indexed', 'uranium', 'spartan',
55
+ 'merlin lab', 'merlin', 'ola finance', 'ola', 'pickle finance', 'pickle',
56
+ 'alpha finance', 'alpha homora', 'mushroom', '88mph', 'cover protocol',
57
+ 'kucoin', 'bitmart', 'vulcan forged', 'ronin network',
58
+ ]
59
+
60
+ /**
61
+ * Returns true if the protocol name matches any known exploit target.
62
+ * Case-insensitive substring match.
63
+ */
64
+ export function isExploitTarget(name: string): boolean {
65
+ const lower = name.toLowerCase()
66
+ return EXPLOIT_HISTORY_TARGETS.some((target) => lower.includes(target))
67
+ }
68
+ // Protocols without a GitHub repo produce only speculative Claude-only reviews with no real
69
+ // code evidence, so they're not actionable for disclosure. Only queue them if their TVL is
70
+ // exceptionally large (≥ $500M) — large enough that an architectural threat model has value.
71
+ const NO_GITHUB_MIN_TVL = 500_000_000
72
+
73
+ export async function discoverProtocols(): Promise<Protocol[]> {
74
+ await log('Starting protocol discovery from DeFiLlama + contest calendars...')
75
+
76
+ let protocols: Protocol[] = []
77
+
78
+ try {
79
+ protocols = await fetchDefiLlama()
80
+ await log(`DeFiLlama: found ${protocols.length} new protocols with TVL > $${TVL_THRESHOLD.toLocaleString()}`)
81
+ } catch (err) {
82
+ await log(`DeFiLlama fetch error: ${(err as Error).message}`)
83
+ }
84
+
85
+ try {
86
+ const contestProtocols = await fetchContestProtocols()
87
+ if (contestProtocols.length > 0) {
88
+ await log(`Contest calendars: found ${contestProtocols.length} active audit contest protocols`)
89
+ protocols = [...protocols, ...contestProtocols]
90
+ }
91
+ } catch (err) {
92
+ await log(`Contest scraper error: ${(err as Error).message}`)
93
+ }
94
+
95
+ return protocols
96
+ }
97
+
98
+ async function fetchDefiLlama(): Promise<Protocol[]> {
99
+ const res = await fetch(DEFILLAMA_API, {
100
+ headers: { 'User-Agent': 'white-hat-scanner/1.0' },
101
+ signal: AbortSignal.timeout(30_000),
102
+ })
103
+
104
+ if (!res.ok) {
105
+ throw new Error(`DeFiLlama API returned ${res.status}`)
106
+ }
107
+
108
+ const data = (await res.json()) as DefiLlamaProtocol[]
109
+ const cutoff = Date.now() - MAX_AGE_DAYS * 24 * 60 * 60 * 1000
110
+
111
+ const filtered: Protocol[] = []
112
+ for (const p of data) {
113
+ const tvl = p.tvl ?? 0
114
+ const listedAt = p.listedAt ? p.listedAt * 1000 : 0
115
+
116
+ if (tvl < TVL_THRESHOLD) continue
117
+ if (listedAt && listedAt < cutoff) continue
118
+
119
+ // DeFiLlama github field is org names; build a searchable URL
120
+ const githubOrg = p.github && p.github.length > 0 ? p.github[0] : undefined
121
+ const github = githubOrg ? `https://github.com/${githubOrg}` : undefined
122
+ filtered.push({
123
+ id: p.id,
124
+ name: p.name,
125
+ github,
126
+ chain: p.chain || (p.chains && p.chains[0]) || 'unknown',
127
+ tvl,
128
+ listedAt: listedAt || Date.now(),
129
+ })
130
+ }
131
+
132
+ return filtered
133
+ }
134
+
135
+ export async function queueNewProtocols(protocols: Protocol[]): Promise<number> {
136
+ const redis = getRedis()
137
+ let queued = 0
138
+ let skippedNoGithub = 0
139
+ let prioritized = 0
140
+
141
+ for (const p of protocols) {
142
+ // Skip protocols that lack source code unless they are very high TVL.
143
+ // No-GitHub protocols generate only speculative architectural assessments
144
+ // (no real code to audit), and PR #5 already skips their disclosures —
145
+ // so queueing them wastes scan slots.
146
+ if (!p.github && p.tvl < NO_GITHUB_MIN_TVL) {
147
+ skippedNoGithub++
148
+ continue
149
+ }
150
+
151
+ const alreadyScanned = await redis.sismember('whiteh:scanned', p.id)
152
+ if (alreadyScanned) continue
153
+
154
+ // Use a parallel set (whiteh:queued) to track what's in the queue.
155
+ // lpos() on a JSON-object list requires an exact string match against the full
156
+ // serialised entry — it would never match a bare ID — so it was silently broken.
157
+ const alreadyQueued = await redis.sismember('whiteh:queued', p.id)
158
+ if (alreadyQueued) continue
159
+
160
+ // Protocols with known exploit history jump to the front of the queue —
161
+ // prior hacks indicate architectural weaknesses likely to recur.
162
+ if (isExploitTarget(p.name)) {
163
+ await redis.lpush('whiteh:queue', JSON.stringify(p))
164
+ prioritized++
165
+ } else {
166
+ await redis.rpush('whiteh:queue', JSON.stringify(p))
167
+ }
168
+ await redis.sadd('whiteh:queued', p.id)
169
+ queued++
170
+ }
171
+
172
+ if (queued > 0 || skippedNoGithub > 0) {
173
+ const priorityNote = prioritized > 0 ? ` (${prioritized} exploit-history targets front-queued)` : ''
174
+ await log(`Queued ${queued} new protocols for scanning (skipped ${skippedNoGithub} with no source code)${priorityNote}`)
175
+ }
176
+
177
+ return queued
178
+ }
179
+
180
+ /**
181
+ * Reorder existing queue entries so that exploit-history targets move to the front.
182
+ * Safe to call at startup — rewrites the queue atomically.
183
+ */
184
+ export async function prioritizeExploitTargets(): Promise<number> {
185
+ const redis = getRedis()
186
+ const all = await redis.lrange('whiteh:queue', 0, -1)
187
+ const priority: string[] = []
188
+ const normal: string[] = []
189
+
190
+ for (const raw of all) {
191
+ try {
192
+ const p = JSON.parse(raw) as Protocol
193
+ if (isExploitTarget(p.name)) {
194
+ priority.push(raw)
195
+ } else {
196
+ normal.push(raw)
197
+ }
198
+ } catch {
199
+ normal.push(raw)
200
+ }
201
+ }
202
+
203
+ if (priority.length === 0) return 0
204
+
205
+ await redis.del('whiteh:queue')
206
+ const ordered = [...priority, ...normal]
207
+ if (ordered.length > 0) {
208
+ await redis.rpush('whiteh:queue', ...ordered)
209
+ }
210
+ await log(`Queue reordered: ${priority.length} exploit-history targets moved to front, ${normal.length} normal`)
211
+ return priority.length
212
+ }
213
+
214
+ /**
215
+ * Prune existing queue entries that have no GitHub repo and TVL below threshold,
216
+ * and remove entries for protocols already in whiteh:scanned (duplicate cleanup).
217
+ * Rebuilds whiteh:queued set from the surviving entries.
218
+ * Called once at startup.
219
+ */
220
+ export async function pruneNoGithubFromQueue(): Promise<number> {
221
+ const redis = getRedis()
222
+ const all = await redis.lrange('whiteh:queue', 0, -1)
223
+ const keep: string[] = []
224
+ const seenIds = new Set<string>()
225
+ let pruned = 0
226
+
227
+ for (const raw of all) {
228
+ let p: Protocol
229
+ try {
230
+ p = JSON.parse(raw) as Protocol
231
+ } catch {
232
+ pruned++
233
+ continue
234
+ }
235
+
236
+ if (!p.github && p.tvl < NO_GITHUB_MIN_TVL) {
237
+ pruned++
238
+ continue
239
+ }
240
+
241
+ // Drop entries already scanned or already kept once (deduplicate)
242
+ const alreadyScanned = await redis.sismember('whiteh:scanned', p.id)
243
+ if (alreadyScanned || seenIds.has(p.id)) {
244
+ pruned++
245
+ continue
246
+ }
247
+
248
+ seenIds.add(p.id)
249
+ keep.push(raw)
250
+ }
251
+
252
+ // Atomically replace queue and rebuild the queued-ID set
253
+ await redis.del('whiteh:queue')
254
+ await redis.del('whiteh:queued')
255
+ if (keep.length > 0) {
256
+ await redis.rpush('whiteh:queue', ...keep)
257
+ for (const raw of keep) {
258
+ try {
259
+ const p = JSON.parse(raw) as Protocol
260
+ await redis.sadd('whiteh:queued', p.id)
261
+ } catch { /* skip */ }
262
+ }
263
+ }
264
+
265
+ if (pruned > 0) {
266
+ await log(`Queue pruned: removed ${pruned} entries (no-source/already-scanned/duplicates), ${keep.length} remaining`)
267
+ }
268
+
269
+ return pruned
270
+ }
271
+
272
+ export async function dequeueProtocol(): Promise<Protocol | null> {
273
+ const redis = getRedis()
274
+
275
+ // Pop entries until we find one that hasn't been scanned yet.
276
+ // Duplicate entries accumulated before the whiteh:queued fix was deployed
277
+ // (or race conditions between discovery and scan) are silently discarded here.
278
+ for (let attempts = 0; attempts < 20; attempts++) {
279
+ const raw = await redis.lpop('whiteh:queue')
280
+ if (!raw) return null
281
+
282
+ let p: Protocol
283
+ try {
284
+ p = JSON.parse(raw) as Protocol
285
+ } catch {
286
+ continue // malformed — discard
287
+ }
288
+
289
+ await redis.srem('whiteh:queued', p.id)
290
+
291
+ const alreadyScanned = await redis.sismember('whiteh:scanned', p.id)
292
+ if (!alreadyScanned) return p
293
+ // Already done — discard duplicate and try next
294
+ }
295
+
296
+ return null
297
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { discoverProtocols, queueNewProtocols, dequeueProtocol, pruneNoGithubFromQueue, prioritizeExploitTargets } from './discovery'
2
+ import { analyzeProtocol } from './analyzer'
3
+ import { scoreFindings } from './scorer'
4
+ import { createDisclosureRecord, checkDisclosureTimelines } from './disclosure'
5
+ import { sendAlert } from './notifier'
6
+ import { generateSubmissionDraft } from './submission'
7
+ import { getRedis, log } from './redis'
8
+
9
+ const DISCOVER_INTERVAL_MS = 6 * 60 * 60 * 1000
10
+ const SCAN_INTERVAL_MS = 30 * 60 * 1000
11
+ const DISCLOSURE_CHECK_INTERVAL_MS = 60 * 60 * 1000
12
+
13
+ async function runDiscovery(): Promise<void> {
14
+ try {
15
+ const protocols = await discoverProtocols()
16
+ const queued = await queueNewProtocols(protocols)
17
+ await log(`Discovery complete: ${protocols.length} protocols found, ${queued} new queued`)
18
+ } catch (err) {
19
+ await log(`Discovery error: ${(err as Error).message}`)
20
+ }
21
+ }
22
+
23
+ async function processScanQueue(): Promise<void> {
24
+ const redis = getRedis()
25
+
26
+ try {
27
+ const queueLen = await redis.llen('whiteh:queue')
28
+ await log(`Scan queue: ${queueLen} protocols pending`)
29
+
30
+ if (queueLen === 0) return
31
+
32
+ const protocol = await dequeueProtocol()
33
+ if (!protocol) return
34
+
35
+ await log(`Processing: ${protocol.name}`)
36
+
37
+ const result = await analyzeProtocol(protocol)
38
+ const scored = scoreFindings(result)
39
+
40
+ await redis.sadd('whiteh:scanned', protocol.id)
41
+
42
+ // Only track disclosures when source code was actually reviewed — no-source findings are
43
+ // speculative architectural assessments and don't meet the bar for responsible disclosure.
44
+ if (scored.sourceAvailable) {
45
+ await createDisclosureRecord(result, scored)
46
+ }
47
+
48
+ if (scored.needsAlert) {
49
+ await sendAlert(scored)
50
+ }
51
+
52
+ // Generate Immunefi submission draft for qualifying HIGH/CRITICAL findings (human review required)
53
+ await generateSubmissionDraft(result, scored)
54
+
55
+ await log(
56
+ `Completed: ${protocol.name} — ${scored.riskLevel} (severity: ${scored.severity.toFixed(1)}, bounty: $${scored.estimatedBounty})`
57
+ )
58
+ } catch (err) {
59
+ await log(`Scan queue error: ${(err as Error).message}`)
60
+ }
61
+ }
62
+
63
+ async function runDisclosureCheck(): Promise<void> {
64
+ try {
65
+ await checkDisclosureTimelines()
66
+ } catch (err) {
67
+ await log(`Disclosure check error: ${(err as Error).message}`)
68
+ }
69
+ }
70
+
71
+ async function main(): Promise<void> {
72
+ await log('=== white-hat-scanner starting ===')
73
+ await log(`Node ${process.version} | PID ${process.pid}`)
74
+
75
+ process.on('unhandledRejection', async (reason) => {
76
+ await log(`Unhandled rejection: ${reason}`)
77
+ })
78
+
79
+ process.on('uncaughtException', async (err) => {
80
+ await log(`Uncaught exception: ${err.message}`)
81
+ process.exit(1)
82
+ })
83
+
84
+ // One-time startup: prune queue bloat from before the GitHub filter was added.
85
+ await pruneNoGithubFromQueue()
86
+ // One-time startup: reorder existing queue so exploit-history targets scan first.
87
+ await prioritizeExploitTargets()
88
+
89
+ // Run immediately on startup
90
+ await runDiscovery()
91
+ await processScanQueue()
92
+ await runDisclosureCheck()
93
+
94
+ // Set up recurring intervals
95
+ setInterval(() => { runDiscovery().catch(console.error) }, DISCOVER_INTERVAL_MS)
96
+ setInterval(() => { processScanQueue().catch(console.error) }, SCAN_INTERVAL_MS)
97
+ setInterval(() => { runDisclosureCheck().catch(console.error) }, DISCLOSURE_CHECK_INTERVAL_MS)
98
+
99
+ await log('Service running — discovery every 6h, scanning every 30m, disclosure check every 1h')
100
+ }
101
+
102
+ main().catch(async (err) => {
103
+ console.error('[fatal]', err)
104
+ process.exit(1)
105
+ })