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