sentri_analyzer_move/
analyzer.rs1use 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
10pub 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 let module_name = extract_module_name(&source).unwrap_or_else(|| "move_module".to_string());
27
28 let resources = extract_resource_types(&source);
30 info!("Found {} resource types in Move module", resources.len());
31
32 let functions = extract_functions_with_analysis(&source, &resources);
34 info!("Found {} functions in Move module", functions.len());
35
36 let mut program = ProgramModel::new(
38 module_name,
39 "move".to_string(),
40 path.to_string_lossy().to_string(),
41 );
42
43 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 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 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 let source = std::fs::read_to_string(path).map_err(sentri_core::InvarError::IoError)?;
75 let lines: Vec<&str> = source.lines().collect();
76
77 for (line_idx, line) in lines.iter().enumerate() {
79 let line_num = line_idx + 1;
80
81 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 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 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 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 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
156fn 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
171fn 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
195fn 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 if (trimmed.contains("public fun ")
207 || trimmed.contains("fun ")
208 || trimmed.contains("entry fun "))
209 && !trimmed.contains("//")
210 {
211 let is_public = trimmed.contains("public ");
213 let is_entry = trimmed.contains("entry ");
214
215 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 let params = extract_move_function_params(func_part);
230
231 let has_mutable_ref =
233 func_part.contains("&mut ") || func_part.contains("acquires ");
234
235 let (reads, mutates) = analyze_move_function_body(&lines, i, resources);
237
238 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
263fn 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 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
289fn 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 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_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 for resource in resources {
325 if trimmed.contains(resource) {
326 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
345fn 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 if body.contains("move_from") && !body.contains("_") {
357 mutates.insert("MOVE_RESOURCE_LEAK".to_string());
359 }
360
361 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 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 if body.contains("move_to") && !body.contains("&signer") && !body.contains("signer::") {
381 mutates.insert("MOVE_MISSING_SIGNER".to_string());
382 }
383
384 if body.contains("borrow_global_mut") && !body.contains("assert!") && !body.contains("require")
386 {
387 mutates.insert("MOVE_UNGUARDED_MUTATION".to_string());
388 }
389
390 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 if body_lower.contains("f32") || body_lower.contains("f64") {
401 mutates.insert("MOVE_FLOATING_POINT".to_string());
402 }
403
404 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 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 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}