aptu_core/security/
patterns.rs1use crate::security::types::{Finding, PatternDefinition};
10use regex::Regex;
11use std::sync::LazyLock;
12
13const PATTERNS_JSON: &str = include_str!("patterns.json");
15
16static PATTERN_ENGINE: LazyLock<PatternEngine> = LazyLock::new(|| {
18 PatternEngine::from_embedded_json()
19 .expect("Failed to load embedded security patterns - patterns.json is malformed")
20});
21
22#[derive(Debug)]
24pub struct PatternEngine {
25 patterns: Vec<CompiledPattern>,
26}
27
28#[derive(Debug)]
30struct CompiledPattern {
31 definition: PatternDefinition,
32 regex: Regex,
33}
34
35impl PatternEngine {
36 pub fn from_embedded_json() -> anyhow::Result<Self> {
42 let definitions: Vec<PatternDefinition> = serde_json::from_str(PATTERNS_JSON)?;
43 let mut patterns = Vec::new();
44
45 for def in definitions {
46 let regex = Regex::new(&def.pattern)?;
47 patterns.push(CompiledPattern {
48 definition: def,
49 regex,
50 });
51 }
52
53 Ok(Self { patterns })
54 }
55
56 #[must_use]
58 pub fn global() -> &'static Self {
59 &PATTERN_ENGINE
60 }
61
62 pub fn scan(&self, content: &str, file_path: &str) -> Vec<Finding> {
73 let mut findings = Vec::new();
74 let file_ext = std::path::Path::new(file_path)
75 .extension()
76 .and_then(|e| e.to_str())
77 .map(|e| format!(".{e}"));
78
79 for (line_num, line) in content.lines().enumerate() {
80 for compiled in &self.patterns {
81 if (compiled.definition.file_extensions.is_empty()
83 || matches!(&file_ext, Some(ext) if compiled.definition.file_extensions.contains(ext)))
84 && let Some(mat) = compiled.regex.find(line)
85 {
86 tracing::debug!(
87 pattern_id = %compiled.definition.id,
88 file = %file_path,
89 line = line_num + 1,
90 "Security pattern matched"
91 );
92
93 findings.push(Finding {
94 pattern_id: compiled.definition.id.clone(),
95 description: compiled.definition.description.clone(),
96 severity: compiled.definition.severity,
97 confidence: compiled.definition.confidence,
98 file_path: file_path.to_string(),
99 line_number: line_num + 1,
100 matched_text: mat.as_str().to_string(),
101 cwe: compiled.definition.cwe.clone(),
102 });
103 }
104 }
105 }
106
107 findings
108 }
109
110 #[must_use]
112 pub fn pattern_count(&self) -> usize {
113 self.patterns.len()
114 }
115
116 #[must_use]
118 pub fn definitions(&self) -> Vec<PatternDefinition> {
119 self.patterns.iter().map(|c| c.definition.clone()).collect()
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::security::types::{Confidence, Severity};
127
128 #[test]
129 fn test_pattern_engine_loads() {
130 let engine = PatternEngine::from_embedded_json().unwrap();
131 assert!(
132 engine.pattern_count() >= 22,
133 "Should have at least 22 patterns"
134 );
135 }
136
137 #[test]
138 fn test_global_engine() {
139 let engine = PatternEngine::global();
140 assert!(engine.pattern_count() >= 10);
141 }
142
143 #[test]
144 fn test_hardcoded_api_key_detection() {
145 let engine = PatternEngine::global();
146 let code = r#"
147 let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
148 let secret_key = "secret_1234567890abcdefghij";
149 "#;
150
151 let findings = engine.scan(code, "test.rs");
152 assert!(!findings.is_empty(), "Should detect hardcoded secrets");
153
154 let api_key_finding = findings
155 .iter()
156 .find(|f| f.pattern_id == "hardcoded-api-key");
157 assert!(api_key_finding.is_some(), "Should detect API key");
158
159 if let Some(finding) = api_key_finding {
160 assert_eq!(finding.severity, Severity::Critical);
161 assert_eq!(finding.confidence, Confidence::High);
162 assert_eq!(finding.cwe, Some("CWE-798".to_string()));
163 }
164 }
165
166 #[test]
167 fn test_sql_injection_detection() {
168 let engine = PatternEngine::global();
169 let code = r#"
170 query("SELECT * FROM users WHERE id = " + user_input);
171 execute(format!("DELETE FROM {} WHERE id = {}", table, id));
172 "#;
173
174 let findings = engine.scan(code, "database.rs");
175 assert!(!findings.is_empty(), "Should detect SQL injection patterns");
176
177 let concat_finding = findings
178 .iter()
179 .find(|f| f.pattern_id == "sql-injection-concat");
180 assert!(concat_finding.is_some(), "Should detect concatenation");
181
182 let format_finding = findings
183 .iter()
184 .find(|f| f.pattern_id == "sql-injection-format");
185 assert!(format_finding.is_some(), "Should detect format string");
186 }
187
188 #[test]
189 fn test_path_traversal_detection() {
190 let engine = PatternEngine::global();
191 let code = r#"
192 open("../../etc/passwd");
193 read("..\..\..\windows\system32\config\sam");
194 "#;
195
196 let findings = engine.scan(code, "file_handler.rs");
197 assert!(!findings.is_empty(), "Should detect path traversal");
198
199 let finding = &findings[0];
200 assert_eq!(finding.pattern_id, "path-traversal");
201 assert_eq!(finding.severity, Severity::High);
202 }
203
204 #[test]
205 fn test_weak_crypto_detection() {
206 let engine = PatternEngine::global();
207 let code = r"
208 let hash = md5(password);
209 let digest = SHA1(data);
210 ";
211
212 let findings = engine.scan(code, "crypto.rs");
213 assert_eq!(findings.len(), 2, "Should detect both MD5 and SHA1");
214
215 assert!(findings.iter().any(|f| f.pattern_id == "weak-crypto-md5"));
216 assert!(findings.iter().any(|f| f.pattern_id == "weak-crypto-sha1"));
217 }
218
219 #[test]
220 fn test_file_extension_filtering() {
221 let engine = PatternEngine::global();
222 let js_code = "element.innerHTML = userInput + '<div>';";
223
224 let js_findings = engine.scan(js_code, "app.js");
226 assert!(!js_findings.is_empty(), "Should detect XSS in JS file");
227
228 let rs_findings = engine.scan(js_code, "app.rs");
230 assert!(
231 rs_findings.is_empty(),
232 "Should not detect XSS pattern in Rust file"
233 );
234 }
235
236 #[test]
237 fn test_no_false_positives_on_safe_code() {
238 let engine = PatternEngine::global();
239 let safe_code = r#"
240 // Safe code examples
241 let config = load_config();
242 let result = query_with_params("SELECT * FROM users WHERE id = ?", &[id]);
243 let hash = sha256(data);
244 let random = OsRng.gen::<u64>();
245 "#;
246
247 let findings = engine.scan(safe_code, "safe.rs");
248 assert!(
249 findings.is_empty(),
250 "Should not have false positives on safe code"
251 );
252 }
253
254 #[test]
255 fn test_ssrf_detection() {
256 let engine = PatternEngine::global();
257
258 let code_bare = r"
260 let response = reqwest::get(user_url).await;
261 ";
262 let findings_bare = engine.scan(code_bare, "app.rs");
263 assert!(
264 findings_bare
265 .iter()
266 .any(|f| f.pattern_id == "ssrf-http-request"),
267 "Should detect SSRF pattern with bare variable URL"
268 );
269
270 let code_concat = r#"
272 let response = reqwest::get(user_url + "/path").await;
273 "#;
274 let findings_concat = engine.scan(code_concat, "app.rs");
275 assert!(
276 findings_concat
277 .iter()
278 .any(|f| f.pattern_id == "ssrf-http-request"),
279 "Should detect SSRF pattern with concatenated variable URL"
280 );
281 }
282
283 #[test]
284 fn test_open_redirect_detection() {
285 let engine = PatternEngine::global();
286 let code = r"
287 location.href = req.query.url;
288 ";
289
290 let findings = engine.scan(code, "app.js");
291 assert!(
292 findings.iter().any(|f| f.pattern_id == "open-redirect"),
293 "Should detect open redirect pattern from user input"
294 );
295 }
296
297 #[test]
298 fn test_github_token_pattern() {
299 let engine = PatternEngine::global();
300
301 let code_short = r#"
303 token = "ghs_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB"
304 "#;
305 let findings = engine.scan(code_short, "test.rs");
306 assert!(
307 findings
308 .iter()
309 .any(|f| f.pattern_id == "leaked-github-token"),
310 "Should detect short opaque ghs_ token"
311 );
312
313 let code_jwt = r#"
315 token = "ghs_AAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB.CCCCCCCCCCCCCCCCCCCC"
316 "#;
317 let findings = engine.scan(code_jwt, "test.rs");
318 assert!(
319 findings
320 .iter()
321 .any(|f| f.pattern_id == "leaked-github-token"),
322 "Should detect long JWT-format ghs_ token"
323 );
324
325 let code_wrong_prefix = r#"
327 ghp_token = "ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB"
328 ghu_token = "ghu_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB"
329 "#;
330 let findings = engine.scan(code_wrong_prefix, "test.rs");
331 assert!(
332 !findings
333 .iter()
334 .any(|f| f.pattern_id == "leaked-github-token"),
335 "Should not detect ghp_ or ghu_ prefixed tokens"
336 );
337 }
338
339 #[test]
340 fn test_all_patterns_have_remediation_and_authority_url() {
341 let engine = PatternEngine::from_embedded_json().unwrap();
342 for def in engine.definitions() {
343 assert!(
344 def.remediation.as_deref().is_some_and(|s| !s.is_empty()),
345 "Pattern '{}' is missing a non-empty remediation",
346 def.id
347 );
348 assert!(
349 def.authority_url.as_deref().is_some_and(|s| !s.is_empty()),
350 "Pattern '{}' is missing a non-empty authority_url",
351 def.id
352 );
353 }
354 }
355
356 #[test]
357 fn test_sarif_with_rules_includes_rule_metadata() {
358 use crate::security::sarif::SarifReport;
359 use crate::security::types::{Confidence, Severity};
360
361 let engine = PatternEngine::from_embedded_json().unwrap();
362 let patterns = engine.definitions();
363
364 let finding = Finding {
365 pattern_id: "hardcoded-api-key".to_string(),
366 description: "Hardcoded API key detected".to_string(),
367 severity: Severity::Critical,
368 confidence: Confidence::High,
369 file_path: "src/config.rs".to_string(),
370 line_number: 1,
371 matched_text: "api_key = \"sk-abc\"".to_string(),
372 cwe: Some("CWE-798".to_string()),
373 };
374
375 let report = SarifReport::with_rules(vec![finding], &patterns);
376 let json = serde_json::to_string(&report).unwrap();
377
378 assert!(
379 !report.runs[0].tool.driver.rules.is_empty(),
380 "rules array must not be empty"
381 );
382 assert!(
383 json.contains("hardcoded-api-key"),
384 "JSON must contain rule id"
385 );
386 assert!(
387 json.contains("helpUri") || json.contains("help_uri") || json.contains("cwe.mitre.org"),
388 "JSON must contain authority URL"
389 );
390 }
391
392 #[test]
393 fn test_line_number_accuracy() {
394 let engine = PatternEngine::global();
395 let code = "line 1\nline 2\napi_key = \"sk-1234567890abcdefghijklmnopqrstuvwxyz\"\nline 4";
396
397 let findings = engine.scan(code, "test.rs");
398 assert_eq!(findings.len(), 1);
399 assert_eq!(
400 findings[0].line_number, 3,
401 "Should report correct line number"
402 );
403 }
404}