Skip to main content

infigraph_core/security/
detect.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use super::rules::{find_sanitizer_for, Finding, ScanStats, RULES};
6
7/// Scan the project rooted at `root` for security issues.
8///
9/// Walks all non-vendor files and applies pattern-based rules.
10pub fn scan_project(root: &Path) -> Result<ScanStats> {
11    let mut stats = ScanStats::default();
12
13    walk_and_scan(root, root, &mut stats)?;
14    // Sort findings: Critical first, then High, etc.
15    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(()), // skip binary files
77    };
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}