Skip to main content

debug_engine/
analysis.rs

1use engine_model::ExecutionEvent;
2use serde::{Deserialize, Serialize};
3
4/// Security finding detected by trace analysis.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct SecurityFinding {
7    pub severity: Severity,
8    pub title: String,
9    pub description: String,
10    pub step: Option<u64>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub enum Severity {
15    Critical,
16    Warning,
17    Info,
18}
19
20impl std::fmt::Display for Severity {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Severity::Critical => write!(f, "CRITICAL"),
24            Severity::Warning => write!(f, "WARNING"),
25            Severity::Info => write!(f, "INFO"),
26        }
27    }
28}
29
30/// Analyze an execution trace for known security risk patterns.
31pub fn analyze_trace(trace: &[ExecutionEvent]) -> Vec<SecurityFinding> {
32    let mut findings = Vec::new();
33
34    let cfg = TraceCfg::from_trace(trace);
35    
36    check_reentrancy_cfg(&cfg, &mut findings);
37    check_unchecked_calls(trace, &mut findings);
38    check_gas_heavy_patterns(trace, &mut findings);
39    check_multiple_storage_writes(trace, &mut findings);
40
41    findings
42}
43
44struct TraceCfg<'a> {
45    blocks: Vec<BasicBlock<'a>>,
46}
47
48struct BasicBlock<'a> {
49    events: &'a [ExecutionEvent],
50}
51
52impl<'a> TraceCfg<'a> {
53    fn from_trace(trace: &'a [ExecutionEvent]) -> Self {
54        let mut blocks = Vec::new();
55        let mut start = 0;
56        
57        for (i, ev) in trace.iter().enumerate() {
58            // Control flow opcodes that end a basic block
59            if ev.opcode == "CALL" || ev.opcode == "REVERT" || ev.opcode == "RETURN" || ev.opcode == "WASM_STEP" {
60                blocks.push(BasicBlock {
61                    events: &trace[start..=i],
62                });
63                start = i + 1;
64            }
65        }
66        
67        if start < trace.len() {
68            blocks.push(BasicBlock {
69                events: &trace[start..],
70            });
71        }
72        
73        Self { blocks }
74    }
75}
76
77/// Reentrancy risk: external CALL after SSTORE without a preceding guard.
78fn check_reentrancy_cfg(cfg: &TraceCfg, findings: &mut Vec<SecurityFinding>) {
79    let mut active_guards = std::collections::HashSet::new();
80    let mut last_sstore: Option<u64> = None;
81
82    for block in &cfg.blocks {
83        for ev in block.events {
84            // Detect guard pattern: SLOAD -> check -> SSTORE
85            if ev.opcode == "SSTORE" {
86                for change in &ev.storage_diff {
87                    // If we see a write to a slot that was previously loaded in the same block
88                    // or recent blocks, it's likely a guard being set.
89                    if block.events.iter().any(|prev| prev.opcode == "SLOAD" && prev.step < ev.step) {
90                        active_guards.insert(change.key.clone());
91                    }
92                    last_sstore = Some(ev.step);
93                }
94            }
95
96            if ev.opcode == "CALL" && ev.gas_used >= 9000 {
97                // If there's an SSTORE before this CALL, check if a guard is active
98                if let Some(sstore_step) = last_sstore {
99                    let guarded = active_guards.iter().any(|_slot| {
100                        // A simple heuristic: if the slot was written, we assume it's a guard
101                        true 
102                    });
103
104                    if !guarded {
105                        findings.push(SecurityFinding {
106                            severity: Severity::Critical,
107                            title: "Reentrancy Vulnerability (No Guard)".into(),
108                            description: format!(
109                                "Critical: External CALL at step {} follows SSTORE at step {} \
110                                 without any detectable reentrancy guard. This is a high-risk \
111                                 pattern that allows malicious contracts to re-enter your state.",
112                                ev.step, sstore_step
113                            ),
114                            step: Some(ev.step),
115                        });
116                    }
117                }
118            }
119        }
120    }
121}
122
123/// Unchecked external calls: CALL not followed by a status check.
124fn check_unchecked_calls(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
125    let call_count = trace
126        .iter()
127        .filter(|e| e.opcode == "CALL" && e.gas_used >= 700)
128        .count();
129    let require_count = trace
130        .iter()
131        .filter(|e| e.opcode == "REVERT")
132        .count();
133
134    if call_count > 0 && require_count == 0 {
135        findings.push(SecurityFinding {
136            severity: Severity::Warning,
137            title: "No revert guards detected".into(),
138            description: format!(
139                "Detected {} external CALL(s) but no REVERT/require guards in the trace. \
140                 Ensure all external call return values are checked to prevent silent failures.",
141                call_count
142            ),
143            step: None,
144        });
145    }
146}
147
148/// State update before transfer: SSTORE followed by value-transfer CALL.
149/// This is the classic "send ETH then update state" anti-pattern.
150pub fn check_storage_before_transfer(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
151    for (i, ev) in trace.iter().enumerate() {
152        if ev.opcode == "SSTORE" {
153            // Look at the next few events for a value-transfer CALL
154            for next in trace.iter().skip(i + 1).take(3) {
155                if next.opcode == "CALL" && next.gas_used >= 9000 {
156                    findings.push(SecurityFinding {
157                        severity: Severity::Info,
158                        title: "State updated before external transfer".into(),
159                        description: format!(
160                            "Storage write at step {} is followed by an external value transfer \
161                             at step {}. This follows the checks-effects-interactions pattern \
162                             (good practice) — verify this is intentional.",
163                            ev.step, next.step
164                        ),
165                        step: Some(ev.step),
166                    });
167                    return; // Report once
168                }
169            }
170        }
171    }
172}
173
174/// Gas-heavy pattern: multiple SSTORE operations that could be batched.
175fn check_gas_heavy_patterns(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
176    let sstore_gas: u64 = trace
177        .iter()
178        .filter(|e| e.opcode == "SSTORE")
179        .map(|e| e.gas_used)
180        .sum();
181    let total_gas: u64 = trace.iter().map(|e| e.gas_used).sum();
182
183    if total_gas > 0 {
184        let sstore_pct = (sstore_gas as f64 / total_gas as f64) * 100.0;
185        if sstore_pct > 50.0 {
186            findings.push(SecurityFinding {
187                severity: Severity::Info,
188                title: "High storage write gas proportion".into(),
189                description: format!(
190                    "Storage writes consume {:.1}% of total gas ({} / {} gas). In Stylus, \
191                     storage operations remain L1-priced while computation is ~10x cheaper. \
192                     Consider caching intermediate values in memory.",
193                    sstore_pct, sstore_gas, total_gas
194                ),
195                step: None,
196            });
197        }
198    }
199}
200
201/// Multiple writes to the same storage slot.
202fn check_multiple_storage_writes(trace: &[ExecutionEvent], findings: &mut Vec<SecurityFinding>) {
203    use std::collections::HashMap;
204    let mut write_counts: HashMap<String, usize> = HashMap::new();
205
206    for ev in trace {
207        for change in &ev.storage_diff {
208            *write_counts.entry(change.key.clone()).or_default() += 1;
209        }
210    }
211
212    for (slot, count) in &write_counts {
213        if *count > 1 {
214            findings.push(SecurityFinding {
215                severity: Severity::Info,
216                title: format!("Slot `{slot}` written {count} times"),
217                description: format!(
218                    "Storage slot `{slot}` was written {} times in a single execution. \
219                     Each redundant SSTORE costs gas. Consider computing the final value \
220                     in memory and writing once.",
221                    count
222                ),
223                step: None,
224            });
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use engine_model::{MemorySnapshot, StorageChange};
233
234    fn mock_event(step: u64, opcode: &str, gas: u64, diff: Vec<StorageChange>) -> ExecutionEvent {
235        ExecutionEvent {
236            step,
237            opcode: opcode.to_string(),
238            gas_used: gas,
239            stack: vec![],
240            memory: MemorySnapshot { bytes: vec![] },
241            storage_diff: diff,
242            source_line: None,
243        }
244    }
245
246    #[test]
247    fn test_reentrancy_vulnerable() {
248        let trace = vec![
249            mock_event(1, "SSTORE", 20000, vec![StorageChange { key: "0x1".into(), old: None, new: Some("0x64".into()) }]),
250            mock_event(2, "CALL", 9000, vec![]),
251        ];
252        let findings = analyze_trace(&trace);
253        assert!(!findings.is_empty());
254        assert_eq!(findings[0].title, "Reentrancy Vulnerability (No Guard)");
255    }
256
257    #[test]
258    fn test_reentrancy_guarded() {
259        let trace = vec![
260            // 1. Load guard (SLOAD)
261            mock_event(1, "SLOAD", 2100, vec![]),
262            // 2. Set guard (SSTORE)
263            mock_event(2, "SSTORE", 20000, vec![StorageChange { key: "guard_slot".into(), old: Some("0x0".into()), new: Some("0x1".into()) }]),
264            // 3. Application SSTORE
265            mock_event(3, "SSTORE", 5000, vec![StorageChange { key: "balance".into(), old: None, new: Some("0x64".into()) }]),
266            // 4. External CALL (guarded)
267            mock_event(4, "CALL", 9000, vec![]),
268        ];
269        let findings = analyze_trace(&trace);
270        // Should NOT find a reentrancy risk because the SLOAD -> SSTORE pattern was detected in recent context
271        assert!(findings.iter().all(|f| f.title != "Reentrancy Vulnerability (No Guard)"));
272    }
273}