1use 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#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SchemaPruning {
48 pub schema: String,
50 pub eligible: usize,
52 pub pruned: usize,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum RouteOutcome {
59 Evaluated,
61 EvaluatedUnknown,
64 Dropped,
66 Errored,
68}
69
70pub struct RouteResult {
72 pub results: ProcessResult,
74 pub schema: Option<String>,
76 pub outcome: RouteOutcome,
78}
79
80fn 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
98enum Routed1 {
100 Skip,
102 Eval {
104 set: usize,
105 detections: Vec<EvaluationResult>,
106 },
107}
108
109fn 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
119fn 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
134fn 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
165fn 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
193pub struct SchemaRouter {
196 classifier: SchemaClassifier,
197 plan: RoutingPlan,
198 engines: Vec<Engine>,
200 field_maps: Vec<HashMap<String, Vec<String>>>,
202 correlation: Option<CorrelationEngine>,
204 logsource_extractor: Option<LogSourceExtractor>,
208}
209
210impl SchemaRouter {
211 #[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 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 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 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 pub fn on_unknown(&self) -> OnUnknown {
287 self.plan.on_unknown()
288 }
289
290 pub fn has_correlations(&self) -> bool {
292 self.correlation.is_some()
293 }
294
295 pub fn detection_rule_count(&self) -> usize {
300 self.engines.first().map(|e| e.rule_count()).unwrap_or(0)
301 }
302
303 pub fn engine_rule_counts(&self) -> Vec<usize> {
307 self.engines.iter().map(Engine::rule_count).collect()
308 }
309
310 pub fn logsource_pruned_total(&self) -> u64 {
313 self.engines
314 .iter()
315 .map(Engine::logsource_pruned_total)
316 .sum()
317 }
318
319 pub fn logsource_absent_total(&self) -> u64 {
322 self.engines
323 .iter()
324 .map(Engine::logsource_absent_total)
325 .sum()
326 }
327
328 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 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 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 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 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 pub fn export_state(&self) -> Option<CorrelationSnapshot> {
408 self.correlation.as_ref().map(|c| c.export_state())
409 }
410
411 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 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 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 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 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 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 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 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 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 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 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 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 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 bindings: vec![],
723 };
724 let plan = RoutingPlan::from_config(&config);
725 let mut router = SchemaRouter::build(
726 &collection,
727 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 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 let event = json!({
777 "EventID": 1,
778 "ProcessGuid": "{abc}",
779 "Image": "C:/Windows/System32/cmd.exe",
780 "CommandLine": "cmd /c whoami"
781 });
782
783 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 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 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 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 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, )
895 .unwrap();
896
897 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, )
940 .unwrap();
941 assert_eq!(router.engine_rule_counts(), vec![2, 2]);
942 }
943}