aptu_core/security/
patterns.rs1use crate::security::types::{Finding, PatternDefinition};
6use regex::Regex;
7use std::sync::LazyLock;
8
9const PATTERNS_JSON: &str = include_str!("patterns.json");
11
12static PATTERN_ENGINE: LazyLock<PatternEngine> = LazyLock::new(|| {
14 PatternEngine::from_embedded_json()
15 .expect("Failed to load embedded security patterns - patterns.json is malformed")
16});
17
18#[derive(Debug)]
20pub struct PatternEngine {
21 patterns: Vec<CompiledPattern>,
22}
23
24#[derive(Debug)]
26struct CompiledPattern {
27 definition: PatternDefinition,
28 regex: Regex,
29}
30
31impl PatternEngine {
32 pub fn from_embedded_json() -> anyhow::Result<Self> {
38 let definitions: Vec<PatternDefinition> = serde_json::from_str(PATTERNS_JSON)?;
39 let mut patterns = Vec::new();
40
41 for def in definitions {
42 let regex = Regex::new(&def.pattern)?;
43 patterns.push(CompiledPattern {
44 definition: def,
45 regex,
46 });
47 }
48
49 Ok(Self { patterns })
50 }
51
52 #[must_use]
54 pub fn global() -> &'static Self {
55 &PATTERN_ENGINE
56 }
57
58 pub fn scan(&self, content: &str, file_path: &str) -> Vec<Finding> {
69 let mut findings = Vec::new();
70 let file_ext = std::path::Path::new(file_path)
71 .extension()
72 .and_then(|e| e.to_str())
73 .map(|e| format!(".{e}"));
74
75 for (line_num, line) in content.lines().enumerate() {
76 for compiled in &self.patterns {
77 if !compiled.definition.file_extensions.is_empty() {
79 if let Some(ref ext) = file_ext {
80 if !compiled.definition.file_extensions.contains(ext) {
81 continue;
82 }
83 } else {
84 continue;
85 }
86 }
87
88 if let Some(mat) = compiled.regex.find(line) {
89 tracing::debug!(
90 pattern_id = %compiled.definition.id,
91 file = %file_path,
92 line = line_num + 1,
93 "Security pattern matched"
94 );
95
96 findings.push(Finding {
97 pattern_id: compiled.definition.id.clone(),
98 description: compiled.definition.description.clone(),
99 severity: compiled.definition.severity,
100 confidence: compiled.definition.confidence,
101 file_path: file_path.to_string(),
102 line_number: line_num + 1,
103 matched_text: mat.as_str().to_string(),
104 cwe: compiled.definition.cwe.clone(),
105 });
106 }
107 }
108 }
109
110 findings
111 }
112
113 #[must_use]
115 pub fn pattern_count(&self) -> usize {
116 self.patterns.len()
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::security::types::{Confidence, Severity};
124
125 #[test]
126 fn test_pattern_engine_loads() {
127 let engine = PatternEngine::from_embedded_json().unwrap();
128 assert!(
129 engine.pattern_count() >= 10,
130 "Should have at least 10 patterns"
131 );
132 }
133
134 #[test]
135 fn test_global_engine() {
136 let engine = PatternEngine::global();
137 assert!(engine.pattern_count() >= 10);
138 }
139
140 #[test]
141 fn test_hardcoded_api_key_detection() {
142 let engine = PatternEngine::global();
143 let code = r#"
144 let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
145 let secret_key = "secret_1234567890abcdefghij";
146 "#;
147
148 let findings = engine.scan(code, "test.rs");
149 assert!(!findings.is_empty(), "Should detect hardcoded secrets");
150
151 let api_key_finding = findings
152 .iter()
153 .find(|f| f.pattern_id == "hardcoded-api-key");
154 assert!(api_key_finding.is_some(), "Should detect API key");
155
156 if let Some(finding) = api_key_finding {
157 assert_eq!(finding.severity, Severity::Critical);
158 assert_eq!(finding.confidence, Confidence::High);
159 assert_eq!(finding.cwe, Some("CWE-798".to_string()));
160 }
161 }
162
163 #[test]
164 fn test_sql_injection_detection() {
165 let engine = PatternEngine::global();
166 let code = r#"
167 query("SELECT * FROM users WHERE id = " + user_input);
168 execute(format!("DELETE FROM {} WHERE id = {}", table, id));
169 "#;
170
171 let findings = engine.scan(code, "database.rs");
172 assert!(!findings.is_empty(), "Should detect SQL injection patterns");
173
174 let concat_finding = findings
175 .iter()
176 .find(|f| f.pattern_id == "sql-injection-concat");
177 assert!(concat_finding.is_some(), "Should detect concatenation");
178
179 let format_finding = findings
180 .iter()
181 .find(|f| f.pattern_id == "sql-injection-format");
182 assert!(format_finding.is_some(), "Should detect format string");
183 }
184
185 #[test]
186 fn test_path_traversal_detection() {
187 let engine = PatternEngine::global();
188 let code = r#"
189 open("../../etc/passwd");
190 read("..\..\..\windows\system32\config\sam");
191 "#;
192
193 let findings = engine.scan(code, "file_handler.rs");
194 assert!(!findings.is_empty(), "Should detect path traversal");
195
196 let finding = &findings[0];
197 assert_eq!(finding.pattern_id, "path-traversal");
198 assert_eq!(finding.severity, Severity::High);
199 }
200
201 #[test]
202 fn test_weak_crypto_detection() {
203 let engine = PatternEngine::global();
204 let code = r"
205 let hash = md5(password);
206 let digest = SHA1(data);
207 ";
208
209 let findings = engine.scan(code, "crypto.rs");
210 assert_eq!(findings.len(), 2, "Should detect both MD5 and SHA1");
211
212 assert!(findings.iter().any(|f| f.pattern_id == "weak-crypto-md5"));
213 assert!(findings.iter().any(|f| f.pattern_id == "weak-crypto-sha1"));
214 }
215
216 #[test]
217 fn test_file_extension_filtering() {
218 let engine = PatternEngine::global();
219 let js_code = "element.innerHTML = userInput + '<div>';";
220
221 let js_findings = engine.scan(js_code, "app.js");
223 assert!(!js_findings.is_empty(), "Should detect XSS in JS file");
224
225 let rs_findings = engine.scan(js_code, "app.rs");
227 assert!(
228 rs_findings.is_empty(),
229 "Should not detect XSS pattern in Rust file"
230 );
231 }
232
233 #[test]
234 fn test_no_false_positives_on_safe_code() {
235 let engine = PatternEngine::global();
236 let safe_code = r#"
237 // Safe code examples
238 let config = load_config();
239 let result = query_with_params("SELECT * FROM users WHERE id = ?", &[id]);
240 let hash = sha256(data);
241 let random = OsRng.gen::<u64>();
242 "#;
243
244 let findings = engine.scan(safe_code, "safe.rs");
245 assert!(
246 findings.is_empty(),
247 "Should not have false positives on safe code"
248 );
249 }
250
251 #[test]
252 fn test_line_number_accuracy() {
253 let engine = PatternEngine::global();
254 let code = "line 1\nline 2\napi_key = \"sk-1234567890abcdefghijklmnopqrstuvwxyz\"\nline 4";
255
256 let findings = engine.scan(code, "test.rs");
257 assert_eq!(findings.len(), 1);
258 assert_eq!(
259 findings[0].line_number, 3,
260 "Should report correct line number"
261 );
262 }
263}