aptu_core/security/
scanner.rs1use crate::security::ignore::SecurityConfig;
6use crate::security::patterns::PatternEngine;
7use crate::security::types::Finding;
8
9#[derive(Debug)]
11pub struct SecurityScanner {
12 engine: &'static PatternEngine,
13 config: SecurityConfig,
14}
15
16impl SecurityScanner {
17 #[must_use]
19 pub fn new() -> Self {
20 Self {
21 engine: PatternEngine::global(),
22 config: SecurityConfig::default(),
23 }
24 }
25
26 #[must_use]
36 pub fn with_config(config: SecurityConfig) -> Self {
37 Self {
38 engine: PatternEngine::global(),
39 config,
40 }
41 }
42
43 #[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 if line.starts_with("+++") {
61 if let Some(path) = line.strip_prefix("+++ b/") {
63 current_file = path.to_string();
64 }
65 continue;
66 }
67
68 if line.starts_with("@@") {
70 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 if let Some(code) = line.strip_prefix('+') {
85 if code.starts_with("++") {
87 continue;
88 }
89
90 let line_findings = self.engine.scan(code, ¤t_file);
92 for mut finding in line_findings {
93 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 current_line_num += 1;
102 }
103 }
104
105 findings
106 }
107
108 #[must_use]
122 pub fn scan_file(&self, content: &str, file_path: &str) -> Vec<Finding> {
123 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 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 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 let findings = scanner.scan_file(code, "src/config.rs");
295 assert!(!findings.is_empty(), "Should detect in src/");
296
297 let findings = scanner.scan_file(code, "tests/config.rs");
299 assert!(findings.is_empty(), "Should ignore in tests/");
300
301 let findings = scanner.scan_file(code, "vendor/lib.rs");
303 assert!(findings.is_empty(), "Should ignore in vendor/");
304 }
305}