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//!
5//! Patterns are defined in `patterns.json` (embedded at compile time). See [`PatternDefinition`]
6//! for the field schema. After editing, run `cargo test -p aptu-core` to validate JSON structure,
7//! required fields, and regex compilation.
8
9use crate::security::types::{Finding, PatternDefinition};
10use regex::Regex;
11use std::sync::LazyLock;
12
13/// Embedded pattern database JSON.
14const PATTERNS_JSON: &str = include_str!("patterns.json");
15
16/// Compiled pattern engine (initialized once on first use).
17static 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/// Pattern engine for security scanning.
23#[derive(Debug)]
24pub struct PatternEngine {
25    patterns: Vec<CompiledPattern>,
26}
27
28/// A pattern with pre-compiled regex.
29#[derive(Debug)]
30struct CompiledPattern {
31    definition: PatternDefinition,
32    regex: Regex,
33}
34
35impl PatternEngine {
36    /// Creates a pattern engine from the embedded JSON patterns.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the JSON is malformed or regex compilation fails.
41    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    /// Gets the global pattern engine instance.
57    #[must_use]
58    pub fn global() -> &'static Self {
59        &PATTERN_ENGINE
60    }
61
62    /// Scans text content for security vulnerabilities.
63    ///
64    /// # Arguments
65    ///
66    /// * `content` - The text content to scan
67    /// * `file_path` - Path to the file being scanned (for filtering and reporting)
68    ///
69    /// # Returns
70    ///
71    /// A vector of security findings.
72    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                // Skip if pattern has file extension filter and doesn't match
82                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    /// Returns the number of loaded patterns.
111    #[must_use]
112    pub fn pattern_count(&self) -> usize {
113        self.patterns.len()
114    }
115
116    /// Returns cloned pattern definitions (for SARIF rule metadata injection).
117    #[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        // Should detect in .js file
225        let js_findings = engine.scan(js_code, "app.js");
226        assert!(!js_findings.is_empty(), "Should detect XSS in JS file");
227
228        // Should NOT detect in .rs file (pattern has file extension filter)
229        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        // Test bare variable call
259        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        // Test concatenation call
271        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        // Case 1: Short opaque ghs_ token (40 chars after prefix)
302        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        // Case 2: Long JWT-format ghs_ token (two dots, ~520 total chars)
314        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        // Case 3: Wrong prefix (ghp_ and ghu_) should not match
326        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}