1use crate::types::*;
6use anyhow::Result;
7use regex::Regex;
8use std::collections::HashSet;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12pub struct Analyzer {
13 target: PathBuf,
14 language: Language,
15 verbose: bool,
16}
17
18impl Analyzer {
19 pub fn new(target: &Path) -> Result<Self> {
20 Self::build(target, false)
21 }
22
23 pub fn new_verbose(target: &Path) -> Result<Self> {
24 Self::build(target, true)
25 }
26
27 fn build(target: &Path, verbose: bool) -> Result<Self> {
28 if !target.exists() {
29 anyhow::bail!("Target does not exist: {}", target.display());
30 }
31
32 let language = if target.is_file() {
33 Language::detect(target.to_str().unwrap_or(""))
34 } else {
35 Self::detect_directory_language(target)?
37 };
38
39 Ok(Self {
40 target: target.to_path_buf(),
41 language,
42 verbose,
43 })
44 }
45
46 pub fn analyze(&self) -> Result<XRayReport> {
47 let mut global_stats = ProgramStatistics {
48 total_lines: 0,
49 unsafe_blocks: 0,
50 panic_sites: 0,
51 unwrap_calls: 0,
52 allocation_sites: 0,
53 io_operations: 0,
54 threading_constructs: 0,
55 };
56 let mut all_weak_points = Vec::new();
57 let mut file_statistics = Vec::new();
58
59 let files = self.collect_source_files()?;
61
62 let base = if self.target.is_dir() {
64 self.target.clone()
65 } else {
66 self.target.parent().unwrap_or(Path::new(".")).to_path_buf()
67 };
68
69 for file in &files {
70 let raw_bytes = match fs::read(file) {
71 Ok(b) => b,
72 Err(e) => {
73 if self.verbose {
74 eprintln!("Skipping unreadable file: {} ({})", file.display(), e);
75 }
76 continue;
77 }
78 };
79
80 let content = match String::from_utf8(raw_bytes.clone()) {
82 Ok(s) => s,
83 Err(_) => {
84 let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.decode(&raw_bytes);
85 if had_errors {
86 if self.verbose {
87 eprintln!(
88 "Skipping non-text file: {} (neither UTF-8 nor Latin-1)",
89 file.display()
90 );
91 }
92 continue;
93 }
94 cow.into_owned()
95 }
96 };
97
98 let rel_path = file
99 .strip_prefix(&base)
100 .unwrap_or(file)
101 .to_string_lossy()
102 .to_string();
103
104 let mut file_stats = ProgramStatistics {
106 total_lines: 0,
107 unsafe_blocks: 0,
108 panic_sites: 0,
109 unwrap_calls: 0,
110 allocation_sites: 0,
111 io_operations: 0,
112 threading_constructs: 0,
113 };
114
115 file_stats.total_lines = content.lines().count();
116
117 let mut file_weak_points = Vec::new();
119
120 match self.language {
122 Language::Rust => {
123 self.analyze_rust(&content, &mut file_stats, &mut file_weak_points, &rel_path)?;
124 }
125 Language::C | Language::Cpp => {
126 self.analyze_c_cpp(
127 &content,
128 &mut file_stats,
129 &mut file_weak_points,
130 &rel_path,
131 )?;
132 }
133 Language::Go => {
134 self.analyze_go(&content, &mut file_stats, &mut file_weak_points, &rel_path)?;
135 }
136 Language::Python => {
137 self.analyze_python(
138 &content,
139 &mut file_stats,
140 &mut file_weak_points,
141 &rel_path,
142 )?;
143 }
144 _ => {
145 self.analyze_generic(&content, &mut file_stats, &rel_path)?;
146 }
147 }
148
149 global_stats.total_lines += file_stats.total_lines;
151 global_stats.unsafe_blocks += file_stats.unsafe_blocks;
152 global_stats.panic_sites += file_stats.panic_sites;
153 global_stats.unwrap_calls += file_stats.unwrap_calls;
154 global_stats.allocation_sites += file_stats.allocation_sites;
155 global_stats.io_operations += file_stats.io_operations;
156 global_stats.threading_constructs += file_stats.threading_constructs;
157
158 all_weak_points.extend(file_weak_points);
160
161 let has_findings = file_stats.unsafe_blocks > 0
163 || file_stats.panic_sites > 0
164 || file_stats.unwrap_calls > 0
165 || file_stats.allocation_sites > 0
166 || file_stats.io_operations > 0
167 || file_stats.threading_constructs > 0;
168
169 if has_findings {
170 file_statistics.push(FileStatistics {
171 file_path: rel_path,
172 lines: file_stats.total_lines,
173 unsafe_blocks: file_stats.unsafe_blocks,
174 panic_sites: file_stats.panic_sites,
175 unwrap_calls: file_stats.unwrap_calls,
176 allocation_sites: file_stats.allocation_sites,
177 io_operations: file_stats.io_operations,
178 threading_constructs: file_stats.threading_constructs,
179 });
180 }
181 }
182
183 let frameworks = self.detect_frameworks(&files)?;
185
186 let recommended_attacks = self.generate_recommendations(&all_weak_points, &global_stats);
188
189 Ok(XRayReport {
190 program_path: self.target.clone(),
191 language: self.language,
192 frameworks,
193 weak_points: all_weak_points,
194 statistics: global_stats,
195 file_statistics,
196 recommended_attacks,
197 })
198 }
199
200 fn collect_source_files(&self) -> Result<Vec<PathBuf>> {
201 let mut files = Vec::new();
202
203 if self.target.is_file() {
204 files.push(self.target.clone());
205 } else {
206 self.walk_directory(&self.target, &mut files)?;
207 }
208
209 Ok(files)
210 }
211
212 fn walk_directory(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
213 for entry in fs::read_dir(dir)? {
214 let entry = entry?;
215 let path = entry.path();
216
217 if path.is_dir() {
218 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
220 if !["target", "build", "node_modules", ".git", "vendor"].contains(&name) {
221 self.walk_directory(&path, files)?;
222 }
223 } else if path.is_file() {
224 let lang = Language::detect(path.to_str().unwrap_or(""));
225 if lang != Language::Unknown {
226 files.push(path);
227 }
228 }
229 }
230
231 Ok(())
232 }
233
234 fn detect_directory_language(dir: &Path) -> Result<Language> {
235 let mut counts = std::collections::HashMap::new();
236
237 Self::count_languages_recursive(dir, &mut counts, 0)?;
238
239 counts.remove(&Language::Unknown);
240
241 counts
242 .into_iter()
243 .max_by_key(|(_, count)| *count)
244 .map(|(lang, _)| lang)
245 .ok_or_else(|| anyhow::anyhow!("Could not detect language"))
246 }
247
248 fn count_languages_recursive(
249 dir: &Path,
250 counts: &mut std::collections::HashMap<Language, usize>,
251 depth: usize,
252 ) -> Result<()> {
253 if depth > 10 {
254 return Ok(());
255 }
256 for entry in fs::read_dir(dir)? {
257 let entry = entry?;
258 let path = entry.path();
259 let name = entry.file_name();
260 let name_str = name.to_str().unwrap_or("");
261
262 if path.is_dir() {
263 if name_str.starts_with('.')
265 || name_str == "target"
266 || name_str == "node_modules"
267 || name_str == "vendor"
268 || name_str == "build"
269 {
270 continue;
271 }
272 Self::count_languages_recursive(&path, counts, depth + 1)?;
273 } else if path.is_file() {
274 let lang = Language::detect(path.to_str().unwrap_or(""));
275 *counts.entry(lang).or_insert(0) += 1;
276 }
277 }
278 Ok(())
279 }
280
281 fn analyze_rust(
282 &self,
283 content: &str,
284 stats: &mut ProgramStatistics,
285 weak_points: &mut Vec<WeakPoint>,
286 file_path: &str,
287 ) -> Result<()> {
288 stats.unsafe_blocks += content.matches("unsafe {").count();
290 stats.unsafe_blocks += content.matches("unsafe fn").count();
291
292 stats.panic_sites += content.matches("panic!(").count();
294 stats.panic_sites += content.matches("unreachable!(").count();
295
296 stats.unwrap_calls += content.matches(".unwrap()").count();
298 stats.unwrap_calls += content.matches(".expect(").count();
299
300 stats.allocation_sites += content.matches("Vec::new()").count();
302 stats.allocation_sites += content.matches("Box::new(").count();
303 stats.allocation_sites += content.matches("String::new()").count();
304
305 stats.io_operations += content.matches("std::fs::").count();
307 stats.io_operations += content.matches("std::io::").count();
308
309 stats.threading_constructs += content.matches("std::thread::").count();
311 stats.threading_constructs += content.matches("std::sync::").count();
312
313 if stats.unsafe_blocks > 0 {
315 weak_points.push(WeakPoint {
316 category: WeakPointCategory::UnsafeCode,
317 location: Some(file_path.to_string()),
318 severity: Severity::High,
319 description: format!("{} unsafe blocks in {}", stats.unsafe_blocks, file_path),
320 recommended_attack: vec![AttackAxis::Memory, AttackAxis::Concurrency],
321 });
322 }
323
324 if stats.unwrap_calls > 5 {
325 weak_points.push(WeakPoint {
326 category: WeakPointCategory::PanicPath,
327 location: Some(file_path.to_string()),
328 severity: Severity::Medium,
329 description: format!(
330 "{} unwrap/expect calls in {}",
331 stats.unwrap_calls, file_path
332 ),
333 recommended_attack: vec![AttackAxis::Memory, AttackAxis::Disk],
334 });
335 }
336
337 Ok(())
338 }
339
340 fn analyze_c_cpp(
341 &self,
342 content: &str,
343 stats: &mut ProgramStatistics,
344 weak_points: &mut Vec<WeakPoint>,
345 file_path: &str,
346 ) -> Result<()> {
347 stats.allocation_sites += content.matches("malloc(").count();
349 stats.allocation_sites += content.matches("calloc(").count();
350 stats.allocation_sites += content.matches("new ").count();
351
352 stats.io_operations += content.matches("fopen(").count();
354 stats.io_operations += content.matches("read(").count();
355 stats.io_operations += content.matches("write(").count();
356
357 stats.threading_constructs += content.matches("pthread_").count();
359 stats.threading_constructs += content.matches("std::thread").count();
360
361 let unchecked_malloc = Regex::new(r"malloc\([^)]+\)\s*;").unwrap();
363 if unchecked_malloc.is_match(content) {
364 weak_points.push(WeakPoint {
365 category: WeakPointCategory::UncheckedAllocation,
366 location: Some(file_path.to_string()),
367 severity: Severity::Critical,
368 description: format!("Unchecked malloc in {}", file_path),
369 recommended_attack: vec![AttackAxis::Memory],
370 });
371 }
372
373 Ok(())
374 }
375
376 fn analyze_go(
377 &self,
378 content: &str,
379 stats: &mut ProgramStatistics,
380 weak_points: &mut Vec<WeakPoint>,
381 file_path: &str,
382 ) -> Result<()> {
383 stats.allocation_sites += content.matches("make(").count();
384 stats.threading_constructs += content.matches("go func").count();
385 stats.threading_constructs += content.matches("go ").count();
386
387 let go_count = content.matches("go ").count();
389 if go_count > 10 {
390 weak_points.push(WeakPoint {
391 category: WeakPointCategory::ResourceLeak,
392 location: Some(file_path.to_string()),
393 severity: Severity::Medium,
394 description: format!("{} goroutines spawned in {}", go_count, file_path),
395 recommended_attack: vec![AttackAxis::Concurrency, AttackAxis::Memory],
396 });
397 }
398
399 Ok(())
400 }
401
402 fn analyze_python(
403 &self,
404 content: &str,
405 stats: &mut ProgramStatistics,
406 weak_points: &mut Vec<WeakPoint>,
407 file_path: &str,
408 ) -> Result<()> {
409 stats.io_operations += content.matches("open(").count();
410 stats.threading_constructs += content.matches("threading.").count();
411
412 if content.contains("while True:") {
414 weak_points.push(WeakPoint {
415 category: WeakPointCategory::UnboundedLoop,
416 location: Some(file_path.to_string()),
417 severity: Severity::High,
418 description: format!("Unbounded while True loop in {}", file_path),
419 recommended_attack: vec![AttackAxis::Cpu, AttackAxis::Time],
420 });
421 }
422
423 Ok(())
424 }
425
426 fn analyze_generic(
427 &self,
428 content: &str,
429 stats: &mut ProgramStatistics,
430 _file_path: &str,
431 ) -> Result<()> {
432 stats.allocation_sites += content.matches("alloc").count();
434 stats.io_operations += content.matches("open").count();
435 stats.threading_constructs += content.matches("thread").count();
436
437 Ok(())
438 }
439
440 fn detect_frameworks(&self, files: &[PathBuf]) -> Result<Vec<Framework>> {
441 let mut frameworks = HashSet::new();
442
443 for file in files {
444 let content = match fs::read_to_string(file) {
445 Ok(c) => c,
446 Err(_) => continue,
447 };
448
449 if content.contains("actix_web")
451 || content.contains("warp")
452 || content.contains("axum")
453 || content.contains("rocket")
454 || content.contains("express")
455 || content.contains("flask")
456 {
457 frameworks.insert(Framework::WebServer);
458 }
459
460 if content.contains("diesel")
462 || content.contains("sqlx")
463 || content.contains("mongodb")
464 || content.contains("postgres")
465 {
466 frameworks.insert(Framework::Database);
467 }
468
469 if content.contains("kafka") || content.contains("rabbitmq") || content.contains("nats")
471 {
472 frameworks.insert(Framework::MessageQueue);
473 }
474
475 if content.contains("redis") || content.contains("memcached") {
477 frameworks.insert(Framework::Cache);
478 }
479
480 if content.contains("tokio") || content.contains("async_std") {
482 frameworks.insert(Framework::Networking);
483 }
484
485 if content.contains("rayon") || content.contains("crossbeam") {
487 frameworks.insert(Framework::Concurrent);
488 }
489 }
490
491 Ok(frameworks.into_iter().collect())
492 }
493
494 fn generate_recommendations(
495 &self,
496 weak_points: &[WeakPoint],
497 stats: &ProgramStatistics,
498 ) -> Vec<AttackAxis> {
499 let mut recommendations = HashSet::new();
500
501 for wp in weak_points {
503 recommendations.extend(&wp.recommended_attack);
504 }
505
506 if stats.allocation_sites > 10 {
508 recommendations.insert(AttackAxis::Memory);
509 }
510
511 if stats.io_operations > 5 {
512 recommendations.insert(AttackAxis::Disk);
513 }
514
515 if stats.threading_constructs > 3 {
516 recommendations.insert(AttackAxis::Concurrency);
517 }
518
519 recommendations.insert(AttackAxis::Cpu);
521
522 recommendations.into_iter().collect()
523 }
524}