Skip to main content

panic_attacker/xray/
analyzer.rs

1// SPDX-License-Identifier: PMPL-1.0-or-later
2
3//! Core X-Ray analyzer implementation
4
5use 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            // For directories, scan for predominant language
36            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        // Collect all source files
60        let files = self.collect_source_files()?;
61
62        // Strip prefix for display paths
63        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            // Try UTF-8 first, then Latin-1 fallback
81            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            // Fresh per-file statistics
105            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            // Per-file weak points
118            let mut file_weak_points = Vec::new();
119
120            // Language-specific analysis
121            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            // Accumulate into global stats
150            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            // Collect per-file weak points
159            all_weak_points.extend(file_weak_points);
160
161            // Build FileStatistics for non-trivial files
162            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        // Detect frameworks
184        let frameworks = self.detect_frameworks(&files)?;
185
186        // Generate recommendations
187        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                // Skip common non-source directories
219                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                // Skip build artifacts and hidden dirs
264                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        // Count unsafe blocks
289        stats.unsafe_blocks += content.matches("unsafe {").count();
290        stats.unsafe_blocks += content.matches("unsafe fn").count();
291
292        // Count panic sites
293        stats.panic_sites += content.matches("panic!(").count();
294        stats.panic_sites += content.matches("unreachable!(").count();
295
296        // Count unwraps
297        stats.unwrap_calls += content.matches(".unwrap()").count();
298        stats.unwrap_calls += content.matches(".expect(").count();
299
300        // Count allocations
301        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        // Count I/O operations
306        stats.io_operations += content.matches("std::fs::").count();
307        stats.io_operations += content.matches("std::io::").count();
308
309        // Count threading
310        stats.threading_constructs += content.matches("std::thread::").count();
311        stats.threading_constructs += content.matches("std::sync::").count();
312
313        // Detect weak points (per-file, not running-total)
314        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        // Count allocations
348        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        // Count I/O
353        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        // Count threading
358        stats.threading_constructs += content.matches("pthread_").count();
359        stats.threading_constructs += content.matches("std::thread").count();
360
361        // Detect weak points
362        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        // Detect goroutine leaks
388        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        // Detect unbounded loops
413        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        // Generic heuristics
433        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            // Web servers
450            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            // Databases
461            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            // Message queues
470            if content.contains("kafka") || content.contains("rabbitmq") || content.contains("nats")
471            {
472                frameworks.insert(Framework::MessageQueue);
473            }
474
475            // Caching
476            if content.contains("redis") || content.contains("memcached") {
477                frameworks.insert(Framework::Cache);
478            }
479
480            // Networking
481            if content.contains("tokio") || content.contains("async_std") {
482                frameworks.insert(Framework::Networking);
483            }
484
485            // Concurrency
486            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        // Based on weak points
502        for wp in weak_points {
503            recommendations.extend(&wp.recommended_attack);
504        }
505
506        // Based on statistics
507        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        // Always include CPU stress
520        recommendations.insert(AttackAxis::Cpu);
521
522        recommendations.into_iter().collect()
523    }
524}