rsigma_eval/engine/mod.rs
1//! Rule evaluation engine with logsource routing.
2//!
3//! The `Engine` manages a set of compiled Sigma rules and evaluates events
4//! against them. It supports optional logsource-based pre-filtering to
5//! reduce the number of rules evaluated per event.
6
7pub(crate) mod bloom_index;
8#[cfg(feature = "daachorse-index")]
9pub(crate) mod cross_rule_ac;
10#[cfg(test)]
11mod diff_tests;
12mod filters;
13#[cfg(test)]
14mod tests;
15
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use rsigma_parser::{
19 ConditionExpr, FilterRule, FilterRuleTarget, LogSource, SigmaCollection, SigmaRule,
20};
21
22use rsigma_ir::{IrRule, LowerOptions, lower_rule};
23
24use crate::candidate_index::CandidateIndex;
25use crate::compiler::{
26 CompiledRule, compile_detection, compile_to_compiled, evaluate_rule_with_bloom,
27};
28use crate::error::{EvalError, Result};
29use crate::event::Event;
30use crate::logsource::LogSourceExtractor;
31use crate::pipeline::{Pipeline, TransformedRule, apply_pipelines, transform_collection};
32use crate::result::{EvaluationResult, MatchDetailLevel};
33use crate::rule_metadata::{RuleBundleMetadata, RuleMetadataLookup};
34
35use bloom_index::{BloomCache, FieldBloomIndex};
36
37pub use filters::apply_filters;
38use filters::{
39 FilterTarget, filter_detection_key, filter_targets, logsource_compatible, logsource_matches,
40 namespaced_filter_condition, stable_references,
41};
42
43/// The main rule evaluation engine.
44///
45/// Holds a set of compiled rules and provides methods to evaluate events
46/// against them. Supports optional logsource routing for performance.
47///
48/// # Example
49///
50/// ```rust
51/// use rsigma_parser::parse_sigma_yaml;
52/// use rsigma_eval::{Engine, Event};
53/// use rsigma_eval::event::JsonEvent;
54/// use serde_json::json;
55///
56/// let yaml = r#"
57/// title: Detect Whoami
58/// logsource:
59/// product: windows
60/// category: process_creation
61/// detection:
62/// selection:
63/// CommandLine|contains: 'whoami'
64/// condition: selection
65/// level: medium
66/// "#;
67///
68/// let collection = parse_sigma_yaml(yaml).unwrap();
69/// let mut engine = Engine::new();
70/// engine.add_collection(&collection).unwrap();
71///
72/// let event_val = json!({"CommandLine": "cmd /c whoami"});
73/// let event = JsonEvent::borrow(&event_val);
74/// let matches = engine.evaluate(&event);
75/// assert_eq!(matches.len(), 1);
76/// assert_eq!(matches[0].header.rule_title, "Detect Whoami");
77/// ```
78#[derive(Debug)]
79pub(crate) struct IdentifiedEvaluation {
80 pub result: EvaluationResult,
81 pub rule_id: Option<String>,
82 pub rule_name: Option<String>,
83}
84
85pub struct Engine {
86 rules: Vec<CompiledRule>,
87 /// Post-pipeline, pre-filter HIR for rules added via the parsed-rule paths,
88 /// retained so [`Engine::save_hir`] can serialize a restart cache. Kept in
89 /// step with `rules` for those paths; rules added via `add_compiled_rule`
90 /// have no HIR and are not represented here.
91 ir_rules: Vec<IrRule>,
92 pipelines: Vec<Pipeline>,
93 /// Global override: include the full event JSON in all match results.
94 /// When `true`, overrides per-rule `rsigma.include_event` custom attributes.
95 include_event: bool,
96 /// Verbosity of the match detail recorded on detection results.
97 /// `Off` by default, which preserves the historical `{ field, value }`
98 /// wire shape. See [`Engine::set_match_detail`].
99 match_detail: MatchDetailLevel,
100 /// Monotonic counter used to namespace injected filter detections,
101 /// preventing key collisions when multiple filters share detection names.
102 filter_counter: usize,
103 /// Witness-based inverted index from event shape to candidate rule
104 /// indices. Rebuilt after every rule mutation (add, filter).
105 rule_index: CandidateIndex,
106 /// Per-field bloom filter over positive substring needles. Rebuilt
107 /// alongside `rule_index`. Consulted only when `bloom_prefilter` is
108 /// enabled.
109 bloom_index: FieldBloomIndex,
110 /// Toggle for bloom pre-filtering. Off by default: the per-event probe
111 /// overhead exceeds the savings on rule sets where most events overlap
112 /// with at least one needle's trigrams. Workloads with many substring
113 /// rules and mostly-non-matching events (e.g. high-volume telemetry
114 /// streams against an active threat-intel ruleset) opt in via
115 /// [`Engine::set_bloom_prefilter`].
116 bloom_prefilter: bool,
117 /// Memory budget the bloom builder is allowed to consume across all
118 /// per-field filters. `None` means use the crate default
119 /// (`bloom_index::DEFAULT_MAX_TOTAL_BYTES`, 1 MB).
120 bloom_max_bytes: Option<usize>,
121 /// Opt-in event-logsource extractor for conflict-based rule pruning.
122 /// `None` (default) leaves the hot path unchanged; when `Some`, the
123 /// engine extracts each event's logsource once and skips rules whose
124 /// logsource conflicts (see [`Engine::set_logsource_extractor`]).
125 logsource_extractor: Option<LogSourceExtractor>,
126 /// Monotonic count of rules skipped because their logsource conflicts
127 /// with the event's, whether pruned by the index's product partitioning
128 /// or by the residual check in the evaluation loop. Incremented only when
129 /// an extractor is set; surfaced via [`Engine::logsource_pruned_total`].
130 logsource_pruned: AtomicU64,
131 /// Monotonic count of `evaluate` calls where the extractor produced no
132 /// logsource at all (fail-open: every rule was evaluated). Surfaced via
133 /// [`Engine::logsource_absent_total`].
134 logsource_absent: AtomicU64,
135 /// Cross-rule Aho-Corasick index for substring patterns, gated on the
136 /// `daachorse-index` feature. Built only when [`cross_rule_ac_enabled`]
137 /// is `true`; [`cross_rule_ac_prunable`] is the conservative per-rule
138 /// flag computed at the same time so the `evaluate` hot path can drop
139 /// rules safely.
140 ///
141 /// [`cross_rule_ac_enabled`]: Self::cross_rule_ac_enabled
142 /// [`cross_rule_ac_prunable`]: Self::cross_rule_ac_prunable
143 #[cfg(feature = "daachorse-index")]
144 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex,
145 /// Toggle for the cross-rule AC pre-filter. Off by default; the index
146 /// only pays off on rule sets > 5K rules with many shared substring
147 /// patterns. See [`Engine::set_cross_rule_ac`].
148 #[cfg(feature = "daachorse-index")]
149 cross_rule_ac_enabled: bool,
150 /// Per-rule conservative AC-prunability flag. `true` iff the rule's
151 /// firing requires at least one positive substring match (no `Exact`,
152 /// `Regex`, `Numeric`, `Not`, etc.), so dropping the rule on a
153 /// "no AC hit" verdict is provably correct.
154 #[cfg(feature = "daachorse-index")]
155 cross_rule_ac_prunable: Vec<bool>,
156}
157
158impl Engine {
159 /// Create a new empty engine.
160 pub fn new() -> Self {
161 Engine {
162 rules: Vec::new(),
163 ir_rules: Vec::new(),
164 pipelines: Vec::new(),
165 include_event: false,
166 match_detail: MatchDetailLevel::Off,
167 filter_counter: 0,
168 rule_index: CandidateIndex::empty(),
169 bloom_index: FieldBloomIndex::empty(),
170 bloom_prefilter: false,
171 bloom_max_bytes: None,
172 logsource_extractor: None,
173 logsource_pruned: AtomicU64::new(0),
174 logsource_absent: AtomicU64::new(0),
175 #[cfg(feature = "daachorse-index")]
176 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex::empty(),
177 #[cfg(feature = "daachorse-index")]
178 cross_rule_ac_enabled: false,
179 #[cfg(feature = "daachorse-index")]
180 cross_rule_ac_prunable: Vec::new(),
181 }
182 }
183
184 /// Create a new engine with a pipeline.
185 pub fn new_with_pipeline(pipeline: Pipeline) -> Self {
186 Engine {
187 rules: Vec::new(),
188 ir_rules: Vec::new(),
189 pipelines: vec![pipeline],
190 include_event: false,
191 match_detail: MatchDetailLevel::Off,
192 filter_counter: 0,
193 rule_index: CandidateIndex::empty(),
194 bloom_index: FieldBloomIndex::empty(),
195 bloom_prefilter: false,
196 bloom_max_bytes: None,
197 logsource_extractor: None,
198 logsource_pruned: AtomicU64::new(0),
199 logsource_absent: AtomicU64::new(0),
200 #[cfg(feature = "daachorse-index")]
201 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex::empty(),
202 #[cfg(feature = "daachorse-index")]
203 cross_rule_ac_enabled: false,
204 #[cfg(feature = "daachorse-index")]
205 cross_rule_ac_prunable: Vec::new(),
206 }
207 }
208
209 /// Enable or disable bloom-filter pre-filtering of positive substring
210 /// detection items.
211 ///
212 /// When enabled, `evaluate*` short-circuits any positive substring
213 /// matcher (`Contains` / `StartsWith` / `EndsWith` / `AhoCorasickSet`,
214 /// alone or wrapped in `CaseInsensitiveGroup`) whose field cannot
215 /// possibly contain a needle trigram.
216 ///
217 /// Disabled by default. The per-event probe (trigram extraction +
218 /// double hashing) costs ~1 µs on a typical CommandLine field, which
219 /// outweighs the savings on rule sets where most events overlap with
220 /// at least one needle. Enable for workloads that pair many substring
221 /// rules with mostly-non-matching events; benchmark with
222 /// `eval_bloom_rejection` before flipping it on in production.
223 pub fn set_bloom_prefilter(&mut self, enabled: bool) {
224 self.bloom_prefilter = enabled;
225 }
226
227 /// Returns whether bloom pre-filtering is currently enabled.
228 pub fn bloom_prefilter_enabled(&self) -> bool {
229 self.bloom_prefilter
230 }
231
232 /// Set the memory budget for the per-field bloom index.
233 ///
234 /// Must be called **before** `add_collection` / `add_rule` for the new
235 /// budget to take effect on the existing rule set; otherwise it is
236 /// applied at the next index rebuild. The default budget is 1 MB,
237 /// shared across all per-field filters. Lower the cap on memory-
238 /// constrained deployments; raise it for large rule sets where the
239 /// default starts evicting useful filters.
240 pub fn set_bloom_max_bytes(&mut self, max_bytes: usize) {
241 self.bloom_max_bytes = Some(max_bytes);
242 if !self.rules.is_empty() {
243 self.rebuild_index();
244 }
245 }
246
247 /// Returns the configured bloom memory budget, if one has been set
248 /// explicitly. `None` means the crate default (1 MB) is in use.
249 pub fn bloom_max_bytes(&self) -> Option<usize> {
250 self.bloom_max_bytes
251 }
252
253 /// Enable or disable opt-in, conflict-based logsource pruning.
254 ///
255 /// When set to `Some`, `evaluate` extracts each event's logsource once via
256 /// the [`LogSourceExtractor`] and skips any candidate rule whose logsource
257 /// conflicts with the event's (a dimension set on both sides that
258 /// disagrees). A dimension unset on either side is a wildcard, so an event
259 /// tagged only `product: windows` skips `product: linux` rules while still
260 /// evaluating Windows-category and logsource-less rules.
261 ///
262 /// Disabled by default (`None`), leaving the hot path unchanged. Pruning
263 /// fails open: an event with no extractable logsource evaluates every
264 /// rule. The extractor is read on every `evaluate` call, so it can be
265 /// swapped at runtime (e.g. carried across a hot-reload).
266 pub fn set_logsource_extractor(&mut self, extractor: Option<LogSourceExtractor>) {
267 self.logsource_extractor = extractor;
268 }
269
270 /// Returns the configured logsource extractor, if any. `None` means
271 /// logsource pruning is disabled.
272 pub fn logsource_extractor(&self) -> Option<&LogSourceExtractor> {
273 self.logsource_extractor.as_ref()
274 }
275
276 /// Total rules skipped because their logsource conflicted with an event's,
277 /// since engine creation. Zero unless an extractor is set.
278 ///
279 /// Counts both sources of conflict pruning: always-evaluated rules the
280 /// candidate index never returns because their `product` bucket conflicts,
281 /// and candidate rules the residual `logsource_compatible` check drops in
282 /// the evaluation loop.
283 pub fn logsource_pruned_total(&self) -> u64 {
284 self.logsource_pruned.load(Ordering::Relaxed)
285 }
286
287 /// Total `evaluate` calls where the extractor produced no logsource and
288 /// pruning failed open (every rule evaluated). Zero unless an extractor
289 /// is set.
290 pub fn logsource_absent_total(&self) -> u64 {
291 self.logsource_absent.load(Ordering::Relaxed)
292 }
293
294 /// Static view of how many loaded rules are eligible (logsource-compatible)
295 /// versus pruned (conflicting) for `event_logsource`, returned as
296 /// `(eligible, pruned)`. Used to report how much of a ruleset a given
297 /// logsource (for example a schema's implied logsource) actually evaluates,
298 /// independent of any specific event's field values.
299 pub fn logsource_eligibility(&self, event_logsource: &LogSource) -> (usize, usize) {
300 let mut eligible = 0;
301 let mut pruned = 0;
302 for rule in &self.rules {
303 if logsource_compatible(&rule.logsource, event_logsource) {
304 eligible += 1;
305 } else {
306 pruned += 1;
307 }
308 }
309 (eligible, pruned)
310 }
311
312 /// Enable or disable the cross-rule Aho-Corasick pre-filter.
313 ///
314 /// When enabled, the engine builds a single per-field
315 /// `DoubleArrayAhoCorasick` automaton over every positive substring
316 /// needle from every rule and drops AC-prunable rules from the
317 /// candidate set when none of their patterns hit the event.
318 ///
319 /// Off by default. Pays off on large rule sets (> ~5K rules) with many
320 /// shared substring patterns (threat-intel feeds, IOC packs). For
321 /// smaller rule sets the per-rule [`AhoCorasickSet`] matcher already
322 /// handles the workload optimally; the cross-rule index only adds
323 /// build-time and lookup overhead. Benchmark with `eval_cross_rule_ac`
324 /// against representative rule sets before enabling in production.
325 ///
326 /// Available behind the `daachorse-index` Cargo feature.
327 ///
328 /// [`AhoCorasickSet`]: crate::matcher::CompiledMatcher::AhoCorasickSet
329 #[cfg(feature = "daachorse-index")]
330 pub fn set_cross_rule_ac(&mut self, enabled: bool) {
331 self.cross_rule_ac_enabled = enabled;
332 if enabled && !self.rules.is_empty() {
333 self.rebuild_index();
334 }
335 }
336
337 /// Returns whether the cross-rule AC pre-filter is currently enabled.
338 /// Available behind the `daachorse-index` Cargo feature.
339 #[cfg(feature = "daachorse-index")]
340 pub fn cross_rule_ac_enabled(&self) -> bool {
341 self.cross_rule_ac_enabled
342 }
343
344 /// Set global `include_event` — when `true`, all match results include
345 /// the full event JSON regardless of per-rule custom attributes.
346 pub fn set_include_event(&mut self, include: bool) {
347 self.include_event = include;
348 }
349
350 /// Set the match-detail verbosity for detection results.
351 ///
352 /// `Off` (default) records each match as `{ field, value }`, identical to
353 /// pre-enrichment releases. `Summary` adds the originating selection, the
354 /// matcher kind, and case sensitivity, and reports keyword and absence
355 /// matches that `Off` omits. `Full` additionally records the pattern that
356 /// fired. The extra work runs only when a rule matches and only above
357 /// `Off`, so the default hot path is unchanged.
358 pub fn set_match_detail(&mut self, level: MatchDetailLevel) {
359 self.match_detail = level;
360 }
361
362 /// Returns the configured match-detail verbosity.
363 pub fn match_detail(&self) -> MatchDetailLevel {
364 self.match_detail
365 }
366
367 /// Add a pipeline to the engine.
368 ///
369 /// Pipelines are applied to rules during `add_rule` / `add_collection`.
370 /// Only affects rules added **after** this call.
371 pub fn add_pipeline(&mut self, pipeline: Pipeline) {
372 self.pipelines.push(pipeline);
373 self.pipelines.sort_by_key(|p| p.priority);
374 }
375
376 /// Add a single parsed Sigma rule.
377 ///
378 /// If pipelines are set, the rule is cloned and transformed before
379 /// compilation. The rule index folds the new rule incrementally; the
380 /// bloom index also folds it incrementally and only triggers a full
381 /// rebuild when its doubling watermark is reached, so this call is
382 /// amortized O(1) per rule. With the `daachorse-index` feature
383 /// enabled **and** the cross-rule AC index turned on at runtime, the
384 /// call falls back to a full rebuild because the daachorse automaton
385 /// has no incremental update path.
386 pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
387 self.compile_and_store(rule)?;
388 self.index_append_last_rule();
389 Ok(())
390 }
391
392 /// Add many parsed Sigma rules in a single batch.
393 ///
394 /// Each rule is compiled (with the engine's pipelines applied, if any)
395 /// and pushed onto the rule set. Compilation errors are collected and
396 /// returned as `(rule_index_in_input, error)` pairs without aborting the
397 /// batch; rules that did compile remain loaded. The inverted index and
398 /// per-field bloom filter are rebuilt **once** at the end of the batch.
399 ///
400 /// Prefer this over a loop of [`Engine::add_rule`] when loading large
401 /// rule sets: the per-call rebuild is O(N) in the total rule count, so
402 /// per-rule adds turn a 3K-rule corpus into O(N²) work.
403 pub fn add_rules<'a, I>(&mut self, rules: I) -> Vec<(usize, EvalError)>
404 where
405 I: IntoIterator<Item = &'a SigmaRule>,
406 {
407 let mut errors = Vec::new();
408 for (idx, rule) in rules.into_iter().enumerate() {
409 if let Err(e) = self.compile_and_store(rule) {
410 errors.push((idx, e));
411 }
412 }
413 self.rebuild_index();
414 errors
415 }
416
417 /// Add all detection rules from a parsed collection, then apply filters.
418 ///
419 /// Filter rules modify referenced detection rules by appending exclusion
420 /// conditions. Correlation rules are handled by `CorrelationEngine`.
421 /// The inverted index is rebuilt once after all rules and filters are loaded.
422 pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
423 for rule in &collection.rules {
424 self.compile_and_store(rule)?;
425 }
426 for filter in &collection.filters {
427 self.apply_filter_no_rebuild(filter)?;
428 }
429 self.rebuild_index();
430 Ok(())
431 }
432
433 /// Lower a rule to HIR (applying any configured pipelines first), compile
434 /// it, and store both the HIR and the compiled rule. Shared by the single-
435 /// and batched-add paths so they stay behaviourally identical, and so the
436 /// retained HIR (`ir_rules`) tracks `rules` for [`Engine::save_hir`].
437 ///
438 /// Both pushes happen only after lowering and compilation succeed, so a
439 /// failing rule leaves neither vector mutated.
440 fn compile_and_store(&mut self, rule: &SigmaRule) -> Result<()> {
441 let ir = self.lower_with_pipelines(rule)?;
442 let compiled = compile_to_compiled(&ir)?;
443 self.ir_rules.push(ir);
444 self.rules.push(compiled);
445 Ok(())
446 }
447
448 /// Lower a rule to HIR, applying any configured pipelines first.
449 fn lower_with_pipelines(&self, rule: &SigmaRule) -> Result<IrRule> {
450 if self.pipelines.is_empty() {
451 Ok(lower_rule(rule, &LowerOptions::default())?)
452 } else {
453 let mut transformed = rule.clone();
454 apply_pipelines(&self.pipelines, &mut transformed)?;
455 Ok(lower_rule(&transformed, &LowerOptions::default())?)
456 }
457 }
458
459 /// Serialize the retained rule HIR to a versioned cache blob (see
460 /// [`rsigma_ir::encode_rules`]).
461 ///
462 /// The blob captures rules added via the parsed-rule paths (`add_rule`,
463 /// `add_rules`, `add_collection`, and the pipeline variants) in
464 /// post-pipeline, pre-filter form. It does **not** capture filter
465 /// injections (applied to the compiled rules) or rules added via
466 /// [`Engine::add_compiled_rule`] / [`Engine::extend_compiled_rules`].
467 /// Re-apply any filters after [`Engine::load_hir`] if the engine used them.
468 pub fn save_hir(&self) -> Result<Vec<u8>> {
469 Ok(rsigma_ir::encode_rules(&self.ir_rules)?)
470 }
471
472 /// Load rules from a HIR cache blob produced by [`Engine::save_hir`],
473 /// compiling each into the engine and rebuilding the indexes once.
474 ///
475 /// Rules are appended to any already loaded, so a warm start loads into a
476 /// fresh [`Engine::new`]. A blob whose schema version differs from this
477 /// build's is rejected (see [`rsigma_ir::decode_rules`]).
478 pub fn load_hir(&mut self, bytes: &[u8]) -> Result<()> {
479 let rules = rsigma_ir::decode_rules(bytes)?;
480 for ir in rules {
481 let compiled = compile_to_compiled(&ir)?;
482 self.ir_rules.push(ir);
483 self.rules.push(compiled);
484 }
485 self.rebuild_index();
486 Ok(())
487 }
488
489 /// Apply this engine's pipelines to `rule` and return the rewritten rule,
490 /// without compiling or loading anything.
491 ///
492 /// Use this to read what the configured pipelines do to a rule: the
493 /// rewritten `logsource`, the conditions a pipeline injected, or which
494 /// transformation ids fired. Loading a rule discards the rewritten Sigma AST
495 /// and keeps only the compiled form, so this is the way to see it.
496 ///
497 /// The engine is not modified and no rule is added. Cost is one clone plus
498 /// one pipeline pass, the same as the load path pays, so call it once per
499 /// rule set load rather than per event. If only the rewritten logsource
500 /// matters, read it from [`Engine::rules`] after loading instead: the
501 /// compiled rules already carry it.
502 pub fn transform_rule(&self, rule: &SigmaRule) -> Result<TransformedRule> {
503 crate::pipeline::transform_rule(&self.pipelines, rule)
504 }
505
506 /// Apply this engine's pipelines to every detection rule in `collection`.
507 ///
508 /// Per-rule equivalent of [`Engine::transform_rule`], in collection order.
509 pub fn transform_collection(
510 &self,
511 collection: &SigmaCollection,
512 ) -> Result<Vec<TransformedRule>> {
513 transform_collection(&self.pipelines, collection)
514 }
515
516 /// Add all detection rules from a collection, applying the given pipelines.
517 ///
518 /// This is a convenience method that temporarily sets pipelines, adds the
519 /// collection, then clears them. The inverted index is rebuilt once after
520 /// all rules and filters are loaded.
521 pub fn add_collection_with_pipelines(
522 &mut self,
523 collection: &SigmaCollection,
524 pipelines: &[Pipeline],
525 ) -> Result<()> {
526 let prev = std::mem::take(&mut self.pipelines);
527 self.pipelines = pipelines.to_vec();
528 self.pipelines.sort_by_key(|p| p.priority);
529 let result = self.add_collection(collection);
530 self.pipelines = prev;
531 result
532 }
533
534 /// Apply a filter rule to all referenced detection rules and rebuild the index.
535 pub fn apply_filter(&mut self, filter: &FilterRule) -> Result<()> {
536 self.apply_filter_no_rebuild(filter)?;
537 self.rebuild_index();
538 Ok(())
539 }
540
541 /// Apply a filter rule without rebuilding the index.
542 /// Used internally when multiple mutations are batched.
543 fn apply_filter_no_rebuild(&mut self, filter: &FilterRule) -> Result<()> {
544 // Compile filter detections
545 let mut filter_detections = Vec::new();
546 for (name, detection) in &filter.detection.named {
547 let compiled = compile_detection(detection)?;
548 filter_detections.push((name.clone(), compiled));
549 }
550
551 if filter_detections.is_empty() {
552 return Ok(());
553 }
554
555 let fc = self.filter_counter;
556 self.filter_counter += 1;
557
558 let identities: Vec<_> = self
559 .rules
560 .iter()
561 .map(|rule| (rule.id.as_deref(), rule.name.as_deref()))
562 .collect();
563 let stable = stable_references(filter, &identities);
564 let rewritten_cond = namespaced_filter_condition(filter, fc);
565
566 let mut matched_any = false;
567 for rule in &mut self.rules {
568 let target = FilterTarget {
569 id: rule.id.as_deref(),
570 name: rule.name.as_deref(),
571 title: &rule.title,
572 logsource: &rule.logsource,
573 };
574 if !filter_targets(filter, &stable, &target) {
575 continue;
576 }
577 for (name, compiled) in &filter_detections {
578 rule.detections
579 .insert(filter_detection_key(fc, name), compiled.clone());
580 }
581 rule.conditions = rule
582 .conditions
583 .iter()
584 .map(|cond| ConditionExpr::And(vec![cond.clone(), rewritten_cond.clone()]))
585 .collect();
586 matched_any = true;
587 }
588
589 if let FilterRuleTarget::Specific(_) = &filter.rules
590 && !matched_any
591 {
592 log::warn!(
593 "filter '{}' references rules {:?} but none matched any loaded rule",
594 filter.title,
595 filter.rules
596 );
597 }
598
599 Ok(())
600 }
601
602 /// Add a pre-compiled rule directly. The rule index folds the new
603 /// rule incrementally; the bloom index also folds it incrementally
604 /// and only triggers a full rebuild when its doubling watermark is
605 /// reached, so this call is amortized O(1) per rule. With the
606 /// cross-rule AC index enabled (`daachorse-index` feature, runtime
607 /// toggle), this falls back to a full rebuild because daachorse has
608 /// no incremental update path.
609 pub fn add_compiled_rule(&mut self, rule: CompiledRule) {
610 self.rules.push(rule);
611 self.index_append_last_rule();
612 }
613
614 /// Add many pre-compiled rules in a single batch. The inverted index
615 /// and bloom filter are rebuilt exactly once at the end, regardless of
616 /// how many rules are appended.
617 pub fn extend_compiled_rules<I>(&mut self, rules: I)
618 where
619 I: IntoIterator<Item = CompiledRule>,
620 {
621 self.rules.extend(rules);
622 self.rebuild_index();
623 }
624
625 /// Rebuild every per-engine index from the current rule set.
626 ///
627 /// Used by batched rule loads (`add_rules`, `extend_compiled_rules`,
628 /// `add_collection`) and by mutations that rewrite existing rules
629 /// (`apply_filter`), where rebuilding once over the final shape is
630 /// cheaper than maintaining incremental state across mutations. The
631 /// single-rule paths use [`Engine::index_append_last_rule`] instead.
632 fn rebuild_index(&mut self) {
633 self.rule_index = CandidateIndex::build(&self.rules);
634 self.bloom_index = match self.bloom_max_bytes {
635 Some(budget) => FieldBloomIndex::build_with_budget(&self.rules, budget),
636 None => FieldBloomIndex::build(&self.rules),
637 };
638 #[cfg(feature = "daachorse-index")]
639 {
640 if self.cross_rule_ac_enabled {
641 self.cross_rule_ac_index = cross_rule_ac::CrossRuleAcIndex::build(&self.rules);
642 self.cross_rule_ac_prunable = self
643 .rules
644 .iter()
645 .map(cross_rule_ac::rule_is_ac_prunable)
646 .collect();
647 } else {
648 self.cross_rule_ac_index = cross_rule_ac::CrossRuleAcIndex::empty();
649 self.cross_rule_ac_prunable.clear();
650 }
651 }
652 }
653
654 /// Fold the rule most recently pushed onto `self.rules` into the
655 /// inverted and bloom indexes incrementally. Cost is bounded by the
656 /// new rule's detection tree size, not by the total rule count.
657 ///
658 /// The bloom index periodically forces a full rebuild via its
659 /// doubling watermark to re-enforce the memory budget and reset the
660 /// FPR drift that incremental inserts accumulate. Cross-rule AC
661 /// (daachorse) has no incremental story, so when it is enabled this
662 /// call falls back to [`Engine::rebuild_index`].
663 fn index_append_last_rule(&mut self) {
664 #[cfg(feature = "daachorse-index")]
665 {
666 if self.cross_rule_ac_enabled {
667 self.rebuild_index();
668 return;
669 }
670 }
671
672 let new_idx = self.rules.len() - 1;
673 let rule = &self.rules[new_idx];
674 self.rule_index.append_rule(new_idx, rule);
675 self.bloom_index.append_rule(rule);
676
677 // Both indexes carry an automaton that cannot absorb a single rule in
678 // place, so each parks the affected rules in a conservative fallback
679 // and clears it at its own doubling watermark.
680 if self.rule_index.should_rebuild(self.rules.len()) {
681 self.rule_index = CandidateIndex::build(&self.rules);
682 }
683 if self.bloom_index.should_rebuild(self.rules.len()) {
684 self.bloom_index = match self.bloom_max_bytes {
685 Some(budget) => FieldBloomIndex::build_with_budget(&self.rules, budget),
686 None => FieldBloomIndex::build(&self.rules),
687 };
688 }
689 }
690
691 /// Evaluate an event against candidate rules using the inverted index.
692 ///
693 /// When a logsource extractor is configured (see
694 /// [`Engine::set_logsource_extractor`]) the event's logsource is derived
695 /// from it and used for conflict-based pruning.
696 pub fn evaluate<E: Event>(&self, event: &E) -> Vec<EvaluationResult> {
697 self.evaluate_identified(event)
698 .into_iter()
699 .map(|identified| identified.result)
700 .collect()
701 }
702
703 pub(crate) fn evaluate_identified<E: Event>(&self, event: &E) -> Vec<IdentifiedEvaluation> {
704 let event_logsource = self
705 .logsource_extractor
706 .as_ref()
707 .map(|ex| ex.extract(event));
708 self.evaluate_identified_inner(event, event_logsource.as_ref())
709 }
710
711 /// Evaluate an event with a caller-resolved event logsource for
712 /// conflict-based pruning, bypassing the engine's own extractor.
713 ///
714 /// The schema router uses this to feed a per-event logsource resolved from
715 /// the event's explicit fields plus the recognized schema's implied
716 /// logsource, so cross-product rules are pruned even when the event carries
717 /// no explicit `product`/`service`/`category` field. Pruning is
718 /// conflict-based: a rule is skipped only when a dimension is set on both
719 /// the rule and `event_logsource` and the values differ.
720 pub fn evaluate_pruned<E: Event>(
721 &self,
722 event: &E,
723 event_logsource: &LogSource,
724 ) -> Vec<EvaluationResult> {
725 self.evaluate_pruned_identified(event, event_logsource)
726 .into_iter()
727 .map(|identified| identified.result)
728 .collect()
729 }
730
731 pub(crate) fn evaluate_pruned_identified<E: Event>(
732 &self,
733 event: &E,
734 event_logsource: &LogSource,
735 ) -> Vec<IdentifiedEvaluation> {
736 self.evaluate_identified_inner(event, Some(event_logsource))
737 }
738
739 fn evaluate_identified_inner<E: Event>(
740 &self,
741 event: &E,
742 event_logsource: Option<&LogSource>,
743 ) -> Vec<IdentifiedEvaluation> {
744 if self.bloom_prefilter {
745 self.evaluate_with_bloom_path(event, event_logsource)
746 } else {
747 self.evaluate_no_bloom_path(event, event_logsource)
748 }
749 }
750
751 /// Build the cross-rule AC keep-mask for `event`, or `None` when the
752 /// cross-rule index is disabled or empty (no filtering needed).
753 ///
754 /// `Some(mask)` answers "should this rule survive the cross-rule AC
755 /// filter": `mask[idx] = true` means keep, `false` means drop.
756 /// Non-AC-prunable rules are always kept.
757 #[cfg(feature = "daachorse-index")]
758 fn cross_rule_ac_keep_mask<E: Event>(&self, event: &E) -> Option<Vec<bool>> {
759 if !self.cross_rule_ac_enabled || self.cross_rule_ac_index.is_empty() {
760 return None;
761 }
762 let mut hits = vec![false; self.rules.len()];
763 self.cross_rule_ac_index.mark_hits(event, &mut hits);
764 // Compose: keep = !ac_prunable OR ac_hit. The prunable vector and
765 // the rule slice are kept aligned by `rebuild_index`.
766 for (idx, slot) in hits.iter_mut().enumerate() {
767 if !self
768 .cross_rule_ac_prunable
769 .get(idx)
770 .copied()
771 .unwrap_or(false)
772 {
773 *slot = true;
774 }
775 }
776 Some(hits)
777 }
778
779 #[cfg(not(feature = "daachorse-index"))]
780 #[inline(always)]
781 fn cross_rule_ac_keep_mask<E: Event>(&self, _event: &E) -> Option<Vec<bool>> {
782 None
783 }
784
785 /// Pick the candidate rule set for `event`. When a logsource extractor
786 /// produced an event logsource, the product-partitioned index drops
787 /// always-evaluated rules of a conflicting product; otherwise the full
788 /// candidate set is returned (zero behaviour change when pruning is off).
789 fn logsource_candidates<E: Event>(
790 &self,
791 event: &E,
792 event_logsource: Option<&LogSource>,
793 ) -> Vec<usize> {
794 match event_logsource {
795 Some(ls) => {
796 // Observability: count the fail-open case (no logsource at all)
797 // and the always-evaluated rules pruned by product conflict.
798 if ls.product.is_none() && ls.service.is_none() && ls.category.is_none() {
799 self.logsource_absent.fetch_add(1, Ordering::Relaxed);
800 }
801 let pruned = self
802 .rule_index
803 .conflicting_unindexable_count(ls.product.as_deref());
804 if pruned > 0 {
805 self.logsource_pruned
806 .fetch_add(pruned as u64, Ordering::Relaxed);
807 }
808 self.rule_index
809 .candidates_with_logsource(event, ls.product.as_deref())
810 }
811 None => self.rule_index.candidates(event),
812 }
813 }
814
815 fn evaluate_no_bloom_path<E: Event>(
816 &self,
817 event: &E,
818 event_logsource: Option<&LogSource>,
819 ) -> Vec<IdentifiedEvaluation> {
820 // Pass the zero-sized `NoBloom` lookup so this monomorphizes to the
821 // same straight-line code as the pre-bloom engine while still
822 // threading the configured match-detail level.
823 let keep = self.cross_rule_ac_keep_mask(event);
824 // `event_logsource` is `None` (the default) unless pruning is enabled,
825 // leaving the loop's behaviour unchanged.
826 let candidates = self.logsource_candidates(event, event_logsource);
827 let mut results = Vec::new();
828 let mut pruned = 0u64;
829 for idx in candidates {
830 if let Some(ref mask) = keep
831 && !mask[idx]
832 {
833 continue;
834 }
835 let rule = &self.rules[idx];
836 if let Some(event_ls) = event_logsource
837 && !logsource_compatible(&rule.logsource, event_ls)
838 {
839 pruned += 1;
840 continue;
841 }
842 if let Some(mut m) =
843 evaluate_rule_with_bloom(rule, event, &bloom_index::NoBloom, self.match_detail)
844 {
845 if self.include_event
846 && let Some(d) = m.as_detection_mut()
847 && d.event.is_none()
848 {
849 d.event = Some(event.to_json());
850 }
851 results.push(IdentifiedEvaluation {
852 result: m,
853 rule_id: rule.id.clone(),
854 rule_name: rule.name.clone(),
855 });
856 }
857 }
858 self.record_logsource_pruned(pruned);
859 results
860 }
861
862 fn evaluate_with_bloom_path<E: Event>(
863 &self,
864 event: &E,
865 event_logsource: Option<&LogSource>,
866 ) -> Vec<IdentifiedEvaluation> {
867 let bloom = BloomCache::new(&self.bloom_index, event);
868 let keep = self.cross_rule_ac_keep_mask(event);
869 // `event_logsource` is `None` (the default) unless pruning is enabled,
870 // leaving the loop's behaviour unchanged.
871 let candidates = self.logsource_candidates(event, event_logsource);
872 let mut results = Vec::new();
873 let mut pruned = 0u64;
874 for idx in candidates {
875 if let Some(ref mask) = keep
876 && !mask[idx]
877 {
878 continue;
879 }
880 let rule = &self.rules[idx];
881 if let Some(event_ls) = event_logsource
882 && !logsource_compatible(&rule.logsource, event_ls)
883 {
884 pruned += 1;
885 continue;
886 }
887 if let Some(mut m) = evaluate_rule_with_bloom(rule, event, &bloom, self.match_detail) {
888 if self.include_event
889 && let Some(d) = m.as_detection_mut()
890 && d.event.is_none()
891 {
892 d.event = Some(event.to_json());
893 }
894 results.push(IdentifiedEvaluation {
895 result: m,
896 rule_id: rule.id.clone(),
897 rule_name: rule.name.clone(),
898 });
899 }
900 }
901 self.record_logsource_pruned(pruned);
902 results
903 }
904
905 /// Fold one event's residual logsource-conflict tally into the counter.
906 ///
907 /// Kept out of the loop so a conflicting rule costs an increment on a
908 /// local rather than an atomic.
909 fn record_logsource_pruned(&self, pruned: u64) {
910 if pruned > 0 {
911 self.logsource_pruned.fetch_add(pruned, Ordering::Relaxed);
912 }
913 }
914
915 /// Evaluate an event against candidate rules matching the given logsource.
916 ///
917 /// Uses the inverted index for candidate pre-filtering, then applies the
918 /// logsource constraint. Only rules whose logsource is compatible with
919 /// `event_logsource` are evaluated.
920 pub fn evaluate_with_logsource<E: Event>(
921 &self,
922 event: &E,
923 event_logsource: &LogSource,
924 ) -> Vec<EvaluationResult> {
925 if self.bloom_prefilter {
926 self.evaluate_with_logsource_with_bloom(event, event_logsource)
927 } else {
928 self.evaluate_with_logsource_no_bloom(event, event_logsource)
929 }
930 }
931
932 fn evaluate_with_logsource_no_bloom<E: Event>(
933 &self,
934 event: &E,
935 event_logsource: &LogSource,
936 ) -> Vec<EvaluationResult> {
937 let keep = self.cross_rule_ac_keep_mask(event);
938 let mut results = Vec::new();
939 for idx in self.rule_index.candidates(event) {
940 if let Some(ref mask) = keep
941 && !mask[idx]
942 {
943 continue;
944 }
945 let rule = &self.rules[idx];
946 if logsource_matches(&rule.logsource, event_logsource)
947 && let Some(mut m) =
948 evaluate_rule_with_bloom(rule, event, &bloom_index::NoBloom, self.match_detail)
949 {
950 if self.include_event
951 && let Some(d) = m.as_detection_mut()
952 && d.event.is_none()
953 {
954 d.event = Some(event.to_json());
955 }
956 results.push(m);
957 }
958 }
959 results
960 }
961
962 fn evaluate_with_logsource_with_bloom<E: Event>(
963 &self,
964 event: &E,
965 event_logsource: &LogSource,
966 ) -> Vec<EvaluationResult> {
967 let bloom = BloomCache::new(&self.bloom_index, event);
968 let keep = self.cross_rule_ac_keep_mask(event);
969 let mut results = Vec::new();
970 for idx in self.rule_index.candidates(event) {
971 if let Some(ref mask) = keep
972 && !mask[idx]
973 {
974 continue;
975 }
976 let rule = &self.rules[idx];
977 if logsource_matches(&rule.logsource, event_logsource)
978 && let Some(mut m) =
979 evaluate_rule_with_bloom(rule, event, &bloom, self.match_detail)
980 {
981 if self.include_event
982 && let Some(d) = m.as_detection_mut()
983 && d.event.is_none()
984 {
985 d.event = Some(event.to_json());
986 }
987 results.push(m);
988 }
989 }
990 results
991 }
992
993 /// Reference evaluator: evaluate every loaded rule, bypassing the
994 /// candidate index, the cross-rule AC keep-mask, and the bloom
995 /// pre-filter.
996 ///
997 /// This is the differential oracle for every pre-filter layer. It is
998 /// authoritative by construction, because it applies nothing but
999 /// [`evaluate_rule_with_bloom`] and the same logsource-conflict rule the
1000 /// production path applies to a surviving candidate. Any disagreement
1001 /// with [`Engine::evaluate`] is therefore a pre-filter bug: a pre-filter
1002 /// may only ever over-approximate the candidate set.
1003 ///
1004 /// Results come back in ascending rule order. Callers comparing against
1005 /// [`Engine::evaluate`] must compare order-insensitively, since candidate
1006 /// iteration order is not part of the engine's contract.
1007 #[cfg(test)]
1008 pub(crate) fn evaluate_full_scan<E: Event>(&self, event: &E) -> Vec<EvaluationResult> {
1009 let event_logsource = self
1010 .logsource_extractor
1011 .as_ref()
1012 .map(|ex| ex.extract(event));
1013 self.full_scan_filtered(event, |rule| match event_logsource.as_ref() {
1014 Some(event_ls) => logsource_compatible(&rule.logsource, event_ls),
1015 None => true,
1016 })
1017 }
1018
1019 /// [`Engine::evaluate_full_scan`] for the caller-resolved-logsource path,
1020 /// mirroring [`Engine::evaluate_pruned`]'s conflict-based filtering.
1021 #[cfg(test)]
1022 pub(crate) fn evaluate_pruned_full_scan<E: Event>(
1023 &self,
1024 event: &E,
1025 event_logsource: &LogSource,
1026 ) -> Vec<EvaluationResult> {
1027 self.full_scan_filtered(event, |rule| {
1028 logsource_compatible(&rule.logsource, event_logsource)
1029 })
1030 }
1031
1032 /// [`Engine::evaluate_full_scan`] for the strict-routing path, mirroring
1033 /// [`Engine::evaluate_with_logsource`]'s use of `logsource_matches`.
1034 #[cfg(test)]
1035 pub(crate) fn evaluate_with_logsource_full_scan<E: Event>(
1036 &self,
1037 event: &E,
1038 event_logsource: &LogSource,
1039 ) -> Vec<EvaluationResult> {
1040 self.full_scan_filtered(event, |rule| {
1041 logsource_matches(&rule.logsource, event_logsource)
1042 })
1043 }
1044
1045 #[cfg(test)]
1046 fn full_scan_filtered<E: Event>(
1047 &self,
1048 event: &E,
1049 keep: impl Fn(&CompiledRule) -> bool,
1050 ) -> Vec<EvaluationResult> {
1051 let mut results = Vec::new();
1052 for rule in &self.rules {
1053 if !keep(rule) {
1054 continue;
1055 }
1056 if let Some(mut m) =
1057 evaluate_rule_with_bloom(rule, event, &bloom_index::NoBloom, self.match_detail)
1058 {
1059 if self.include_event
1060 && let Some(d) = m.as_detection_mut()
1061 && d.event.is_none()
1062 {
1063 d.event = Some(event.to_json());
1064 }
1065 results.push(m);
1066 }
1067 }
1068 results
1069 }
1070
1071 /// Evaluate a batch of events, returning per-event match results.
1072 ///
1073 /// When the `parallel` feature is enabled, events are evaluated concurrently
1074 /// using rayon's work-stealing thread pool. Otherwise, falls back to
1075 /// sequential evaluation.
1076 pub fn evaluate_batch<E: Event + Sync>(&self, events: &[&E]) -> Vec<Vec<EvaluationResult>> {
1077 #[cfg(feature = "parallel")]
1078 {
1079 use rayon::prelude::*;
1080 events.par_iter().map(|e| self.evaluate(e)).collect()
1081 }
1082 #[cfg(not(feature = "parallel"))]
1083 {
1084 events.iter().map(|e| self.evaluate(e)).collect()
1085 }
1086 }
1087
1088 /// Number of rules loaded in the engine.
1089 pub fn rule_count(&self) -> usize {
1090 self.rules.len()
1091 }
1092
1093 /// Access the compiled rules.
1094 pub fn rules(&self) -> &[CompiledRule] {
1095 &self.rules
1096 }
1097
1098 /// Resolve a rule key (an id, or a title for a rule without one) to the
1099 /// documentation of every loaded rule that carries it.
1100 pub fn rule_metadata(&self, key: &str) -> RuleMetadataLookup {
1101 let mut variants = Vec::new();
1102 self.collect_rule_metadata(key, &mut variants);
1103 RuleMetadataLookup::from_variants(variants)
1104 }
1105
1106 pub(crate) fn collect_rule_metadata(&self, key: &str, out: &mut Vec<RuleBundleMetadata>) {
1107 crate::rule_metadata::matching_detections(&self.rules, key, out);
1108 }
1109}
1110
1111impl Default for Engine {
1112 fn default() -> Self {
1113 Self::new()
1114 }
1115}