infigraph_core/security/
detect.rs1use std::path::Path;
2
3use anyhow::Result;
4
5use super::rules::{find_sanitizer_for, Finding, ScanStats, RULES};
6
7pub fn scan_project(root: &Path) -> Result<ScanStats> {
11 let mut stats = ScanStats::default();
12
13 walk_and_scan(root, root, &mut stats)?;
14 stats.findings.sort_by(|a, b| {
16 a.severity
17 .cmp(&b.severity)
18 .then(a.file.cmp(&b.file))
19 .then(a.line.cmp(&b.line))
20 });
21
22 Ok(stats)
23}
24
25static IGNORE_DIRS: &[&str] = &[
26 ".git",
27 "node_modules",
28 ".venv",
29 "venv",
30 "target",
31 "build",
32 "dist",
33 "__pycache__",
34 ".tox",
35 ".infigraph",
36 "vendor",
37 ".idea",
38 ".mypy_cache",
39 "coverage",
40 ".pytest_cache",
41];
42
43fn walk_and_scan(root: &Path, dir: &Path, stats: &mut ScanStats) -> Result<()> {
44 for entry in std::fs::read_dir(dir)? {
45 let entry = entry?;
46 let path = entry.path();
47 let name = entry.file_name();
48 let name_str = name.to_string_lossy();
49
50 if path.is_dir() {
51 if !IGNORE_DIRS.contains(&name_str.as_ref()) && !name_str.starts_with('.') {
52 walk_and_scan(root, &path, stats)?;
53 }
54 } else if path.is_file() {
55 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
56 let rel = path
57 .strip_prefix(root)
58 .unwrap_or(&path)
59 .to_string_lossy()
60 .replace('\\', "/");
61 scan_file(&path, &rel, ext, stats)?;
62 }
63 }
64 }
65 Ok(())
66}
67
68pub(crate) fn scan_file(
69 path: &Path,
70 rel_path: &str,
71 ext: &str,
72 stats: &mut ScanStats,
73) -> Result<()> {
74 let content = match std::fs::read_to_string(path) {
75 Ok(c) => c,
76 Err(_) => return Ok(()), };
78
79 stats.files_scanned += 1;
80 let ext_lower = ext.to_lowercase();
81 let all_lines: Vec<&str> = content.lines().collect();
82
83 for (line_idx, line) in all_lines.iter().enumerate() {
84 let line_lower = line.to_lowercase();
85 let line_no = (line_idx + 1) as u32;
86
87 for rule in RULES {
88 if let Some(exts) = rule.extensions {
89 if !exts.contains(&ext_lower.as_str()) {
90 continue;
91 }
92 }
93
94 if !line_lower.contains(rule.pattern) {
95 continue;
96 }
97
98 if let Some(excl) = rule.exclude_if {
99 if line_lower.contains(&excl.to_lowercase() as &str) {
100 continue;
101 }
102 }
103
104 let col = line_lower.find(rule.pattern).unwrap_or(0) as u32 + 1;
105 let category = (rule.category)();
106
107 let sanitizer_hit = find_sanitizer_for(&category, &all_lines, line_idx);
108 let suppressed = sanitizer_hit.is_some();
109
110 stats.findings.push(Finding {
111 file: rel_path.to_string(),
112 line: line_no,
113 col,
114 severity: rule.severity.clone(),
115 category,
116 rule_id: rule.id.to_string(),
117 message: rule.message.to_string(),
118 snippet: line.trim().chars().take(120).collect(),
119 suppressed,
120 sanitizer_hint: sanitizer_hit,
121 });
122 }
123 }
124
125 Ok(())
126}