Skip to main content

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}