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