Skip to main content

rsigma_eval/
router.rs

1//! Multi-engine schema router: classify each event, route it to the detection
2//! engine built for its schema's pipeline-set, and feed every detection into
3//! one shared correlation store.
4//!
5//! # Design
6//!
7//! - One [`Engine`] per deduplicated pipeline-set (index-aligned with
8//!   [`RoutingPlan::pipeline_sets`]). The schema's pipeline is applied to the
9//!   detection rules in its engine, exactly as a single-pipeline run would.
10//! - One shared [`CorrelationEngine`] (present only when the rule set has
11//!   correlation rules), built Sigma-native (no pipeline). Detections from any
12//!   per-schema engine feed into it via
13//!   [`CorrelationEngine::correlate_detections`].
14//! - Cross-schema correlation grouping works because the group-by extraction is
15//!   schema-aware: each set carries a `Sigma -> event field` map (derived from
16//!   its pipelines' field-name mappings), and the event is wrapped in a
17//!   [`MappedEvent`] before correlation so the Sigma-native group-by names
18//!   resolve to the schema's field names. The window store stays shared, keyed
19//!   by the logical correlation plus the extracted group values.
20//!
21//! This subsumes the single-schema case (one pipeline-set is the degenerate
22//! configuration), so there is no separate code path for "routing off".
23
24use std::collections::HashMap;
25
26use rsigma_parser::{LogSource, SigmaCollection, SigmaRule};
27
28use crate::correlation_engine::{
29    CorrelationConfig, CorrelationEngine, CorrelationSnapshot, CorrelationStateSnapshot,
30    ProcessResult,
31};
32use crate::engine::Engine;
33use crate::error::Result;
34use crate::event::{Event, MappedEvent};
35use crate::logsource::LogSourceExtractor;
36use crate::pipeline::Pipeline;
37use crate::pipeline::transformations::Transformation;
38use crate::result::EvaluationResult;
39use crate::result::MatchDetailLevel;
40use crate::rule_metadata::RuleMetadataLookup;
41use crate::schema::{OnUnknown, RouteDecision, RoutingPlan, SchemaClassifier};
42
43/// Per-schema logsource pruning summary: how many rules a schema's events
44/// evaluate versus how many are pruned by its implied logsource. A static view
45/// (independent of any specific event's field values) for operator visibility.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SchemaPruning {
48    /// The recognized schema name.
49    pub schema: String,
50    /// Rules evaluated for this schema (logsource-compatible).
51    pub eligible: usize,
52    /// Rules pruned for this schema (logsource-conflicting).
53    pub pruned: usize,
54}
55
56/// What the router did with an event, for reporting and `on_unknown` handling.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum RouteOutcome {
59    /// Evaluated against a bound or known schema's set.
60    Evaluated,
61    /// Evaluated against the default set because the schema was unrecognized
62    /// (`on_unknown: warn` or `passthrough`).
63    EvaluatedUnknown,
64    /// Dropped without evaluating (`on_unknown: drop`).
65    Dropped,
66    /// Dropped and flagged as an error (`on_unknown: error`).
67    Errored,
68}
69
70/// The result of routing one event.
71pub struct RouteResult {
72    /// Evaluation results (empty when dropped or errored).
73    pub results: ProcessResult,
74    /// The classified schema name, or `None` when unrecognized.
75    pub schema: Option<String>,
76    /// What the router did.
77    pub outcome: RouteOutcome,
78}
79
80/// Collect a combined `Sigma -> [event field]` map from a pipeline-set's
81/// field-name mappings, used for schema-aware correlation group-by extraction.
82fn collect_field_map(pipelines: &[Pipeline]) -> HashMap<String, Vec<String>> {
83    let mut map: HashMap<String, Vec<String>> = HashMap::new();
84    for pipeline in pipelines {
85        for item in &pipeline.transformations {
86            if let Transformation::FieldNameMapping { mapping } = &item.transformation {
87                for (from, to) in mapping {
88                    map.entry(from.clone())
89                        .or_default()
90                        .extend(to.iter().cloned());
91                }
92            }
93        }
94    }
95    map
96}
97
98/// Outcome of the stateless phase for one event in [`SchemaRouter::process_batch`].
99enum Routed1 {
100    /// Dropped or errored (`on_unknown`): no results.
101    Skip,
102    /// Evaluate detections against the shared correlation store under set `set`.
103    Eval {
104        set: usize,
105        detections: Vec<EvaluationResult>,
106    },
107}
108
109/// Keep a detection rule when partitioning a per-schema engine: rules with no
110/// product apply to every platform; a product-tagged rule is kept only when its
111/// product is among the set's allowed products (lowercased).
112fn rule_product_kept(rule: &SigmaRule, products: &std::collections::HashSet<String>) -> bool {
113    match &rule.logsource.product {
114        None => true,
115        Some(p) => products.contains(&p.to_ascii_lowercase()),
116    }
117}
118
119/// Whether a pipeline rewrites a rule's product via `change_logsource`, which
120/// makes pre-pipeline product partitioning unsafe (a rule could be re-producted
121/// at compile time). Such a set keeps its full ruleset.
122fn pipeline_changes_product(pipeline: &Pipeline) -> bool {
123    pipeline.transformations.iter().any(|item| {
124        matches!(
125            &item.transformation,
126            Transformation::ChangeLogsource {
127                product: Some(_),
128                ..
129            }
130        )
131    })
132}
133
134/// Resolve an event's logsource for conflict-based pruning: the extractor's
135/// value (explicit event fields, then static/format defaults) wins, and the
136/// recognized schema's implied logsource fills any dimension left unset. This
137/// is what lets a `product`-less event still prune cross-product rules once its
138/// schema is known (for example a `sysmon`-classified event implies
139/// `product: windows`).
140fn resolve_event_logsource<E: Event>(
141    extractor: &LogSourceExtractor,
142    implied: Option<&LogSource>,
143    event: &E,
144) -> LogSource {
145    let mut ls = extractor.extract(event);
146    if let Some(implied) = implied {
147        if ls.product.is_none() {
148            ls.product = implied.product.clone();
149        }
150        if ls.service.is_none() {
151            ls.service = implied.service.clone();
152        }
153        if ls.category.is_none() {
154            ls.category = implied.category.clone();
155        }
156        for (key, value) in &implied.custom {
157            ls.custom
158                .entry(key.clone())
159                .or_insert_with(|| value.clone());
160        }
161    }
162    ls
163}
164
165/// Stateless detection for one event: classify, decide, evaluate. Borrows only
166/// shared state so it can run in parallel across a batch. When a logsource
167/// extractor is configured, the event's logsource is resolved (explicit fields
168/// plus the schema's implied logsource) and fed into conflict-based pruning.
169fn detect_one<E: Event>(
170    classifier: &SchemaClassifier,
171    plan: &RoutingPlan,
172    engines: &[Engine],
173    extractor: Option<&LogSourceExtractor>,
174    event: &E,
175) -> Routed1 {
176    let schema = classifier.classify(event).map(|m| m.name);
177    match plan.decide(schema.as_deref()) {
178        RouteDecision::Drop | RouteDecision::Error => Routed1::Skip,
179        RouteDecision::Evaluate { set, .. } => {
180            let detections = match extractor {
181                Some(ex) => {
182                    let implied = schema.as_deref().and_then(|s| plan.schema_logsource(s));
183                    let ls = resolve_event_logsource(ex, implied, event);
184                    engines[set].evaluate_pruned(event, &ls)
185                }
186                None => engines[set].evaluate(event),
187            };
188            Routed1::Eval { set, detections }
189        }
190    }
191}
192
193/// A multi-engine router over a classifier, a [`RoutingPlan`], one detection
194/// engine per pipeline-set, and one shared correlation store.
195pub struct SchemaRouter {
196    classifier: SchemaClassifier,
197    plan: RoutingPlan,
198    /// One detection engine per pipeline-set (index = set index).
199    engines: Vec<Engine>,
200    /// `Sigma -> event field` map per pipeline-set, for correlation group-by.
201    field_maps: Vec<HashMap<String, Vec<String>>>,
202    /// Shared correlation store; `None` when there are no correlation rules.
203    correlation: Option<CorrelationEngine>,
204    /// Event-logsource extractor for conflict-based pruning; `None` disables
205    /// pruning. Resolution happens per event in the router (extractor value
206    /// plus the schema's implied logsource), so it is not set on the engines.
207    logsource_extractor: Option<LogSourceExtractor>,
208}
209
210impl SchemaRouter {
211    /// Build a router. `pipeline_sets` must be index-aligned with
212    /// `plan.pipeline_sets()` (one resolved pipeline list per set).
213    #[allow(clippy::too_many_arguments)]
214    pub fn build(
215        collection: &SigmaCollection,
216        classifier: SchemaClassifier,
217        plan: RoutingPlan,
218        pipeline_sets: Vec<Vec<Pipeline>>,
219        corr_config: CorrelationConfig,
220        include_event: bool,
221        match_detail: MatchDetailLevel,
222        logsource_extractor: Option<LogSourceExtractor>,
223        partition_rules: bool,
224    ) -> Result<Self> {
225        // Optional, gated per-schema rule partitioning: each engine bound only
226        // to platform-locked schemas compiles just the rules whose product can
227        // apply, cutting the N-copies memory cost. Off by default and disabled
228        // for any set whose pipelines rewrite product.
229        let partition = if partition_rules {
230            plan.set_product_partition()
231        } else {
232            vec![None; pipeline_sets.len()]
233        };
234
235        let mut engines = Vec::with_capacity(pipeline_sets.len());
236        let mut field_maps = Vec::with_capacity(pipeline_sets.len());
237        for (idx, set) in pipeline_sets.iter().enumerate() {
238            let mut engine = Engine::new();
239            engine.set_include_event(include_event);
240            engine.set_match_detail(match_detail);
241            for p in set {
242                engine.add_pipeline(p.clone());
243            }
244            // Partition only when the set has an allowed-product set and no
245            // pipeline rewrites product; otherwise compile the full ruleset.
246            let partitioned = partition
247                .get(idx)
248                .and_then(|o| o.as_ref())
249                .filter(|_| !set.iter().any(pipeline_changes_product));
250            match partitioned {
251                Some(products) => {
252                    let mut filtered = collection.clone();
253                    filtered.rules.retain(|r| rule_product_kept(r, products));
254                    engine.add_collection(&filtered)?;
255                }
256                None => engine.add_collection(collection)?,
257            }
258            engines.push(engine);
259            field_maps.push(collect_field_map(set));
260        }
261
262        // The shared correlation store is Sigma-native (no pipeline): group-by
263        // names stay logical and are mapped per schema at feed time. Its inner
264        // detection engine is unused (routed detection runs in `engines`).
265        let correlation = if collection.correlations.is_empty() {
266            None
267        } else {
268            let mut ce = CorrelationEngine::new(corr_config);
269            ce.set_include_event(include_event);
270            ce.set_match_detail(match_detail);
271            ce.add_collection(collection)?;
272            Some(ce)
273        };
274
275        Ok(SchemaRouter {
276            classifier,
277            plan,
278            engines,
279            field_maps,
280            correlation,
281            logsource_extractor,
282        })
283    }
284
285    /// The unknown-handling policy this router enforces.
286    pub fn on_unknown(&self) -> OnUnknown {
287        self.plan.on_unknown()
288    }
289
290    /// Whether this router has a correlation store.
291    pub fn has_correlations(&self) -> bool {
292        self.correlation.is_some()
293    }
294
295    /// Number of detection rules (same across every per-schema engine, unless
296    /// per-schema rule partitioning is enabled; see [`engine_rule_counts`]).
297    ///
298    /// [`engine_rule_counts`]: SchemaRouter::engine_rule_counts
299    pub fn detection_rule_count(&self) -> usize {
300        self.engines.first().map(|e| e.rule_count()).unwrap_or(0)
301    }
302
303    /// Per-pipeline-set detection rule counts, in set order. Equal across sets
304    /// unless per-schema rule partitioning is enabled, in which case
305    /// platform-locked sets carry fewer rules than the default set.
306    pub fn engine_rule_counts(&self) -> Vec<usize> {
307        self.engines.iter().map(Engine::rule_count).collect()
308    }
309
310    /// Total rule candidates pruned by logsource across every per-schema
311    /// engine (each event routes to exactly one engine).
312    pub fn logsource_pruned_total(&self) -> u64 {
313        self.engines
314            .iter()
315            .map(Engine::logsource_pruned_total)
316            .sum()
317    }
318
319    /// Total evaluate calls with no extractable event logsource (fail-open)
320    /// across every per-schema engine.
321    pub fn logsource_absent_total(&self) -> u64 {
322        self.engines
323            .iter()
324            .map(Engine::logsource_absent_total)
325            .sum()
326    }
327
328    /// Static per-schema pruning summary: for each schema with an implied
329    /// logsource, how many rules its events evaluate versus prune. Empty when
330    /// logsource routing is disabled (no extractor). Sorted by descending
331    /// pruned count, then schema name.
332    pub fn schema_pruning_summary(&self) -> Vec<SchemaPruning> {
333        if self.logsource_extractor.is_none() {
334            return Vec::new();
335        }
336        let mut out = Vec::new();
337        for schema in self.plan.schemas_with_logsource() {
338            let Some(implied) = self.plan.schema_logsource(&schema) else {
339                continue;
340            };
341            let set = match self.plan.decide(Some(&schema)) {
342                RouteDecision::Evaluate { set, .. } => set,
343                RouteDecision::Drop | RouteDecision::Error => 0,
344            };
345            let (eligible, pruned) = self.engines[set].logsource_eligibility(implied);
346            out.push(SchemaPruning {
347                schema,
348                eligible,
349                pruned,
350            });
351        }
352        out.sort_by(|a, b| {
353            b.pruned
354                .cmp(&a.pruned)
355                .then_with(|| a.schema.cmp(&b.schema))
356        });
357        out
358    }
359
360    /// Number of correlation rules in the shared store (0 when none).
361    pub fn correlation_rule_count(&self) -> usize {
362        self.correlation
363            .as_ref()
364            .map(|c| c.correlation_rule_count())
365            .unwrap_or(0)
366    }
367
368    /// Number of live correlation window-state entries (0 when none).
369    pub fn state_count(&self) -> usize {
370        self.correlation
371            .as_ref()
372            .map(|c| c.state_count())
373            .unwrap_or(0)
374    }
375
376    /// Resolve a rule key (an id, or a title for a rule without one) to the
377    /// documentation of every loaded rule that carries it.
378    ///
379    /// A rule set is compiled once per pipeline-set, so the same rule usually
380    /// yields one candidate per schema. Candidates whose post-pipeline metadata
381    /// is identical collapse to a single answer; a rule whose pipelines rewrite
382    /// its documentation differently per schema stays
383    /// [`Ambiguous`](RuleMetadataLookup::Ambiguous).
384    pub fn rule_metadata(&self, key: &str) -> RuleMetadataLookup {
385        let mut variants = Vec::new();
386        for engine in &self.engines {
387            engine.collect_rule_metadata(key, &mut variants);
388        }
389        if let Some(correlation) = &self.correlation {
390            correlation.collect_rule_metadata(key, &mut variants);
391        }
392        RuleMetadataLookup::from_variants(variants)
393    }
394
395    /// Introspect the shared correlation store, if any (id/group filtered).
396    pub fn correlation_introspect(
397        &self,
398        id: Option<&str>,
399        group: Option<&str>,
400    ) -> Option<CorrelationStateSnapshot> {
401        self.correlation
402            .as_ref()
403            .map(|c| c.introspect_filtered(id, group))
404    }
405
406    /// Export the shared correlation state, if any, for hot-reload carry-over.
407    pub fn export_state(&self) -> Option<CorrelationSnapshot> {
408        self.correlation.as_ref().map(|c| c.export_state())
409    }
410
411    /// Import previously exported correlation state into the shared store.
412    /// No-op (returns `true`) when there is no correlation store.
413    pub fn import_state(&mut self, snapshot: CorrelationSnapshot) -> bool {
414        match &mut self.correlation {
415            Some(c) => c.import_state(snapshot),
416            None => true,
417        }
418    }
419
420    /// Stateless classify + detection for a batch. Safe to call under a shared
421    /// borrow when there is no correlation store; see [`Self::process_batch`].
422    pub fn detect_batch<E: Event + Sync>(&self, events: &[&E]) -> Vec<ProcessResult> {
423        let classifier = &self.classifier;
424        let plan = &self.plan;
425        let engines = &self.engines;
426        let extractor = self.logsource_extractor.as_ref();
427        let phase1: Vec<Routed1> = {
428            #[cfg(feature = "parallel")]
429            {
430                use rayon::prelude::*;
431                events
432                    .par_iter()
433                    .map(|e| detect_one(classifier, plan, engines, extractor, *e))
434                    .collect()
435            }
436            #[cfg(not(feature = "parallel"))]
437            {
438                events
439                    .iter()
440                    .map(|e| detect_one(classifier, plan, engines, extractor, *e))
441                    .collect()
442            }
443        };
444        phase1
445            .into_iter()
446            .map(|routed| match routed {
447                Routed1::Skip => Vec::new(),
448                Routed1::Eval { detections, .. } => detections,
449            })
450            .collect()
451    }
452
453    /// Route a batch of events: parallel classify + detection, then sequential
454    /// correlation into the shared store. Mirrors
455    /// `CorrelationEngine::process_batch`: the stateless phase runs concurrently
456    /// (under the `parallel` feature) and the stateful correlation phase runs
457    /// in order. Drop/error outcomes yield empty results for that event.
458    ///
459    /// When there is no correlation store this is equivalent to [`Self::detect_batch`]
460    /// and only needs a shared borrow of the router.
461    pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
462        if self.correlation.is_none() {
463            return self.detect_batch(events);
464        }
465
466        // Stateless phase: classify + route + detect. Borrows only `&self`
467        // fields, so it parallelizes; correlation state is untouched here.
468        let classifier = &self.classifier;
469        let plan = &self.plan;
470        let engines = &self.engines;
471        let extractor = self.logsource_extractor.as_ref();
472        let phase1: Vec<Routed1> = {
473            #[cfg(feature = "parallel")]
474            {
475                use rayon::prelude::*;
476                events
477                    .par_iter()
478                    .map(|e| detect_one(classifier, plan, engines, extractor, *e))
479                    .collect()
480            }
481            #[cfg(not(feature = "parallel"))]
482            {
483                events
484                    .iter()
485                    .map(|e| detect_one(classifier, plan, engines, extractor, *e))
486                    .collect()
487            }
488        };
489
490        // Stateful phase: feed detections into the shared correlation store in
491        // event order. Disjoint field borrows let the field maps and the
492        // correlation store be held at once.
493        let field_maps = &self.field_maps;
494        let correlation = self.correlation.as_mut().expect("checked above");
495        phase1
496            .into_iter()
497            .zip(events)
498            .map(|(routed, event)| match routed {
499                Routed1::Skip => Vec::new(),
500                Routed1::Eval { set, detections } => {
501                    let mapped = MappedEvent::new(*event, &field_maps[set]);
502                    correlation.correlate_detections(&mapped, detections)
503                }
504            })
505            .collect()
506    }
507
508    /// Classify and route one event.
509    pub fn route(&mut self, event: &impl Event) -> RouteResult {
510        let schema = self.classifier.classify(event).map(|m| m.name);
511        match self.plan.decide(schema.as_deref()) {
512            RouteDecision::Drop => RouteResult {
513                results: Vec::new(),
514                schema,
515                outcome: RouteOutcome::Dropped,
516            },
517            RouteDecision::Error => RouteResult {
518                results: Vec::new(),
519                schema,
520                outcome: RouteOutcome::Errored,
521            },
522            RouteDecision::Evaluate { set, unknown } => {
523                let detections = match self.logsource_extractor.as_ref() {
524                    Some(ex) => {
525                        let implied = schema
526                            .as_deref()
527                            .and_then(|s| self.plan.schema_logsource(s));
528                        let ls = resolve_event_logsource(ex, implied, event);
529                        self.engines[set].evaluate_pruned(event, &ls)
530                    }
531                    None => self.engines[set].evaluate(event),
532                };
533                let results = match &mut self.correlation {
534                    Some(ce) => {
535                        let mapped = MappedEvent::new(event, &self.field_maps[set]);
536                        ce.correlate_detections(&mapped, detections)
537                    }
538                    None => detections,
539                };
540                RouteResult {
541                    results,
542                    schema,
543                    outcome: if unknown {
544                        RouteOutcome::EvaluatedUnknown
545                    } else {
546                        RouteOutcome::Evaluated
547                    },
548                }
549            }
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::JsonEvent;
558    use crate::pipeline::parse_pipeline;
559    use crate::schema::RoutingConfig;
560    use rsigma_parser::parse_sigma_yaml;
561    use serde_json::json;
562
563    const RULES: &str = r#"
564title: Whoami
565id: rule-whoami
566logsource:
567    category: process_creation
568    product: windows
569detection:
570    selection:
571        CommandLine|contains: whoami
572    condition: selection
573level: high
574"#;
575
576    const ECS_PIPELINE: &str = r#"
577name: ecs_test
578priority: 20
579transformations:
580  - id: map
581    type: field_name_mapping
582    mapping:
583      CommandLine: process.command_line
584      User: user.name
585"#;
586
587    fn plan(bindings: &[(&str, &[&str])]) -> RoutingPlan {
588        let config = RoutingConfig {
589            on_unknown: OnUnknown::Warn,
590            default_pipelines: vec![],
591            aliases: std::collections::HashMap::new(),
592            bindings: bindings
593                .iter()
594                .map(|(s, ps)| crate::schema::SchemaBinding {
595                    schema: (*s).to_string(),
596                    pipelines: ps.iter().map(|p| (*p).to_string()).collect(),
597                    logsource: None,
598                })
599                .collect(),
600        };
601        RoutingPlan::from_config(&config)
602    }
603
604    #[test]
605    fn routes_ecs_event_to_ecs_engine() {
606        let collection = parse_sigma_yaml(RULES).unwrap();
607        // set 0 = default (no pipeline, Sigma-native fields), set 1 = ECS.
608        let ecs = parse_pipeline(ECS_PIPELINE).unwrap();
609        let plan = plan(&[("ecs", &["ecs_test"])]);
610        let mut router = SchemaRouter::build(
611            &collection,
612            SchemaClassifier::builtin(),
613            plan,
614            vec![vec![], vec![ecs]],
615            CorrelationConfig::default(),
616            false,
617            MatchDetailLevel::Off,
618            None,
619            false,
620        )
621        .unwrap();
622
623        // ECS event: fields are renamed; only the ECS engine matches it.
624        let ecs_event = json!({"ecs.version": "8.0.0", "process.command_line": "cmd /c whoami"});
625        let r = router.route(&JsonEvent::borrow(&ecs_event));
626        assert_eq!(r.schema.as_deref(), Some("ecs"));
627        assert_eq!(r.outcome, RouteOutcome::Evaluated);
628        assert_eq!(r.results.len(), 1, "ECS event matches via the ECS engine");
629
630        // A Sigma-native event with the same command is unrecognized here
631        // (no ecs.version, no sysmon markers) -> generic_json -> default set,
632        // which has no pipeline, so the rule's CommandLine matches it.
633        let native = json!({"CommandLine": "cmd /c whoami"});
634        let r = router.route(&JsonEvent::borrow(&native));
635        assert_eq!(r.schema.as_deref(), Some("generic_json"));
636        assert_eq!(r.results.len(), 1);
637    }
638
639    #[test]
640    fn cross_schema_correlation_groups_the_same_entity() {
641        // A detection rule plus an event_count correlation grouped by User.
642        // The same user appears once as an ECS event (user.name) and once as a
643        // Sigma-native event (User); they must land in the same window and fire.
644        let rules = r#"
645title: Whoami
646id: rule-whoami
647logsource:
648    category: process_creation
649    product: windows
650detection:
651    selection:
652        CommandLine|contains: whoami
653    condition: selection
654level: high
655---
656title: Repeated whoami by user
657correlation:
658    type: event_count
659    rules:
660        - rule-whoami
661    group-by:
662        - User
663    timespan: 1h
664    condition:
665        gte: 2
666level: high
667"#;
668        let collection = parse_sigma_yaml(rules).unwrap();
669        let ecs = parse_pipeline(ECS_PIPELINE).unwrap();
670        // set 0 = default (Sigma-native), set 1 = ECS. ecs schema -> set 1;
671        // everything else (incl. the generic event) -> default set 0.
672        let plan = plan(&[("ecs", &["ecs_test"])]);
673
674        let config = CorrelationConfig {
675            timestamp_fallback: crate::correlation_engine::TimestampFallback::WallClock,
676            ..Default::default()
677        };
678
679        let mut router = SchemaRouter::build(
680            &collection,
681            SchemaClassifier::builtin(),
682            plan,
683            vec![vec![], vec![ecs]],
684            config,
685            false,
686            MatchDetailLevel::Off,
687            None,
688            false,
689        )
690        .unwrap();
691
692        // First occurrence: ECS event for user alice.
693        let ecs_event = json!({
694            "ecs.version": "8.0.0",
695            "process.command_line": "cmd /c whoami",
696            "user.name": "alice"
697        });
698        let r1 = router.route(&JsonEvent::borrow(&ecs_event));
699        assert_eq!(r1.schema.as_deref(), Some("ecs"));
700        assert!(
701            !r1.results.iter().any(|r| r.is_correlation()),
702            "first event must not fire the count>=2 correlation yet"
703        );
704
705        // Second occurrence: Sigma-native event for the SAME user alice.
706        let native_event = json!({"CommandLine": "cmd /c whoami", "User": "alice"});
707        let r2 = router.route(&JsonEvent::borrow(&native_event));
708        assert!(
709            r2.results.iter().any(|r| r.is_correlation()),
710            "the two events share group User=alice across schemas and must correlate"
711        );
712    }
713
714    #[test]
715    fn drop_policy_skips_unknown_events() {
716        let collection = parse_sigma_yaml(RULES).unwrap();
717        let config = RoutingConfig {
718            on_unknown: OnUnknown::Drop,
719            default_pipelines: vec![],
720            aliases: std::collections::HashMap::new(),
721            // Bind generic_json away so a plain event is truly unknown.
722            bindings: vec![],
723        };
724        let plan = RoutingPlan::from_config(&config);
725        let mut router = SchemaRouter::build(
726            &collection,
727            // Classifier with no generic_json: only ECS recognized, everything
728            // else is unknown.
729            SchemaClassifier::new(vec![]),
730            plan,
731            vec![vec![]],
732            CorrelationConfig::default(),
733            false,
734            MatchDetailLevel::Off,
735            None,
736            false,
737        )
738        .unwrap();
739
740        let native = json!({"CommandLine": "cmd /c whoami"});
741        let r = router.route(&JsonEvent::borrow(&native));
742        assert_eq!(r.schema, None);
743        assert_eq!(r.outcome, RouteOutcome::Dropped);
744        assert!(r.results.is_empty());
745    }
746
747    #[test]
748    fn schema_derived_logsource_prunes_cross_product_rules() {
749        // A Windows rule and a Linux rule that both match the same CommandLine.
750        let rules = r#"
751title: Win whoami
752id: win-whoami
753logsource:
754    category: process_creation
755    product: windows
756detection:
757    selection:
758        CommandLine|contains: whoami
759    condition: selection
760level: high
761---
762title: Linux whoami
763id: linux-whoami
764logsource:
765    category: process_creation
766    product: linux
767detection:
768    selection:
769        CommandLine|contains: whoami
770    condition: selection
771level: high
772"#;
773        let collection = parse_sigma_yaml(rules).unwrap();
774        // A flat Sysmon event with no explicit `product` field. It classifies
775        // as `sysmon`, whose built-in implied logsource is product: windows.
776        let event = json!({
777            "EventID": 1,
778            "ProcessGuid": "{abc}",
779            "Image": "C:/Windows/System32/cmd.exe",
780            "CommandLine": "cmd /c whoami"
781        });
782
783        // Without an extractor, no pruning: both rules fire.
784        let mut plain = SchemaRouter::build(
785            &collection,
786            SchemaClassifier::builtin(),
787            plan(&[]),
788            vec![vec![]],
789            CorrelationConfig::default(),
790            false,
791            MatchDetailLevel::Off,
792            None,
793            false,
794        )
795        .unwrap();
796        let r = plain.route(&JsonEvent::borrow(&event));
797        assert_eq!(r.schema.as_deref(), Some("sysmon"));
798        assert_eq!(r.results.len(), 2, "no pruning without an extractor");
799
800        // With an extractor, the schema-derived product (windows) prunes the
801        // Linux rule while keeping the Windows rule, even though the event
802        // carries no explicit product field.
803        let mut pruned = SchemaRouter::build(
804            &collection,
805            SchemaClassifier::builtin(),
806            plan(&[]),
807            vec![vec![]],
808            CorrelationConfig::default(),
809            false,
810            MatchDetailLevel::Off,
811            Some(LogSourceExtractor::new()),
812            false,
813        )
814        .unwrap();
815        let r = pruned.route(&JsonEvent::borrow(&event));
816        assert_eq!(r.schema.as_deref(), Some("sysmon"));
817        assert_eq!(
818            r.results.len(),
819            1,
820            "schema-derived product prunes the Linux rule"
821        );
822        assert_eq!(pruned.logsource_pruned_total(), 1);
823
824        // The static per-schema summary reflects the same eligibility: for the
825        // sysmon schema (product: windows) the Linux rule is pruned and the
826        // Windows rule stays eligible. Cross-platform schemas are absent, and
827        // the summary is empty without an extractor.
828        let summary = pruned.schema_pruning_summary();
829        let sysmon = summary
830            .iter()
831            .find(|s| s.schema == "sysmon")
832            .expect("sysmon in summary");
833        assert_eq!(sysmon.eligible, 1);
834        assert_eq!(sysmon.pruned, 1);
835        assert!(!summary.iter().any(|s| s.schema == "ecs"));
836        assert!(plain.schema_pruning_summary().is_empty());
837    }
838
839    #[test]
840    fn partition_rules_compiles_only_applicable_rules_per_set() {
841        // Windows, Linux, and a product-less rule that all match `whoami`.
842        let rules = r#"
843title: Win whoami
844id: win-whoami
845logsource:
846    category: process_creation
847    product: windows
848detection:
849    selection:
850        CommandLine|contains: whoami
851    condition: selection
852level: high
853---
854title: Linux whoami
855id: linux-whoami
856logsource:
857    category: process_creation
858    product: linux
859detection:
860    selection:
861        CommandLine|contains: whoami
862    condition: selection
863level: high
864---
865title: Any whoami
866id: any-whoami
867logsource:
868    category: process_creation
869detection:
870    selection:
871        CommandLine|contains: whoami
872    condition: selection
873level: high
874"#;
875        let collection = parse_sigma_yaml(rules).unwrap();
876        // Bind sysmon (implied product: windows) to a non-default pipeline-set
877        // (a no-op field mapping so the set differs from the default set).
878        let passthrough = parse_pipeline(
879            "name: passthrough\npriority: 10\ntransformations:\n  - id: noop\n    type: field_name_mapping\n    mapping:\n      __unused_a: __unused_b\n",
880        )
881        .unwrap();
882        let plan = plan(&[("sysmon", &["passthrough"])]);
883
884        let router = SchemaRouter::build(
885            &collection,
886            SchemaClassifier::builtin(),
887            plan,
888            vec![vec![], vec![passthrough]],
889            CorrelationConfig::default(),
890            false,
891            MatchDetailLevel::Off,
892            None,
893            true, // partition rules
894        )
895        .unwrap();
896
897        // Default set keeps all 3; the sysmon set drops the Linux rule and
898        // keeps the Windows and product-less rules.
899        assert_eq!(router.engine_rule_counts(), vec![3, 2]);
900    }
901
902    #[test]
903    fn partition_rules_off_keeps_full_ruleset() {
904        let rules = r#"
905title: Win whoami
906id: win-whoami
907logsource:
908    product: windows
909detection:
910    selection:
911        CommandLine|contains: whoami
912    condition: selection
913---
914title: Linux whoami
915id: linux-whoami
916logsource:
917    product: linux
918detection:
919    selection:
920        CommandLine|contains: whoami
921    condition: selection
922"#;
923        let collection = parse_sigma_yaml(rules).unwrap();
924        let passthrough = parse_pipeline(
925            "name: passthrough\npriority: 10\ntransformations:\n  - id: noop\n    type: field_name_mapping\n    mapping:\n      __unused_a: __unused_b\n",
926        )
927        .unwrap();
928        let plan = plan(&[("sysmon", &["passthrough"])]);
929        let router = SchemaRouter::build(
930            &collection,
931            SchemaClassifier::builtin(),
932            plan,
933            vec![vec![], vec![passthrough]],
934            CorrelationConfig::default(),
935            false,
936            MatchDetailLevel::Off,
937            None,
938            false, // partitioning off
939        )
940        .unwrap();
941        assert_eq!(router.engine_rule_counts(), vec![2, 2]);
942    }
943}