Skip to main content

aptu_core/security/
patterns.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Security pattern engine with regex-based vulnerability detection.
4
5use crate::security::types::{Finding, PatternDefinition};
6use regex::Regex;
7use std::sync::LazyLock;
8
9/// Embedded pattern database JSON.
10const PATTERNS_JSON: &str = include_str!("patterns.json");
11
12/// Compiled pattern engine (initialized once on first use).
13static 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/// Pattern engine for security scanning.
19#[derive(Debug)]
20pub struct PatternEngine {
21    patterns: Vec<CompiledPattern>,
22}
23
24/// A pattern with pre-compiled regex.
25#[derive(Debug)]
26struct CompiledPattern {
27    definition: PatternDefinition,
28    regex: Regex,
29}
30
31impl PatternEngine {
32    /// Creates a pattern engine from the embedded JSON patterns.
33    ///
34    /// # Errors
35    ///
36    /// Returns an error if the JSON is malformed or regex compilation fails.
37    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    /// Gets the global pattern engine instance.
53    #[must_use]
54    pub fn global() -> &'static Self {
55        &PATTERN_ENGINE
56    }
57
58    /// Scans text content for security vulnerabilities.
59    ///
60    /// # Arguments
61    ///
62    /// * `content` - The text content to scan
63    /// * `file_path` - Path to the file being scanned (for filtering and reporting)
64    ///
65    /// # Returns
66    ///
67    /// A vector of security findings.
68    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                // Skip if pattern has file extension filter and doesn't match
78                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    /// Returns the number of loaded patterns.
114    #[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        // Should detect in .js file
222        let js_findings = engine.scan(js_code, "app.js");
223        assert!(!js_findings.is_empty(), "Should detect XSS in JS file");
224
225        // Should NOT detect in .rs file (pattern has file extension filter)
226        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}