1use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum PatternError {
10 #[error("pattern must start with '[' and end with ']'")]
11 MissingBrackets,
12
13 #[error("empty pattern")]
14 EmptyPattern,
15
16 #[error("invalid object type: {0}")]
17 InvalidObjectType(String),
18
19 #[error("missing colon separator between object type and property")]
20 MissingColon,
21
22 #[error("missing comparison operator")]
23 MissingOperator,
24
25 #[error("invalid comparison operator: {0}")]
26 InvalidOperator(String),
27
28 #[error("unbalanced brackets")]
29 UnbalancedBrackets,
30
31 #[error("invalid pattern syntax: {0}")]
32 InvalidSyntax(String),
33}
34
35const VALID_OBJECT_TYPES: &[&str] = &[
37 "artifact",
38 "autonomous-system",
39 "directory",
40 "domain-name",
41 "email-addr",
42 "email-message",
43 "file",
44 "ipv4-addr",
45 "ipv6-addr",
46 "mac-addr",
47 "mutex",
48 "network-traffic",
49 "process",
50 "software",
51 "url",
52 "user-account",
53 "windows-registry-key",
54 "x509-certificate",
55];
56
57const VALID_OPERATORS: &[&str] = &[
59 "=",
60 "!=",
61 ">",
62 ">=",
63 "<",
64 "<=",
65 "IN",
66 "MATCHES",
67 "LIKE",
68 "ISSUBSET",
69 "ISSUPERSET",
70];
71
72const VALID_COMBINERS: &[&str] = &["AND", "OR", "FOLLOWEDBY"];
74
75pub fn validate_pattern(pattern: &str) -> Result<(), PatternError> {
93 let trimmed = pattern.trim();
94
95 if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
97 return Err(PatternError::MissingBrackets);
98 }
99
100 let open_count = trimmed.chars().filter(|c| *c == '[').count();
102 let close_count = trimmed.chars().filter(|c| *c == ']').count();
103 if open_count != close_count {
104 return Err(PatternError::UnbalancedBrackets);
105 }
106
107 let inner = &trimmed[1..trimmed.len() - 1].trim();
109
110 if inner.is_empty() {
111 return Err(PatternError::EmptyPattern);
112 }
113
114 let patterns = split_by_combiners(inner);
116
117 for pattern_part in patterns {
118 validate_single_comparison(pattern_part.trim())?;
119 }
120
121 Ok(())
122}
123
124fn split_by_combiners(pattern: &str) -> Vec<&str> {
126 let mut parts = vec![];
129 let mut last_pos = 0;
130
131 for combiner in VALID_COMBINERS {
132 if let Some(pos) = pattern.find(combiner) {
133 if !is_inside_quotes(pattern, pos) {
135 parts.push(&pattern[last_pos..pos]);
136 last_pos = pos + combiner.len();
137 }
138 }
139 }
140
141 if parts.is_empty() {
142 vec![pattern]
143 } else {
144 parts.push(&pattern[last_pos..]);
145 parts
146 }
147}
148
149fn is_inside_quotes(s: &str, pos: usize) -> bool {
151 let before = &s[..pos];
152 let single_quotes = before.chars().filter(|c| *c == '\'').count();
153 let double_quotes = before.chars().filter(|c| *c == '"').count();
154
155 single_quotes % 2 != 0 || double_quotes % 2 != 0
157}
158
159fn validate_single_comparison(expr: &str) -> Result<(), PatternError> {
161 if expr.starts_with('[') && expr.ends_with(']') {
163 return validate_pattern(&format!("[{}]", expr));
164 }
165
166 let colon_pos = expr.find(':').ok_or(PatternError::MissingColon)?;
171
172 let object_type = expr[..colon_pos].trim();
173
174 if !VALID_OBJECT_TYPES.contains(&object_type) {
176 return Err(PatternError::InvalidObjectType(object_type.to_string()));
177 }
178
179 let rest = &expr[colon_pos + 1..];
180
181 let mut found_operator = None;
183 for op in VALID_OPERATORS {
184 if rest.contains(op) {
185 found_operator = Some(op);
186 break;
187 }
188 }
189
190 if found_operator.is_none() {
191 return Err(PatternError::MissingOperator);
192 }
193
194 Ok(())
195}
196
197pub struct PatternBuilder {
199 parts: Vec<String>,
200}
201
202impl PatternBuilder {
203 pub fn new() -> Self {
204 Self { parts: Vec::new() }
205 }
206
207 pub fn compare(
209 mut self,
210 object_type: &str,
211 property: &str,
212 operator: &str,
213 value: &str,
214 ) -> Self {
215 let expr = format!("{}:{} {} {}", object_type, property, operator, value);
216 self.parts.push(expr);
217 self
218 }
219
220 pub fn and(mut self) -> Self {
222 if !self.parts.is_empty() {
223 self.parts.push(" AND ".to_string());
224 }
225 self
226 }
227
228 pub fn or(mut self) -> Self {
230 if !self.parts.is_empty() {
231 self.parts.push(" OR ".to_string());
232 }
233 self
234 }
235
236 pub fn build(self) -> String {
238 format!("[{}]", self.parts.join(""))
239 }
240}
241
242impl Default for PatternBuilder {
243 fn default() -> Self {
244 Self::new()
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_valid_simple_pattern() {
254 assert!(validate_pattern("[file:hashes.MD5 = 'abc123']").is_ok());
255 assert!(validate_pattern("[ipv4-addr:value = '192.168.1.1']").is_ok());
256 assert!(validate_pattern("[domain-name:value = 'evil.com']").is_ok());
257 }
258
259 #[test]
260 fn test_valid_complex_pattern() {
261 assert!(validate_pattern("[file:name = 'malware.exe' AND file:size > 1000]").is_ok());
262 assert!(
263 validate_pattern("[ipv4-addr:value = '10.0.0.1' OR ipv4-addr:value = '10.0.0.2']")
264 .is_ok()
265 );
266 }
267
268 #[test]
269 fn test_missing_brackets() {
270 assert!(matches!(
271 validate_pattern("file:hashes.MD5 = 'abc123'"),
272 Err(PatternError::MissingBrackets)
273 ));
274 }
275
276 #[test]
277 fn test_empty_pattern() {
278 assert!(matches!(
279 validate_pattern("[]"),
280 Err(PatternError::EmptyPattern)
281 ));
282 assert!(matches!(
283 validate_pattern("[ ]"),
284 Err(PatternError::EmptyPattern)
285 ));
286 }
287
288 #[test]
289 fn test_invalid_object_type() {
290 assert!(matches!(
291 validate_pattern("[invalid-type:property = 'value']"),
292 Err(PatternError::InvalidObjectType(_))
293 ));
294 }
295
296 #[test]
297 fn test_missing_colon() {
298 assert!(matches!(
299 validate_pattern("[file-hashes.MD5 = 'abc123']"),
300 Err(PatternError::MissingColon)
301 ));
302 }
303
304 #[test]
305 fn test_pattern_builder() {
306 let pattern = PatternBuilder::new()
307 .compare("file", "hashes.MD5", "=", "'abc123'")
308 .and()
309 .compare("file", "size", ">", "1000")
310 .build();
311
312 assert_eq!(pattern, "[file:hashes.MD5 = 'abc123' AND file:size > 1000]");
313 assert!(validate_pattern(&pattern).is_ok());
314 }
315
316 #[test]
317 fn test_operators() {
318 assert!(validate_pattern("[file:size > 1000]").is_ok());
319 assert!(validate_pattern("[file:size >= 1000]").is_ok());
320 assert!(validate_pattern("[file:size < 1000]").is_ok());
321 assert!(validate_pattern("[file:size <= 1000]").is_ok());
322 assert!(validate_pattern("[file:size != 1000]").is_ok());
323 }
324
325 #[test]
326 fn test_network_traffic_pattern() {
327 assert!(validate_pattern("[network-traffic:src_port = 443]").is_ok());
328 assert!(validate_pattern("[network-traffic:protocols[0] = 'tcp']").is_ok());
329 }
330
331 #[test]
332 fn test_process_pattern() {
333 assert!(validate_pattern("[process:name = 'cmd.exe']").is_ok());
334 assert!(validate_pattern("[process:pid > 100]").is_ok());
335 }
336
337 #[test]
338 fn test_x509_pattern() {
339 assert!(validate_pattern("[x509-certificate:hashes.SHA-256 = 'abc...']").is_ok());
340 assert!(validate_pattern("[x509-certificate:subject = 'CN=Evil Corp']").is_ok());
341 }
342}