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}