Skip to main content

aptu_core/security/
scanner.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Security scanner orchestration for PR diffs.
4
5use crate::security::ignore::SecurityConfig;
6use crate::security::patterns::PatternEngine;
7use crate::security::types::Finding;
8
9/// Security scanner for analyzing code changes.
10#[derive(Debug)]
11pub struct SecurityScanner {
12    engine: &'static PatternEngine,
13    config: SecurityConfig,
14}
15
16impl SecurityScanner {
17    /// Creates a new security scanner using the global pattern engine.
18    #[must_use]
19    pub fn new() -> Self {
20        Self {
21            engine: PatternEngine::global(),
22            config: SecurityConfig::default(),
23        }
24    }
25
26    /// Creates a new security scanner with custom configuration.
27    ///
28    /// # Arguments
29    ///
30    /// * `config` - Security configuration for ignore rules
31    ///
32    /// # Returns
33    ///
34    /// A new scanner instance with the provided configuration.
35    #[must_use]
36    pub fn with_config(config: SecurityConfig) -> Self {
37        Self {
38            engine: PatternEngine::global(),
39            config,
40        }
41    }
42
43    /// Scans a PR diff for security vulnerabilities.
44    ///
45    /// # Arguments
46    ///
47    /// * `diff` - The unified diff text from a pull request
48    ///
49    /// # Returns
50    ///
51    /// A vector of security findings from added/modified lines.
52    #[must_use]
53    pub fn scan_diff(&self, diff: &str) -> Vec<Finding> {
54        let mut findings = Vec::new();
55        let mut current_file = String::new();
56        let mut current_line_num = 0;
57
58        for line in diff.lines() {
59            // Track current file being processed
60            if line.starts_with("+++") {
61                // Extract file path from "+++ b/path/to/file"
62                if let Some(path) = line.strip_prefix("+++ b/") {
63                    current_file = path.to_string();
64                }
65                continue;
66            }
67
68            // Track line numbers from diff hunks
69            if line.starts_with("@@") {
70                // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
71                if let Some(new_pos) = line.split('+').nth(1)
72                    && let Some(line_num_str) = new_pos.split(',').next()
73                {
74                    current_line_num = line_num_str
75                        .split_whitespace()
76                        .next()
77                        .and_then(|s| s.parse::<usize>().ok())
78                        .unwrap_or(0);
79                }
80                continue;
81            }
82
83            // Only scan added lines (starting with '+')
84            if let Some(code) = line.strip_prefix('+') {
85                // Skip if it's the file marker line
86                if code.starts_with("++") {
87                    continue;
88                }
89
90                // Scan the added line
91                let line_findings = self.engine.scan(code, &current_file);
92                for mut finding in line_findings {
93                    // Override line number with actual diff position
94                    finding.line_number = current_line_num;
95                    findings.push(finding);
96                }
97
98                current_line_num += 1;
99            } else if !line.starts_with('-') && !line.starts_with('\\') {
100                // Context lines (no prefix) also increment line number
101                current_line_num += 1;
102            }
103        }
104
105        findings
106    }
107
108    /// Scans file content directly (not a diff).
109    ///
110    /// Skips scanning entirely if the file path is in an ignored directory.
111    /// Otherwise, filters out findings based on configured ignore rules.
112    ///
113    /// # Arguments
114    ///
115    /// * `content` - The file content to scan
116    /// * `file_path` - Path to the file
117    ///
118    /// # Returns
119    ///
120    /// A vector of security findings, excluding ignored patterns and paths.
121    #[must_use]
122    pub fn scan_file(&self, content: &str, file_path: &str) -> Vec<Finding> {
123        // Early exit: skip scanning if path is in an ignored directory
124        if self.config.should_ignore_path(file_path) {
125            return Vec::new();
126        }
127
128        let findings = self.engine.scan(content, file_path);
129        findings
130            .into_iter()
131            .filter(|finding| !self.config.should_ignore(finding))
132            .collect()
133    }
134}
135
136impl Default for SecurityScanner {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_scanner_creation() {
148        let scanner = SecurityScanner::new();
149        assert!(scanner.engine.pattern_count() > 0);
150    }
151
152    #[test]
153    fn test_scan_file() {
154        let scanner = SecurityScanner::new();
155        let code = r#"
156            let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
157        "#;
158
159        let findings = scanner.scan_file(code, "config.rs");
160        assert!(!findings.is_empty(), "Should detect hardcoded secret");
161    }
162
163    #[test]
164    fn test_scan_diff_basic() {
165        let scanner = SecurityScanner::new();
166        let diff = r#"
167diff --git a/src/config.rs b/src/config.rs
168index 1234567..abcdefg 100644
169--- a/src/config.rs
170+++ b/src/config.rs
171@@ -10,3 +10,4 @@ fn load_config() {
172     let host = "localhost";
173+    let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
174 }
175"#;
176
177        let findings = scanner.scan_diff(diff);
178        assert!(
179            !findings.is_empty(),
180            "Should detect hardcoded API key in diff"
181        );
182        assert_eq!(findings[0].file_path, "src/config.rs");
183    }
184
185    #[test]
186    fn test_scan_diff_ignores_removed_lines() {
187        let scanner = SecurityScanner::new();
188        let diff = r#"
189diff --git a/src/old.rs b/src/old.rs
190--- a/src/old.rs
191+++ b/src/old.rs
192@@ -1,2 +1,1 @@
193-let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
194+let api_key = env::var("API_KEY").unwrap();
195"#;
196
197        let findings = scanner.scan_diff(diff);
198        // Should not detect the removed line (with '-' prefix)
199        // Should only scan the added line which is safe
200        assert!(
201            findings.is_empty(),
202            "Should not detect secrets in removed lines"
203        );
204    }
205
206    #[test]
207    fn test_scan_diff_multiple_files() {
208        let scanner = SecurityScanner::new();
209        let diff = r#"
210diff --git a/src/auth.rs b/src/auth.rs
211--- a/src/auth.rs
212+++ b/src/auth.rs
213@@ -1,1 +1,2 @@
214 fn authenticate() {
215+    let password = "hardcoded123";
216 }
217diff --git a/src/db.rs b/src/db.rs
218--- a/src/db.rs
219+++ b/src/db.rs
220@@ -1,1 +1,2 @@
221 fn query_user(id: &str) {
222+    execute("SELECT * FROM users WHERE id = " + id);
223 }
224"#;
225
226        let findings = scanner.scan_diff(diff);
227        assert!(
228            findings.len() >= 2,
229            "Should detect issues in multiple files"
230        );
231
232        let auth_findings: Vec<_> = findings
233            .iter()
234            .filter(|f| f.file_path == "src/auth.rs")
235            .collect();
236        assert!(!auth_findings.is_empty(), "Should find issue in auth.rs");
237
238        let db_findings: Vec<_> = findings
239            .iter()
240            .filter(|f| f.file_path == "src/db.rs")
241            .collect();
242        assert!(!db_findings.is_empty(), "Should find issue in db.rs");
243    }
244
245    #[test]
246    fn test_scan_diff_line_numbers() {
247        let scanner = SecurityScanner::new();
248        let diff = r#"
249diff --git a/test.rs b/test.rs
250--- a/test.rs
251+++ b/test.rs
252@@ -5,2 +5,3 @@ fn main() {
253     println!("line 5");
254     println!("line 6");
255+    let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
256"#;
257
258        let findings = scanner.scan_diff(diff);
259        assert_eq!(findings.len(), 1);
260        // The added line should be at line 7 (after lines 5 and 6)
261        assert_eq!(findings[0].line_number, 7);
262    }
263
264    #[test]
265    fn test_scan_empty_diff() {
266        let scanner = SecurityScanner::new();
267        let findings = scanner.scan_diff("");
268        assert!(findings.is_empty());
269    }
270
271    #[test]
272    fn test_default_constructor() {
273        let scanner = SecurityScanner::default();
274        assert!(scanner.engine.pattern_count() > 0);
275    }
276
277    #[test]
278    #[allow(deprecated)]
279    fn test_with_config() {
280        let config = SecurityConfig::with_defaults();
281        let scanner = SecurityScanner::with_config(config);
282        assert!(scanner.engine.pattern_count() > 0);
283    }
284
285    #[test]
286    #[allow(deprecated)]
287    fn test_scan_file_filters_ignored_paths() {
288        let config = SecurityConfig::with_defaults();
289        let scanner = SecurityScanner::with_config(config);
290
291        let code = r#"let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";"#;
292
293        // Should detect in normal file
294        let findings = scanner.scan_file(code, "src/config.rs");
295        assert!(!findings.is_empty(), "Should detect in src/");
296
297        // Should ignore in test file
298        let findings = scanner.scan_file(code, "tests/config.rs");
299        assert!(findings.is_empty(), "Should ignore in tests/");
300
301        // Should ignore in vendor file
302        let findings = scanner.scan_file(code, "vendor/lib.rs");
303        assert!(findings.is_empty(), "Should ignore in vendor/");
304    }
305}