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 rsigma_parser::{
15 ConditionExpr, FilterRule, FilterRuleTarget, LogSource, SigmaCollection, SigmaRule,
16};
17
18use crate::compiler::{CompiledRule, compile_detection, compile_rule, evaluate_rule_with_bloom};
19use crate::error::{EvalError, Result};
20use crate::event::Event;
21use crate::pipeline::{Pipeline, apply_pipelines};
22use crate::result::{EvaluationResult, MatchDetailLevel};
23use crate::rule_index::RuleIndex;
24
25use bloom_index::{BloomCache, FieldBloomIndex};
26
27use filters::{filter_logsource_contains, logsource_matches, rewrite_condition_identifiers};
28
29/// The main rule evaluation engine.
30///
31/// Holds a set of compiled rules and provides methods to evaluate events
32/// against them. Supports optional logsource routing for performance.
33///
34/// # Example
35///
36/// ```rust
37/// use rsigma_parser::parse_sigma_yaml;
38/// use rsigma_eval::{Engine, Event};
39/// use rsigma_eval::event::JsonEvent;
40/// use serde_json::json;
41///
42/// let yaml = r#"
43/// title: Detect Whoami
44/// logsource:
45/// product: windows
46/// category: process_creation
47/// detection:
48/// selection:
49/// CommandLine|contains: 'whoami'
50/// condition: selection
51/// level: medium
52/// "#;
53///
54/// let collection = parse_sigma_yaml(yaml).unwrap();
55/// let mut engine = Engine::new();
56/// engine.add_collection(&collection).unwrap();
57///
58/// let event_val = json!({"CommandLine": "cmd /c whoami"});
59/// let event = JsonEvent::borrow(&event_val);
60/// let matches = engine.evaluate(&event);
61/// assert_eq!(matches.len(), 1);
62/// assert_eq!(matches[0].header.rule_title, "Detect Whoami");
63/// ```
64pub struct Engine {
65 rules: Vec<CompiledRule>,
66 pipelines: Vec<Pipeline>,
67 /// Global override: include the full event JSON in all match results.
68 /// When `true`, overrides per-rule `rsigma.include_event` custom attributes.
69 include_event: bool,
70 /// Verbosity of the match detail recorded on detection results.
71 /// `Off` by default, which preserves the historical `{ field, value }`
72 /// wire shape. See [`Engine::set_match_detail`].
73 match_detail: MatchDetailLevel,
74 /// Monotonic counter used to namespace injected filter detections,
75 /// preventing key collisions when multiple filters share detection names.
76 filter_counter: usize,
77 /// Inverted index mapping `(field, exact_value)` to candidate rule indices.
78 /// Rebuilt after every rule mutation (add, filter).
79 rule_index: RuleIndex,
80 /// Per-field bloom filter over positive substring needles. Rebuilt
81 /// alongside `rule_index`. Consulted only when `bloom_prefilter` is
82 /// enabled.
83 bloom_index: FieldBloomIndex,
84 /// Toggle for bloom pre-filtering. Off by default: the per-event probe
85 /// overhead exceeds the savings on rule sets where most events overlap
86 /// with at least one needle's trigrams. Workloads with many substring
87 /// rules and mostly-non-matching events (e.g. high-volume telemetry
88 /// streams against an active threat-intel ruleset) opt in via
89 /// [`Engine::set_bloom_prefilter`].
90 bloom_prefilter: bool,
91 /// Memory budget the bloom builder is allowed to consume across all
92 /// per-field filters. `None` means use the crate default
93 /// (`bloom_index::DEFAULT_MAX_TOTAL_BYTES`, 1 MB).
94 bloom_max_bytes: Option<usize>,
95 /// Cross-rule Aho-Corasick index for substring patterns, gated on the
96 /// `daachorse-index` feature. Built only when [`cross_rule_ac_enabled`]
97 /// is `true`; [`cross_rule_ac_prunable`] is the conservative per-rule
98 /// flag computed at the same time so the `evaluate` hot path can drop
99 /// rules safely.
100 ///
101 /// [`cross_rule_ac_enabled`]: Self::cross_rule_ac_enabled
102 /// [`cross_rule_ac_prunable`]: Self::cross_rule_ac_prunable
103 #[cfg(feature = "daachorse-index")]
104 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex,
105 /// Toggle for the cross-rule AC pre-filter. Off by default; the index
106 /// only pays off on rule sets > 5K rules with many shared substring
107 /// patterns. See [`Engine::set_cross_rule_ac`].
108 #[cfg(feature = "daachorse-index")]
109 cross_rule_ac_enabled: bool,
110 /// Per-rule conservative AC-prunability flag. `true` iff the rule's
111 /// firing requires at least one positive substring match (no `Exact`,
112 /// `Regex`, `Numeric`, `Not`, etc.), so dropping the rule on a
113 /// "no AC hit" verdict is provably correct.
114 #[cfg(feature = "daachorse-index")]
115 cross_rule_ac_prunable: Vec<bool>,
116}
117
118impl Engine {
119 /// Create a new empty engine.
120 pub fn new() -> Self {
121 Engine {
122 rules: Vec::new(),
123 pipelines: Vec::new(),
124 include_event: false,
125 match_detail: MatchDetailLevel::Off,
126 filter_counter: 0,
127 rule_index: RuleIndex::empty(),
128 bloom_index: FieldBloomIndex::empty(),
129 bloom_prefilter: false,
130 bloom_max_bytes: None,
131 #[cfg(feature = "daachorse-index")]
132 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex::empty(),
133 #[cfg(feature = "daachorse-index")]
134 cross_rule_ac_enabled: false,
135 #[cfg(feature = "daachorse-index")]
136 cross_rule_ac_prunable: Vec::new(),
137 }
138 }
139
140 /// Create a new engine with a pipeline.
141 pub fn new_with_pipeline(pipeline: Pipeline) -> Self {
142 Engine {
143 rules: Vec::new(),
144 pipelines: vec![pipeline],
145 include_event: false,
146 match_detail: MatchDetailLevel::Off,
147 filter_counter: 0,
148 rule_index: RuleIndex::empty(),
149 bloom_index: FieldBloomIndex::empty(),
150 bloom_prefilter: false,
151 bloom_max_bytes: None,
152 #[cfg(feature = "daachorse-index")]
153 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex::empty(),
154 #[cfg(feature = "daachorse-index")]
155 cross_rule_ac_enabled: false,
156 #[cfg(feature = "daachorse-index")]
157 cross_rule_ac_prunable: Vec::new(),
158 }
159 }
160
161 /// Enable or disable bloom-filter pre-filtering of positive substring
162 /// detection items.
163 ///
164 /// When enabled, `evaluate*` short-circuits any positive substring
165 /// matcher (`Contains` / `StartsWith` / `EndsWith` / `AhoCorasickSet`,
166 /// alone or wrapped in `CaseInsensitiveGroup`) whose field cannot
167 /// possibly contain a needle trigram.
168 ///
169 /// Disabled by default. The per-event probe (trigram extraction +
170 /// double hashing) costs ~1 µs on a typical CommandLine field, which
171 /// outweighs the savings on rule sets where most events overlap with
172 /// at least one needle. Enable for workloads that pair many substring
173 /// rules with mostly-non-matching events; benchmark with
174 /// `eval_bloom_rejection` before flipping it on in production.
175 pub fn set_bloom_prefilter(&mut self, enabled: bool) {
176 self.bloom_prefilter = enabled;
177 }
178
179 /// Returns whether bloom pre-filtering is currently enabled.
180 pub fn bloom_prefilter_enabled(&self) -> bool {
181 self.bloom_prefilter
182 }
183
184 /// Set the memory budget for the per-field bloom index.
185 ///
186 /// Must be called **before** `add_collection` / `add_rule` for the new
187 /// budget to take effect on the existing rule set; otherwise it is
188 /// applied at the next index rebuild. The default budget is 1 MB,
189 /// shared across all per-field filters. Lower the cap on memory-
190 /// constrained deployments; raise it for large rule sets where the
191 /// default starts evicting useful filters.
192 pub fn set_bloom_max_bytes(&mut self, max_bytes: usize) {
193 self.bloom_max_bytes = Some(max_bytes);
194 if !self.rules.is_empty() {
195 self.rebuild_index();
196 }
197 }
198
199 /// Returns the configured bloom memory budget, if one has been set
200 /// explicitly. `None` means the crate default (1 MB) is in use.
201 pub fn bloom_max_bytes(&self) -> Option<usize> {
202 self.bloom_max_bytes
203 }
204
205 /// Enable or disable the cross-rule Aho-Corasick pre-filter.
206 ///
207 /// When enabled, the engine builds a single per-field
208 /// `DoubleArrayAhoCorasick` automaton over every positive substring
209 /// needle from every rule and drops AC-prunable rules from the
210 /// candidate set when none of their patterns hit the event.
211 ///
212 /// Off by default. Pays off on large rule sets (> ~5K rules) with many
213 /// shared substring patterns (threat-intel feeds, IOC packs). For
214 /// smaller rule sets the per-rule [`AhoCorasickSet`] matcher already
215 /// handles the workload optimally; the cross-rule index only adds
216 /// build-time and lookup overhead. Benchmark with `eval_cross_rule_ac`
217 /// against representative rule sets before enabling in production.
218 ///
219 /// Available behind the `daachorse-index` Cargo feature.
220 ///
221 /// [`AhoCorasickSet`]: crate::matcher::CompiledMatcher::AhoCorasickSet
222 #[cfg(feature = "daachorse-index")]
223 pub fn set_cross_rule_ac(&mut self, enabled: bool) {
224 self.cross_rule_ac_enabled = enabled;
225 if enabled && !self.rules.is_empty() {
226 self.rebuild_index();
227 }
228 }
229
230 /// Returns whether the cross-rule AC pre-filter is currently enabled.
231 /// Available behind the `daachorse-index` Cargo feature.
232 #[cfg(feature = "daachorse-index")]
233 pub fn cross_rule_ac_enabled(&self) -> bool {
234 self.cross_rule_ac_enabled
235 }
236
237 /// Set global `include_event` — when `true`, all match results include
238 /// the full event JSON regardless of per-rule custom attributes.
239 pub fn set_include_event(&mut self, include: bool) {
240 self.include_event = include;
241 }
242
243 /// Set the match-detail verbosity for detection results.
244 ///
245 /// `Off` (default) records each match as `{ field, value }`, identical to
246 /// pre-enrichment releases. `Summary` adds the originating selection, the
247 /// matcher kind, and case sensitivity, and reports keyword and absence
248 /// matches that `Off` omits. `Full` additionally records the pattern that
249 /// fired. The extra work runs only when a rule matches and only above
250 /// `Off`, so the default hot path is unchanged.
251 pub fn set_match_detail(&mut self, level: MatchDetailLevel) {
252 self.match_detail = level;
253 }
254
255 /// Returns the configured match-detail verbosity.
256 pub fn match_detail(&self) -> MatchDetailLevel {
257 self.match_detail
258 }
259
260 /// Add a pipeline to the engine.
261 ///
262 /// Pipelines are applied to rules during `add_rule` / `add_collection`.
263 /// Only affects rules added **after** this call.
264 pub fn add_pipeline(&mut self, pipeline: Pipeline) {
265 self.pipelines.push(pipeline);
266 self.pipelines.sort_by_key(|p| p.priority);
267 }
268
269 /// Add a single parsed Sigma rule.
270 ///
271 /// If pipelines are set, the rule is cloned and transformed before
272 /// compilation. The rule index folds the new rule incrementally; the
273 /// bloom index also folds it incrementally and only triggers a full
274 /// rebuild when its doubling watermark is reached, so this call is
275 /// amortized O(1) per rule. With the `daachorse-index` feature
276 /// enabled **and** the cross-rule AC index turned on at runtime, the
277 /// call falls back to a full rebuild because the daachorse automaton
278 /// has no incremental update path.
279 pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
280 let compiled = self.compile_with_pipelines(rule)?;
281 self.rules.push(compiled);
282 self.index_append_last_rule();
283 Ok(())
284 }
285
286 /// Add many parsed Sigma rules in a single batch.
287 ///
288 /// Each rule is compiled (with the engine's pipelines applied, if any)
289 /// and pushed onto the rule set. Compilation errors are collected and
290 /// returned as `(rule_index_in_input, error)` pairs without aborting the
291 /// batch; rules that did compile remain loaded. The inverted index and
292 /// per-field bloom filter are rebuilt **once** at the end of the batch.
293 ///
294 /// Prefer this over a loop of [`Engine::add_rule`] when loading large
295 /// rule sets: the per-call rebuild is O(N) in the total rule count, so
296 /// per-rule adds turn a 3K-rule corpus into O(N²) work.
297 pub fn add_rules<'a, I>(&mut self, rules: I) -> Vec<(usize, EvalError)>
298 where
299 I: IntoIterator<Item = &'a SigmaRule>,
300 {
301 let mut errors = Vec::new();
302 for (idx, rule) in rules.into_iter().enumerate() {
303 match self.compile_with_pipelines(rule) {
304 Ok(compiled) => self.rules.push(compiled),
305 Err(e) => errors.push((idx, e)),
306 }
307 }
308 self.rebuild_index();
309 errors
310 }
311
312 /// Add all detection rules from a parsed collection, then apply filters.
313 ///
314 /// Filter rules modify referenced detection rules by appending exclusion
315 /// conditions. Correlation rules are handled by `CorrelationEngine`.
316 /// The inverted index is rebuilt once after all rules and filters are loaded.
317 pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
318 for rule in &collection.rules {
319 let compiled = self.compile_with_pipelines(rule)?;
320 self.rules.push(compiled);
321 }
322 for filter in &collection.filters {
323 self.apply_filter_no_rebuild(filter)?;
324 }
325 self.rebuild_index();
326 Ok(())
327 }
328
329 /// Compile a rule, applying any configured pipelines first. Shared by
330 /// the single- and batched-add paths so they stay behaviourally
331 /// identical.
332 fn compile_with_pipelines(&self, rule: &SigmaRule) -> Result<CompiledRule> {
333 if self.pipelines.is_empty() {
334 compile_rule(rule)
335 } else {
336 let mut transformed = rule.clone();
337 apply_pipelines(&self.pipelines, &mut transformed)?;
338 compile_rule(&transformed)
339 }
340 }
341
342 /// Add all detection rules from a collection, applying the given pipelines.
343 ///
344 /// This is a convenience method that temporarily sets pipelines, adds the
345 /// collection, then clears them. The inverted index is rebuilt once after
346 /// all rules and filters are loaded.
347 pub fn add_collection_with_pipelines(
348 &mut self,
349 collection: &SigmaCollection,
350 pipelines: &[Pipeline],
351 ) -> Result<()> {
352 let prev = std::mem::take(&mut self.pipelines);
353 self.pipelines = pipelines.to_vec();
354 self.pipelines.sort_by_key(|p| p.priority);
355 let result = self.add_collection(collection);
356 self.pipelines = prev;
357 result
358 }
359
360 /// Apply a filter rule to all referenced detection rules and rebuild the index.
361 pub fn apply_filter(&mut self, filter: &FilterRule) -> Result<()> {
362 self.apply_filter_no_rebuild(filter)?;
363 self.rebuild_index();
364 Ok(())
365 }
366
367 /// Apply a filter rule without rebuilding the index.
368 /// Used internally when multiple mutations are batched.
369 fn apply_filter_no_rebuild(&mut self, filter: &FilterRule) -> Result<()> {
370 // Compile filter detections
371 let mut filter_detections = Vec::new();
372 for (name, detection) in &filter.detection.named {
373 let compiled = compile_detection(detection)?;
374 filter_detections.push((name.clone(), compiled));
375 }
376
377 if filter_detections.is_empty() {
378 return Ok(());
379 }
380
381 let fc = self.filter_counter;
382 self.filter_counter += 1;
383
384 // Rewrite the filter's own condition expression with namespaced identifiers
385 // so that `selection` becomes `__filter_0_selection`, etc.
386 let rewritten_cond = if let Some(cond_expr) = filter.detection.conditions.first() {
387 rewrite_condition_identifiers(cond_expr, fc)
388 } else {
389 // No explicit condition: AND all detections (legacy fallback)
390 if filter_detections.len() == 1 {
391 ConditionExpr::Identifier(format!("__filter_{fc}_{}", filter_detections[0].0))
392 } else {
393 ConditionExpr::And(
394 filter_detections
395 .iter()
396 .map(|(name, _)| ConditionExpr::Identifier(format!("__filter_{fc}_{name}")))
397 .collect(),
398 )
399 }
400 };
401
402 // Find and modify referenced rules
403 let mut matched_any = false;
404 for rule in &mut self.rules {
405 let rule_matches = match &filter.rules {
406 FilterRuleTarget::Any => true,
407 FilterRuleTarget::Specific(refs) => refs
408 .iter()
409 .any(|r| rule.id.as_deref() == Some(r.as_str()) || rule.title == *r),
410 };
411
412 // Also check logsource compatibility if the filter specifies one
413 if rule_matches {
414 if let Some(ref filter_ls) = filter.logsource
415 && !filter_logsource_contains(filter_ls, &rule.logsource)
416 {
417 continue;
418 }
419
420 // Inject filter detections into the rule
421 for (name, compiled) in &filter_detections {
422 rule.detections
423 .insert(format!("__filter_{fc}_{name}"), compiled.clone());
424 }
425
426 // Wrap each existing rule condition with the filter condition
427 rule.conditions = rule
428 .conditions
429 .iter()
430 .map(|cond| ConditionExpr::And(vec![cond.clone(), rewritten_cond.clone()]))
431 .collect();
432 matched_any = true;
433 }
434 }
435
436 if let FilterRuleTarget::Specific(_) = &filter.rules
437 && !matched_any
438 {
439 log::warn!(
440 "filter '{}' references rules {:?} but none matched any loaded rule",
441 filter.title,
442 filter.rules
443 );
444 }
445
446 Ok(())
447 }
448
449 /// Add a pre-compiled rule directly. The rule index folds the new
450 /// rule incrementally; the bloom index also folds it incrementally
451 /// and only triggers a full rebuild when its doubling watermark is
452 /// reached, so this call is amortized O(1) per rule. With the
453 /// cross-rule AC index enabled (`daachorse-index` feature, runtime
454 /// toggle), this falls back to a full rebuild because daachorse has
455 /// no incremental update path.
456 pub fn add_compiled_rule(&mut self, rule: CompiledRule) {
457 self.rules.push(rule);
458 self.index_append_last_rule();
459 }
460
461 /// Add many pre-compiled rules in a single batch. The inverted index
462 /// and bloom filter are rebuilt exactly once at the end, regardless of
463 /// how many rules are appended.
464 pub fn extend_compiled_rules<I>(&mut self, rules: I)
465 where
466 I: IntoIterator<Item = CompiledRule>,
467 {
468 self.rules.extend(rules);
469 self.rebuild_index();
470 }
471
472 /// Rebuild every per-engine index from the current rule set.
473 ///
474 /// Used by batched rule loads (`add_rules`, `extend_compiled_rules`,
475 /// `add_collection`) and by mutations that rewrite existing rules
476 /// (`apply_filter`), where rebuilding once over the final shape is
477 /// cheaper than maintaining incremental state across mutations. The
478 /// single-rule paths use [`Engine::index_append_last_rule`] instead.
479 fn rebuild_index(&mut self) {
480 self.rule_index = RuleIndex::build(&self.rules);
481 self.bloom_index = match self.bloom_max_bytes {
482 Some(budget) => FieldBloomIndex::build_with_budget(&self.rules, budget),
483 None => FieldBloomIndex::build(&self.rules),
484 };
485 #[cfg(feature = "daachorse-index")]
486 {
487 if self.cross_rule_ac_enabled {
488 self.cross_rule_ac_index = cross_rule_ac::CrossRuleAcIndex::build(&self.rules);
489 self.cross_rule_ac_prunable = self
490 .rules
491 .iter()
492 .map(cross_rule_ac::rule_is_ac_prunable)
493 .collect();
494 } else {
495 self.cross_rule_ac_index = cross_rule_ac::CrossRuleAcIndex::empty();
496 self.cross_rule_ac_prunable.clear();
497 }
498 }
499 }
500
501 /// Fold the rule most recently pushed onto `self.rules` into the
502 /// inverted and bloom indexes incrementally. Cost is bounded by the
503 /// new rule's detection tree size, not by the total rule count.
504 ///
505 /// The bloom index periodically forces a full rebuild via its
506 /// doubling watermark to re-enforce the memory budget and reset the
507 /// FPR drift that incremental inserts accumulate. Cross-rule AC
508 /// (daachorse) has no incremental story, so when it is enabled this
509 /// call falls back to [`Engine::rebuild_index`].
510 fn index_append_last_rule(&mut self) {
511 #[cfg(feature = "daachorse-index")]
512 {
513 if self.cross_rule_ac_enabled {
514 self.rebuild_index();
515 return;
516 }
517 }
518
519 let new_idx = self.rules.len() - 1;
520 let rule = &self.rules[new_idx];
521 self.rule_index.append_rule(new_idx, rule);
522 self.bloom_index.append_rule(rule);
523
524 if self.bloom_index.should_rebuild(self.rules.len()) {
525 self.bloom_index = match self.bloom_max_bytes {
526 Some(budget) => FieldBloomIndex::build_with_budget(&self.rules, budget),
527 None => FieldBloomIndex::build(&self.rules),
528 };
529 }
530 }
531
532 /// Evaluate an event against candidate rules using the inverted index.
533 pub fn evaluate<E: Event>(&self, event: &E) -> Vec<EvaluationResult> {
534 if self.bloom_prefilter {
535 self.evaluate_with_bloom_path(event)
536 } else {
537 self.evaluate_no_bloom_path(event)
538 }
539 }
540
541 /// Build the cross-rule AC keep-mask for `event`, or `None` when the
542 /// cross-rule index is disabled or empty (no filtering needed).
543 ///
544 /// `Some(mask)` answers "should this rule survive the cross-rule AC
545 /// filter": `mask[idx] = true` means keep, `false` means drop.
546 /// Non-AC-prunable rules are always kept.
547 #[cfg(feature = "daachorse-index")]
548 fn cross_rule_ac_keep_mask<E: Event>(&self, event: &E) -> Option<Vec<bool>> {
549 if !self.cross_rule_ac_enabled || self.cross_rule_ac_index.is_empty() {
550 return None;
551 }
552 let mut hits = vec![false; self.rules.len()];
553 self.cross_rule_ac_index.mark_hits(event, &mut hits);
554 // Compose: keep = !ac_prunable OR ac_hit. The prunable vector and
555 // the rule slice are kept aligned by `rebuild_index`.
556 for (idx, slot) in hits.iter_mut().enumerate() {
557 if !self
558 .cross_rule_ac_prunable
559 .get(idx)
560 .copied()
561 .unwrap_or(false)
562 {
563 *slot = true;
564 }
565 }
566 Some(hits)
567 }
568
569 #[cfg(not(feature = "daachorse-index"))]
570 #[inline(always)]
571 fn cross_rule_ac_keep_mask<E: Event>(&self, _event: &E) -> Option<Vec<bool>> {
572 None
573 }
574
575 fn evaluate_no_bloom_path<E: Event>(&self, event: &E) -> Vec<EvaluationResult> {
576 // Pass the zero-sized `NoBloom` lookup so this monomorphizes to the
577 // same straight-line code as the pre-bloom engine while still
578 // threading the configured match-detail level.
579 let keep = self.cross_rule_ac_keep_mask(event);
580 let mut results = Vec::new();
581 for idx in self.rule_index.candidates(event) {
582 if let Some(ref mask) = keep
583 && !mask[idx]
584 {
585 continue;
586 }
587 let rule = &self.rules[idx];
588 if let Some(mut m) =
589 evaluate_rule_with_bloom(rule, event, &bloom_index::NoBloom, self.match_detail)
590 {
591 if self.include_event
592 && let Some(d) = m.as_detection_mut()
593 && d.event.is_none()
594 {
595 d.event = Some(event.to_json());
596 }
597 results.push(m);
598 }
599 }
600 results
601 }
602
603 fn evaluate_with_bloom_path<E: Event>(&self, event: &E) -> Vec<EvaluationResult> {
604 let bloom = BloomCache::new(&self.bloom_index, event);
605 let keep = self.cross_rule_ac_keep_mask(event);
606 let mut results = Vec::new();
607 for idx in self.rule_index.candidates(event) {
608 if let Some(ref mask) = keep
609 && !mask[idx]
610 {
611 continue;
612 }
613 let rule = &self.rules[idx];
614 if let Some(mut m) = evaluate_rule_with_bloom(rule, event, &bloom, self.match_detail) {
615 if self.include_event
616 && let Some(d) = m.as_detection_mut()
617 && d.event.is_none()
618 {
619 d.event = Some(event.to_json());
620 }
621 results.push(m);
622 }
623 }
624 results
625 }
626
627 /// Evaluate an event against candidate rules matching the given logsource.
628 ///
629 /// Uses the inverted index for candidate pre-filtering, then applies the
630 /// logsource constraint. Only rules whose logsource is compatible with
631 /// `event_logsource` are evaluated.
632 pub fn evaluate_with_logsource<E: Event>(
633 &self,
634 event: &E,
635 event_logsource: &LogSource,
636 ) -> Vec<EvaluationResult> {
637 if self.bloom_prefilter {
638 self.evaluate_with_logsource_with_bloom(event, event_logsource)
639 } else {
640 self.evaluate_with_logsource_no_bloom(event, event_logsource)
641 }
642 }
643
644 fn evaluate_with_logsource_no_bloom<E: Event>(
645 &self,
646 event: &E,
647 event_logsource: &LogSource,
648 ) -> Vec<EvaluationResult> {
649 let keep = self.cross_rule_ac_keep_mask(event);
650 let mut results = Vec::new();
651 for idx in self.rule_index.candidates(event) {
652 if let Some(ref mask) = keep
653 && !mask[idx]
654 {
655 continue;
656 }
657 let rule = &self.rules[idx];
658 if logsource_matches(&rule.logsource, event_logsource)
659 && let Some(mut m) =
660 evaluate_rule_with_bloom(rule, event, &bloom_index::NoBloom, self.match_detail)
661 {
662 if self.include_event
663 && let Some(d) = m.as_detection_mut()
664 && d.event.is_none()
665 {
666 d.event = Some(event.to_json());
667 }
668 results.push(m);
669 }
670 }
671 results
672 }
673
674 fn evaluate_with_logsource_with_bloom<E: Event>(
675 &self,
676 event: &E,
677 event_logsource: &LogSource,
678 ) -> Vec<EvaluationResult> {
679 let bloom = BloomCache::new(&self.bloom_index, event);
680 let keep = self.cross_rule_ac_keep_mask(event);
681 let mut results = Vec::new();
682 for idx in self.rule_index.candidates(event) {
683 if let Some(ref mask) = keep
684 && !mask[idx]
685 {
686 continue;
687 }
688 let rule = &self.rules[idx];
689 if logsource_matches(&rule.logsource, event_logsource)
690 && let Some(mut m) =
691 evaluate_rule_with_bloom(rule, event, &bloom, self.match_detail)
692 {
693 if self.include_event
694 && let Some(d) = m.as_detection_mut()
695 && d.event.is_none()
696 {
697 d.event = Some(event.to_json());
698 }
699 results.push(m);
700 }
701 }
702 results
703 }
704
705 /// Evaluate a batch of events, returning per-event match results.
706 ///
707 /// When the `parallel` feature is enabled, events are evaluated concurrently
708 /// using rayon's work-stealing thread pool. Otherwise, falls back to
709 /// sequential evaluation.
710 pub fn evaluate_batch<E: Event + Sync>(&self, events: &[&E]) -> Vec<Vec<EvaluationResult>> {
711 #[cfg(feature = "parallel")]
712 {
713 use rayon::prelude::*;
714 events.par_iter().map(|e| self.evaluate(e)).collect()
715 }
716 #[cfg(not(feature = "parallel"))]
717 {
718 events.iter().map(|e| self.evaluate(e)).collect()
719 }
720 }
721
722 /// Number of rules loaded in the engine.
723 pub fn rule_count(&self) -> usize {
724 self.rules.len()
725 }
726
727 /// Access the compiled rules.
728 pub fn rules(&self) -> &[CompiledRule] {
729 &self.rules
730 }
731}
732
733impl Default for Engine {
734 fn default() -> Self {
735 Self::new()
736 }
737}