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
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
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -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
|
+
})
|