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;
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}