Skip to main content

threatflux_string_analysis/
patterns.rs

1//! Pattern matching and pattern provider functionality.
2
3use crate::types::{
4    AnalysisError, AnalysisResult, MAX_DESCRIPTION_BYTES, MAX_REGEX_BYTES, compact_string,
5    regex_error_reason, validate_identifier,
6};
7use regex::{Regex, RegexBuilder};
8use serde::{Deserialize, Serialize};
9
10const REGEX_COMPILED_SIZE_LIMIT: usize = 2 * 1024 * 1024;
11const REGEX_DFA_SIZE_LIMIT: usize = 4 * 1024 * 1024;
12pub(crate) const MAX_PATTERNS: usize = 4_096;
13
14/// A validated, compiled pattern used for analysis and categorization.
15#[derive(Debug, Clone)]
16pub struct Pattern {
17    /// Unique name for the pattern.
18    pub name: String,
19    /// Compiled regular expression.
20    pub regex: Regex,
21    /// Category assigned when the pattern matches.
22    pub category: String,
23    /// Human-readable explanation of the match.
24    pub description: String,
25    /// Whether a match contributes a suspicious indicator.
26    pub is_suspicious: bool,
27    /// Severity from 0 through 10 when the match is suspicious.
28    pub severity: u8,
29}
30
31impl Pattern {
32    pub(crate) fn validate(&self) -> AnalysisResult<()> {
33        validate_pattern_fields(&self.name, &self.category, &self.description, self.severity)?;
34        validate_regex_source(self.regex.as_str())
35    }
36
37    pub(crate) fn compact(mut self) -> Self {
38        self.name = compact_string(self.name);
39        self.category = compact_string(self.category);
40        self.description = compact_string(self.description);
41        self
42    }
43}
44
45/// Serializable pattern definition.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct PatternDef {
49    /// Unique identifier for the pattern.
50    pub name: String,
51    /// Regular expression source.
52    pub regex: String,
53    /// Category assigned when the pattern matches.
54    pub category: String,
55    /// Human-readable explanation of the match.
56    pub description: String,
57    /// Whether a match contributes a suspicious indicator.
58    pub is_suspicious: bool,
59    /// Severity from 0 through 10 when suspicious.
60    pub severity: u8,
61}
62
63impl PatternDef {
64    /// Validate and compile this definition.
65    pub fn compile(self) -> AnalysisResult<Pattern> {
66        validate_pattern_fields(&self.name, &self.category, &self.description, self.severity)?;
67        validate_regex_source(&self.regex)?;
68
69        let regex = RegexBuilder::new(&self.regex)
70            .size_limit(REGEX_COMPILED_SIZE_LIMIT)
71            .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
72            .build()
73            .map_err(|error| AnalysisError::InvalidRegex {
74                context: "pattern definition",
75                reason: regex_error_reason(&error),
76            })?;
77
78        Ok(Pattern {
79            name: compact_string(self.name),
80            regex,
81            category: compact_string(self.category),
82            description: compact_string(self.description),
83            is_suspicious: self.is_suspicious,
84            severity: self.severity,
85        })
86    }
87}
88
89fn validate_regex_source(source: &str) -> AnalysisResult<()> {
90    if source.len() > MAX_REGEX_BYTES {
91        Err(AnalysisError::InputTooLarge {
92            field: "pattern.regex",
93            actual: source.len(),
94            limit: MAX_REGEX_BYTES,
95        })
96    } else {
97        Ok(())
98    }
99}
100
101fn validate_pattern_fields(
102    name: &str,
103    category: &str,
104    description: &str,
105    severity: u8,
106) -> AnalysisResult<()> {
107    validate_identifier("pattern", name)?;
108    validate_identifier("category", category)?;
109    if description.len() > MAX_DESCRIPTION_BYTES {
110        return Err(AnalysisError::InputTooLarge {
111            field: "pattern.description",
112            actual: description.len(),
113            limit: MAX_DESCRIPTION_BYTES,
114        });
115    }
116    if description.trim().is_empty() {
117        return Err(AnalysisError::InvalidIdentifier {
118            kind: "pattern description",
119            name: description.to_string(),
120            reason: "must not be empty or whitespace-only",
121        });
122    }
123    if description.chars().any(char::is_control) {
124        return Err(AnalysisError::InvalidIdentifier {
125            kind: "pattern description",
126            name: description.to_string(),
127            reason: "must not contain control characters",
128        });
129    }
130    if severity > 10 {
131        return Err(AnalysisError::InvalidSeverity { severity });
132    }
133    Ok(())
134}
135
136/// Provider interface for validated analysis patterns.
137pub trait PatternProvider: Send + Sync {
138    /// Return a snapshot of all patterns in evaluation order.
139    fn get_patterns(&self) -> Vec<Pattern>;
140
141    /// Validate and add a uniquely named pattern.
142    fn add_pattern(&mut self, pattern: PatternDef) -> AnalysisResult<()>;
143
144    /// Remove an existing pattern by name.
145    fn remove_pattern(&mut self, name: &str) -> AnalysisResult<()>;
146
147    /// Atomically validate and replace an existing pattern by name.
148    fn update_pattern(&mut self, pattern: PatternDef) -> AnalysisResult<()>;
149}
150
151/// Built-in pattern provider.
152///
153/// Generic artifacts such as URLs, IP addresses, paths, registry keys, and
154/// algorithm names are informational categories. Only higher-signal patterns
155/// contribute suspicious indicators.
156pub struct DefaultPatternProvider {
157    patterns: Vec<Pattern>,
158}
159
160impl DefaultPatternProvider {
161    /// Build the validated built-in pattern set.
162    pub fn new() -> AnalysisResult<Self> {
163        let mut provider = Self::empty();
164        for definition in builtin_pattern_definitions() {
165            provider.add_pattern(definition)?;
166        }
167        Ok(provider)
168    }
169
170    /// Create a provider without built-in patterns.
171    pub fn empty() -> Self {
172        Self {
173            patterns: Vec::new(),
174        }
175    }
176}
177
178impl PatternProvider for DefaultPatternProvider {
179    fn get_patterns(&self) -> Vec<Pattern> {
180        self.patterns.clone()
181    }
182
183    fn add_pattern(&mut self, pattern_def: PatternDef) -> AnalysisResult<()> {
184        if self
185            .patterns
186            .iter()
187            .any(|pattern| pattern.name == pattern_def.name)
188        {
189            return Err(AnalysisError::DuplicateName {
190                kind: "pattern",
191                name: pattern_def.name.to_string(),
192            });
193        }
194        if self.patterns.len() >= MAX_PATTERNS {
195            return Err(AnalysisError::CapacityExceeded {
196                resource: "pattern provider patterns",
197                limit: MAX_PATTERNS,
198            });
199        }
200        self.patterns.push(pattern_def.compile()?);
201        Ok(())
202    }
203
204    fn remove_pattern(&mut self, name: &str) -> AnalysisResult<()> {
205        validate_identifier("pattern", name)?;
206        let Some(index) = self
207            .patterns
208            .iter()
209            .position(|pattern| pattern.name == name)
210        else {
211            return Err(AnalysisError::NotFound {
212                kind: "pattern",
213                name: name.to_string(),
214            });
215        };
216        self.patterns.remove(index);
217        Ok(())
218    }
219
220    fn update_pattern(&mut self, pattern_def: PatternDef) -> AnalysisResult<()> {
221        validate_identifier("pattern", &pattern_def.name)?;
222        let Some(index) = self
223            .patterns
224            .iter()
225            .position(|pattern| pattern.name == pattern_def.name)
226        else {
227            return Err(AnalysisError::NotFound {
228                kind: "pattern",
229                name: pattern_def.name.to_string(),
230            });
231        };
232
233        // Compile before mutating so a failed replacement preserves the old pattern.
234        let replacement = pattern_def.compile()?;
235        self.patterns[index] = replacement;
236        Ok(())
237    }
238}
239
240impl Default for DefaultPatternProvider {
241    fn default() -> Self {
242        match Self::new() {
243            Ok(provider) => provider,
244            Err(error) => panic!("built-in pattern definitions must be valid: {error}"),
245        }
246    }
247}
248
249fn builtin_pattern_definitions() -> Vec<PatternDef> {
250    vec![
251        informational(
252            "url",
253            r"(?i)\b(?:https?|ftp|ssh|telnet|rdp)://[^\s]+",
254            "network",
255            "URL or network-protocol reference",
256        ),
257        informational(
258            "ipv4_address",
259            r"\b(?:(?:25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\.){3}(?:25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\b",
260            "network",
261            "Syntactically valid IPv4 address",
262        ),
263        informational(
264            "crypto_algorithm",
265            r"(?i)\b(?:base64|rot13|xor|aes|des|rsa)\b",
266            "crypto",
267            "Cryptographic or encoding algorithm name",
268        ),
269        informational(
270            "base64_candidate",
271            r"^(?:(?:[A-Za-z0-9+/]{4}){8,}|(?:[A-Za-z0-9+/]{4}){7,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=))$",
272            "encoding",
273            "String shaped like Base64 data",
274        ),
275        informational(
276            "temporary_or_system_path",
277            r"(?i)(?:\\temp\\|/tmp/|\\windows\\system32(?:\\|$))",
278            "path",
279            "Temporary or operating-system path",
280        ),
281        informational(
282            "registry_key",
283            r"(?i)(?:\bHKEY_[A-Z_]+|SOFTWARE\\Microsoft\\Windows)",
284            "registry",
285            "Windows registry-key reference",
286        ),
287        suspicious(
288            "shell_interpreter",
289            r#"(?ix)(?:(?:^|[^A-Za-z0-9_])(?:cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?)(?:$|[^A-Za-z0-9_])|(?:^|[\s"'`])(?:/bin/)?(?:ba|da|z|k)?sh\s+-c(?:\s|$))"#,
290            "command",
291            "Command-shell interpreter token",
292            6,
293        ),
294        suspicious(
295            "dynamic_execution_call",
296            r"(?i)\b(?:eval|exec|system|shell)\s*\(",
297            "execution",
298            "Dynamic code or command execution call",
299            7,
300        ),
301        suspicious(
302            "credential_assignment",
303            r"(?i)\b(?:password|credential|secret|token|api[_-]?key)\b\s*[:=]",
304            "credential",
305            "Credential-related value assignment",
306            8,
307        ),
308        suspicious(
309            "malware_behavior_term",
310            r"(?i)\b(?:dropper|payload|rootkit|process[ _-]?inject(?:ion|or)?|keylog(?:ger|ging)?)\b",
311            "malware",
312            "High-signal malware behavior term",
313            9,
314        ),
315        suspicious(
316            "surveillance_behavior_term",
317            r"(?i)\b(?:screen[ _-]?capture|webcam[ _-]?capture|microphone[ _-]?recording)\b",
318            "surveillance",
319            "High-signal surveillance behavior term",
320            8,
321        ),
322    ]
323}
324
325fn informational(name: &str, regex: &str, category: &str, description: &str) -> PatternDef {
326    PatternDef {
327        name: name.to_string(),
328        regex: regex.to_string(),
329        category: category.to_string(),
330        description: description.to_string(),
331        is_suspicious: false,
332        severity: 0,
333    }
334}
335
336fn suspicious(
337    name: &str,
338    regex: &str,
339    category: &str,
340    description: &str,
341    severity: u8,
342) -> PatternDef {
343    PatternDef {
344        name: name.to_string(),
345        regex: regex.to_string(),
346        category: category.to_string(),
347        description: description.to_string(),
348        is_suspicious: true,
349        severity,
350    }
351}