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}