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
7mod filters;
8#[cfg(test)]
9mod tests;
10
11use rsigma_parser::{
12    ConditionExpr, FilterRule, FilterRuleTarget, LogSource, SigmaCollection, SigmaRule,
13};
14
15use crate::compiler::{CompiledRule, compile_detection, compile_rule, evaluate_rule};
16use crate::error::Result;
17use crate::event::Event;
18use crate::pipeline::{Pipeline, apply_pipelines};
19use crate::result::MatchResult;
20use crate::rule_index::RuleIndex;
21
22use filters::{filter_logsource_contains, logsource_matches, rewrite_condition_identifiers};
23
24/// The main rule evaluation engine.
25///
26/// Holds a set of compiled rules and provides methods to evaluate events
27/// against them. Supports optional logsource routing for performance.
28///
29/// # Example
30///
31/// ```rust
32/// use rsigma_parser::parse_sigma_yaml;
33/// use rsigma_eval::{Engine, Event};
34/// use rsigma_eval::event::JsonEvent;
35/// use serde_json::json;
36///
37/// let yaml = r#"
38/// title: Detect Whoami
39/// logsource:
40///     product: windows
41///     category: process_creation
42/// detection:
43///     selection:
44///         CommandLine|contains: 'whoami'
45///     condition: selection
46/// level: medium
47/// "#;
48///
49/// let collection = parse_sigma_yaml(yaml).unwrap();
50/// let mut engine = Engine::new();
51/// engine.add_collection(&collection).unwrap();
52///
53/// let event_val = json!({"CommandLine": "cmd /c whoami"});
54/// let event = JsonEvent::borrow(&event_val);
55/// let matches = engine.evaluate(&event);
56/// assert_eq!(matches.len(), 1);
57/// assert_eq!(matches[0].rule_title, "Detect Whoami");
58/// ```
59pub struct Engine {
60    rules: Vec<CompiledRule>,
61    pipelines: Vec<Pipeline>,
62    /// Global override: include the full event JSON in all match results.
63    /// When `true`, overrides per-rule `rsigma.include_event` custom attributes.
64    include_event: bool,
65    /// Monotonic counter used to namespace injected filter detections,
66    /// preventing key collisions when multiple filters share detection names.
67    filter_counter: usize,
68    /// Inverted index mapping `(field, exact_value)` to candidate rule indices.
69    /// Rebuilt after every rule mutation (add, filter).
70    rule_index: RuleIndex,
71}
72
73impl Engine {
74    /// Create a new empty engine.
75    pub fn new() -> Self {
76        Engine {
77            rules: Vec::new(),
78            pipelines: Vec::new(),
79            include_event: false,
80            filter_counter: 0,
81            rule_index: RuleIndex::empty(),
82        }
83    }
84
85    /// Create a new engine with a pipeline.
86    pub fn new_with_pipeline(pipeline: Pipeline) -> Self {
87        Engine {
88            rules: Vec::new(),
89            pipelines: vec![pipeline],
90            include_event: false,
91            filter_counter: 0,
92            rule_index: RuleIndex::empty(),
93        }
94    }
95
96    /// Set global `include_event` — when `true`, all match results include
97    /// the full event JSON regardless of per-rule custom attributes.
98    pub fn set_include_event(&mut self, include: bool) {
99        self.include_event = include;
100    }
101
102    /// Add a pipeline to the engine.
103    ///
104    /// Pipelines are applied to rules during `add_rule` / `add_collection`.
105    /// Only affects rules added **after** this call.
106    pub fn add_pipeline(&mut self, pipeline: Pipeline) {
107        self.pipelines.push(pipeline);
108        self.pipelines.sort_by_key(|p| p.priority);
109    }
110
111    /// Add a single parsed Sigma rule.
112    ///
113    /// If pipelines are set, the rule is cloned and transformed before compilation.
114    /// The inverted index is rebuilt after adding the rule.
115    pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
116        let compiled = if self.pipelines.is_empty() {
117            compile_rule(rule)?
118        } else {
119            let mut transformed = rule.clone();
120            apply_pipelines(&self.pipelines, &mut transformed)?;
121            compile_rule(&transformed)?
122        };
123        self.rules.push(compiled);
124        self.rebuild_index();
125        Ok(())
126    }
127
128    /// Add all detection rules from a parsed collection, then apply filters.
129    ///
130    /// Filter rules modify referenced detection rules by appending exclusion
131    /// conditions. Correlation rules are handled by `CorrelationEngine`.
132    /// The inverted index is rebuilt once after all rules and filters are loaded.
133    pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
134        for rule in &collection.rules {
135            let compiled = if self.pipelines.is_empty() {
136                compile_rule(rule)?
137            } else {
138                let mut transformed = rule.clone();
139                apply_pipelines(&self.pipelines, &mut transformed)?;
140                compile_rule(&transformed)?
141            };
142            self.rules.push(compiled);
143        }
144        for filter in &collection.filters {
145            self.apply_filter_no_rebuild(filter)?;
146        }
147        self.rebuild_index();
148        Ok(())
149    }
150
151    /// Add all detection rules from a collection, applying the given pipelines.
152    ///
153    /// This is a convenience method that temporarily sets pipelines, adds the
154    /// collection, then clears them. The inverted index is rebuilt once after
155    /// all rules and filters are loaded.
156    pub fn add_collection_with_pipelines(
157        &mut self,
158        collection: &SigmaCollection,
159        pipelines: &[Pipeline],
160    ) -> Result<()> {
161        let prev = std::mem::take(&mut self.pipelines);
162        self.pipelines = pipelines.to_vec();
163        self.pipelines.sort_by_key(|p| p.priority);
164        let result = self.add_collection(collection);
165        self.pipelines = prev;
166        result
167    }
168
169    /// Apply a filter rule to all referenced detection rules and rebuild the index.
170    pub fn apply_filter(&mut self, filter: &FilterRule) -> Result<()> {
171        self.apply_filter_no_rebuild(filter)?;
172        self.rebuild_index();
173        Ok(())
174    }
175
176    /// Apply a filter rule without rebuilding the index.
177    /// Used internally when multiple mutations are batched.
178    fn apply_filter_no_rebuild(&mut self, filter: &FilterRule) -> Result<()> {
179        // Compile filter detections
180        let mut filter_detections = Vec::new();
181        for (name, detection) in &filter.detection.named {
182            let compiled = compile_detection(detection)?;
183            filter_detections.push((name.clone(), compiled));
184        }
185
186        if filter_detections.is_empty() {
187            return Ok(());
188        }
189
190        let fc = self.filter_counter;
191        self.filter_counter += 1;
192
193        // Rewrite the filter's own condition expression with namespaced identifiers
194        // so that `selection` becomes `__filter_0_selection`, etc.
195        let rewritten_cond = if let Some(cond_expr) = filter.detection.conditions.first() {
196            rewrite_condition_identifiers(cond_expr, fc)
197        } else {
198            // No explicit condition: AND all detections (legacy fallback)
199            if filter_detections.len() == 1 {
200                ConditionExpr::Identifier(format!("__filter_{fc}_{}", filter_detections[0].0))
201            } else {
202                ConditionExpr::And(
203                    filter_detections
204                        .iter()
205                        .map(|(name, _)| ConditionExpr::Identifier(format!("__filter_{fc}_{name}")))
206                        .collect(),
207                )
208            }
209        };
210
211        // Find and modify referenced rules
212        let mut matched_any = false;
213        for rule in &mut self.rules {
214            let rule_matches = match &filter.rules {
215                FilterRuleTarget::Any => true,
216                FilterRuleTarget::Specific(refs) => refs
217                    .iter()
218                    .any(|r| rule.id.as_deref() == Some(r.as_str()) || rule.title == *r),
219            };
220
221            // Also check logsource compatibility if the filter specifies one
222            if rule_matches {
223                if let Some(ref filter_ls) = filter.logsource
224                    && !filter_logsource_contains(filter_ls, &rule.logsource)
225                {
226                    continue;
227                }
228
229                // Inject filter detections into the rule
230                for (name, compiled) in &filter_detections {
231                    rule.detections
232                        .insert(format!("__filter_{fc}_{name}"), compiled.clone());
233                }
234
235                // Wrap each existing rule condition with the filter condition
236                rule.conditions = rule
237                    .conditions
238                    .iter()
239                    .map(|cond| ConditionExpr::And(vec![cond.clone(), rewritten_cond.clone()]))
240                    .collect();
241                matched_any = true;
242            }
243        }
244
245        if let FilterRuleTarget::Specific(_) = &filter.rules
246            && !matched_any
247        {
248            log::warn!(
249                "filter '{}' references rules {:?} but none matched any loaded rule",
250                filter.title,
251                filter.rules
252            );
253        }
254
255        Ok(())
256    }
257
258    /// Add a pre-compiled rule directly and rebuild the index.
259    pub fn add_compiled_rule(&mut self, rule: CompiledRule) {
260        self.rules.push(rule);
261        self.rebuild_index();
262    }
263
264    /// Rebuild the inverted index from the current rule set.
265    fn rebuild_index(&mut self) {
266        self.rule_index = RuleIndex::build(&self.rules);
267    }
268
269    /// Evaluate an event against candidate rules using the inverted index.
270    pub fn evaluate<E: Event>(&self, event: &E) -> Vec<MatchResult> {
271        let mut results = Vec::new();
272        for idx in self.rule_index.candidates(event) {
273            let rule = &self.rules[idx];
274            if let Some(mut m) = evaluate_rule(rule, event) {
275                if self.include_event && m.event.is_none() {
276                    m.event = Some(event.to_json());
277                }
278                results.push(m);
279            }
280        }
281        results
282    }
283
284    /// Evaluate an event against candidate rules matching the given logsource.
285    ///
286    /// Uses the inverted index for candidate pre-filtering, then applies the
287    /// logsource constraint. Only rules whose logsource is compatible with
288    /// `event_logsource` are evaluated.
289    pub fn evaluate_with_logsource<E: Event>(
290        &self,
291        event: &E,
292        event_logsource: &LogSource,
293    ) -> Vec<MatchResult> {
294        let mut results = Vec::new();
295        for idx in self.rule_index.candidates(event) {
296            let rule = &self.rules[idx];
297            if logsource_matches(&rule.logsource, event_logsource)
298                && let Some(mut m) = evaluate_rule(rule, event)
299            {
300                if self.include_event && m.event.is_none() {
301                    m.event = Some(event.to_json());
302                }
303                results.push(m);
304            }
305        }
306        results
307    }
308
309    /// Evaluate a batch of events, returning per-event match results.
310    ///
311    /// When the `parallel` feature is enabled, events are evaluated concurrently
312    /// using rayon's work-stealing thread pool. Otherwise, falls back to
313    /// sequential evaluation.
314    pub fn evaluate_batch<E: Event + Sync>(&self, events: &[&E]) -> Vec<Vec<MatchResult>> {
315        #[cfg(feature = "parallel")]
316        {
317            use rayon::prelude::*;
318            events.par_iter().map(|e| self.evaluate(e)).collect()
319        }
320        #[cfg(not(feature = "parallel"))]
321        {
322            events.iter().map(|e| self.evaluate(e)).collect()
323        }
324    }
325
326    /// Number of rules loaded in the engine.
327    pub fn rule_count(&self) -> usize {
328        self.rules.len()
329    }
330
331    /// Access the compiled rules.
332    pub fn rules(&self) -> &[CompiledRule] {
333        &self.rules
334    }
335}
336
337impl Default for Engine {
338    fn default() -> Self {
339        Self::new()
340    }
341}