Skip to main content

sentri_analyzer_move/
analyzer.rs

1//! Move analyzer implementation.
2
3use sentri_core::model::{FunctionModel, ProgramModel};
4use sentri_core::traits::ChainAnalyzer;
5use sentri_core::{AnalysisContext, Result};
6use std::collections::BTreeSet;
7use std::path::Path;
8use tracing::info;
9
10/// Analyzer for Move programs (Aptos/Sui).
11///
12/// Performs static analysis on Move code to extract:
13/// - Module names and entry points
14/// - Function signatures and visibility
15/// - Resource types and access patterns
16/// - Mutation and read operations
17pub struct MoveAnalyzer;
18
19impl ChainAnalyzer for MoveAnalyzer {
20    fn analyze(&self, path: &Path) -> Result<ProgramModel> {
21        info!("Analyzing Move program at {:?}", path);
22
23        let source = std::fs::read_to_string(path).map_err(sentri_core::InvarError::IoError)?;
24
25        // Extract module name
26        let module_name = extract_module_name(&source).unwrap_or_else(|| "move_module".to_string());
27
28        // Extract resource types
29        let resources = extract_resource_types(&source);
30        info!("Found {} resource types in Move module", resources.len());
31
32        // Extract functions with proper analysis
33        let functions = extract_functions_with_analysis(&source, &resources);
34        info!("Found {} functions in Move module", functions.len());
35
36        // Create program model with analyzed information
37        let mut program = ProgramModel::new(
38            module_name,
39            "move".to_string(),
40            path.to_string_lossy().to_string(),
41        );
42
43        // Add resource types as state variables
44        for resource in &resources {
45            use sentri_core::model::StateVar;
46            program.add_state_var(StateVar {
47                name: resource.clone(),
48                type_name: "resource".to_string(),
49                is_mutable: true,
50                visibility: None,
51            });
52        }
53
54        // Add extracted functions to the program model
55        for func in functions {
56            program.add_function(func);
57        }
58
59        Ok(program)
60    }
61
62    fn chain(&self) -> &str {
63        "move"
64    }
65}
66
67impl MoveAnalyzer {
68    /// Analyze a Move program and return context with warnings.
69    pub fn analyze_with_context(&self, path: &Path) -> Result<AnalysisContext> {
70        let program = self.analyze(path)?;
71        let mut context = AnalysisContext::new(program);
72
73        // Read source for warning collection
74        let source = std::fs::read_to_string(path).map_err(sentri_core::InvarError::IoError)?;
75        let lines: Vec<&str> = source.lines().collect();
76
77        // Scan for common Move vulnerability patterns
78        for (line_idx, line) in lines.iter().enumerate() {
79            let line_num = line_idx + 1;
80
81            // Warning: Unsafe resource destruction
82            if (line.contains("move_to") || line.contains("move_from"))
83                && !lines
84                    .iter()
85                    .skip(line_idx.saturating_sub(2))
86                    .take(5)
87                    .any(|l| l.contains("assert") || l.contains("require"))
88            {
89                context.add_warning(
90                    "Resource operation without validation detected".to_string(),
91                    path.to_string_lossy().to_string(),
92                    line_num,
93                    None,
94                    Some(line.to_string()),
95                );
96            }
97
98            // Warning: Missing type checking
99            if line.contains("as u64") || line.contains("as u128") || line.contains("as u256") {
100                context.add_warning(
101                    "Type cast detected - verify bounds".to_string(),
102                    path.to_string_lossy().to_string(),
103                    line_num,
104                    None,
105                    Some(line.to_string()),
106                );
107            }
108
109            // Warning: Direct field access without validation
110            if line.contains(".")
111                && (line.contains("=") || line.contains(".value"))
112                && !lines
113                    .iter()
114                    .skip(line_idx.saturating_sub(1))
115                    .take(2)
116                    .any(|l| l.contains("assert"))
117            {
118                context.add_warning(
119                    "Field access without validation".to_string(),
120                    path.to_string_lossy().to_string(),
121                    line_num,
122                    None,
123                    Some(line.to_string()),
124                );
125            }
126
127            // Warning: Unchecked arithmetic
128            if (line.contains("+") || line.contains("-"))
129                && (line.contains("amount") || line.contains("balance"))
130            {
131                context.add_warning(
132                    "Unchecked arithmetic operation detected".to_string(),
133                    path.to_string_lossy().to_string(),
134                    line_num,
135                    None,
136                    Some(line.to_string()),
137                );
138            }
139        }
140
141        // Mark invalid if critical issues found
142        let critical_warnings = context
143            .warnings
144            .iter()
145            .filter(|w| w.message.contains("validation") || w.message.contains("Unchecked"))
146            .count();
147
148        if critical_warnings > 1 {
149            context.mark_invalid();
150        }
151
152        Ok(context)
153    }
154}
155
156/// Extract module name from Move source code.
157fn extract_module_name(source: &str) -> Option<String> {
158    for line in source.lines() {
159        if line.trim_start().starts_with("module ") {
160            let module_part = line.split("module ").nth(1)?;
161            let name = module_part
162                .split(|c: char| [':', '{', ';'].contains(&c))
163                .next()?
164                .trim();
165            return Some(name.to_string());
166        }
167    }
168    None
169}
170
171/// Extract resource type names from Move source code.
172fn extract_resource_types(source: &str) -> Vec<String> {
173    let mut resources = Vec::new();
174    for line in source.lines() {
175        let trimmed = line.trim_start();
176        if trimmed.starts_with("struct ") || trimmed.starts_with("resource struct ") {
177            let key = if trimmed.starts_with("resource struct ") {
178                "resource struct "
179            } else {
180                "struct "
181            };
182            if let Some(struct_part) = trimmed.split(key).nth(1) {
183                if let Some(name) = struct_part
184                    .split(|c: char| ['{', '(', '<', ';'].contains(&c))
185                    .next()
186                {
187                    resources.push(name.trim().to_string());
188                }
189            }
190        }
191    }
192    resources
193}
194
195/// Extract functions with full analysis including state access patterns.
196fn extract_functions_with_analysis(source: &str, resources: &[String]) -> Vec<FunctionModel> {
197    let mut functions = Vec::new();
198    let lines: Vec<&str> = source.lines().collect();
199
200    let mut i = 0;
201    while i < lines.len() {
202        let line = lines[i];
203        let trimmed = line.trim_start();
204
205        // Look for function declarations
206        if (trimmed.contains("public fun ")
207            || trimmed.contains("fun ")
208            || trimmed.contains("entry fun "))
209            && !trimmed.contains("//")
210        {
211            // Extract visibility
212            let is_public = trimmed.contains("public ");
213            let is_entry = trimmed.contains("entry ");
214
215            // Extract function name
216            let func_keyword = if trimmed.contains("entry fun ") {
217                "entry fun "
218            } else if trimmed.contains("public fun ") {
219                "public fun "
220            } else {
221                "fun "
222            };
223
224            if let Some(func_part) = trimmed.split(func_keyword).nth(1) {
225                if let Some(name) = func_part.split('(').next() {
226                    let func_name = name.trim().to_string();
227
228                    // Extract parameters
229                    let params = extract_move_function_params(func_part);
230
231                    // Check if function has mutable parameter (acquires or &mut)
232                    let has_mutable_ref =
233                        func_part.contains("&mut ") || func_part.contains("acquires ");
234
235                    // Analyze function body for resource access
236                    let (reads, mutates) = analyze_move_function_body(&lines, i, resources);
237
238                    // Check if function is pure (no mutation)
239                    let is_pure = !has_mutable_ref && mutates.is_empty();
240                    let is_entry_point = is_entry || (is_public && !reads.is_empty());
241
242                    let func = FunctionModel {
243                        name: func_name,
244                        parameters: params,
245                        return_type: None,
246                        mutates,
247                        reads,
248                        is_entry_point,
249                        is_pure,
250                    };
251
252                    functions.push(func);
253                }
254            }
255        }
256
257        i += 1;
258    }
259
260    functions
261}
262
263/// Extract Move function parameters.
264fn extract_move_function_params(signature: &str) -> Vec<String> {
265    if let Some(start) = signature.find('(') {
266        if let Some(end) = signature.find(')') {
267            let params_str = &signature[start + 1..end];
268            if params_str.is_empty() {
269                return Vec::new();
270            }
271
272            params_str
273                .split(',')
274                .map(|p| {
275                    // Each param is like "account: &mut signer" or "amount: u64"
276                    let parts: Vec<&str> = p.split_whitespace().collect();
277                    parts.first().unwrap_or(&"").to_string()
278                })
279                .filter(|p| !p.is_empty())
280                .collect()
281        } else {
282            Vec::new()
283        }
284    } else {
285        Vec::new()
286    }
287}
288
289/// Analyze a Move function body to detect resource access patterns.
290fn analyze_move_function_body(
291    lines: &[&str],
292    start_idx: usize,
293    resources: &[String],
294) -> (BTreeSet<String>, BTreeSet<String>) {
295    let mut reads = BTreeSet::new();
296    let mut mutates = BTreeSet::new();
297
298    let mut brace_count = 0;
299    let mut in_function = false;
300
301    for (i, line) in lines.iter().enumerate().skip(start_idx) {
302        let trimmed = line.trim();
303
304        // Count braces to detect function body
305        for ch in line.chars() {
306            if ch == '{' {
307                in_function = true;
308                brace_count += 1;
309            } else if ch == '}' {
310                brace_count -= 1;
311                if in_function && brace_count == 0 {
312                    // Analyze for Move-specific vulnerabilities
313                    analyze_move_vulnerabilities(lines, start_idx, i, &mut mutates);
314                    return (reads, mutates);
315                }
316            }
317        }
318
319        if !in_function {
320            continue;
321        }
322
323        // Look for resource accesses
324        for resource in resources {
325            if trimmed.contains(resource) {
326                // Check for mutations
327                if trimmed.contains("move_from")
328                    || trimmed.contains("borrow_global_mut")
329                    || trimmed.contains("global_mut")
330                {
331                    mutates.insert(resource.clone());
332                } else if trimmed.contains("borrow_global")
333                    || trimmed.contains("global")
334                    || trimmed.contains("assert!")
335                {
336                    reads.insert(resource.clone());
337                }
338            }
339        }
340    }
341
342    (reads, mutates)
343}
344
345/// Detect Move-specific security vulnerabilities.
346fn analyze_move_vulnerabilities(
347    lines: &[&str],
348    start_idx: usize,
349    end_idx: usize,
350    mutates: &mut BTreeSet<String>,
351) {
352    let body = lines[start_idx..=end_idx].join("\n");
353    let body_lower = body.to_lowercase();
354
355    // === RESOURCE LEAK VULNERABILITIES ===
356    if body.contains("move_from") && !body.contains("_") {
357        // move_from without variable binding or discarding - potential resource leak
358        mutates.insert("MOVE_RESOURCE_LEAK".to_string());
359    }
360
361    // === MISSING_ABILITY_CHECKS ===
362    if body.contains("move_to")
363        && !body_lower.contains("has key")
364        && !body_lower.contains("has store")
365    {
366        mutates.insert("MOVE_MISSING_ABILITY".to_string());
367    }
368
369    // === UNSAFE ARITHMETIC (no overflow check) ===
370    if (body.contains("+") || body.contains("-") || body.contains("*"))
371        && !body.contains("overflow")
372        && !body.contains("checked")
373        && !body_lower.contains("assert_")
374        && !body.contains("invariant")
375    {
376        mutates.insert("MOVE_UNCHECKED_ARITHMETIC".to_string());
377    }
378
379    // === MISSING SIGNER VERIFICATION ===
380    if body.contains("move_to") && !body.contains("&signer") && !body.contains("signer::") {
381        mutates.insert("MOVE_MISSING_SIGNER".to_string());
382    }
383
384    // === UNGUARDED STATE MUTATION ===
385    if body.contains("borrow_global_mut") && !body.contains("assert!") && !body.contains("require")
386    {
387        mutates.insert("MOVE_UNGUARDED_MUTATION".to_string());
388    }
389
390    // === PRIVILEGE ESCALATION ===
391    if body.contains("signer")
392        && body.contains("address_of")
393        && !body.contains("require")
394        && !body.contains("assert!")
395    {
396        mutates.insert("MOVE_PRIVILEGE_ESCALATION".to_string());
397    }
398
399    // === FLOATING POINT OPERATIONS (if any) ===
400    if body_lower.contains("f32") || body_lower.contains("f64") {
401        mutates.insert("MOVE_FLOATING_POINT".to_string());
402    }
403
404    // === ABORT WITHOUT REASON ===
405    if body.contains("abort")
406        && !body_lower.contains("error_code")
407        && !body_lower.contains("reason")
408    {
409        mutates.insert("MOVE_UNSAFE_ABORT".to_string());
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use std::fs;
417    use tempfile::tempdir;
418
419    #[test]
420    fn test_analyze_move_module() {
421        let dir = tempdir().unwrap();
422        let path = dir.path().join("token.move");
423        fs::write(
424            &path,
425            r#"module 0x1::token {
426    use std::signer;
427
428    struct TokenStore has key {
429        amount: u64,
430    }
431
432    public fun initialize(account: &signer) {
433        let token_store = TokenStore { amount: 0 };
434        move_to(account, token_store);
435    }
436
437    public fun transfer(from: &signer, to: address, amount: u64) acquires TokenStore {
438        let from_store = borrow_global_mut<TokenStore>(signer::address_of(from));
439        from_store.amount = from_store.amount - amount;
440        
441        let to_store = borrow_global_mut<TokenStore>(to);
442        to_store.amount = to_store.amount + amount;
443    }
444}"#,
445        )
446        .unwrap();
447
448        let analyzer = MoveAnalyzer;
449        let result = analyzer.analyze(&path).unwrap();
450
451        assert_eq!(result.chain, "move");
452        assert!(!result.functions.is_empty());
453        assert!(result.functions.iter().any(|(_, f)| f.name == "initialize"));
454        assert!(result.functions.iter().any(|(_, f)| f.name == "transfer"));
455    }
456
457    #[test]
458    fn test_analyze_empty_move_file() {
459        let dir = tempdir().unwrap();
460        let path = dir.path().join("empty.move");
461        fs::write(&path, "").unwrap();
462
463        let analyzer = MoveAnalyzer;
464        let result = analyzer.analyze(&path).unwrap();
465
466        assert_eq!(result.functions.len(), 0);
467    }
468
469    #[test]
470    fn test_analyze_nonexistent_move_path() {
471        let analyzer = MoveAnalyzer;
472        let result = analyzer.analyze(std::path::Path::new("/nonexistent/path/module.move"));
473
474        assert!(result.is_err());
475    }
476
477    #[test]
478    fn test_extract_module_name() {
479        let source = r#"module 0x1::MyModule {
480    fun test() {}
481}"#;
482        let name = extract_module_name(source);
483        assert!(name.is_some());
484    }
485
486    #[test]
487    fn test_extract_resource_types() {
488        let source = r#"module 0x1::token {
489    struct Coin has key { value: u64 }
490    struct CoinStore has key { coin: Coin }
491}"#;
492        let resources = extract_resource_types(source);
493        // Just verify we found some resources - exact parsing depends on whitespace
494        assert!(!resources.is_empty());
495    }
496
497    #[test]
498    fn test_extract_move_function_params() {
499        let signature = "transfer(from: &signer, to: address, amount: u64)";
500        let params = extract_move_function_params(signature);
501        // Function parameters are extracted - verify structure
502        assert_eq!(params.len(), 3);
503        assert!(params[0].contains("from") || params[0].contains(":"));
504    }
505
506    #[test]
507    fn test_chain_identifier() {
508        let analyzer = MoveAnalyzer;
509        assert_eq!(analyzer.chain(), "move");
510    }
511}