Skip to main content

rsigma_eval/matcher/
mod.rs

1//! Compiled matchers for zero-allocation hot-path evaluation.
2//!
3//! Each `CompiledMatcher` variant is pre-compiled at rule load time.
4//! At evaluation time, `matches()` performs the comparison against an
5//! [`EventValue`](crate::event::EventValue) from the event with no
6//! dynamic dispatch or allocation.
7
8mod helpers;
9mod matching;
10
11pub use helpers::{ascii_lowercase_cow, parse_expand_template, sigma_string_to_regex};
12
13use aho_corasick::AhoCorasick;
14use regex::{Regex, RegexSet};
15
16use crate::event::Event;
17use crate::result::MatcherKind;
18use ipnet::IpNet;
19use rsigma_ir::IrStrOp;
20
21/// Upper bound on the length of a `pattern` string recorded in match
22/// detail. Long Aho-Corasick / regex-set joins are truncated with an
23/// ellipsis so a single match cannot bloat the output line.
24const MAX_PATTERN_LEN: usize = 256;
25
26/// A structural description of a compiled matcher, used to populate the
27/// match-detail fields on a [`FieldMatch`](crate::result::FieldMatch).
28///
29/// Purely descriptive: it reports the matcher's shape and pattern, not
30/// which value matched. Composite matchers collapse to
31/// [`MatcherKind::OneOf`] with their child patterns joined.
32#[derive(Debug, Clone)]
33pub struct MatchDescriptor {
34    /// The matcher kind that fired.
35    pub kind: MatcherKind,
36    /// The pattern the matcher tested against, truncated to `MAX_PATTERN_LEN`.
37    pub pattern: Option<String>,
38    /// Whether matching was case-sensitive, when meaningful.
39    pub case_sensitive: Option<bool>,
40    /// Whether the matcher is negated.
41    pub negated: bool,
42}
43
44fn truncate_pattern(s: String) -> String {
45    if s.len() <= MAX_PATTERN_LEN {
46        return s;
47    }
48    let mut end = MAX_PATTERN_LEN.saturating_sub(3);
49    while end > 0 && !s.is_char_boundary(end) {
50        end -= 1;
51    }
52    let mut out = s[..end].to_string();
53    out.push_str("...");
54    out
55}
56
57fn numeric_descriptor(op: &str, n: f64) -> MatchDescriptor {
58    MatchDescriptor {
59        kind: MatcherKind::Numeric,
60        pattern: Some(format!("{op} {n}")),
61        case_sensitive: None,
62        negated: false,
63    }
64}
65
66fn join_child_patterns(children: &[CompiledMatcher]) -> String {
67    children
68        .iter()
69        .filter_map(|c| c.describe().pattern)
70        .collect::<Vec<_>>()
71        .join(", ")
72}
73
74fn expand_template_to_string(parts: &[ExpandPart]) -> String {
75    let mut s = String::new();
76    for part in parts {
77        match part {
78            ExpandPart::Literal(t) => s.push_str(t),
79            ExpandPart::Placeholder(name) => {
80                s.push('%');
81                s.push_str(name);
82                s.push('%');
83            }
84        }
85    }
86    s
87}
88
89/// A pre-compiled matcher for a single value comparison.
90///
91/// All string matchers store their values in the form needed for comparison
92/// (Unicode-lowercased for case-insensitive). The `case_insensitive` flag
93/// controls whether the input is lowercased before comparison.
94#[derive(Debug, Clone)]
95pub enum CompiledMatcher {
96    // -- String matchers --
97    /// Exact string equality.
98    Exact {
99        value: String,
100        case_insensitive: bool,
101    },
102    /// Substring containment.
103    Contains {
104        value: String,
105        case_insensitive: bool,
106    },
107    /// String starts with prefix.
108    StartsWith {
109        value: String,
110        case_insensitive: bool,
111    },
112    /// String ends with suffix.
113    EndsWith {
114        value: String,
115        case_insensitive: bool,
116    },
117    /// Compiled regex pattern (flags baked in at compile time).
118    Regex(Regex),
119
120    /// Multi-pattern substring match via Aho-Corasick automaton.
121    ///
122    /// Built by the optimizer when an `AnyOf` group contains
123    /// `AHO_CORASICK_THRESHOLD` or more plain `Contains` matchers with the
124    /// same case sensitivity. Replaces the sequential O(N * haystack_len)
125    /// scan of `AnyOf([Contains, ...])` with a single linear pass.
126    ///
127    /// **Invariant**: this variant only encodes `AnyOf` (OR) semantics.
128    /// `AllOf(Contains)` (`|all` modifier) MUST NOT be collapsed into this
129    /// variant - the optimizer enforces this.
130    ///
131    /// **Case insensitivity**: when `case_insensitive` is true, needles are
132    /// stored pre-lowered (matching the `Contains` invariant) and the hot
133    /// path lowers the haystack via [`ascii_lowercase_cow`] before searching.
134    /// The `AhoCorasick` automaton itself is built case-sensitively.
135    AhoCorasickSet {
136        automaton: AhoCorasick,
137        case_insensitive: bool,
138        /// Pre-lowered needles in the same order they were fed to
139        /// [`AhoCorasick::new`]. Retained so downstream consumers (e.g. the
140        /// engine's per-field bloom builder) can recover the pattern set
141        /// without parsing the automaton's internal state.
142        needles: Vec<String>,
143    },
144
145    /// Multi-pattern regex match via [`regex::RegexSet`].
146    ///
147    /// Built by the optimizer when an `AnyOf` group contains
148    /// `REGEX_SET_THRESHOLD` or more individual `Regex` matchers. Compiles
149    /// every pattern into a single combined DFA so one traversal of the
150    /// haystack tests all patterns at once.
151    ///
152    /// **Pattern reconstruction**: the optimizer rebuilds the set from each
153    /// matcher's [`Regex::as_str`], which preserves any inline flags the
154    /// compiler inlined (e.g. `(?i)`, `(?ims)`). This relies on the eval
155    /// crate's regex builder always inlining flags into the pattern string
156    /// rather than configuring them via `RegexBuilder`. A unit test guards
157    /// against future drift in that contract.
158    RegexSetMatch { set: RegexSet, mode: GroupMode },
159
160    // -- Network --
161    /// CIDR network match for IP addresses.
162    Cidr(IpNet),
163
164    // -- Numeric --
165    /// Numeric equality.
166    NumericEq(f64),
167    /// Numeric greater-than.
168    NumericGt(f64),
169    /// Numeric greater-than-or-equal.
170    NumericGte(f64),
171    /// Numeric less-than.
172    NumericLt(f64),
173    /// Numeric less-than-or-equal.
174    NumericLte(f64),
175
176    // -- Special --
177    /// Field existence check. `true` = field must exist, `false` = must not exist.
178    Exists(bool),
179    /// Compare against another field's value.
180    ///
181    /// `op` is equality, or a substring, prefix, or suffix check
182    /// (`fieldref` followed by `contains`, `startswith`, or `endswith`).
183    FieldRef {
184        field: String,
185        op: IrStrOp,
186        case_insensitive: bool,
187    },
188    /// Match null / missing values.
189    Null,
190    /// Boolean equality.
191    BoolEq(bool),
192
193    // -- Expand --
194    /// Placeholder expansion: `%fieldname%` is resolved from the event at
195    /// match time and the result is compared with `op`.
196    Expand {
197        template: Vec<ExpandPart>,
198        op: IrStrOp,
199        case_insensitive: bool,
200    },
201
202    // -- Timestamp --
203    /// Extract a time component from a timestamp field value and match it.
204    TimestampPart {
205        part: TimePart,
206        inner: Box<CompiledMatcher>,
207    },
208
209    // -- Negation --
210    /// Negated matcher: matches if the inner matcher does NOT match.
211    Not(Box<CompiledMatcher>),
212
213    // -- Composite --
214    /// Match if ANY child matches (OR).
215    AnyOf(Vec<CompiledMatcher>),
216    /// Match if ALL children match (AND).
217    AllOf(Vec<CompiledMatcher>),
218
219    /// A composite of case-insensitive string matchers that lowers the haystack
220    /// once before dispatching to children.
221    ///
222    /// Built by the optimizer when an `AnyOf` or `AllOf` group is composed
223    /// entirely of case-insensitive string matchers (`Contains`, `StartsWith`,
224    /// `EndsWith`, `Exact`, `AhoCorasickSet`, plus regexes that carry the
225    /// `(?i)` flag, plus `Not` / nested `AnyOf` / `AllOf` whose every leaf
226    /// satisfies these rules).
227    ///
228    /// **Invariant**: every child must be pre-lowerable. The optimizer's
229    /// `is_pre_lowerable` validator enforces this; `matches_pre_lowered`
230    /// `debug_assert!`s on violation.
231    ///
232    /// **Why this exists**: a mixed `AnyOf([Contains, StartsWith, EndsWith])`
233    /// previously called `s.to_lowercase()` once per child. Pre-lowering the
234    /// haystack a single time and dispatching to a CI-aware match path
235    /// eliminates the redundant allocations.
236    CaseInsensitiveGroup {
237        children: Vec<CompiledMatcher>,
238        mode: GroupMode,
239    },
240}
241
242/// Reduction mode for composite matchers.
243///
244/// `Any` corresponds to OR semantics; `All` to AND. Used by
245/// `CaseInsensitiveGroup` to encode whether a single match suffices or every
246/// child must match.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum GroupMode {
249    /// At least one child must match (`AnyOf` semantics).
250    Any,
251    /// Every child must match (`AllOf` semantics).
252    All,
253}
254
255/// A part of an expand template.
256#[derive(Debug, Clone)]
257pub enum ExpandPart {
258    /// Literal text.
259    Literal(String),
260    /// A placeholder field name (between `%` delimiters).
261    Placeholder(String),
262}
263
264/// Which time component to extract from a timestamp.
265#[derive(Debug, Clone, Copy)]
266pub enum TimePart {
267    Minute,
268    Hour,
269    Day,
270    Week,
271    Month,
272    Year,
273}
274
275impl CompiledMatcher {
276    /// Check if this matcher matches any string value in the event.
277    /// Used for keyword detection (field-less matching).
278    ///
279    /// `AllOf` requires every child to match somewhere in the event, not all
280    /// in the same value, and `Not` holds when the inner matcher matches no
281    /// value. Other matchers test each value with `matches_str` through a
282    /// short-circuiting traversal, avoiding a `Vec` of all strings.
283    pub fn matches_keyword(&self, event: &impl Event) -> bool {
284        match self {
285            CompiledMatcher::AllOf(children)
286            | CompiledMatcher::CaseInsensitiveGroup {
287                children,
288                mode: GroupMode::All,
289            } => children.iter().all(|m| m.matches_keyword(event)),
290            CompiledMatcher::Not(inner) => !inner.matches_keyword(event),
291            _ => event.any_string_value(&|s| self.matches_str(s)),
292        }
293    }
294
295    /// Describe this matcher's shape for match-detail reporting.
296    ///
297    /// Runs only when assembling a detection result above
298    /// [`MatchDetailLevel::Off`](crate::result::MatchDetailLevel), never on
299    /// the matching hot path.
300    pub fn describe(&self) -> MatchDescriptor {
301        match self {
302            CompiledMatcher::Exact {
303                value,
304                case_insensitive,
305            } => MatchDescriptor {
306                kind: MatcherKind::Exact,
307                pattern: Some(truncate_pattern(value.clone())),
308                case_sensitive: Some(!case_insensitive),
309                negated: false,
310            },
311            CompiledMatcher::Contains {
312                value,
313                case_insensitive,
314            } => MatchDescriptor {
315                kind: MatcherKind::Contains,
316                pattern: Some(truncate_pattern(value.clone())),
317                case_sensitive: Some(!case_insensitive),
318                negated: false,
319            },
320            CompiledMatcher::StartsWith {
321                value,
322                case_insensitive,
323            } => MatchDescriptor {
324                kind: MatcherKind::StartsWith,
325                pattern: Some(truncate_pattern(value.clone())),
326                case_sensitive: Some(!case_insensitive),
327                negated: false,
328            },
329            CompiledMatcher::EndsWith {
330                value,
331                case_insensitive,
332            } => MatchDescriptor {
333                kind: MatcherKind::EndsWith,
334                pattern: Some(truncate_pattern(value.clone())),
335                case_sensitive: Some(!case_insensitive),
336                negated: false,
337            },
338            CompiledMatcher::Regex(re) => MatchDescriptor {
339                kind: MatcherKind::Regex,
340                pattern: Some(truncate_pattern(re.as_str().to_string())),
341                case_sensitive: None,
342                negated: false,
343            },
344            CompiledMatcher::AhoCorasickSet {
345                needles,
346                case_insensitive,
347                ..
348            } => MatchDescriptor {
349                kind: MatcherKind::OneOf,
350                pattern: Some(truncate_pattern(needles.join(", "))),
351                case_sensitive: Some(!case_insensitive),
352                negated: false,
353            },
354            CompiledMatcher::RegexSetMatch { set, .. } => MatchDescriptor {
355                kind: MatcherKind::OneOf,
356                pattern: Some(truncate_pattern(set.patterns().join(", "))),
357                case_sensitive: None,
358                negated: false,
359            },
360            CompiledMatcher::Cidr(net) => MatchDescriptor {
361                kind: MatcherKind::Cidr,
362                pattern: Some(net.to_string()),
363                case_sensitive: None,
364                negated: false,
365            },
366            CompiledMatcher::NumericEq(n) => numeric_descriptor("=", *n),
367            CompiledMatcher::NumericGt(n) => numeric_descriptor(">", *n),
368            CompiledMatcher::NumericGte(n) => numeric_descriptor(">=", *n),
369            CompiledMatcher::NumericLt(n) => numeric_descriptor("<", *n),
370            CompiledMatcher::NumericLte(n) => numeric_descriptor("<=", *n),
371            CompiledMatcher::Exists(expect) => MatchDescriptor {
372                kind: MatcherKind::Exists,
373                pattern: Some(expect.to_string()),
374                case_sensitive: None,
375                negated: false,
376            },
377            CompiledMatcher::FieldRef {
378                field,
379                case_insensitive,
380                ..
381            } => MatchDescriptor {
382                kind: MatcherKind::FieldRef,
383                pattern: Some(field.clone()),
384                case_sensitive: Some(!case_insensitive),
385                negated: false,
386            },
387            CompiledMatcher::Null => MatchDescriptor {
388                kind: MatcherKind::Null,
389                pattern: None,
390                case_sensitive: None,
391                negated: false,
392            },
393            CompiledMatcher::BoolEq(b) => MatchDescriptor {
394                kind: MatcherKind::Bool,
395                pattern: Some(b.to_string()),
396                case_sensitive: None,
397                negated: false,
398            },
399            CompiledMatcher::Expand {
400                template,
401                case_insensitive,
402                ..
403            } => MatchDescriptor {
404                kind: MatcherKind::Expand,
405                pattern: Some(truncate_pattern(expand_template_to_string(template))),
406                case_sensitive: Some(!case_insensitive),
407                negated: false,
408            },
409            CompiledMatcher::TimestampPart { inner, .. } => {
410                let inner_d = inner.describe();
411                MatchDescriptor {
412                    kind: MatcherKind::Timestamp,
413                    pattern: inner_d.pattern,
414                    case_sensitive: inner_d.case_sensitive,
415                    negated: inner_d.negated,
416                }
417            }
418            CompiledMatcher::Not(inner) => {
419                let mut d = inner.describe();
420                d.negated = !d.negated;
421                d
422            }
423            CompiledMatcher::AnyOf(ms) | CompiledMatcher::AllOf(ms) => MatchDescriptor {
424                kind: MatcherKind::OneOf,
425                pattern: Some(truncate_pattern(join_child_patterns(ms))),
426                case_sensitive: None,
427                negated: false,
428            },
429            CompiledMatcher::CaseInsensitiveGroup { children, .. } => MatchDescriptor {
430                kind: MatcherKind::OneOf,
431                pattern: Some(truncate_pattern(join_child_patterns(children))),
432                case_sensitive: Some(false),
433                negated: false,
434            },
435        }
436    }
437}
438
439#[cfg(test)]
440mod describe_tests {
441    use super::*;
442
443    #[test]
444    fn string_matchers_report_kind_pattern_and_case() {
445        let d = CompiledMatcher::Contains {
446            value: "abc".to_string(),
447            case_insensitive: true,
448        }
449        .describe();
450        assert_eq!(d.kind, MatcherKind::Contains);
451        assert_eq!(d.pattern.as_deref(), Some("abc"));
452        assert_eq!(d.case_sensitive, Some(false));
453        assert!(!d.negated);
454
455        let cased = CompiledMatcher::EndsWith {
456            value: "\\powershell.exe".to_string(),
457            case_insensitive: false,
458        }
459        .describe();
460        assert_eq!(cased.kind, MatcherKind::EndsWith);
461        assert_eq!(cased.case_sensitive, Some(true));
462    }
463
464    #[test]
465    fn numeric_exists_and_null_descriptors() {
466        let gt = CompiledMatcher::NumericGt(5.0).describe();
467        assert_eq!(gt.kind, MatcherKind::Numeric);
468        assert_eq!(gt.pattern.as_deref(), Some("> 5"));
469
470        let exists = CompiledMatcher::Exists(false).describe();
471        assert_eq!(exists.kind, MatcherKind::Exists);
472        assert_eq!(exists.pattern.as_deref(), Some("false"));
473
474        let null = CompiledMatcher::Null.describe();
475        assert_eq!(null.kind, MatcherKind::Null);
476        assert!(null.pattern.is_none());
477    }
478
479    #[test]
480    fn not_inverts_negated_flag_and_keeps_inner_kind() {
481        let inner = CompiledMatcher::Contains {
482            value: "evil".to_string(),
483            case_insensitive: true,
484        };
485        let d = CompiledMatcher::Not(Box::new(inner)).describe();
486        assert_eq!(d.kind, MatcherKind::Contains);
487        assert!(d.negated);
488        assert_eq!(d.pattern.as_deref(), Some("evil"));
489    }
490
491    #[test]
492    fn composite_collapses_to_one_of_with_joined_patterns() {
493        let d = CompiledMatcher::AnyOf(vec![
494            CompiledMatcher::Contains {
495                value: "foo".to_string(),
496                case_insensitive: true,
497            },
498            CompiledMatcher::Contains {
499                value: "bar".to_string(),
500                case_insensitive: true,
501            },
502        ])
503        .describe();
504        assert_eq!(d.kind, MatcherKind::OneOf);
505        assert_eq!(d.pattern.as_deref(), Some("foo, bar"));
506    }
507
508    #[test]
509    fn long_patterns_are_truncated() {
510        let long = "x".repeat(MAX_PATTERN_LEN * 2);
511        let d = CompiledMatcher::Contains {
512            value: long,
513            case_insensitive: true,
514        }
515        .describe();
516        let pattern = d.pattern.unwrap();
517        assert!(pattern.len() <= MAX_PATTERN_LEN);
518        assert!(pattern.ends_with("..."));
519    }
520}