Skip to main content

stix_rs/
pattern.rs

1//! STIX Pattern Language Validator
2//!
3//! Validates STIX 2.1 pattern expressions used in Indicator objects.
4//! Patterns follow the format: `[object-type:property comparison-op value]`
5
6use 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
35/// Valid STIX Cyber Observable object types
36const 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
57/// Valid comparison operators in STIX patterns
58const VALID_OPERATORS: &[&str] = &[
59    "=",
60    "!=",
61    ">",
62    ">=",
63    "<",
64    "<=",
65    "IN",
66    "MATCHES",
67    "LIKE",
68    "ISSUBSET",
69    "ISSUPERSET",
70];
71
72/// Valid pattern combiners
73const VALID_COMBINERS: &[&str] = &["AND", "OR", "FOLLOWEDBY"];
74
75/// Validates a STIX pattern string
76///
77/// # Examples
78///
79/// ```
80/// use stix_rs::pattern::validate_pattern;
81///
82/// // Valid patterns
83/// assert!(validate_pattern("[file:hashes.MD5 = 'abc123']").is_ok());
84/// assert!(validate_pattern("[ipv4-addr:value = '192.168.1.1']").is_ok());
85/// assert!(validate_pattern("[file:name = 'malware.exe' AND file:size > 1000]").is_ok());
86///
87/// // Invalid patterns
88/// assert!(validate_pattern("file:hashes.MD5 = 'abc123'").is_err()); // Missing brackets
89/// assert!(validate_pattern("[]").is_err()); // Empty
90/// assert!(validate_pattern("[invalid-type:prop = 'value']").is_err()); // Invalid type
91/// ```
92pub fn validate_pattern(pattern: &str) -> Result<(), PatternError> {
93    let trimmed = pattern.trim();
94
95    // Check for brackets
96    if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
97        return Err(PatternError::MissingBrackets);
98    }
99
100    // Check for balanced brackets
101    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    // Remove outer brackets
108    let inner = &trimmed[1..trimmed.len() - 1].trim();
109
110    if inner.is_empty() {
111        return Err(PatternError::EmptyPattern);
112    }
113
114    // Split by logical operators (AND, OR, FOLLOWEDBY)
115    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
124/// Split pattern by logical combiners while respecting quotes and nested brackets
125fn split_by_combiners(pattern: &str) -> Vec<&str> {
126    // Simple implementation: if no combiners, return the whole pattern
127    // For complex patterns with nested brackets, this would need more sophisticated parsing
128    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            // Check if combiner is not inside quotes
134            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
149/// Check if a position in a string is inside quotes
150fn 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    // If odd number of quotes before position, we're inside quotes
156    single_quotes % 2 != 0 || double_quotes % 2 != 0
157}
158
159/// Validate a single comparison expression
160fn validate_single_comparison(expr: &str) -> Result<(), PatternError> {
161    // Handle nested brackets (for complex expressions)
162    if expr.starts_with('[') && expr.ends_with(']') {
163        return validate_pattern(&format!("[{}]", expr));
164    }
165
166    // Pattern format: object-type:property operator value
167    // Example: file:hashes.MD5 = 'abc123'
168
169    // Find the colon separator
170    let colon_pos = expr.find(':').ok_or(PatternError::MissingColon)?;
171
172    let object_type = expr[..colon_pos].trim();
173
174    // Validate object type
175    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    // Find the operator
182    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
197/// Pattern builder for constructing valid STIX patterns programmatically
198pub struct PatternBuilder {
199    parts: Vec<String>,
200}
201
202impl PatternBuilder {
203    pub fn new() -> Self {
204        Self { parts: Vec::new() }
205    }
206
207    /// Add a comparison expression
208    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    /// Add an AND combiner
221    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    /// Add an OR combiner
229    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    /// Build the final pattern
237    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}