Skip to main content

rsigma_eval/
engine.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
7use rsigma_parser::{ConditionExpr, FilterRule, LogSource, SigmaCollection, SigmaRule};
8
9use crate::compiler::{CompiledRule, compile_detection, compile_rule, evaluate_rule};
10use crate::error::Result;
11use crate::event::Event;
12use crate::pipeline::{Pipeline, apply_pipelines};
13use crate::result::MatchResult;
14use crate::rule_index::RuleIndex;
15
16/// The main rule evaluation engine.
17///
18/// Holds a set of compiled rules and provides methods to evaluate events
19/// against them. Supports optional logsource routing for performance.
20///
21/// # Example
22///
23/// ```rust
24/// use rsigma_parser::parse_sigma_yaml;
25/// use rsigma_eval::{Engine, Event};
26/// use rsigma_eval::event::JsonEvent;
27/// use serde_json::json;
28///
29/// let yaml = r#"
30/// title: Detect Whoami
31/// logsource:
32///     product: windows
33///     category: process_creation
34/// detection:
35///     selection:
36///         CommandLine|contains: 'whoami'
37///     condition: selection
38/// level: medium
39/// "#;
40///
41/// let collection = parse_sigma_yaml(yaml).unwrap();
42/// let mut engine = Engine::new();
43/// engine.add_collection(&collection).unwrap();
44///
45/// let event_val = json!({"CommandLine": "cmd /c whoami"});
46/// let event = JsonEvent::borrow(&event_val);
47/// let matches = engine.evaluate(&event);
48/// assert_eq!(matches.len(), 1);
49/// assert_eq!(matches[0].rule_title, "Detect Whoami");
50/// ```
51pub struct Engine {
52    rules: Vec<CompiledRule>,
53    pipelines: Vec<Pipeline>,
54    /// Global override: include the full event JSON in all match results.
55    /// When `true`, overrides per-rule `rsigma.include_event` custom attributes.
56    include_event: bool,
57    /// Monotonic counter used to namespace injected filter detections,
58    /// preventing key collisions when multiple filters share detection names.
59    filter_counter: usize,
60    /// Inverted index mapping `(field, exact_value)` to candidate rule indices.
61    /// Rebuilt after every rule mutation (add, filter).
62    rule_index: RuleIndex,
63}
64
65impl Engine {
66    /// Create a new empty engine.
67    pub fn new() -> Self {
68        Engine {
69            rules: Vec::new(),
70            pipelines: Vec::new(),
71            include_event: false,
72            filter_counter: 0,
73            rule_index: RuleIndex::empty(),
74        }
75    }
76
77    /// Create a new engine with a pipeline.
78    pub fn new_with_pipeline(pipeline: Pipeline) -> Self {
79        Engine {
80            rules: Vec::new(),
81            pipelines: vec![pipeline],
82            include_event: false,
83            filter_counter: 0,
84            rule_index: RuleIndex::empty(),
85        }
86    }
87
88    /// Set global `include_event` — when `true`, all match results include
89    /// the full event JSON regardless of per-rule custom attributes.
90    pub fn set_include_event(&mut self, include: bool) {
91        self.include_event = include;
92    }
93
94    /// Add a pipeline to the engine.
95    ///
96    /// Pipelines are applied to rules during `add_rule` / `add_collection`.
97    /// Only affects rules added **after** this call.
98    pub fn add_pipeline(&mut self, pipeline: Pipeline) {
99        self.pipelines.push(pipeline);
100        self.pipelines.sort_by_key(|p| p.priority);
101    }
102
103    /// Add a single parsed Sigma rule.
104    ///
105    /// If pipelines are set, the rule is cloned and transformed before compilation.
106    /// The inverted index is rebuilt after adding the rule.
107    pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
108        let compiled = if self.pipelines.is_empty() {
109            compile_rule(rule)?
110        } else {
111            let mut transformed = rule.clone();
112            apply_pipelines(&self.pipelines, &mut transformed)?;
113            compile_rule(&transformed)?
114        };
115        self.rules.push(compiled);
116        self.rebuild_index();
117        Ok(())
118    }
119
120    /// Add all detection rules from a parsed collection, then apply filters.
121    ///
122    /// Filter rules modify referenced detection rules by appending exclusion
123    /// conditions. Correlation rules are handled by `CorrelationEngine`.
124    /// The inverted index is rebuilt once after all rules and filters are loaded.
125    pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
126        for rule in &collection.rules {
127            let compiled = if self.pipelines.is_empty() {
128                compile_rule(rule)?
129            } else {
130                let mut transformed = rule.clone();
131                apply_pipelines(&self.pipelines, &mut transformed)?;
132                compile_rule(&transformed)?
133            };
134            self.rules.push(compiled);
135        }
136        for filter in &collection.filters {
137            self.apply_filter_no_rebuild(filter)?;
138        }
139        self.rebuild_index();
140        Ok(())
141    }
142
143    /// Add all detection rules from a collection, applying the given pipelines.
144    ///
145    /// This is a convenience method that temporarily sets pipelines, adds the
146    /// collection, then clears them. The inverted index is rebuilt once after
147    /// all rules and filters are loaded.
148    pub fn add_collection_with_pipelines(
149        &mut self,
150        collection: &SigmaCollection,
151        pipelines: &[Pipeline],
152    ) -> Result<()> {
153        let prev = std::mem::take(&mut self.pipelines);
154        self.pipelines = pipelines.to_vec();
155        self.pipelines.sort_by_key(|p| p.priority);
156        let result = self.add_collection(collection);
157        self.pipelines = prev;
158        result
159    }
160
161    /// Apply a filter rule to all referenced detection rules and rebuild the index.
162    pub fn apply_filter(&mut self, filter: &FilterRule) -> Result<()> {
163        self.apply_filter_no_rebuild(filter)?;
164        self.rebuild_index();
165        Ok(())
166    }
167
168    /// Apply a filter rule without rebuilding the index.
169    /// Used internally when multiple mutations are batched.
170    fn apply_filter_no_rebuild(&mut self, filter: &FilterRule) -> Result<()> {
171        // Compile filter detections
172        let mut filter_detections = Vec::new();
173        for (name, detection) in &filter.detection.named {
174            let compiled = compile_detection(detection)?;
175            filter_detections.push((name.clone(), compiled));
176        }
177
178        if filter_detections.is_empty() {
179            return Ok(());
180        }
181
182        let fc = self.filter_counter;
183        self.filter_counter += 1;
184
185        // Build the filter condition expression: AND of all filter detections
186        // Keys are namespaced with the filter counter to avoid collisions when
187        // multiple filters share detection names (e.g. both use "selection").
188        let filter_cond = if filter_detections.len() == 1 {
189            ConditionExpr::Identifier(format!("__filter_{fc}_{}", filter_detections[0].0))
190        } else {
191            ConditionExpr::And(
192                filter_detections
193                    .iter()
194                    .map(|(name, _)| ConditionExpr::Identifier(format!("__filter_{fc}_{name}")))
195                    .collect(),
196            )
197        };
198
199        // Find and modify referenced rules
200        let mut matched_any = false;
201        for rule in &mut self.rules {
202            let rule_matches = filter.rules.is_empty() // empty = applies to all
203                || filter.rules.iter().any(|r| {
204                    rule.id.as_deref() == Some(r.as_str())
205                        || rule.title == *r
206                });
207
208            // Also check logsource compatibility if the filter specifies one
209            if rule_matches {
210                if let Some(ref filter_ls) = filter.logsource
211                    && !logsource_compatible(&rule.logsource, filter_ls)
212                {
213                    continue;
214                }
215
216                // Inject filter detections into the rule
217                for (name, compiled) in &filter_detections {
218                    rule.detections
219                        .insert(format!("__filter_{fc}_{name}"), compiled.clone());
220                }
221
222                // Wrap each existing condition: original AND NOT filter
223                rule.conditions = rule
224                    .conditions
225                    .iter()
226                    .map(|cond| {
227                        ConditionExpr::And(vec![
228                            cond.clone(),
229                            ConditionExpr::Not(Box::new(filter_cond.clone())),
230                        ])
231                    })
232                    .collect();
233                matched_any = true;
234            }
235        }
236
237        if !filter.rules.is_empty() && !matched_any {
238            log::warn!(
239                "filter '{}' references rules {:?} but none matched any loaded rule",
240                filter.title,
241                filter.rules
242            );
243        }
244
245        Ok(())
246    }
247
248    /// Add a pre-compiled rule directly and rebuild the index.
249    pub fn add_compiled_rule(&mut self, rule: CompiledRule) {
250        self.rules.push(rule);
251        self.rebuild_index();
252    }
253
254    /// Rebuild the inverted index from the current rule set.
255    fn rebuild_index(&mut self) {
256        self.rule_index = RuleIndex::build(&self.rules);
257    }
258
259    /// Evaluate an event against candidate rules using the inverted index.
260    pub fn evaluate<E: Event>(&self, event: &E) -> Vec<MatchResult> {
261        let mut results = Vec::new();
262        for idx in self.rule_index.candidates(event) {
263            let rule = &self.rules[idx];
264            if let Some(mut m) = evaluate_rule(rule, event) {
265                if self.include_event && m.event.is_none() {
266                    m.event = Some(event.to_json());
267                }
268                results.push(m);
269            }
270        }
271        results
272    }
273
274    /// Evaluate an event against candidate rules matching the given logsource.
275    ///
276    /// Uses the inverted index for candidate pre-filtering, then applies the
277    /// logsource constraint. Only rules whose logsource is compatible with
278    /// `event_logsource` are evaluated.
279    pub fn evaluate_with_logsource<E: Event>(
280        &self,
281        event: &E,
282        event_logsource: &LogSource,
283    ) -> Vec<MatchResult> {
284        let mut results = Vec::new();
285        for idx in self.rule_index.candidates(event) {
286            let rule = &self.rules[idx];
287            if logsource_matches(&rule.logsource, event_logsource)
288                && let Some(mut m) = evaluate_rule(rule, event)
289            {
290                if self.include_event && m.event.is_none() {
291                    m.event = Some(event.to_json());
292                }
293                results.push(m);
294            }
295        }
296        results
297    }
298
299    /// Evaluate a batch of events, returning per-event match results.
300    ///
301    /// When the `parallel` feature is enabled, events are evaluated concurrently
302    /// using rayon's work-stealing thread pool. Otherwise, falls back to
303    /// sequential evaluation.
304    pub fn evaluate_batch<E: Event + Sync>(&self, events: &[&E]) -> Vec<Vec<MatchResult>> {
305        #[cfg(feature = "parallel")]
306        {
307            use rayon::prelude::*;
308            events.par_iter().map(|e| self.evaluate(e)).collect()
309        }
310        #[cfg(not(feature = "parallel"))]
311        {
312            events.iter().map(|e| self.evaluate(e)).collect()
313        }
314    }
315
316    /// Number of rules loaded in the engine.
317    pub fn rule_count(&self) -> usize {
318        self.rules.len()
319    }
320
321    /// Access the compiled rules.
322    pub fn rules(&self) -> &[CompiledRule] {
323        &self.rules
324    }
325}
326
327impl Default for Engine {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333/// Check if a rule's logsource is compatible with an event's logsource.
334///
335/// The rule matches if every non-`None` field in the rule's logsource has
336/// the same value in the event's logsource. Fields the rule doesn't specify
337/// are ignored (wildcard).
338/// Symmetric compatibility check: two logsources are compatible if every field
339/// that *both* specify has the same value (case-insensitive). Fields that only
340/// one side specifies are ignored — e.g. a filter with `product: windows` is
341/// compatible with a rule that has `category: process_creation, product: windows`.
342fn logsource_compatible(a: &LogSource, b: &LogSource) -> bool {
343    fn field_compatible(a: &Option<String>, b: &Option<String>) -> bool {
344        match (a, b) {
345            (Some(va), Some(vb)) => va.eq_ignore_ascii_case(vb),
346            _ => true, // one or both unspecified — no conflict
347        }
348    }
349
350    field_compatible(&a.category, &b.category)
351        && field_compatible(&a.product, &b.product)
352        && field_compatible(&a.service, &b.service)
353}
354
355/// Asymmetric check: every field specified in `rule_ls` must be present and
356/// match in `event_ls`. Used for routing events to rules by logsource.
357fn logsource_matches(rule_ls: &LogSource, event_ls: &LogSource) -> bool {
358    if let Some(ref cat) = rule_ls.category {
359        match &event_ls.category {
360            Some(ec) if ec.eq_ignore_ascii_case(cat) => {}
361            _ => return false,
362        }
363    }
364    if let Some(ref prod) = rule_ls.product {
365        match &event_ls.product {
366            Some(ep) if ep.eq_ignore_ascii_case(prod) => {}
367            _ => return false,
368        }
369    }
370    if let Some(ref svc) = rule_ls.service {
371        match &event_ls.service {
372            Some(es) if es.eq_ignore_ascii_case(svc) => {}
373            _ => return false,
374        }
375    }
376    true
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::event::JsonEvent;
383    use rsigma_parser::parse_sigma_yaml;
384    use serde_json::json;
385
386    fn make_engine_with_rule(yaml: &str) -> Engine {
387        let collection = parse_sigma_yaml(yaml).unwrap();
388        let mut engine = Engine::new();
389        engine.add_collection(&collection).unwrap();
390        engine
391    }
392
393    #[test]
394    fn test_simple_match() {
395        let engine = make_engine_with_rule(
396            r#"
397title: Detect Whoami
398logsource:
399    product: windows
400    category: process_creation
401detection:
402    selection:
403        CommandLine|contains: 'whoami'
404    condition: selection
405level: medium
406"#,
407        );
408
409        let ev = json!({"CommandLine": "cmd /c whoami /all"});
410        let event = JsonEvent::borrow(&ev);
411        let matches = engine.evaluate(&event);
412        assert_eq!(matches.len(), 1);
413        assert_eq!(matches[0].rule_title, "Detect Whoami");
414    }
415
416    #[test]
417    fn test_no_match() {
418        let engine = make_engine_with_rule(
419            r#"
420title: Detect Whoami
421logsource:
422    product: windows
423    category: process_creation
424detection:
425    selection:
426        CommandLine|contains: 'whoami'
427    condition: selection
428level: medium
429"#,
430        );
431
432        let ev = json!({"CommandLine": "ipconfig /all"});
433        let event = JsonEvent::borrow(&ev);
434        let matches = engine.evaluate(&event);
435        assert!(matches.is_empty());
436    }
437
438    #[test]
439    fn test_and_not_filter() {
440        let engine = make_engine_with_rule(
441            r#"
442title: Suspicious Process
443logsource:
444    product: windows
445detection:
446    selection:
447        CommandLine|contains: 'whoami'
448    filter:
449        User: 'SYSTEM'
450    condition: selection and not filter
451level: high
452"#,
453        );
454
455        // Match: whoami by non-SYSTEM user
456        let ev = json!({"CommandLine": "whoami", "User": "admin"});
457        let event = JsonEvent::borrow(&ev);
458        assert_eq!(engine.evaluate(&event).len(), 1);
459
460        // No match: whoami by SYSTEM
461        let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
462        let event2 = JsonEvent::borrow(&ev2);
463        assert!(engine.evaluate(&event2).is_empty());
464    }
465
466    #[test]
467    fn test_multiple_values_or() {
468        let engine = make_engine_with_rule(
469            r#"
470title: Recon Commands
471logsource:
472    product: windows
473detection:
474    selection:
475        CommandLine|contains:
476            - 'whoami'
477            - 'ipconfig'
478            - 'net user'
479    condition: selection
480level: medium
481"#,
482        );
483
484        let ev = json!({"CommandLine": "ipconfig /all"});
485        let event = JsonEvent::borrow(&ev);
486        assert_eq!(engine.evaluate(&event).len(), 1);
487
488        let ev2 = json!({"CommandLine": "dir"});
489        let event2 = JsonEvent::borrow(&ev2);
490        assert!(engine.evaluate(&event2).is_empty());
491    }
492
493    #[test]
494    fn test_logsource_routing() {
495        let engine = make_engine_with_rule(
496            r#"
497title: Windows Process
498logsource:
499    product: windows
500    category: process_creation
501detection:
502    selection:
503        CommandLine|contains: 'whoami'
504    condition: selection
505level: medium
506"#,
507        );
508
509        let ev = json!({"CommandLine": "whoami"});
510        let event = JsonEvent::borrow(&ev);
511
512        // Matching logsource
513        let ls_match = LogSource {
514            product: Some("windows".into()),
515            category: Some("process_creation".into()),
516            ..Default::default()
517        };
518        assert_eq!(engine.evaluate_with_logsource(&event, &ls_match).len(), 1);
519
520        // Non-matching logsource
521        let ls_nomatch = LogSource {
522            product: Some("linux".into()),
523            category: Some("process_creation".into()),
524            ..Default::default()
525        };
526        assert!(
527            engine
528                .evaluate_with_logsource(&event, &ls_nomatch)
529                .is_empty()
530        );
531    }
532
533    #[test]
534    fn test_selector_1_of() {
535        let engine = make_engine_with_rule(
536            r#"
537title: Multiple Selections
538logsource:
539    product: windows
540detection:
541    selection_cmd:
542        CommandLine|contains: 'cmd'
543    selection_ps:
544        CommandLine|contains: 'powershell'
545    condition: 1 of selection_*
546level: medium
547"#,
548        );
549
550        let ev = json!({"CommandLine": "powershell.exe -enc"});
551        let event = JsonEvent::borrow(&ev);
552        assert_eq!(engine.evaluate(&event).len(), 1);
553    }
554
555    #[test]
556    fn test_filter_rule_application() {
557        // A filter rule that excludes SYSTEM user from the detection
558        let yaml = r#"
559title: Suspicious Process
560id: rule-001
561logsource:
562    product: windows
563    category: process_creation
564detection:
565    selection:
566        CommandLine|contains: 'whoami'
567    condition: selection
568level: high
569---
570title: Filter SYSTEM
571filter:
572    rules:
573        - rule-001
574    selection:
575        User: 'SYSTEM'
576    condition: selection
577"#;
578        let collection = parse_sigma_yaml(yaml).unwrap();
579        assert_eq!(collection.rules.len(), 1);
580        assert_eq!(collection.filters.len(), 1);
581
582        let mut engine = Engine::new();
583        engine.add_collection(&collection).unwrap();
584
585        // Match: whoami by non-SYSTEM user
586        let ev = json!({"CommandLine": "whoami", "User": "admin"});
587        let event = JsonEvent::borrow(&ev);
588        assert_eq!(engine.evaluate(&event).len(), 1);
589
590        // No match: whoami by SYSTEM (filtered out)
591        let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
592        let event2 = JsonEvent::borrow(&ev2);
593        assert!(engine.evaluate(&event2).is_empty());
594    }
595
596    #[test]
597    fn test_filter_rule_no_ref_applies_to_all() {
598        // A filter rule with empty `rules` applies to all rules
599        let yaml = r#"
600title: Detection A
601id: det-a
602logsource:
603    product: windows
604detection:
605    sel:
606        EventType: alert
607    condition: sel
608---
609title: Filter Out Test Env
610filter:
611    rules: []
612    selection:
613        Environment: 'test'
614    condition: selection
615"#;
616        let collection = parse_sigma_yaml(yaml).unwrap();
617        let mut engine = Engine::new();
618        engine.add_collection(&collection).unwrap();
619
620        let ev = json!({"EventType": "alert", "Environment": "prod"});
621        let event = JsonEvent::borrow(&ev);
622        assert_eq!(engine.evaluate(&event).len(), 1);
623
624        let ev2 = json!({"EventType": "alert", "Environment": "test"});
625        let event2 = JsonEvent::borrow(&ev2);
626        assert!(engine.evaluate(&event2).is_empty());
627    }
628
629    #[test]
630    fn test_multiple_rules() {
631        let yaml = r#"
632title: Rule A
633logsource:
634    product: windows
635detection:
636    selection:
637        CommandLine|contains: 'whoami'
638    condition: selection
639level: low
640---
641title: Rule B
642logsource:
643    product: windows
644detection:
645    selection:
646        CommandLine|contains: 'ipconfig'
647    condition: selection
648level: low
649"#;
650        let collection = parse_sigma_yaml(yaml).unwrap();
651        let mut engine = Engine::new();
652        engine.add_collection(&collection).unwrap();
653        assert_eq!(engine.rule_count(), 2);
654
655        // Only Rule A matches
656        let ev = json!({"CommandLine": "whoami"});
657        let event = JsonEvent::borrow(&ev);
658        let matches = engine.evaluate(&event);
659        assert_eq!(matches.len(), 1);
660        assert_eq!(matches[0].rule_title, "Rule A");
661    }
662
663    // =========================================================================
664    // Filter rule edge cases
665    // =========================================================================
666
667    #[test]
668    fn test_filter_by_rule_name() {
669        // Filter that references a rule by title (not ID)
670        let yaml = r#"
671title: Detect Mimikatz
672logsource:
673    product: windows
674detection:
675    selection:
676        CommandLine|contains: 'mimikatz'
677    condition: selection
678level: critical
679---
680title: Exclude Admin Tools
681filter:
682    rules:
683        - Detect Mimikatz
684    selection:
685        ParentImage|endswith: '\admin_toolkit.exe'
686    condition: selection
687"#;
688        let collection = parse_sigma_yaml(yaml).unwrap();
689        let mut engine = Engine::new();
690        engine.add_collection(&collection).unwrap();
691
692        // Match: mimikatz not launched by admin toolkit
693        let ev = json!({"CommandLine": "mimikatz.exe", "ParentImage": "C:\\cmd.exe"});
694        let event = JsonEvent::borrow(&ev);
695        assert_eq!(engine.evaluate(&event).len(), 1);
696
697        // No match: mimikatz launched by admin toolkit (filtered)
698        let ev2 = json!({"CommandLine": "mimikatz.exe", "ParentImage": "C:\\admin_toolkit.exe"});
699        let event2 = JsonEvent::borrow(&ev2);
700        assert!(engine.evaluate(&event2).is_empty());
701    }
702
703    #[test]
704    fn test_filter_multiple_detections() {
705        // Filter with multiple detection items (AND)
706        let yaml = r#"
707title: Suspicious Network
708id: net-001
709logsource:
710    product: windows
711detection:
712    selection:
713        DestinationPort: 443
714    condition: selection
715level: medium
716---
717title: Exclude Trusted
718filter:
719    rules:
720        - net-001
721    trusted_dst:
722        DestinationIp|startswith: '10.'
723    trusted_user:
724        User: 'svc_account'
725    condition: trusted_dst and trusted_user
726"#;
727        let collection = parse_sigma_yaml(yaml).unwrap();
728        let mut engine = Engine::new();
729        engine.add_collection(&collection).unwrap();
730
731        // Match: port 443 to external IP
732        let ev = json!({"DestinationPort": 443, "DestinationIp": "8.8.8.8", "User": "admin"});
733        let event = JsonEvent::borrow(&ev);
734        assert_eq!(engine.evaluate(&event).len(), 1);
735
736        // Match: port 443 to internal IP but different user (filter needs both)
737        let ev2 = json!({"DestinationPort": 443, "DestinationIp": "10.0.0.1", "User": "admin"});
738        let event2 = JsonEvent::borrow(&ev2);
739        assert_eq!(engine.evaluate(&event2).len(), 1);
740
741        // No match: port 443 to internal IP by svc_account (both filter conditions met)
742        let ev3 =
743            json!({"DestinationPort": 443, "DestinationIp": "10.0.0.1", "User": "svc_account"});
744        let event3 = JsonEvent::borrow(&ev3);
745        assert!(engine.evaluate(&event3).is_empty());
746    }
747
748    #[test]
749    fn test_filter_applied_to_multiple_rules() {
750        // Filter with empty rules list applies to all rules
751        let yaml = r#"
752title: Rule One
753id: r1
754logsource:
755    product: windows
756detection:
757    sel:
758        EventID: 1
759    condition: sel
760---
761title: Rule Two
762id: r2
763logsource:
764    product: windows
765detection:
766    sel:
767        EventID: 2
768    condition: sel
769---
770title: Exclude Test
771filter:
772    rules: []
773    selection:
774        Environment: 'test'
775    condition: selection
776"#;
777        let collection = parse_sigma_yaml(yaml).unwrap();
778        let mut engine = Engine::new();
779        engine.add_collection(&collection).unwrap();
780
781        // In prod: both rules should fire
782        let ev1 = json!({"EventID": 1, "Environment": "prod"});
783        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev1)).len(), 1);
784        let ev2 = json!({"EventID": 2, "Environment": "prod"});
785        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev2)).len(), 1);
786
787        // In test: both filtered out
788        let ev3 = json!({"EventID": 1, "Environment": "test"});
789        assert!(engine.evaluate(&JsonEvent::borrow(&ev3)).is_empty());
790        let ev4 = json!({"EventID": 2, "Environment": "test"});
791        assert!(engine.evaluate(&JsonEvent::borrow(&ev4)).is_empty());
792    }
793
794    // =========================================================================
795    // Expand modifier end-to-end
796    // =========================================================================
797
798    #[test]
799    fn test_expand_modifier_yaml() {
800        let yaml = r#"
801title: User Profile Access
802logsource:
803    product: windows
804detection:
805    selection:
806        TargetFilename|expand: 'C:\Users\%username%\AppData\sensitive.dat'
807    condition: selection
808level: high
809"#;
810        let engine = make_engine_with_rule(yaml);
811
812        // Match: path matches after expanding %username% from the event
813        let ev = json!({
814            "TargetFilename": "C:\\Users\\admin\\AppData\\sensitive.dat",
815            "username": "admin"
816        });
817        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
818
819        // No match: different user
820        let ev2 = json!({
821            "TargetFilename": "C:\\Users\\admin\\AppData\\sensitive.dat",
822            "username": "guest"
823        });
824        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
825    }
826
827    #[test]
828    fn test_expand_modifier_multiple_placeholders() {
829        let yaml = r#"
830title: Registry Path
831logsource:
832    product: windows
833detection:
834    selection:
835        RegistryKey|expand: 'HKLM\SOFTWARE\%vendor%\%product%'
836    condition: selection
837level: medium
838"#;
839        let engine = make_engine_with_rule(yaml);
840
841        let ev = json!({
842            "RegistryKey": "HKLM\\SOFTWARE\\Acme\\Widget",
843            "vendor": "Acme",
844            "product": "Widget"
845        });
846        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
847
848        let ev2 = json!({
849            "RegistryKey": "HKLM\\SOFTWARE\\Acme\\Widget",
850            "vendor": "Other",
851            "product": "Widget"
852        });
853        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
854    }
855
856    // =========================================================================
857    // Timestamp modifier end-to-end
858    // =========================================================================
859
860    #[test]
861    fn test_timestamp_hour_modifier_yaml() {
862        let yaml = r#"
863title: Off-Hours Login
864logsource:
865    product: windows
866detection:
867    selection:
868        EventType: 'login'
869    time_filter:
870        Timestamp|hour: 3
871    condition: selection and time_filter
872level: high
873"#;
874        let engine = make_engine_with_rule(yaml);
875
876        // Match: login at 03:xx UTC
877        let ev = json!({"EventType": "login", "Timestamp": "2024-07-10T03:45:00Z"});
878        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
879
880        // No match: login at 14:xx UTC
881        let ev2 = json!({"EventType": "login", "Timestamp": "2024-07-10T14:45:00Z"});
882        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
883    }
884
885    #[test]
886    fn test_timestamp_day_modifier_yaml() {
887        let yaml = r#"
888title: Weekend Activity
889logsource:
890    product: windows
891detection:
892    selection:
893        EventType: 'access'
894    day_check:
895        CreatedAt|day: 25
896    condition: selection and day_check
897level: medium
898"#;
899        let engine = make_engine_with_rule(yaml);
900
901        let ev = json!({"EventType": "access", "CreatedAt": "2024-12-25T10:00:00Z"});
902        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
903
904        let ev2 = json!({"EventType": "access", "CreatedAt": "2024-12-26T10:00:00Z"});
905        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
906    }
907
908    #[test]
909    fn test_timestamp_year_modifier_yaml() {
910        let yaml = r#"
911title: Legacy System
912logsource:
913    product: windows
914detection:
915    selection:
916        EventType: 'auth'
917    old_events:
918        EventTime|year: 2020
919    condition: selection and old_events
920level: low
921"#;
922        let engine = make_engine_with_rule(yaml);
923
924        let ev = json!({"EventType": "auth", "EventTime": "2020-06-15T10:00:00Z"});
925        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
926
927        let ev2 = json!({"EventType": "auth", "EventTime": "2024-06-15T10:00:00Z"});
928        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
929    }
930
931    // =========================================================================
932    // action: repeat through engine
933    // =========================================================================
934
935    #[test]
936    fn test_action_repeat_evaluates_correctly() {
937        // Two rules via repeat: same logsource, different detections
938        let yaml = r#"
939title: Detect Whoami
940logsource:
941    product: windows
942    category: process_creation
943detection:
944    selection:
945        CommandLine|contains: 'whoami'
946    condition: selection
947level: medium
948---
949action: repeat
950title: Detect Ipconfig
951detection:
952    selection:
953        CommandLine|contains: 'ipconfig'
954    condition: selection
955"#;
956        let collection = parse_sigma_yaml(yaml).unwrap();
957        assert_eq!(collection.rules.len(), 2);
958
959        let mut engine = Engine::new();
960        engine.add_collection(&collection).unwrap();
961        assert_eq!(engine.rule_count(), 2);
962
963        // First rule matches whoami
964        let ev1 = json!({"CommandLine": "whoami /all"});
965        let matches1 = engine.evaluate(&JsonEvent::borrow(&ev1));
966        assert_eq!(matches1.len(), 1);
967        assert_eq!(matches1[0].rule_title, "Detect Whoami");
968
969        // Second rule matches ipconfig (inherited logsource/level)
970        let ev2 = json!({"CommandLine": "ipconfig /all"});
971        let matches2 = engine.evaluate(&JsonEvent::borrow(&ev2));
972        assert_eq!(matches2.len(), 1);
973        assert_eq!(matches2[0].rule_title, "Detect Ipconfig");
974
975        // Neither matches dir
976        let ev3 = json!({"CommandLine": "dir"});
977        assert!(engine.evaluate(&JsonEvent::borrow(&ev3)).is_empty());
978    }
979
980    #[test]
981    fn test_action_repeat_with_global() {
982        // Global + repeat: global sets logsource, first doc sets detection,
983        // repeat overrides title and detection
984        let yaml = r#"
985action: global
986logsource:
987    product: windows
988    category: process_creation
989level: high
990---
991title: Detect Net User
992detection:
993    selection:
994        CommandLine|contains: 'net user'
995    condition: selection
996---
997action: repeat
998title: Detect Net Group
999detection:
1000    selection:
1001        CommandLine|contains: 'net group'
1002    condition: selection
1003"#;
1004        let collection = parse_sigma_yaml(yaml).unwrap();
1005        assert_eq!(collection.rules.len(), 2);
1006
1007        let mut engine = Engine::new();
1008        engine.add_collection(&collection).unwrap();
1009
1010        let ev1 = json!({"CommandLine": "net user admin"});
1011        let m1 = engine.evaluate(&JsonEvent::borrow(&ev1));
1012        assert_eq!(m1.len(), 1);
1013        assert_eq!(m1[0].rule_title, "Detect Net User");
1014
1015        let ev2 = json!({"CommandLine": "net group admins"});
1016        let m2 = engine.evaluate(&JsonEvent::borrow(&ev2));
1017        assert_eq!(m2.len(), 1);
1018        assert_eq!(m2[0].rule_title, "Detect Net Group");
1019    }
1020
1021    // =========================================================================
1022    // |neq modifier
1023    // =========================================================================
1024
1025    #[test]
1026    fn test_neq_modifier_yaml() {
1027        let yaml = r#"
1028title: Non-Standard Port
1029logsource:
1030    product: windows
1031detection:
1032    selection:
1033        Protocol: TCP
1034    filter:
1035        DestinationPort|neq: 443
1036    condition: selection and filter
1037level: medium
1038"#;
1039        let engine = make_engine_with_rule(yaml);
1040
1041        // Match: TCP on port 80 (neq 443 is true)
1042        let ev = json!({"Protocol": "TCP", "DestinationPort": "80"});
1043        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1044
1045        // No match: TCP on port 443 (neq 443 is false)
1046        let ev2 = json!({"Protocol": "TCP", "DestinationPort": "443"});
1047        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
1048    }
1049
1050    #[test]
1051    fn test_neq_modifier_integer() {
1052        let yaml = r#"
1053title: Non-Standard Port Numeric
1054logsource:
1055    product: windows
1056detection:
1057    selection:
1058        DestinationPort|neq: 443
1059    condition: selection
1060level: medium
1061"#;
1062        let engine = make_engine_with_rule(yaml);
1063
1064        let ev = json!({"DestinationPort": 80});
1065        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1066
1067        let ev2 = json!({"DestinationPort": 443});
1068        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
1069    }
1070
1071    // =========================================================================
1072    // 1 of them / all of them: underscore exclusion
1073    // =========================================================================
1074
1075    #[test]
1076    fn test_selector_them_excludes_underscore() {
1077        // Sigma spec: `1 of them` / `all of them` excludes identifiers starting with _
1078        let yaml = r#"
1079title: Underscore Test
1080logsource:
1081    product: windows
1082detection:
1083    selection:
1084        CommandLine|contains: 'whoami'
1085    _helper:
1086        User: 'SYSTEM'
1087    condition: all of them
1088level: medium
1089"#;
1090        let engine = make_engine_with_rule(yaml);
1091
1092        // With `all of them` excluding `_helper`, only `selection` needs to match
1093        let ev = json!({"CommandLine": "whoami", "User": "admin"});
1094        assert_eq!(
1095            engine.evaluate(&JsonEvent::borrow(&ev)).len(),
1096            1,
1097            "all of them should exclude _helper, so only selection is required"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_selector_them_includes_non_underscore() {
1103        let yaml = r#"
1104title: Multiple Selections
1105logsource:
1106    product: windows
1107detection:
1108    sel_cmd:
1109        CommandLine|contains: 'cmd'
1110    sel_ps:
1111        CommandLine|contains: 'powershell'
1112    _private:
1113        User: 'admin'
1114    condition: 1 of them
1115level: medium
1116"#;
1117        let engine = make_engine_with_rule(yaml);
1118
1119        // `1 of them` excludes `_private`, so only sel_cmd and sel_ps are considered
1120        let ev = json!({"CommandLine": "cmd.exe", "User": "guest"});
1121        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1122
1123        // _private alone should not count
1124        let ev2 = json!({"CommandLine": "notepad", "User": "admin"});
1125        assert!(
1126            engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty(),
1127            "_private should be excluded from 'them'"
1128        );
1129    }
1130
1131    // =========================================================================
1132    // UTF-16 encoding modifiers
1133    // =========================================================================
1134
1135    #[test]
1136    fn test_utf16le_modifier_yaml() {
1137        // |wide is an alias for |utf16le
1138        let yaml = r#"
1139title: Wide String
1140logsource:
1141    product: windows
1142detection:
1143    selection:
1144        Payload|wide|base64: 'Test'
1145    condition: selection
1146level: medium
1147"#;
1148        let engine = make_engine_with_rule(yaml);
1149
1150        // "Test" in UTF-16LE, then base64 encoded
1151        // T=0x54,0x00 e=0x65,0x00 s=0x73,0x00 t=0x74,0x00
1152        // base64 of [0x54,0x00,0x65,0x00,0x73,0x00,0x74,0x00] = "VABlAHMAdAA="
1153        let ev = json!({"Payload": "VABlAHMAdAA="});
1154        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1155    }
1156
1157    #[test]
1158    fn test_utf16be_modifier_yaml() {
1159        let yaml = r#"
1160title: UTF16BE String
1161logsource:
1162    product: windows
1163detection:
1164    selection:
1165        Payload|utf16be|base64: 'AB'
1166    condition: selection
1167level: medium
1168"#;
1169        let engine = make_engine_with_rule(yaml);
1170
1171        // "AB" in UTF-16BE: A=0x00,0x41 B=0x00,0x42
1172        // base64 of [0x00,0x41,0x00,0x42] = "AEEAQg=="
1173        let ev = json!({"Payload": "AEEAQg=="});
1174        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1175    }
1176
1177    #[test]
1178    fn test_utf16_bom_modifier_yaml() {
1179        let yaml = r#"
1180title: UTF16 BOM String
1181logsource:
1182    product: windows
1183detection:
1184    selection:
1185        Payload|utf16|base64: 'A'
1186    condition: selection
1187level: medium
1188"#;
1189        let engine = make_engine_with_rule(yaml);
1190
1191        // "A" in UTF-16 with BOM: FF FE (BOM) + 41 00 (A in UTF-16LE)
1192        // base64 of [0xFF,0xFE,0x41,0x00] = "//5BAA=="
1193        let ev = json!({"Payload": "//5BAA=="});
1194        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1195    }
1196
1197    // =========================================================================
1198    // Pipeline integration (end-to-end)
1199    // =========================================================================
1200
1201    #[test]
1202    fn test_pipeline_field_mapping_e2e() {
1203        use crate::pipeline::parse_pipeline;
1204
1205        let pipeline_yaml = r#"
1206name: Sysmon to ECS
1207transformations:
1208  - type: field_name_mapping
1209    mapping:
1210      CommandLine: process.command_line
1211    rule_conditions:
1212      - type: logsource
1213        product: windows
1214"#;
1215        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1216
1217        let rule_yaml = r#"
1218title: Detect Whoami
1219logsource:
1220    product: windows
1221    category: process_creation
1222detection:
1223    selection:
1224        CommandLine|contains: 'whoami'
1225    condition: selection
1226level: medium
1227"#;
1228        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1229
1230        let mut engine = Engine::new_with_pipeline(pipeline);
1231        engine.add_collection(&collection).unwrap();
1232
1233        // After pipeline: field is renamed to process.command_line
1234        // So the event must use the original Sigma field name — the pipeline
1235        // maps rule fields, not event fields. Events still use their native schema.
1236        // Actually, after pipeline transforms the rule's field names,
1237        // the rule now looks for "process.command_line" in the event.
1238        let ev = json!({"process.command_line": "cmd /c whoami"});
1239        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1240
1241        // Old field name should no longer match
1242        let ev2 = json!({"CommandLine": "cmd /c whoami"});
1243        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
1244    }
1245
1246    #[test]
1247    fn test_pipeline_add_condition_e2e() {
1248        use crate::pipeline::parse_pipeline;
1249
1250        let pipeline_yaml = r#"
1251name: Add index condition
1252transformations:
1253  - type: add_condition
1254    conditions:
1255      source: windows
1256    rule_conditions:
1257      - type: logsource
1258        product: windows
1259"#;
1260        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1261
1262        let rule_yaml = r#"
1263title: Detect Cmd
1264logsource:
1265    product: windows
1266detection:
1267    selection:
1268        CommandLine|contains: 'cmd'
1269    condition: selection
1270level: low
1271"#;
1272        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1273
1274        let mut engine = Engine::new_with_pipeline(pipeline);
1275        engine.add_collection(&collection).unwrap();
1276
1277        // Must have both the original match AND source=windows
1278        let ev = json!({"CommandLine": "cmd.exe", "source": "windows"});
1279        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1280
1281        // Missing source field: should not match (pipeline added condition)
1282        let ev2 = json!({"CommandLine": "cmd.exe"});
1283        assert!(engine.evaluate(&JsonEvent::borrow(&ev2)).is_empty());
1284    }
1285
1286    #[test]
1287    fn test_pipeline_change_logsource_e2e() {
1288        use crate::pipeline::parse_pipeline;
1289
1290        let pipeline_yaml = r#"
1291name: Change logsource
1292transformations:
1293  - type: change_logsource
1294    product: elastic
1295    category: endpoint
1296    rule_conditions:
1297      - type: logsource
1298        product: windows
1299"#;
1300        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1301
1302        let rule_yaml = r#"
1303title: Test Rule
1304logsource:
1305    product: windows
1306    category: process_creation
1307detection:
1308    selection:
1309        action: test
1310    condition: selection
1311level: low
1312"#;
1313        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1314
1315        let mut engine = Engine::new_with_pipeline(pipeline);
1316        engine.add_collection(&collection).unwrap();
1317
1318        // Rule still evaluates based on detection logic
1319        let ev = json!({"action": "test"});
1320        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1321
1322        // But with logsource routing, the original windows logsource no longer matches
1323        let ls = LogSource {
1324            product: Some("windows".to_string()),
1325            category: Some("process_creation".to_string()),
1326            ..Default::default()
1327        };
1328        assert!(
1329            engine
1330                .evaluate_with_logsource(&JsonEvent::borrow(&ev), &ls)
1331                .is_empty(),
1332            "logsource was changed; windows/process_creation should not match"
1333        );
1334
1335        let ls2 = LogSource {
1336            product: Some("elastic".to_string()),
1337            category: Some("endpoint".to_string()),
1338            ..Default::default()
1339        };
1340        assert_eq!(
1341            engine
1342                .evaluate_with_logsource(&JsonEvent::borrow(&ev), &ls2)
1343                .len(),
1344            1,
1345            "elastic/endpoint should match the transformed logsource"
1346        );
1347    }
1348
1349    #[test]
1350    fn test_pipeline_replace_string_e2e() {
1351        use crate::pipeline::parse_pipeline;
1352
1353        let pipeline_yaml = r#"
1354name: Replace backslash
1355transformations:
1356  - type: replace_string
1357    regex: "\\\\"
1358    replacement: "/"
1359"#;
1360        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1361
1362        let rule_yaml = r#"
1363title: Path Detection
1364logsource:
1365    product: windows
1366detection:
1367    selection:
1368        FilePath|contains: 'C:\Windows'
1369    condition: selection
1370level: low
1371"#;
1372        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1373
1374        let mut engine = Engine::new_with_pipeline(pipeline);
1375        engine.add_collection(&collection).unwrap();
1376
1377        // After replace: rule looks for "C:/Windows" instead of "C:\Windows"
1378        let ev = json!({"FilePath": "C:/Windows/System32/cmd.exe"});
1379        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1380    }
1381
1382    #[test]
1383    fn test_pipeline_skips_non_matching_rules() {
1384        use crate::pipeline::parse_pipeline;
1385
1386        let pipeline_yaml = r#"
1387name: Windows Only
1388transformations:
1389  - type: field_name_prefix
1390    prefix: "win."
1391    rule_conditions:
1392      - type: logsource
1393        product: windows
1394"#;
1395        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1396
1397        // Two rules: one Windows, one Linux
1398        let rule_yaml = r#"
1399title: Windows Rule
1400logsource:
1401    product: windows
1402detection:
1403    selection:
1404        CommandLine|contains: 'whoami'
1405    condition: selection
1406level: low
1407---
1408title: Linux Rule
1409logsource:
1410    product: linux
1411detection:
1412    selection:
1413        CommandLine|contains: 'whoami'
1414    condition: selection
1415level: low
1416"#;
1417        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1418        assert_eq!(collection.rules.len(), 2);
1419
1420        let mut engine = Engine::new_with_pipeline(pipeline);
1421        engine.add_collection(&collection).unwrap();
1422
1423        // Windows rule: field was prefixed to win.CommandLine
1424        let ev_win = json!({"win.CommandLine": "whoami"});
1425        let m = engine.evaluate(&JsonEvent::borrow(&ev_win));
1426        assert_eq!(m.len(), 1);
1427        assert_eq!(m[0].rule_title, "Windows Rule");
1428
1429        // Linux rule: field was NOT prefixed (still CommandLine)
1430        let ev_linux = json!({"CommandLine": "whoami"});
1431        let m2 = engine.evaluate(&JsonEvent::borrow(&ev_linux));
1432        assert_eq!(m2.len(), 1);
1433        assert_eq!(m2[0].rule_title, "Linux Rule");
1434    }
1435
1436    #[test]
1437    fn test_multiple_pipelines_e2e() {
1438        use crate::pipeline::parse_pipeline;
1439
1440        let p1_yaml = r#"
1441name: First Pipeline
1442priority: 10
1443transformations:
1444  - type: field_name_mapping
1445    mapping:
1446      CommandLine: process.args
1447"#;
1448        let p2_yaml = r#"
1449name: Second Pipeline
1450priority: 20
1451transformations:
1452  - type: field_name_suffix
1453    suffix: ".keyword"
1454"#;
1455        let p1 = parse_pipeline(p1_yaml).unwrap();
1456        let p2 = parse_pipeline(p2_yaml).unwrap();
1457
1458        let rule_yaml = r#"
1459title: Test
1460logsource:
1461    product: windows
1462detection:
1463    selection:
1464        CommandLine|contains: 'test'
1465    condition: selection
1466level: low
1467"#;
1468        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1469
1470        let mut engine = Engine::new();
1471        engine.add_pipeline(p1);
1472        engine.add_pipeline(p2);
1473        engine.add_collection(&collection).unwrap();
1474
1475        // After p1: CommandLine -> process.args
1476        // After p2: process.args -> process.args.keyword
1477        let ev = json!({"process.args.keyword": "testing"});
1478        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1479    }
1480
1481    #[test]
1482    fn test_pipeline_drop_detection_item_e2e() {
1483        use crate::pipeline::parse_pipeline;
1484
1485        let pipeline_yaml = r#"
1486name: Drop EventID
1487transformations:
1488  - type: drop_detection_item
1489    field_name_conditions:
1490      - type: include_fields
1491        fields:
1492          - EventID
1493"#;
1494        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1495
1496        let rule_yaml = r#"
1497title: Sysmon Process
1498logsource:
1499    product: windows
1500detection:
1501    selection:
1502        EventID: 1
1503        CommandLine|contains: 'whoami'
1504    condition: selection
1505level: medium
1506"#;
1507        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1508
1509        let mut engine = Engine::new_with_pipeline(pipeline);
1510        engine.add_collection(&collection).unwrap();
1511
1512        // EventID detection item was dropped, so only CommandLine matters
1513        let ev = json!({"CommandLine": "whoami"});
1514        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1515
1516        // Without pipeline, EventID=1 would also be required
1517        let mut engine2 = Engine::new();
1518        engine2.add_collection(&collection).unwrap();
1519        // Without EventID, should not match
1520        assert!(engine2.evaluate(&JsonEvent::borrow(&ev)).is_empty());
1521    }
1522
1523    #[test]
1524    fn test_pipeline_set_state_and_conditional() {
1525        use crate::pipeline::parse_pipeline;
1526
1527        let pipeline_yaml = r#"
1528name: Stateful Pipeline
1529transformations:
1530  - id: mark_windows
1531    type: set_state
1532    key: is_windows
1533    value: "true"
1534    rule_conditions:
1535      - type: logsource
1536        product: windows
1537  - type: field_name_prefix
1538    prefix: "winlog."
1539    rule_conditions:
1540      - type: processing_state
1541        key: is_windows
1542        val: "true"
1543"#;
1544        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1545
1546        let rule_yaml = r#"
1547title: Windows Detect
1548logsource:
1549    product: windows
1550detection:
1551    selection:
1552        CommandLine|contains: 'test'
1553    condition: selection
1554level: low
1555"#;
1556        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1557
1558        let mut engine = Engine::new_with_pipeline(pipeline);
1559        engine.add_collection(&collection).unwrap();
1560
1561        // State was set → prefix was applied
1562        let ev = json!({"winlog.CommandLine": "testing"});
1563        assert_eq!(engine.evaluate(&JsonEvent::borrow(&ev)).len(), 1);
1564    }
1565
1566    #[test]
1567    fn test_evaluate_batch_matches_sequential() {
1568        let yaml = r#"
1569title: Login
1570logsource:
1571    product: windows
1572detection:
1573    selection:
1574        EventType: 'login'
1575    condition: selection
1576---
1577title: Process Create
1578logsource:
1579    product: windows
1580detection:
1581    selection:
1582        EventType: 'process_create'
1583    condition: selection
1584---
1585title: Keyword
1586logsource:
1587    product: windows
1588detection:
1589    selection:
1590        CommandLine|contains: 'whoami'
1591    condition: selection
1592"#;
1593        let collection = parse_sigma_yaml(yaml).unwrap();
1594        let mut engine = Engine::new();
1595        engine.add_collection(&collection).unwrap();
1596
1597        let vals = [
1598            json!({"EventType": "login", "User": "admin"}),
1599            json!({"EventType": "process_create", "CommandLine": "whoami"}),
1600            json!({"EventType": "file_create"}),
1601            json!({"CommandLine": "whoami /all"}),
1602        ];
1603        let events: Vec<JsonEvent> = vals.iter().map(JsonEvent::borrow).collect();
1604
1605        // Sequential
1606        let sequential: Vec<Vec<_>> = events.iter().map(|e| engine.evaluate(e)).collect();
1607
1608        // Batch
1609        let refs: Vec<&JsonEvent> = events.iter().collect();
1610        let batch = engine.evaluate_batch(&refs);
1611
1612        assert_eq!(sequential.len(), batch.len());
1613        for (seq, bat) in sequential.iter().zip(batch.iter()) {
1614            assert_eq!(seq.len(), bat.len());
1615            for (s, b) in seq.iter().zip(bat.iter()) {
1616                assert_eq!(s.rule_title, b.rule_title);
1617            }
1618        }
1619    }
1620}