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,58 @@
1
+ import { getRedis, log } from './redis'
2
+ import type { ScoredFinding } from './scorer'
3
+
4
+ const NOTIFY_CHANNEL = 'cca:notify:money-brain'
5
+ const ALERT_DEDUP_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
6
+
7
+ /** Returns true if we have already sent an alert for this protocol within the dedup window. */
8
+ async function isRecentlyAlerted(protocolName: string): Promise<boolean> {
9
+ const redis = getRedis()
10
+ const raw = await redis.lrange('whiteh:alerts', 0, 99)
11
+ const cutoff = Date.now() - ALERT_DEDUP_TTL_MS
12
+ for (const entry of raw) {
13
+ try {
14
+ const rec = JSON.parse(entry) as { ts: number; protocol: string }
15
+ if (rec.protocol === protocolName && rec.ts >= cutoff) return true
16
+ } catch {}
17
+ }
18
+ return false
19
+ }
20
+
21
+ export async function sendAlert(finding: ScoredFinding): Promise<void> {
22
+ const redis = getRedis()
23
+
24
+ if (await isRecentlyAlerted(finding.protocolName)) {
25
+ await log(`Alert deduped for ${finding.protocolName} (${finding.riskLevel}) — already alerted within 24h`)
26
+ return
27
+ }
28
+
29
+ const bountyStr =
30
+ finding.estimatedBounty > 0
31
+ ? `$${(finding.estimatedBounty / 1000).toFixed(0)}k`
32
+ : 'unknown'
33
+
34
+ const emoji = finding.riskLevel === 'CRITICAL' ? '🚨' : '⚠️'
35
+ const message =
36
+ `${emoji} ${finding.riskLevel} VULN: ${finding.protocolName}\n` +
37
+ `Estimated bounty: ${bountyStr}\n` +
38
+ `Slither findings: ${finding.slitherCount}\n` +
39
+ `Summary: ${finding.disclosureSummary.slice(0, 300)}`
40
+
41
+ const payload = JSON.stringify({ text: message })
42
+
43
+ await redis.lpush(NOTIFY_CHANNEL, payload)
44
+
45
+ // Also track in internal alerts list for health-check queries
46
+ const alertRecord = JSON.stringify({
47
+ ts: Date.now(),
48
+ protocol: finding.protocolName,
49
+ riskLevel: finding.riskLevel,
50
+ bounty: finding.estimatedBounty,
51
+ slitherCount: finding.slitherCount,
52
+ sourceAvailable: finding.sourceAvailable,
53
+ })
54
+ await redis.lpush('whiteh:alerts', alertRecord)
55
+ await redis.ltrim('whiteh:alerts', 0, 199)
56
+
57
+ await log(`Alert sent for ${finding.protocolName} (${finding.riskLevel})`)
58
+ }
package/src/redis.ts ADDED
@@ -0,0 +1,31 @@
1
+ import Redis from 'ioredis'
2
+
3
+ let _redis: Redis | null = null
4
+
5
+ export function getRedis(): Redis {
6
+ if (!_redis) {
7
+ _redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
8
+ retryStrategy: (times) => Math.min(times * 500, 5000),
9
+ maxRetriesPerRequest: null,
10
+ enableReadyCheck: false,
11
+ })
12
+ _redis.on('error', (e) => console.error('[redis]', e.message))
13
+ _redis.on('connect', () => console.log('[redis] connected'))
14
+ }
15
+ return _redis
16
+ }
17
+
18
+ export async function log(message: string): Promise<void> {
19
+ const redis = getRedis()
20
+ const entry = JSON.stringify({ ts: Date.now(), message })
21
+ await redis.lpush('whiteh:log', entry)
22
+ await redis.ltrim('whiteh:log', 0, 999)
23
+ console.log(`[whiteh] ${message}`)
24
+ }
25
+
26
+ export async function closeRedis(): Promise<void> {
27
+ if (_redis) {
28
+ await _redis.quit()
29
+ _redis = null
30
+ }
31
+ }
package/src/scorer.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { AnalysisResult } from './analyzer'
2
+
3
+ export interface ScoredFinding {
4
+ protocolName: string
5
+ riskLevel: AnalysisResult['riskLevel']
6
+ severity: number
7
+ estimatedBounty: number
8
+ disclosureSummary: string
9
+ slitherCount: number
10
+ needsAlert: boolean
11
+ sourceAvailable: boolean
12
+ }
13
+
14
+ const RISK_SCORES: Record<string, number> = {
15
+ CRITICAL: 10,
16
+ HIGH: 7,
17
+ MEDIUM: 4,
18
+ LOW: 1,
19
+ UNKNOWN: 0,
20
+ }
21
+
22
+ export function scoreFindings(result: AnalysisResult): ScoredFinding {
23
+ const severity = RISK_SCORES[result.riskLevel] ?? 0
24
+ const slitherBonus = Math.min(result.slitherFindings.length * 0.5, 3)
25
+ const finalScore = severity + slitherBonus
26
+
27
+ // Alert rules:
28
+ // - Source code must have been reviewed (no speculative architecture assessments)
29
+ // - CRITICAL requires at least 1 Slither finding to confirm — Claude-only CRITICAL is too noisy
30
+ // - HIGH without Slither is acceptable (Claude can catch logic issues Slither misses)
31
+ const needsAlert =
32
+ result.sourceAvailable &&
33
+ (result.riskLevel === 'HIGH' ||
34
+ (result.riskLevel === 'CRITICAL' && result.slitherFindings.length > 0))
35
+
36
+ return {
37
+ protocolName: result.protocolName,
38
+ riskLevel: result.riskLevel,
39
+ severity: finalScore,
40
+ estimatedBounty: result.estimatedBounty,
41
+ disclosureSummary: result.disclosureSummary,
42
+ slitherCount: result.slitherFindings.length,
43
+ needsAlert,
44
+ sourceAvailable: result.sourceAvailable,
45
+ }
46
+ }
@@ -0,0 +1,124 @@
1
+ import { getRedis, log } from './redis'
2
+ import type { AnalysisResult, SlitherStatus } from './analyzer'
3
+ import type { ScoredFinding } from './scorer'
4
+
5
+ export interface SubmissionDraft {
6
+ id: string
7
+ protocolName: string
8
+ riskLevel: string
9
+ bounty: number
10
+ slitherCount: number
11
+ report: string
12
+ createdAt: number
13
+ status: 'pending_review' | 'submitted' | 'rejected'
14
+ }
15
+
16
+ const NOTIFY_CHANNEL = 'cca:notify:money-brain'
17
+
18
+ /** Minimum bounty to bother generating an Immunefi submission draft */
19
+ const MIN_BOUNTY_USD = 10_000
20
+
21
+ /**
22
+ * Format an Immunefi-style bug report from the Claude analysis summary.
23
+ * This is a draft for human review — never auto-submitted.
24
+ */
25
+ function slitherStatusNote(scored: ScoredFinding, status: SlitherStatus): string {
26
+ if (scored.slitherCount > 0) {
27
+ return `Slither static analysis confirmed ${scored.slitherCount} HIGH/CRITICAL finding(s).`
28
+ }
29
+ switch (status) {
30
+ case 'compilation_error':
31
+ return 'Note: Slither could not compile the contracts (likely missing compiler version or unresolved imports). Static analysis results are unavailable; the above is Claude-only analysis. A PoC is required before submission.'
32
+ case 'unavailable':
33
+ return 'Note: Slither was not available in this scan environment. Static analysis results are unavailable; the above is Claude-only analysis.'
34
+ case 'not_applicable':
35
+ return 'Note: Slither static analysis was not applicable (non-EVM chain or no source code available). The above is Claude-only analysis based on protocol architecture.'
36
+ case 'success':
37
+ default:
38
+ return 'Note: Slither ran successfully but found no HIGH/CRITICAL findings. The above is Claude\'s manual review.'
39
+ }
40
+ }
41
+
42
+ export function formatImmunefireport(result: AnalysisResult, scored: ScoredFinding): string {
43
+ const severity = scored.riskLevel === 'CRITICAL' ? 'Critical' : 'High'
44
+ const slitherNote = slitherStatusNote(scored, result.slitherStatus ?? 'not_applicable')
45
+
46
+ return `# Immunefi Bug Report Draft — ${result.protocolName}
47
+ <!-- AUTO-GENERATED DRAFT — requires human review before submission -->
48
+ <!-- Immunefi portal: https://immunefi.com/bug-bounty/${result.protocolName.toLowerCase().replace(/\s+/g, '-')}/information/ -->
49
+
50
+ ## Severity
51
+ ${severity}
52
+
53
+ ## Affected Protocol
54
+ - Name: ${result.protocolName}
55
+ - Chain: ${result.chain ?? 'Ethereum'}
56
+ - TVL at time of report: ${result.tvl != null ? `$${(result.tvl / 1e6).toFixed(1)}M` : 'unknown'}
57
+
58
+ ## Vulnerability Description
59
+ ${result.disclosureSummary}
60
+
61
+ ## Static Analysis
62
+ ${slitherNote}
63
+
64
+ ## Impact
65
+ A successful exploit could result in loss of funds or protocol compromise. Estimated maximum bounty: $${scored.estimatedBounty.toLocaleString()}.
66
+
67
+ ## Proof of Concept
68
+ <!-- TODO: Add PoC exploit code / transaction trace here before submitting -->
69
+ <!-- Without a PoC, most Immunefi programs will reject the report -->
70
+
71
+ ## Recommended Mitigation
72
+ <!-- TODO: Add specific remediation steps here -->
73
+
74
+ ## References
75
+ - Analysis timestamp: ${new Date(result.scannedAt).toISOString()}
76
+ - Internal disclosure ID: ${result.protocolId}
77
+ `
78
+ }
79
+
80
+ /**
81
+ * Generate and store an Immunefi submission draft for a qualifying finding.
82
+ * Only called for HIGH/CRITICAL findings with real source code and bounty > MIN_BOUNTY_USD.
83
+ * The draft is stored in Redis for human review — never auto-submitted.
84
+ */
85
+ export async function generateSubmissionDraft(
86
+ result: AnalysisResult,
87
+ scored: ScoredFinding
88
+ ): Promise<void> {
89
+ if (!scored.sourceAvailable) return
90
+ if (scored.riskLevel !== 'HIGH' && scored.riskLevel !== 'CRITICAL') return
91
+ if (scored.estimatedBounty < MIN_BOUNTY_USD) return
92
+
93
+ const redis = getRedis()
94
+
95
+ const report = formatImmunefireport(result, scored)
96
+
97
+ const draft: SubmissionDraft = {
98
+ id: `${result.protocolId}-${Date.now()}`,
99
+ protocolName: result.protocolName,
100
+ riskLevel: scored.riskLevel,
101
+ bounty: scored.estimatedBounty,
102
+ slitherCount: scored.slitherCount,
103
+ report,
104
+ createdAt: Date.now(),
105
+ status: 'pending_review',
106
+ }
107
+
108
+ await redis.lpush('whiteh:submissions', JSON.stringify(draft))
109
+ await redis.ltrim('whiteh:submissions', 0, 99)
110
+
111
+ // Notify for human review — include the first 400 chars of the report so the reviewer
112
+ // can decide whether it's worth submitting without opening Redis manually.
113
+ const preview = report.slice(0, 400).replace(/\n/g, ' ')
114
+ const notification = JSON.stringify({
115
+ text:
116
+ `📋 IMMUNEFI DRAFT READY — ${scored.riskLevel}: ${result.protocolName}\n` +
117
+ `Bounty: $${(scored.estimatedBounty / 1000).toFixed(0)}k | Slither: ${scored.slitherCount} findings\n` +
118
+ `Preview: ${preview}\n` +
119
+ `Full draft: redis-cli LINDEX whiteh:submissions 0`,
120
+ })
121
+ await redis.rpush(NOTIFY_CHANNEL, notification)
122
+
123
+ await log(`Immunefi submission draft created for ${result.protocolName} (${scored.riskLevel}, bounty: $${scored.estimatedBounty})`)
124
+ }