web3crit-scanner 7.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 +685 -0
- package/bin/web3crit +10 -0
- package/package.json +59 -0
- package/src/analyzers/control-flow.js +256 -0
- package/src/analyzers/data-flow.js +720 -0
- package/src/analyzers/exploit-chain.js +751 -0
- package/src/analyzers/immunefi-classifier.js +515 -0
- package/src/analyzers/poc-validator.js +396 -0
- package/src/analyzers/solodit-enricher.js +1122 -0
- package/src/cli.js +546 -0
- package/src/detectors/access-control-enhanced.js +458 -0
- package/src/detectors/base-detector.js +213 -0
- package/src/detectors/callback-reentrancy.js +362 -0
- package/src/detectors/cross-contract-reentrancy.js +697 -0
- package/src/detectors/delegatecall.js +167 -0
- package/src/detectors/deprecated-functions.js +62 -0
- package/src/detectors/flash-loan.js +408 -0
- package/src/detectors/frontrunning.js +553 -0
- package/src/detectors/gas-griefing.js +701 -0
- package/src/detectors/governance-attacks.js +366 -0
- package/src/detectors/integer-overflow.js +487 -0
- package/src/detectors/oracle-manipulation.js +524 -0
- package/src/detectors/permit-exploits.js +368 -0
- package/src/detectors/precision-loss.js +408 -0
- package/src/detectors/price-manipulation-advanced.js +548 -0
- package/src/detectors/proxy-vulnerabilities.js +651 -0
- package/src/detectors/readonly-reentrancy.js +473 -0
- package/src/detectors/rebasing-token-vault.js +416 -0
- package/src/detectors/reentrancy-enhanced.js +359 -0
- package/src/detectors/selfdestruct.js +259 -0
- package/src/detectors/share-manipulation.js +412 -0
- package/src/detectors/signature-replay.js +409 -0
- package/src/detectors/storage-collision.js +446 -0
- package/src/detectors/timestamp-dependence.js +494 -0
- package/src/detectors/toctou.js +427 -0
- package/src/detectors/token-standard-compliance.js +465 -0
- package/src/detectors/unchecked-call.js +214 -0
- package/src/detectors/vault-inflation.js +421 -0
- package/src/index.js +42 -0
- package/src/package-lock.json +2874 -0
- package/src/package.json +39 -0
- package/src/scanner-enhanced.js +816 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Foundry PoC Validator (Production-Grade)
|
|
8
|
+
*
|
|
9
|
+
* Hard rules enforced:
|
|
10
|
+
* - HIGH/CRITICAL only emitted if PoC compiles, executes on fork, and proves impact
|
|
11
|
+
* - Impact = fund drain, unauthorized transfer, role takeover, invariant break, or permanent DoS
|
|
12
|
+
* - If PoC fails → finding discarded silently
|
|
13
|
+
* - Severity derived from observed PoC results, not heuristics
|
|
14
|
+
*
|
|
15
|
+
* Notes:
|
|
16
|
+
* - Requires Foundry project with foundry.toml + forge-std
|
|
17
|
+
* - In production mode, forge is mandatory
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Impact types that qualify for HIGH/CRITICAL (Immunefi-aligned)
|
|
21
|
+
const IMPACT_PATTERNS = {
|
|
22
|
+
FUND_DRAIN: {
|
|
23
|
+
severity: 'CRITICAL',
|
|
24
|
+
patterns: [
|
|
25
|
+
/balance.*decreased|lost.*funds|drained/i,
|
|
26
|
+
/assertGt\s*\(\s*attackerBalanceAfter\s*,\s*attackerBalanceBefore/i,
|
|
27
|
+
/assertLt\s*\(\s*victimBalanceAfter\s*,\s*victimBalanceBefore/i,
|
|
28
|
+
/profit.*[1-9]\d*/i,
|
|
29
|
+
/stolen.*ether|stolen.*token/i
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
UNAUTHORIZED_TRANSFER: {
|
|
33
|
+
severity: 'CRITICAL',
|
|
34
|
+
patterns: [
|
|
35
|
+
/transfer.*without.*approval/i,
|
|
36
|
+
/unauthorized.*withdrawal/i,
|
|
37
|
+
/assertEq\s*\(\s*attacker.*balance.*,.*victim/i
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
ROLE_TAKEOVER: {
|
|
41
|
+
severity: 'CRITICAL',
|
|
42
|
+
patterns: [
|
|
43
|
+
/owner\s*==\s*attacker/i,
|
|
44
|
+
/hasRole.*attacker.*true/i,
|
|
45
|
+
/admin.*changed|owner.*changed/i,
|
|
46
|
+
/assertEq\s*\(\s*.*\.owner\(\)\s*,\s*attacker/i
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
INVARIANT_BREAK: {
|
|
50
|
+
severity: 'HIGH',
|
|
51
|
+
patterns: [
|
|
52
|
+
/invariant.*broken|invariant.*violated/i,
|
|
53
|
+
/totalSupply.*mismatch/i,
|
|
54
|
+
/shares.*inflated|exchange.*rate.*manipulated/i
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
PERMANENT_DOS: {
|
|
58
|
+
severity: 'HIGH',
|
|
59
|
+
patterns: [
|
|
60
|
+
/permanently.*locked|forever.*frozen/i,
|
|
61
|
+
/cannot.*withdraw|funds.*stuck/i,
|
|
62
|
+
/selfdestruct.*success/i
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
class PocValidator {
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.options = {
|
|
70
|
+
enabled: options.enabled || false,
|
|
71
|
+
requirePass: options.requirePass || false,
|
|
72
|
+
productionMode: options.productionMode || false, // Strict mode: require PoC execution proof
|
|
73
|
+
mode: options.mode || 'test', // 'test' (default) or 'build'
|
|
74
|
+
foundryRoot: options.foundryRoot || null,
|
|
75
|
+
keepTemp: options.keepTemp || false,
|
|
76
|
+
forkUrl: options.forkUrl || null, // Optional RPC URL for fork testing
|
|
77
|
+
...options
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static isForgeAvailable() {
|
|
82
|
+
const res = spawnSync('forge', ['--version'], { shell: true, stdio: 'ignore' });
|
|
83
|
+
return res.status === 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static hasPlaceholders(poc) {
|
|
87
|
+
if (!poc) return true;
|
|
88
|
+
const s = String(poc);
|
|
89
|
+
const placeholderPatterns = [
|
|
90
|
+
/0x\.\.\./, // 0x...
|
|
91
|
+
/\bTARGET_ADDRESS\b/,
|
|
92
|
+
/\bTODO\b/i,
|
|
93
|
+
/address\s+constant\s+\w+\s*=\s*address\(0\)/i, // constant address(0)
|
|
94
|
+
];
|
|
95
|
+
return placeholderPatterns.some(p => p.test(s));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static findFoundryRoot(startPath) {
|
|
99
|
+
if (!startPath) return null;
|
|
100
|
+
let current = startPath;
|
|
101
|
+
try {
|
|
102
|
+
const stat = fs.statSync(current);
|
|
103
|
+
if (stat.isFile()) {
|
|
104
|
+
current = path.dirname(current);
|
|
105
|
+
}
|
|
106
|
+
} catch (_) {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
while (true) {
|
|
111
|
+
const candidate = path.join(current, 'foundry.toml');
|
|
112
|
+
if (fs.existsSync(candidate)) return current;
|
|
113
|
+
const parent = path.dirname(current);
|
|
114
|
+
if (parent === current) break;
|
|
115
|
+
current = parent;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
ensureDir(dirPath) {
|
|
121
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
makeTempTestPath(foundryRoot, finding) {
|
|
125
|
+
const baseDir = path.join(foundryRoot, 'test', '.web3crit');
|
|
126
|
+
this.ensureDir(baseDir);
|
|
127
|
+
const safe = (finding.title || 'Finding')
|
|
128
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
129
|
+
.slice(0, 60);
|
|
130
|
+
const fileName = `${Date.now()}_${safe}.t.sol`;
|
|
131
|
+
return path.join(baseDir, fileName);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Extract impact from forge test output.
|
|
136
|
+
* Returns { impactType, severity, evidence } or null if no impact proven.
|
|
137
|
+
*/
|
|
138
|
+
static extractImpact(forgeOutput) {
|
|
139
|
+
if (!forgeOutput) return null;
|
|
140
|
+
|
|
141
|
+
for (const [impactType, config] of Object.entries(IMPACT_PATTERNS)) {
|
|
142
|
+
for (const pattern of config.patterns) {
|
|
143
|
+
const match = forgeOutput.match(pattern);
|
|
144
|
+
if (match) {
|
|
145
|
+
return {
|
|
146
|
+
impactType,
|
|
147
|
+
severity: config.severity,
|
|
148
|
+
evidence: match[0]
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check for test assertions that passed (indicates exploit succeeded)
|
|
155
|
+
if (/PASS.*test/i.test(forgeOutput) && /assert/i.test(forgeOutput)) {
|
|
156
|
+
// Look for value changes in logs
|
|
157
|
+
const valueMatch = forgeOutput.match(/(\d+)\s*(?:ether|wei|tokens?)/i);
|
|
158
|
+
if (valueMatch) {
|
|
159
|
+
return {
|
|
160
|
+
impactType: 'VALUE_EXTRACTION',
|
|
161
|
+
severity: 'HIGH',
|
|
162
|
+
evidence: `Value movement detected: ${valueMatch[0]}`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Derive severity from observed PoC execution results.
|
|
172
|
+
* Overrides heuristic-based severity with runtime proof.
|
|
173
|
+
*/
|
|
174
|
+
static deriveSeverityFromImpact(impact, originalSeverity) {
|
|
175
|
+
if (!impact) {
|
|
176
|
+
// No proven impact → downgrade to INFO (will be filtered out)
|
|
177
|
+
return 'INFO';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Impact-derived severity takes precedence
|
|
181
|
+
return impact.severity;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Validate a single finding. Returns { ok, reason, details?, impact?, derivedSeverity? }.
|
|
186
|
+
*
|
|
187
|
+
* In production mode:
|
|
188
|
+
* - HIGH/CRITICAL requires PoC execution with proven impact
|
|
189
|
+
* - Severity derived from observed results, not heuristics
|
|
190
|
+
*/
|
|
191
|
+
validateFinding(finding, context = {}) {
|
|
192
|
+
const isHighSeverity = ['CRITICAL', 'HIGH'].includes(finding?.severity);
|
|
193
|
+
|
|
194
|
+
// In production mode, HIGH/CRITICAL MUST have PoC validation
|
|
195
|
+
if (this.options.productionMode && isHighSeverity) {
|
|
196
|
+
if (!finding || !finding.foundryPoC) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
reason: 'Production mode: HIGH/CRITICAL requires Foundry PoC',
|
|
200
|
+
derivedSeverity: 'INFO' // Downgrade
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!this.options.enabled && !this.options.productionMode) {
|
|
206
|
+
return { ok: true, reason: 'PoC validation disabled' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!finding || !finding.foundryPoC) {
|
|
210
|
+
return { ok: false, reason: 'No Foundry PoC attached to finding' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (PocValidator.hasPlaceholders(finding.foundryPoC)) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
reason: 'PoC contains placeholders (0x..., TARGET_ADDRESS, TODO, or constant address(0))',
|
|
217
|
+
derivedSeverity: isHighSeverity ? 'INFO' : finding.severity
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// In production mode, we MUST execute and verify impact
|
|
222
|
+
const mustExecute = this.options.productionMode && isHighSeverity;
|
|
223
|
+
|
|
224
|
+
if (!this.options.requirePass && !mustExecute) {
|
|
225
|
+
return { ok: true, reason: 'PoC basic validation passed (placeholders check only)' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Strict pass requires forge + foundry project root.
|
|
229
|
+
if (!PocValidator.isForgeAvailable()) {
|
|
230
|
+
if (mustExecute) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
reason: 'Production mode: forge required but not found in PATH',
|
|
234
|
+
derivedSeverity: 'INFO'
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { ok: false, reason: 'forge not found in PATH (install Foundry to enable PoC pass gating)' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const foundryRoot =
|
|
241
|
+
this.options.foundryRoot ||
|
|
242
|
+
PocValidator.findFoundryRoot(context.scanTargetPath || process.cwd());
|
|
243
|
+
|
|
244
|
+
if (!foundryRoot) {
|
|
245
|
+
if (mustExecute) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
reason: 'Production mode: No foundry.toml found',
|
|
249
|
+
derivedSeverity: 'INFO'
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { ok: false, reason: 'No foundry.toml found (run inside a Foundry project or pass --foundry-root)' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const testPath = this.makeTempTestPath(foundryRoot, finding);
|
|
256
|
+
const content = String(finding.foundryPoC).trim() + os.EOL;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
fs.writeFileSync(testPath, content, { encoding: 'utf8' });
|
|
260
|
+
|
|
261
|
+
// Build args - always use test mode in production for impact verification
|
|
262
|
+
const args = (this.options.mode === 'build' && !mustExecute)
|
|
263
|
+
? ['build', '--silent']
|
|
264
|
+
: ['test', '--match-path', testPath, '-vvvv']; // Extra verbose for impact extraction
|
|
265
|
+
|
|
266
|
+
// Add fork URL if provided
|
|
267
|
+
if (this.options.forkUrl) {
|
|
268
|
+
args.push('--fork-url', this.options.forkUrl);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const res = spawnSync('forge', args, {
|
|
272
|
+
cwd: foundryRoot,
|
|
273
|
+
shell: true,
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
276
|
+
timeout: 120000 // 2 minute timeout
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const passed = res.status === 0;
|
|
280
|
+
const output = (res.stdout || '') + (res.stderr || '');
|
|
281
|
+
|
|
282
|
+
// Extract impact from test output
|
|
283
|
+
const impact = PocValidator.extractImpact(output);
|
|
284
|
+
|
|
285
|
+
// In production mode, derive severity from observed impact
|
|
286
|
+
let derivedSeverity = finding.severity;
|
|
287
|
+
if (this.options.productionMode && isHighSeverity) {
|
|
288
|
+
derivedSeverity = PocValidator.deriveSeverityFromImpact(impact, finding.severity);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// In production mode, require both passing test AND proven impact for HIGH/CRITICAL
|
|
292
|
+
const impactProven = impact !== null;
|
|
293
|
+
const ok = mustExecute
|
|
294
|
+
? (passed && impactProven)
|
|
295
|
+
: passed;
|
|
296
|
+
|
|
297
|
+
let reason;
|
|
298
|
+
if (ok) {
|
|
299
|
+
reason = impactProven
|
|
300
|
+
? `PoC executed successfully - Impact proven: ${impact.impactType}`
|
|
301
|
+
: 'forge validation passed';
|
|
302
|
+
} else if (!passed) {
|
|
303
|
+
reason = 'forge validation failed (PoC did not compile or tests failed)';
|
|
304
|
+
} else {
|
|
305
|
+
reason = 'Production mode: PoC executed but no qualifying impact detected';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
ok,
|
|
310
|
+
reason,
|
|
311
|
+
impact,
|
|
312
|
+
derivedSeverity,
|
|
313
|
+
details: ok ? undefined : output.slice(0, 4000)
|
|
314
|
+
};
|
|
315
|
+
} finally {
|
|
316
|
+
if (!this.options.keepTemp) {
|
|
317
|
+
try { fs.unlinkSync(testPath); } catch (_) {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Validate findings and optionally drop failures.
|
|
324
|
+
*
|
|
325
|
+
* In production mode:
|
|
326
|
+
* - HIGH/CRITICAL without proven impact are silently discarded
|
|
327
|
+
* - Severity is overridden based on PoC execution results
|
|
328
|
+
*/
|
|
329
|
+
validateFindings(findings, context = {}) {
|
|
330
|
+
const kept = [];
|
|
331
|
+
const dropped = [];
|
|
332
|
+
|
|
333
|
+
for (const f of findings) {
|
|
334
|
+
const verdict = this.validateFinding(f, context);
|
|
335
|
+
|
|
336
|
+
// In production mode, update severity based on PoC results
|
|
337
|
+
const updatedFinding = { ...f, pocValidation: verdict };
|
|
338
|
+
|
|
339
|
+
if (this.options.productionMode && verdict.derivedSeverity) {
|
|
340
|
+
updatedFinding.originalSeverity = f.severity;
|
|
341
|
+
updatedFinding.severity = verdict.derivedSeverity;
|
|
342
|
+
|
|
343
|
+
// If downgraded from HIGH/CRITICAL, move to dropped
|
|
344
|
+
if (['CRITICAL', 'HIGH'].includes(f.severity) &&
|
|
345
|
+
!['CRITICAL', 'HIGH'].includes(verdict.derivedSeverity)) {
|
|
346
|
+
dropped.push(updatedFinding);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (verdict.impact) {
|
|
352
|
+
updatedFinding.provenImpact = verdict.impact;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (verdict.ok) {
|
|
356
|
+
kept.push(updatedFinding);
|
|
357
|
+
} else {
|
|
358
|
+
dropped.push(updatedFinding);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { kept, dropped };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Production-grade validation for HIGH/CRITICAL findings only.
|
|
367
|
+
* Silently discards findings that don't meet the bar.
|
|
368
|
+
*/
|
|
369
|
+
validateForProduction(findings, context = {}) {
|
|
370
|
+
const highSeverityFindings = findings.filter(f =>
|
|
371
|
+
['CRITICAL', 'HIGH'].includes(f.severity)
|
|
372
|
+
);
|
|
373
|
+
const otherFindings = findings.filter(f =>
|
|
374
|
+
!['CRITICAL', 'HIGH'].includes(f.severity)
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// All HIGH/CRITICAL must pass PoC validation with proven impact
|
|
378
|
+
const { kept, dropped } = this.validateFindings(highSeverityFindings, context);
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
// Only return HIGH/CRITICAL that passed validation
|
|
382
|
+
findings: [...kept, ...otherFindings],
|
|
383
|
+
// Track what was dropped for reporting
|
|
384
|
+
droppedHighSeverity: dropped,
|
|
385
|
+
stats: {
|
|
386
|
+
originalHighSeverity: highSeverityFindings.length,
|
|
387
|
+
validatedHighSeverity: kept.length,
|
|
388
|
+
droppedCount: dropped.length
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = PocValidator;
|
|
395
|
+
|
|
396
|
+
|