aegis-audit 2.1.0

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,62 @@
1
+ import axios from 'axios';
2
+
3
+ export async function claudeAnalyze(source, staticFindings, metrics, apiKey) {
4
+ const staticSummary = staticFindings.length > 0
5
+ ? staticFindings.map(f => `- [${f.severity}] ${f.title} (${f.owasp})`).join('\n')
6
+ : '- None';
7
+
8
+ const prompt = `You are an expert Solidity security auditor performing a semantic review that complements automated static analysis. Think like an APT-grade red teamer: focus on business-logic flaws, economic/flash-loan attacks, oracle manipulation, access-control gaps, and centralization risks that pattern matching misses.
9
+
10
+ CONTRACT SOURCE:
11
+ \`\`\`solidity
12
+ ${source.slice(0, 7000)}${source.length > 7000 ? '\n... [truncated]' : ''}
13
+ \`\`\`
14
+
15
+ CONTEXT:
16
+ - Solidity: ${metrics.solidityVersion}
17
+ - AccessControl present: ${metrics.hasAccessControl}
18
+ - ReentrancyGuard present: ${metrics.hasReentrancyGuard}
19
+ - Uses oracle: ${metrics.usesOracle}
20
+ - Upgradeable: ${metrics.isUpgradeable}
21
+
22
+ STATIC ANALYSIS ALREADY FOUND:
23
+ ${staticSummary}
24
+
25
+ Find ADDITIONAL issues the static layer missed. Map each to the OWASP Smart Contract Top 10 (2026): SC01 Access Control, SC02 Business Logic, SC03 Price Oracle Manipulation, SC04 Flash Loan, SC05 Input Validation, SC06 Unchecked External Calls, SC07 Arithmetic, SC08 Reentrancy, SC09 Integer Overflow, SC10 Proxy/Upgradeability.
26
+
27
+ Return ONLY valid JSON (no markdown):
28
+ {
29
+ "summary": "<3-4 sentence executive summary written for a security team>",
30
+ "findings": [
31
+ {
32
+ "severity": "CRITICAL|HIGH|MEDIUM|LOW",
33
+ "title": "<short>",
34
+ "location": "<function or line>",
35
+ "owasp": "SC0X:2026",
36
+ "owaspTitle": "<category name>",
37
+ "cwe": "CWE-XXX",
38
+ "cweName": "<name>",
39
+ "mitre": "TXXXX",
40
+ "mitreName": "<technique>",
41
+ "exploitLikelihood": <1-5>,
42
+ "attackerCost": "low|medium|high",
43
+ "description": "<why it's exploitable>",
44
+ "fix": "<concrete remediation>"
45
+ }
46
+ ]
47
+ }`;
48
+
49
+ const resp = await axios.post(
50
+ 'https://api.anthropic.com/v1/messages',
51
+ { model: 'claude-sonnet-4-20250514', max_tokens: 3000, messages: [{ role: 'user', content: prompt }] },
52
+ { headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, timeout: 90000 }
53
+ );
54
+
55
+ const text = resp.data.content?.[0]?.text || '{}';
56
+ const clean = text.replace(/```json|```/g, '').trim();
57
+ try {
58
+ return JSON.parse(clean);
59
+ } catch {
60
+ return { summary: 'Semantic analysis completed; output could not be parsed. Manual review recommended.', findings: [] };
61
+ }
62
+ }
@@ -0,0 +1,311 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Static detector engine — full OWASP SC Top 10 (2026) coverage
3
+ // Each detector carries: severity, OWASP id, CWE id, MITRE technique,
4
+ // confidence (regex precision), and red-team metadata (exploit likelihood +
5
+ // attacker cost). This is a heuristic layer; the Claude layer adds semantic
6
+ // findings. Heuristics WILL miss things — see DISCLAIMER.
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+
9
+ import { OWASP_SC_2026 as O, MITRE as M, CWE as C } from '../knowledge/frameworks.js';
10
+
11
+ // exploitLikelihood: how readily a remote attacker can trigger it (1-5)
12
+ // attackerCost: rough effort/capital needed (low|medium|high)
13
+ export const DETECTORS = [
14
+ // ── SC01: Access Control ──────────────────────────────────────────────────
15
+ {
16
+ id: 'AC-missing-modifier',
17
+ severity: 'CRITICAL',
18
+ owasp: O.SC01, cwe: C.CWE_284, mitre: M.T1078,
19
+ confidence: 'medium',
20
+ exploitLikelihood: 5, attackerCost: 'low',
21
+ title: 'State-changing function lacks access control',
22
+ pattern: /function\s+(?:set|update|withdrawAll|mint|burn|upgrade|pause|unpause|grant|revoke|destroy|kill|sweep|rescue|setOwner|transferOwnership|changeOwner)\w*\s*\([^)]*\)\s*(?:external|public)(?![^{]*\b(?:onlyOwner|onlyRole|onlyAdmin|require\s*\(\s*msg\.sender|_checkRole|hasRole|auth|restricted|nonReentrant)\b)[^{]*\{/i,
23
+ description: 'A privileged-sounding function is declared public/external with no visible access-control modifier or msg.sender check. Access-control flaws were the single largest loss category in 2024 ($953M).',
24
+ fix: 'Apply an explicit modifier (onlyOwner / onlyRole(ROLE)) or an inline require(msg.sender == authorized). Prefer OpenZeppelin AccessControl with role separation over a single owner.',
25
+ },
26
+ {
27
+ id: 'AC-tx-origin',
28
+ severity: 'HIGH',
29
+ owasp: O.SC01, cwe: C.CWE_285, mitre: M.T1556,
30
+ confidence: 'high',
31
+ exploitLikelihood: 4, attackerCost: 'low',
32
+ title: 'tx.origin used for authorization',
33
+ pattern: /tx\.origin\s*==|require\s*\(\s*tx\.origin/,
34
+ description: 'tx.origin authentication is phishable: a malicious contract the victim calls will still see the victim as tx.origin, bypassing the check.',
35
+ fix: 'Replace tx.origin with msg.sender for all authorization logic.',
36
+ },
37
+ {
38
+ id: 'AC-unprotected-selfdestruct',
39
+ severity: 'HIGH',
40
+ owasp: O.SC01, cwe: C.CWE_284, mitre: M.T1078,
41
+ confidence: 'high',
42
+ exploitLikelihood: 3, attackerCost: 'medium',
43
+ title: 'selfdestruct reachable',
44
+ pattern: /\bselfdestruct\s*\(|\bsuicide\s*\(/,
45
+ description: 'selfdestruct can permanently remove contract code and sweep its balance. If reachable via weak auth or delegatecall, an attacker can destroy the protocol. Deprecated under EIP-6049.',
46
+ fix: 'Remove selfdestruct. If unavoidable, gate behind multi-sig + timelock and document the threat model.',
47
+ },
48
+
49
+ // ── SC10: Proxy & Upgradeability ──────────────────────────────────────────
50
+ {
51
+ id: 'UP-unprotected-initializer',
52
+ severity: 'CRITICAL',
53
+ owasp: O.SC10, cwe: C.CWE_665, mitre: M.T1078,
54
+ confidence: 'medium',
55
+ exploitLikelihood: 5, attackerCost: 'low',
56
+ title: 'Initializer may be unprotected',
57
+ pattern: /function\s+initialize\s*\([^)]*\)\s*(?:external|public)(?![^{]*\b(?:initializer|reinitializer|onlyOwner|require)\b)/,
58
+ description: 'An initialize() function without the initializer modifier can be called by anyone, letting an attacker seize ownership of a freshly deployed proxy (classic proxy takeover).',
59
+ fix: 'Use OpenZeppelin Initializable and the initializer modifier. Disable initializers in the implementation constructor with _disableInitializers().',
60
+ },
61
+ {
62
+ id: 'UP-delegatecall',
63
+ severity: 'HIGH',
64
+ owasp: O.SC10, cwe: C.CWE_829, mitre: M.T1565,
65
+ confidence: 'high',
66
+ exploitLikelihood: 3, attackerCost: 'medium',
67
+ title: 'delegatecall to untrusted/variable target',
68
+ pattern: /\.delegatecall\s*\(/,
69
+ description: 'delegatecall runs external code against this contract\'s storage. If the target is attacker-influenced or upgradeable, storage can be corrupted or control seized.',
70
+ fix: 'Restrict delegatecall to immutable, audited implementation addresses. Use a vetted proxy pattern (UUPS/Transparent) with storage-gap discipline.',
71
+ },
72
+
73
+ // ── SC03: Price Oracle Manipulation ───────────────────────────────────────
74
+ {
75
+ id: 'OR-spot-price',
76
+ severity: 'HIGH',
77
+ owasp: O.SC03, cwe: C.CWE_345, mitre: M.T1565,
78
+ confidence: 'medium',
79
+ exploitLikelihood: 4, attackerCost: 'medium',
80
+ title: 'Spot price used as oracle (manipulable)',
81
+ pattern: /getReserves\s*\(\)|\.price0CumulativeLast|\.price1CumulativeLast|balanceOf\s*\([^)]*\)\s*[*/]\s*|\.getAmountsOut\s*\(/,
82
+ description: 'Reading instantaneous DEX reserves/spot price as a trusted value is manipulable within a single block via flash loans, enabling mispriced borrows and liquidations.',
83
+ fix: 'Use a manipulation-resistant oracle: Chainlink price feeds, or a TWAP with sufficient window. Never use single-block spot price for valuation.',
84
+ },
85
+
86
+ // ── SC04: Flash Loan-Facilitated ──────────────────────────────────────────
87
+ {
88
+ id: 'FL-flashloan-callback',
89
+ severity: 'MEDIUM',
90
+ owasp: O.SC04, cwe: C.CWE_841, mitre: M.T1565,
91
+ confidence: 'low',
92
+ exploitLikelihood: 3, attackerCost: 'low',
93
+ title: 'Flash loan callback present — verify invariants',
94
+ pattern: /function\s+(?:executeOperation|onFlashLoan|receiveFlashLoan|uniswapV[23]Call|pancakeCall)\s*\(/,
95
+ description: 'A flash-loan callback is implemented. These are legitimate, but they are the entry point most often abused to chain price manipulation + logic bugs into single-tx drains. Confirm all economic invariants hold mid-callback.',
96
+ fix: 'Validate caller, enforce post-state invariants, and avoid trusting in-transaction prices. Add reentrancy protection around callback-affected state.',
97
+ },
98
+
99
+ // ── SC08: Reentrancy ──────────────────────────────────────────────────────
100
+ {
101
+ id: 'RE-external-before-state',
102
+ severity: 'CRITICAL',
103
+ owasp: O.SC08, cwe: C.CWE_841b, mitre: M.T1565,
104
+ confidence: 'medium',
105
+ exploitLikelihood: 4, attackerCost: 'low',
106
+ title: 'External call before state update (reentrancy)',
107
+ pattern: /(?:\.call\{\s*value\s*:[^}]*\}\([^)]*\)|\.call\.value\s*\([^)]*\)\s*\([^)]*\))[\s\S]{0,240}?(?:balances?|credit|shares?|deposits?)\s*\[[^\]]+\]\s*(?:-=|=)/,
108
+ description: 'Value-bearing external call appears before the corresponding balance update. A malicious recipient can re-enter and withdraw repeatedly (the DAO-class bug).',
109
+ fix: 'Apply Checks-Effects-Interactions: update state before the external call. Add OpenZeppelin ReentrancyGuard (nonReentrant).',
110
+ },
111
+ {
112
+ id: 'RE-no-guard',
113
+ severity: 'LOW',
114
+ owasp: O.SC08, cwe: C.CWE_362, mitre: M.T1565,
115
+ confidence: 'low',
116
+ exploitLikelihood: 2, attackerCost: 'low',
117
+ title: 'Value transfers without ReentrancyGuard',
118
+ pattern: /\.call\{\s*value/,
119
+ requiresAbsence: /ReentrancyGuard|nonReentrant/,
120
+ description: 'Contract makes value-bearing calls but imports no reentrancy guard. Even with CEI, defense-in-depth is recommended.',
121
+ fix: 'Add OpenZeppelin ReentrancyGuard and apply nonReentrant to all functions making external value calls.',
122
+ },
123
+
124
+ // ── SC06: Unchecked External Calls ────────────────────────────────────────
125
+ {
126
+ id: 'EC-unchecked-call',
127
+ severity: 'HIGH',
128
+ owasp: O.SC06, cwe: C.CWE_252, mitre: M.T1565,
129
+ confidence: 'medium',
130
+ exploitLikelihood: 3, attackerCost: 'low',
131
+ title: 'Unchecked low-level call return value',
132
+ pattern: /[^\S\n]*[\w.]+\.call(?:\{[^}]{0,80}\}|\.value\([^)]{0,40}\))?\([^)]{0,80}\)[^\S\n]*;/,
133
+ requiresAbsence: /\(\s*bool\s+\w+\s*,/,
134
+ description: 'A low-level .call return value is not captured/checked. Silent failure lets execution continue under a false success assumption.',
135
+ fix: '(bool ok, ) = target.call{...}(...); require(ok, "call failed"); Prefer typed interfaces over raw call where possible.',
136
+ },
137
+ {
138
+ id: 'EC-erc20-no-safe',
139
+ severity: 'MEDIUM',
140
+ owasp: O.SC06, cwe: C.CWE_252, mitre: M.T1565,
141
+ confidence: 'medium',
142
+ exploitLikelihood: 2, attackerCost: 'low',
143
+ title: 'ERC20 transfer/transferFrom return not checked',
144
+ pattern: /(?<!Safe(?:ERC20)?\.)\b\w*\.(?:transfer|transferFrom)\s*\([^)]*\)\s*;/,
145
+ description: 'Some tokens (e.g. USDT) return false instead of reverting. Unchecked transfers silently fail, desyncing accounting.',
146
+ fix: 'Use OpenZeppelin SafeERC20 (safeTransfer / safeTransferFrom).',
147
+ },
148
+
149
+ // ── SC05: Lack of Input Validation ────────────────────────────────────────
150
+ {
151
+ id: 'IV-no-zero-address',
152
+ severity: 'MEDIUM',
153
+ owasp: O.SC05, cwe: C.CWE_20, mitre: M.T1190,
154
+ confidence: 'low',
155
+ exploitLikelihood: 2, attackerCost: 'low',
156
+ title: 'Address parameter without zero-address check',
157
+ pattern: /function\s+\w+\s*\([^)]*address\s+\w+[^)]*\)\s*(?:external|public)(?![^{]*require\s*\([^)]*!=\s*address\(0\))/,
158
+ description: 'A function takes an address argument with no visible zero-address validation. Setting critical addresses to 0x0 can brick the contract or burn funds.',
159
+ fix: 'require(addr != address(0), "zero address") for all externally supplied addresses used in state or transfers.',
160
+ },
161
+
162
+ // ── SC07 / SC09: Arithmetic / Overflow ────────────────────────────────────
163
+ {
164
+ id: 'AR-old-solidity',
165
+ severity: 'HIGH',
166
+ owasp: O.SC09, cwe: C.CWE_190, mitre: M.T1565,
167
+ confidence: 'high',
168
+ exploitLikelihood: 3, attackerCost: 'low',
169
+ title: 'Solidity < 0.8.0 without overflow protection',
170
+ pattern: /pragma solidity\s+[^;]*0\.[0-7]\./,
171
+ requiresAbsence: /SafeMath/,
172
+ description: 'Pre-0.8 Solidity has no built-in overflow/underflow checks and no SafeMath import detected. Arithmetic can silently wrap.',
173
+ fix: 'Upgrade to Solidity ^0.8.x, or import and use OpenZeppelin SafeMath consistently.',
174
+ },
175
+ {
176
+ id: 'AR-unchecked-block',
177
+ severity: 'MEDIUM',
178
+ owasp: O.SC07, cwe: C.CWE_682, mitre: M.T1565,
179
+ confidence: 'medium',
180
+ exploitLikelihood: 2, attackerCost: 'medium',
181
+ title: 'unchecked{} arithmetic block',
182
+ pattern: /\bunchecked\s*\{/,
183
+ description: 'unchecked{} disables overflow protection for performance. Legitimate, but each block must be proven safe — a wrong assumption reintroduces overflow bugs.',
184
+ fix: 'Confirm every operation in unchecked blocks cannot overflow for all reachable inputs. Document the invariant inline.',
185
+ },
186
+
187
+ // ── SC02: Business Logic (heuristic flags only) ───────────────────────────
188
+ {
189
+ id: 'BL-divide-before-multiply',
190
+ severity: 'MEDIUM',
191
+ owasp: O.SC02, cwe: C.CWE_682, mitre: M.T1565,
192
+ confidence: 'low',
193
+ exploitLikelihood: 2, attackerCost: 'medium',
194
+ title: 'Division before multiplication (precision loss)',
195
+ pattern: /\/\s*\w+\s*\*\s*\w+/,
196
+ description: 'Dividing before multiplying truncates intermediate results, causing precision loss exploitable in share/interest math, especially when amplified by flash loans.',
197
+ fix: 'Reorder to multiply before dividing, or use a fixed-point math library (PRBMath, ABDKMath).',
198
+ },
199
+
200
+ // ── Randomness / Timestamp (logic class) ──────────────────────────────────
201
+ {
202
+ id: 'Rand-weak-randomness',
203
+ severity: 'HIGH',
204
+ owasp: O.SC02, cwe: C.CWE_330, mitre: M.T1565,
205
+ confidence: 'medium',
206
+ exploitLikelihood: 4, attackerCost: 'low',
207
+ title: 'Insecure on-chain randomness',
208
+ pattern: /keccak256\s*\([^)]*(?:block\.(?:timestamp|number|difficulty|prevrandao)|blockhash)[^)]*\)/,
209
+ description: 'Deriving randomness from block properties is predictable/influenceable by validators, breaking lotteries, mints, and games.',
210
+ fix: 'Use a verifiable randomness source (Chainlink VRF) for any value an attacker would profit from predicting.',
211
+ },
212
+ {
213
+ id: 'TS-timestamp-logic',
214
+ severity: 'LOW',
215
+ owasp: O.SC02, cwe: C.CWE_682, mitre: M.T1565,
216
+ confidence: 'medium',
217
+ exploitLikelihood: 2, attackerCost: 'medium',
218
+ title: 'block.timestamp used in comparison logic',
219
+ pattern: /block\.timestamp\s*[<>]=?|[<>]=?\s*block\.timestamp/,
220
+ description: 'Validators can nudge block.timestamp by a few seconds. Avoid using it for tight deadlines or randomness.',
221
+ fix: 'Allow tolerance, or use block.number for relative timing where precision matters.',
222
+ },
223
+
224
+ // ── DoS (resource exhaustion) ─────────────────────────────────────────────
225
+ {
226
+ id: 'DoS-unbounded-loop',
227
+ severity: 'MEDIUM',
228
+ owasp: O.SC02, cwe: C.CWE_400, mitre: M.T1499,
229
+ confidence: 'low',
230
+ exploitLikelihood: 3, attackerCost: 'low',
231
+ title: 'Unbounded loop over dynamic array',
232
+ pattern: /for\s*\([^;]*;\s*\w+\s*<\s*\w+\.length\s*;/,
233
+ description: 'Looping over an array that an attacker can grow can push gas past the block limit, permanently bricking the function (griefing DoS).',
234
+ fix: 'Bound iterations, use pull-over-push patterns, or paginate. Never iterate attacker-controllable unbounded arrays in a single tx.',
235
+ },
236
+
237
+ // ── Hygiene / supply-chain ────────────────────────────────────────────────
238
+ {
239
+ id: 'HY-floating-pragma',
240
+ severity: 'LOW',
241
+ owasp: O.SC10, cwe: C.CWE_665, mitre: M.T1195,
242
+ confidence: 'high',
243
+ exploitLikelihood: 1, attackerCost: 'high',
244
+ title: 'Floating pragma version',
245
+ pattern: /pragma solidity\s+\^/,
246
+ description: 'A floating ^ pragma allows compilation with future compiler versions whose behavior/bugs are unknown — a small supply-chain risk.',
247
+ fix: 'Pin an exact, audited compiler version, e.g. pragma solidity 0.8.24;',
248
+ },
249
+ ];
250
+
251
+ // Run all detectors against source. Returns normalized findings with framework tags.
252
+ export function runDetectors(source) {
253
+ const findings = [];
254
+ const lines = source.split('\n');
255
+
256
+ for (const d of DETECTORS) {
257
+ // Skip detectors whose "absence" precondition is violated
258
+ if (d.requiresAbsence && d.requiresAbsence.test(source)) continue;
259
+
260
+ if (d.pattern.test(source)) {
261
+ let lineNum = null;
262
+ for (let i = 0; i < lines.length; i++) {
263
+ // Reset lastIndex for global-less regex safety
264
+ if (new RegExp(d.pattern.source, d.pattern.flags.replace('g', '')).test(lines[i])) {
265
+ lineNum = i + 1;
266
+ break;
267
+ }
268
+ }
269
+ findings.push({
270
+ detectorId: d.id,
271
+ severity: d.severity,
272
+ title: d.title,
273
+ location: lineNum ? `Line ${lineNum}` : 'Multiple locations',
274
+ description: d.description,
275
+ fix: d.fix,
276
+ owasp: d.owasp.id,
277
+ owaspTitle: d.owasp.title,
278
+ cwe: d.cwe.id,
279
+ cweName: d.cwe.name,
280
+ mitre: d.mitre.id,
281
+ mitreName: d.mitre.name,
282
+ confidence: d.confidence,
283
+ exploitLikelihood: d.exploitLikelihood,
284
+ attackerCost: d.attackerCost,
285
+ source: 'static',
286
+ });
287
+ }
288
+ }
289
+
290
+ return findings;
291
+ }
292
+
293
+ export function gatherMetrics(source) {
294
+ const lines = source.split('\n');
295
+ return {
296
+ lines: lines.length,
297
+ contracts: (source.match(/\bcontract\s+\w+/g) || []).length,
298
+ functions: (source.match(/\bfunction\s+\w+/g) || []).length,
299
+ modifiers: (source.match(/\bmodifier\s+\w+/g) || []).length,
300
+ externalFunctions: (source.match(/\b(?:external|public)\s+/g) || []).length,
301
+ hasOwnable: /Ownable|onlyOwner/.test(source),
302
+ hasAccessControl: /AccessControl|onlyRole|hasRole/.test(source),
303
+ hasReentrancyGuard: /ReentrancyGuard|nonReentrant/.test(source),
304
+ hasSafeMath: /SafeMath|using SafeMath/.test(source),
305
+ hasSafeERC20: /SafeERC20/.test(source),
306
+ usesOracle: /Chainlink|AggregatorV3|getReserves|oracle/i.test(source),
307
+ isUpgradeable: /Initializable|UUPS|delegatecall|Proxy/i.test(source),
308
+ hasEvents: /emit\s+\w+/.test(source),
309
+ solidityVersion: (source.match(/pragma solidity\s+([^;]+);/) || [])[1]?.trim() || 'unknown',
310
+ };
311
+ }
@@ -0,0 +1,128 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Risk scoring — red-team weighted
3
+ // Combines severity, exploit likelihood, and attacker cost into a single
4
+ // 0-100 security score. Lower attacker cost + higher likelihood = worse score.
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ const SEV_WEIGHT = { CRITICAL: 40, HIGH: 20, MEDIUM: 8, LOW: 2 };
8
+ const COST_FACTOR = { low: 1.0, medium: 0.6, high: 0.3 };
9
+
10
+ export function computeRiskScore(findings) {
11
+ let penalty = 0;
12
+ for (const f of findings) {
13
+ const base = SEV_WEIGHT[f.severity] ?? 5;
14
+ const likelihood = (f.exploitLikelihood ?? 3) / 5; // 0.2 - 1.0
15
+ const cost = COST_FACTOR[f.attackerCost] ?? 0.6;
16
+ penalty += base * likelihood * cost;
17
+ }
18
+ const score = Math.max(0, Math.round(100 - penalty));
19
+
20
+ const verdict = score >= 80 ? 'ACCEPTABLE RISK'
21
+ : score >= 60 ? 'REMEDIATE BEFORE MAINNET'
22
+ : score >= 35 ? 'HIGH RISK — DO NOT DEPLOY'
23
+ : 'CRITICAL RISK — DO NOT DEPLOY';
24
+
25
+ return { score, verdict };
26
+ }
27
+
28
+ // Category breakdown by OWASP family (0-100 each).
29
+ export function categoryBreakdown(findings) {
30
+ const families = {
31
+ 'Access control': ['SC01:2026'],
32
+ 'Business logic': ['SC02:2026'],
33
+ 'Oracle / pricing': ['SC03:2026', 'SC04:2026'],
34
+ 'Input validation': ['SC05:2026'],
35
+ 'External calls': ['SC06:2026'],
36
+ 'Arithmetic': ['SC07:2026', 'SC09:2026'],
37
+ 'Reentrancy': ['SC08:2026'],
38
+ 'Upgradeability': ['SC10:2026'],
39
+ };
40
+ const out = {};
41
+ for (const [label, ids] of Object.entries(families)) {
42
+ const hits = findings.filter(f => ids.includes(f.owasp));
43
+ let penalty = 0;
44
+ for (const f of hits) {
45
+ penalty += (SEV_WEIGHT[f.severity] ?? 5) * ((f.exploitLikelihood ?? 3) / 5);
46
+ }
47
+ out[label] = Math.max(0, Math.round(100 - penalty * 1.5));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // Red-team attack-path synthesis: which findings chain into a high-impact exploit.
53
+ // This is the "think like an APT" lens — single bugs matter less than chains.
54
+ export function synthesizeAttackPaths(findings) {
55
+ const paths = [];
56
+ const has = (owasp) => findings.find(f => f.owasp === owasp);
57
+
58
+ // Chain 1: oracle manipulation + flash loan = classic DeFi drain
59
+ if (has('SC03:2026') && (has('SC04:2026') || true)) {
60
+ const oracle = has('SC03:2026');
61
+ if (oracle) {
62
+ paths.push({
63
+ name: 'Flash-loan price manipulation drain',
64
+ severity: 'CRITICAL',
65
+ steps: [
66
+ 'Attacker takes an uncollateralized flash loan (no capital required).',
67
+ 'Uses the loan to skew the manipulable spot price this contract reads.',
68
+ 'Triggers under-priced borrow / over-valued collateral in the same tx.',
69
+ 'Repays the flash loan and keeps the difference; all atomic, no risk.',
70
+ ],
71
+ mitre: ['T1565', 'T1583'],
72
+ owasp: ['SC03:2026', 'SC04:2026'],
73
+ });
74
+ }
75
+ }
76
+
77
+ // Chain 2: unprotected initializer + delegatecall = proxy takeover
78
+ const init = findings.find(f => f.detectorId === 'UP-unprotected-initializer');
79
+ if (init) {
80
+ paths.push({
81
+ name: 'Proxy takeover via unprotected initializer',
82
+ severity: 'CRITICAL',
83
+ steps: [
84
+ 'Attacker front-runs or back-runs deployment to call initialize().',
85
+ 'Sets themselves as owner/admin of the proxy.',
86
+ 'Uses admin rights to upgrade the implementation to a malicious one.',
87
+ 'Drains all funds through the attacker-controlled logic.',
88
+ ],
89
+ mitre: ['T1078', 'T1556'],
90
+ owasp: ['SC10:2026', 'SC01:2026'],
91
+ });
92
+ }
93
+
94
+ // Chain 3: access control gap + value transfer = direct theft
95
+ const ac = findings.find(f => f.detectorId === 'AC-missing-modifier');
96
+ if (ac) {
97
+ paths.push({
98
+ name: 'Direct fund theft via missing access control',
99
+ severity: 'CRITICAL',
100
+ steps: [
101
+ 'Attacker enumerates public/external state-changing functions.',
102
+ 'Calls the unprotected privileged function directly (e.g. setOwner, withdraw).',
103
+ 'Escalates privileges or transfers funds with no authorization barrier.',
104
+ ],
105
+ mitre: ['T1078'],
106
+ owasp: ['SC01:2026'],
107
+ });
108
+ }
109
+
110
+ // Chain 4: reentrancy + value transfer
111
+ const re = findings.find(f => f.detectorId === 'RE-external-before-state');
112
+ if (re) {
113
+ paths.push({
114
+ name: 'Recursive drain via reentrancy',
115
+ severity: 'CRITICAL',
116
+ steps: [
117
+ 'Attacker deploys a contract with a malicious fallback/receive.',
118
+ 'Calls the vulnerable withdraw; receives value before balance is zeroed.',
119
+ 'Fallback re-enters withdraw repeatedly against stale balance.',
120
+ 'Loops until the contract is drained.',
121
+ ],
122
+ mitre: ['T1565'],
123
+ owasp: ['SC08:2026'],
124
+ });
125
+ }
126
+
127
+ return paths;
128
+ }
@@ -0,0 +1,115 @@
1
+ import ora from 'ora';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs';
4
+ import { loadConfig, auditAppend } from '../utils/secure-config.js';
5
+ import { fetchSource } from '../utils/fetcher.js';
6
+ import { runDetectors, gatherMetrics } from '../analyzers/detectors.js';
7
+ import { claudeAnalyze } from '../analyzers/claude.js';
8
+ import { computeRiskScore, categoryBreakdown, synthesizeAttackPaths } from '../analyzers/risk.js';
9
+ import { generateSBOM, generateSARIF, sha256 } from '../output/formats.js';
10
+ import { renderReport } from '../ui/report.js';
11
+
12
+ chalk.level = 3;
13
+
14
+ export async function auditCommand(target, options) {
15
+ chalk.level = 3;
16
+
17
+ const offline = options.offline || false;
18
+ const config = loadConfig();
19
+ const apiKey = process.env.ANTHROPIC_API_KEY || config.apiKey;
20
+
21
+ if (!offline && !apiKey) {
22
+ console.log('\n' + chalk.hex('#ff4560')('X No API key found.'));
23
+ console.log(chalk.hex('#7a90a8')(' Run ') + chalk.white('solguard config') + chalk.hex('#7a90a8')(', set ANTHROPIC_API_KEY,'));
24
+ console.log(chalk.hex('#7a90a8')(' or use ') + chalk.white('--offline') + chalk.hex('#7a90a8')(' for static detectors only (no source leaves your machine).\n'));
25
+ process.exit(2);
26
+ }
27
+
28
+ const spinner = ora({ color: 'cyan' });
29
+
30
+ try {
31
+ spinner.start(chalk.hex('#7a90a8')('Fetching contract source...'));
32
+ const contractInfo = await fetchSource(target, options.network);
33
+ const hash = sha256(contractInfo.source);
34
+ spinner.succeed(chalk.hex('#00e6b4')(`Source loaded - ${contractInfo.files.length} file(s) - ${contractInfo.source.length.toLocaleString()} bytes - sha256:${hash.slice(0,12)}`));
35
+
36
+ spinner.start(chalk.hex('#7a90a8')('Running OWASP SC Top 10 (2026) static detectors...'));
37
+ const staticFindings = runDetectors(contractInfo.source);
38
+ const metrics = gatherMetrics(contractInfo.source);
39
+ spinner.succeed(chalk.hex('#00e6b4')(`Static analysis complete - ${staticFindings.length} finding(s)`));
40
+
41
+ console.log(
42
+ chalk.hex('#4a5a6a')(` Solidity: ${metrics.solidityVersion} - `) +
43
+ chalk.hex('#4a5a6a')(`Functions: ${metrics.functions} - `) +
44
+ (metrics.hasAccessControl ? chalk.hex('#00e6b4')('AccessControl OK') : chalk.hex('#ffb740')('No AccessControl')) +
45
+ chalk.hex('#4a5a6a')(' - ') +
46
+ (metrics.hasReentrancyGuard ? chalk.hex('#00e6b4')('Guard OK') : chalk.hex('#ffb740')('No Guard')) +
47
+ chalk.hex('#4a5a6a')(' - ') +
48
+ (metrics.usesOracle ? chalk.hex('#ffb740')('Uses oracle') : chalk.hex('#4a5a6a')('No oracle'))
49
+ );
50
+
51
+ let aiFindings = [];
52
+ let aiResult = null;
53
+
54
+ if (!offline) {
55
+ spinner.start(chalk.hex('#7a90a8')('Claude AI semantic analysis (business logic, economic attacks)...'));
56
+ try {
57
+ aiResult = await claudeAnalyze(contractInfo.source, staticFindings, metrics, apiKey);
58
+ aiFindings = (aiResult.findings ?? []).map(f => ({
59
+ ...f, source: 'claude',
60
+ exploitLikelihood: f.exploitLikelihood ?? 3,
61
+ attackerCost: f.attackerCost ?? 'medium',
62
+ }));
63
+ spinner.succeed(chalk.hex('#00e6b4')(`Claude analysis complete - ${aiFindings.length} semantic finding(s)`));
64
+ } catch (e) {
65
+ spinner.warn(chalk.hex('#ffb740')('Claude analysis unavailable - continuing with static findings only'));
66
+ }
67
+ } else {
68
+ console.log(chalk.hex('#4da6ff')(' i Offline mode: source code never left this machine. Static detectors only.'));
69
+ }
70
+
71
+ const deduped = aiFindings.filter(af =>
72
+ !staticFindings.some(sf => (sf.owasp && sf.owasp === af.owasp && sf.severity === af.severity))
73
+ );
74
+ const allFindings = [...staticFindings, ...deduped];
75
+
76
+ const { score, verdict } = computeRiskScore(allFindings);
77
+ const breakdown = categoryBreakdown(allFindings);
78
+ const attackPaths = synthesizeAttackPaths(allFindings);
79
+
80
+ renderReport(contractInfo, allFindings, { score, verdict, breakdown, attackPaths, metrics, aiSummary: aiResult?.summary, offline }, options);
81
+
82
+ if (options.sarif) {
83
+ fs.writeFileSync(options.sarif, JSON.stringify(generateSARIF(contractInfo, allFindings), null, 2));
84
+ console.log(chalk.hex('#00e6b4')('OK ') + `SARIF written to ${chalk.hex('#4da6ff')(options.sarif)}`);
85
+ }
86
+ if (options.sbom) {
87
+ fs.writeFileSync(options.sbom, JSON.stringify(generateSBOM(contractInfo), null, 2));
88
+ console.log(chalk.hex('#00e6b4')('OK ') + `CycloneDX SBOM written to ${chalk.hex('#4da6ff')(options.sbom)}`);
89
+ }
90
+
91
+ auditAppend({ type: 'audit', target, findings: allFindings.length, score });
92
+
93
+ const counts = countBySeverity(allFindings);
94
+ const failThreshold = options.failOn || 'high';
95
+ const shouldFail =
96
+ (failThreshold === 'critical' && counts.CRITICAL > 0) ||
97
+ (failThreshold === 'high' && (counts.CRITICAL > 0 || counts.HIGH > 0)) ||
98
+ (failThreshold === 'medium' && (counts.CRITICAL > 0 || counts.HIGH > 0 || counts.MEDIUM > 0));
99
+
100
+ if (shouldFail && options.ci) {
101
+ console.log('\n' + chalk.hex('#ff4560')(`X CI policy: failing build (threshold=${failThreshold}).`) + '\n');
102
+ process.exit(1);
103
+ }
104
+ } catch (err) {
105
+ spinner.fail(chalk.hex('#ff4560')(err.message || 'Unknown error'));
106
+ if (err.response?.status === 401) console.log('\n' + chalk.hex('#7a90a8')(' Invalid API key. Run: ') + chalk.white('solguard config') + '\n');
107
+ process.exit(2);
108
+ }
109
+ }
110
+
111
+ function countBySeverity(findings) {
112
+ const c = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
113
+ for (const f of findings) c[f.severity] = (c[f.severity] || 0) + 1;
114
+ return c;
115
+ }