threatflux_string_analysis/
patterns.rs1use 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#[derive(Debug, Clone)]
16pub struct Pattern {
17 pub name: String,
19 pub regex: Regex,
21 pub category: String,
23 pub description: String,
25 pub is_suspicious: bool,
27 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct PatternDef {
49 pub name: String,
51 pub regex: String,
53 pub category: String,
55 pub description: String,
57 pub is_suspicious: bool,
59 pub severity: u8,
61}
62
63impl PatternDef {
64 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
136pub trait PatternProvider: Send + Sync {
138 fn get_patterns(&self) -> Vec<Pattern>;
140
141 fn add_pattern(&mut self, pattern: PatternDef) -> AnalysisResult<()>;
143
144 fn remove_pattern(&mut self, name: &str) -> AnalysisResult<()>;
146
147 fn update_pattern(&mut self, pattern: PatternDef) -> AnalysisResult<()>;
149}
150
151pub struct DefaultPatternProvider {
157 patterns: Vec<Pattern>,
158}
159
160impl DefaultPatternProvider {
161 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 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 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}