use crate::security::types::{Finding, PatternDefinition};
use regex::Regex;
use std::sync::LazyLock;
const PATTERNS_JSON: &str = include_str!("patterns.json");
static PATTERN_ENGINE: LazyLock<PatternEngine> = LazyLock::new(|| {
PatternEngine::from_embedded_json()
.expect("Failed to load embedded security patterns - patterns.json is malformed")
});
#[derive(Debug)]
pub struct PatternEngine {
patterns: Vec<CompiledPattern>,
}
#[derive(Debug)]
struct CompiledPattern {
definition: PatternDefinition,
regex: Regex,
}
impl PatternEngine {
pub fn from_embedded_json() -> anyhow::Result<Self> {
let definitions: Vec<PatternDefinition> = serde_json::from_str(PATTERNS_JSON)?;
let mut patterns = Vec::new();
for def in definitions {
let regex = Regex::new(&def.pattern)?;
patterns.push(CompiledPattern {
definition: def,
regex,
});
}
Ok(Self { patterns })
}
#[must_use]
pub fn global() -> &'static Self {
&PATTERN_ENGINE
}
pub fn scan(&self, content: &str, file_path: &str) -> Vec<Finding> {
let mut findings = Vec::new();
let file_ext = std::path::Path::new(file_path)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}"));
for (line_num, line) in content.lines().enumerate() {
for compiled in &self.patterns {
if !compiled.definition.file_extensions.is_empty() {
if let Some(ref ext) = file_ext {
if !compiled.definition.file_extensions.contains(ext) {
continue;
}
} else {
continue;
}
}
if let Some(mat) = compiled.regex.find(line) {
tracing::debug!(
pattern_id = %compiled.definition.id,
file = %file_path,
line = line_num + 1,
"Security pattern matched"
);
findings.push(Finding {
pattern_id: compiled.definition.id.clone(),
description: compiled.definition.description.clone(),
severity: compiled.definition.severity,
confidence: compiled.definition.confidence,
file_path: file_path.to_string(),
line_number: line_num + 1,
matched_text: mat.as_str().to_string(),
cwe: compiled.definition.cwe.clone(),
});
}
}
}
findings
}
#[must_use]
pub fn pattern_count(&self) -> usize {
self.patterns.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::types::{Confidence, Severity};
#[test]
fn test_pattern_engine_loads() {
let engine = PatternEngine::from_embedded_json().unwrap();
assert!(
engine.pattern_count() >= 22,
"Should have at least 22 patterns"
);
}
#[test]
fn test_global_engine() {
let engine = PatternEngine::global();
assert!(engine.pattern_count() >= 10);
}
#[test]
fn test_hardcoded_api_key_detection() {
let engine = PatternEngine::global();
let code = r#"
let api_key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
let secret_key = "secret_1234567890abcdefghij";
"#;
let findings = engine.scan(code, "test.rs");
assert!(!findings.is_empty(), "Should detect hardcoded secrets");
let api_key_finding = findings
.iter()
.find(|f| f.pattern_id == "hardcoded-api-key");
assert!(api_key_finding.is_some(), "Should detect API key");
if let Some(finding) = api_key_finding {
assert_eq!(finding.severity, Severity::Critical);
assert_eq!(finding.confidence, Confidence::High);
assert_eq!(finding.cwe, Some("CWE-798".to_string()));
}
}
#[test]
fn test_sql_injection_detection() {
let engine = PatternEngine::global();
let code = r#"
query("SELECT * FROM users WHERE id = " + user_input);
execute(format!("DELETE FROM {} WHERE id = {}", table, id));
"#;
let findings = engine.scan(code, "database.rs");
assert!(!findings.is_empty(), "Should detect SQL injection patterns");
let concat_finding = findings
.iter()
.find(|f| f.pattern_id == "sql-injection-concat");
assert!(concat_finding.is_some(), "Should detect concatenation");
let format_finding = findings
.iter()
.find(|f| f.pattern_id == "sql-injection-format");
assert!(format_finding.is_some(), "Should detect format string");
}
#[test]
fn test_path_traversal_detection() {
let engine = PatternEngine::global();
let code = r#"
open("../../etc/passwd");
read("..\..\..\windows\system32\config\sam");
"#;
let findings = engine.scan(code, "file_handler.rs");
assert!(!findings.is_empty(), "Should detect path traversal");
let finding = &findings[0];
assert_eq!(finding.pattern_id, "path-traversal");
assert_eq!(finding.severity, Severity::High);
}
#[test]
fn test_weak_crypto_detection() {
let engine = PatternEngine::global();
let code = r"
let hash = md5(password);
let digest = SHA1(data);
";
let findings = engine.scan(code, "crypto.rs");
assert_eq!(findings.len(), 2, "Should detect both MD5 and SHA1");
assert!(findings.iter().any(|f| f.pattern_id == "weak-crypto-md5"));
assert!(findings.iter().any(|f| f.pattern_id == "weak-crypto-sha1"));
}
#[test]
fn test_file_extension_filtering() {
let engine = PatternEngine::global();
let js_code = "element.innerHTML = userInput + '<div>';";
let js_findings = engine.scan(js_code, "app.js");
assert!(!js_findings.is_empty(), "Should detect XSS in JS file");
let rs_findings = engine.scan(js_code, "app.rs");
assert!(
rs_findings.is_empty(),
"Should not detect XSS pattern in Rust file"
);
}
#[test]
fn test_no_false_positives_on_safe_code() {
let engine = PatternEngine::global();
let safe_code = r#"
// Safe code examples
let config = load_config();
let result = query_with_params("SELECT * FROM users WHERE id = ?", &[id]);
let hash = sha256(data);
let random = OsRng.gen::<u64>();
"#;
let findings = engine.scan(safe_code, "safe.rs");
assert!(
findings.is_empty(),
"Should not have false positives on safe code"
);
}
#[test]
fn test_ssrf_detection() {
let engine = PatternEngine::global();
let code_bare = r#"
let response = reqwest::get(user_url).await;
"#;
let findings_bare = engine.scan(code_bare, "app.rs");
assert!(
findings_bare
.iter()
.any(|f| f.pattern_id == "ssrf-http-request"),
"Should detect SSRF pattern with bare variable URL"
);
let code_concat = r#"
let response = reqwest::get(user_url + "/path").await;
"#;
let findings_concat = engine.scan(code_concat, "app.rs");
assert!(
findings_concat
.iter()
.any(|f| f.pattern_id == "ssrf-http-request"),
"Should detect SSRF pattern with concatenated variable URL"
);
}
#[test]
fn test_open_redirect_detection() {
let engine = PatternEngine::global();
let code = r#"
location.href = req.query.url;
"#;
let findings = engine.scan(code, "app.js");
assert!(
findings.iter().any(|f| f.pattern_id == "open-redirect"),
"Should detect open redirect pattern from user input"
);
}
#[test]
fn test_line_number_accuracy() {
let engine = PatternEngine::global();
let code = "line 1\nline 2\napi_key = \"sk-1234567890abcdefghijklmnopqrstuvwxyz\"\nline 4";
let findings = engine.scan(code, "test.rs");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].line_number, 3,
"Should report correct line number"
);
}
}