1mod introspect;
16#[cfg(test)]
17mod tests;
18mod types;
19
20pub use introspect::{CorrelationInfo, CorrelationStateSnapshot, GroupKeyPart, GroupStateInfo};
21pub use types::*;
22
23use std::collections::HashMap;
24
25use chrono::{DateTime, TimeZone, Utc};
26
27use rsigma_parser::{CorrelationRule, CorrelationType, SigmaCollection, SigmaRule, WindowMode};
28
29use crate::correlation::{
30 CompiledCorrelation, EventBuffer, EventRefBuffer, GroupKey, WindowDecision, WindowState,
31 apply_window_open, compile_correlation,
32};
33use crate::engine::{Engine, IdentifiedEvaluation};
34use crate::error::{EvalError, Result};
35use crate::event::{Event, EventValue};
36use crate::pipeline::{Pipeline, apply_pipelines, apply_pipelines_to_correlation};
37use crate::result::{CorrelationBody, EvaluationResult, ResultBody, RuleHeader};
38use crate::rule_metadata::{RuleBundleMetadata, RuleMetadataLookup};
39
40type RuleIdentity = (Option<String>, Option<String>);
41
42const SNAPSHOT_VERSION: u32 = 1;
48
49const MAX_CHAIN_DEPTH: usize = 10;
55
56pub struct CorrelationEngine {
61 engine: Engine,
63 correlations: Vec<CompiledCorrelation>,
65 rule_index: HashMap<String, Vec<usize>>,
68 rule_ids: Vec<(Option<String>, Option<String>, String)>,
71 state: HashMap<(usize, GroupKey), WindowState>,
73 last_alert: HashMap<(usize, GroupKey), i64>,
75 event_buffers: HashMap<(usize, GroupKey), EventBuffer>,
77 event_ref_buffers: HashMap<(usize, GroupKey), EventRefBuffer>,
79 correlation_only_rules: std::collections::HashSet<String>,
83 generated_rules: std::collections::HashSet<String>,
86 config: CorrelationConfig,
88 pipelines: Vec<Pipeline>,
90}
91
92impl CorrelationEngine {
93 pub fn new(config: CorrelationConfig) -> Self {
95 CorrelationEngine {
96 engine: Engine::new(),
97 correlations: Vec::new(),
98 rule_index: HashMap::new(),
99 rule_ids: Vec::new(),
100 state: HashMap::new(),
101 last_alert: HashMap::new(),
102 event_buffers: HashMap::new(),
103 event_ref_buffers: HashMap::new(),
104 correlation_only_rules: std::collections::HashSet::new(),
105 generated_rules: std::collections::HashSet::new(),
106 config,
107 pipelines: Vec::new(),
108 }
109 }
110
111 pub fn add_pipeline(&mut self, pipeline: Pipeline) {
115 self.pipelines.push(pipeline);
116 self.pipelines.sort_by_key(|p| p.priority);
117 }
118
119 pub fn set_include_event(&mut self, include: bool) {
121 self.engine.set_include_event(include);
122 }
123
124 pub fn set_match_detail(&mut self, level: crate::result::MatchDetailLevel) {
127 self.engine.set_match_detail(level);
128 }
129
130 pub fn set_bloom_prefilter(&mut self, enabled: bool) {
134 self.engine.set_bloom_prefilter(enabled);
135 }
136
137 pub fn set_logsource_extractor(
141 &mut self,
142 extractor: Option<crate::logsource::LogSourceExtractor>,
143 ) {
144 self.engine.set_logsource_extractor(extractor);
145 }
146
147 pub fn logsource_pruned_total(&self) -> u64 {
149 self.engine.logsource_pruned_total()
150 }
151
152 pub fn logsource_absent_total(&self) -> u64 {
154 self.engine.logsource_absent_total()
155 }
156
157 pub fn set_bloom_max_bytes(&mut self, max_bytes: usize) {
160 self.engine.set_bloom_max_bytes(max_bytes);
161 }
162
163 #[cfg(feature = "daachorse-index")]
167 pub fn set_cross_rule_ac(&mut self, enabled: bool) {
168 self.engine.set_cross_rule_ac(enabled);
169 }
170
171 pub fn set_correlation_event_mode(&mut self, mode: CorrelationEventMode) {
177 self.config.correlation_event_mode = mode;
178 }
179
180 pub fn set_max_correlation_events(&mut self, max: usize) {
183 self.config.max_correlation_events = max;
184 }
185
186 pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
192 if self.pipelines.is_empty() {
193 self.apply_custom_attributes(&rule.custom_attributes);
194 self.rule_ids
195 .push((rule.id.clone(), rule.name.clone(), rule.title.clone()));
196 self.engine.add_rule(rule)?;
197 } else {
198 let mut transformed = rule.clone();
199 apply_pipelines(&self.pipelines, &mut transformed)?;
200 self.apply_custom_attributes(&transformed.custom_attributes);
201 self.rule_ids.push((
202 transformed.id.clone(),
203 transformed.name.clone(),
204 transformed.title.clone(),
205 ));
206 let compiled = crate::compiler::compile_rule(&transformed)?;
208 self.engine.add_compiled_rule(compiled);
209 }
210 Ok(())
211 }
212
213 fn apply_custom_attributes(
227 &mut self,
228 attrs: &std::collections::HashMap<String, yaml_serde::Value>,
229 ) {
230 if let Some(field) = attrs.get("rsigma.timestamp_field").and_then(|v| v.as_str())
232 && !self.config.timestamp_fields.iter().any(|f| f == field)
233 {
234 self.config.timestamp_fields.insert(0, field.to_string());
235 }
236
237 if let Some(val) = attrs.get("rsigma.suppress").and_then(|v| v.as_str())
239 && self.config.suppress.is_none()
240 && let Ok(ts) = rsigma_parser::Timespan::parse(val)
241 {
242 self.config.suppress = Some(ts.seconds);
243 }
244
245 if let Some(val) = attrs.get("rsigma.action").and_then(|v| v.as_str())
247 && self.config.action_on_match == CorrelationAction::Alert
248 && let Ok(a) = val.parse::<CorrelationAction>()
249 {
250 self.config.action_on_match = a;
251 }
252 }
253
254 pub fn add_correlation(&mut self, corr: &CorrelationRule) -> Result<()> {
256 let owned;
257 let effective = if self.pipelines.is_empty() {
258 corr
259 } else {
260 owned = {
261 let mut c = corr.clone();
262 apply_pipelines_to_correlation(&self.pipelines, &mut c)?;
263 c
264 };
265 &owned
266 };
267
268 self.apply_custom_attributes(&effective.custom_attributes);
271
272 let compiled = compile_correlation(effective)?;
273 let idx = self.correlations.len();
274
275 for rule_ref in &compiled.rule_refs {
277 self.rule_index
278 .entry(rule_ref.clone())
279 .or_default()
280 .push(idx);
281 }
282
283 for rule_ref in &compiled.rule_refs {
286 if compiled.generate {
287 self.generated_rules.insert(rule_ref.clone());
288 self.correlation_only_rules.remove(rule_ref);
289 } else if !self.generated_rules.contains(rule_ref) {
290 self.correlation_only_rules.insert(rule_ref.clone());
291 }
292 }
293
294 self.correlations.push(compiled);
295 Ok(())
296 }
297
298 pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
307 let mut compiled_batch = Vec::with_capacity(collection.rules.len());
308 if self.pipelines.is_empty() {
309 for rule in &collection.rules {
310 self.apply_custom_attributes(&rule.custom_attributes);
311 self.rule_ids
312 .push((rule.id.clone(), rule.name.clone(), rule.title.clone()));
313 compiled_batch.push(crate::compiler::compile_rule(rule)?);
314 }
315 } else {
316 for rule in &collection.rules {
317 let mut transformed = rule.clone();
318 apply_pipelines(&self.pipelines, &mut transformed)?;
319 self.apply_custom_attributes(&transformed.custom_attributes);
320 self.rule_ids.push((
321 transformed.id.clone(),
322 transformed.name.clone(),
323 transformed.title.clone(),
324 ));
325 compiled_batch.push(crate::compiler::compile_rule(&transformed)?);
327 }
328 }
329 self.engine.extend_compiled_rules(compiled_batch);
330 for filter in &collection.filters {
332 self.engine.apply_filter(filter)?;
333 }
334 for corr in &collection.correlations {
335 self.add_correlation(corr)?;
336 }
337 self.validate_rule_refs()?;
338 self.detect_correlation_cycles()?;
339 Ok(())
340 }
341
342 fn validate_rule_refs(&self) -> Result<()> {
345 let mut known: std::collections::HashSet<&str> = std::collections::HashSet::new();
346
347 for (id, name, _) in &self.rule_ids {
348 if let Some(id) = id {
349 known.insert(id.as_str());
350 }
351 if let Some(name) = name {
352 known.insert(name.as_str());
353 }
354 }
355 for corr in &self.correlations {
356 if let Some(ref id) = corr.id {
357 known.insert(id.as_str());
358 }
359 if let Some(ref name) = corr.name {
360 known.insert(name.as_str());
361 }
362 }
363
364 for corr in &self.correlations {
365 for rule_ref in &corr.rule_refs {
366 if !known.contains(rule_ref.as_str()) {
367 return Err(EvalError::UnknownRuleRef(rule_ref.clone()));
368 }
369 }
370 }
371 Ok(())
372 }
373
374 fn detect_correlation_cycles(&self) -> Result<()> {
382 let mut corr_identifiers: HashMap<&str, usize> = HashMap::new();
384 for (idx, corr) in self.correlations.iter().enumerate() {
385 if let Some(ref id) = corr.id {
386 corr_identifiers.insert(id.as_str(), idx);
387 }
388 if let Some(ref name) = corr.name {
389 corr_identifiers.insert(name.as_str(), idx);
390 }
391 }
392
393 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); self.correlations.len()];
395 for (idx, corr) in self.correlations.iter().enumerate() {
396 for rule_ref in &corr.rule_refs {
397 if let Some(&target_idx) = corr_identifiers.get(rule_ref.as_str()) {
398 adj[idx].push(target_idx);
399 }
400 }
401 }
402
403 let mut state = vec![0u8; self.correlations.len()]; let mut path: Vec<usize> = Vec::new();
406
407 for start in 0..self.correlations.len() {
408 if state[start] == 0
409 && let Some(cycle) = Self::dfs_find_cycle(start, &adj, &mut state, &mut path)
410 {
411 let names: Vec<String> = cycle
412 .iter()
413 .map(|&i| {
414 self.correlations[i]
415 .id
416 .as_deref()
417 .or(self.correlations[i].name.as_deref())
418 .unwrap_or(&self.correlations[i].title)
419 .to_string()
420 })
421 .collect();
422 return Err(crate::error::EvalError::CorrelationCycle(
423 names.join(" -> "),
424 ));
425 }
426 }
427 Ok(())
428 }
429
430 fn dfs_find_cycle(
432 node: usize,
433 adj: &[Vec<usize>],
434 state: &mut [u8],
435 path: &mut Vec<usize>,
436 ) -> Option<Vec<usize>> {
437 state[node] = 1; path.push(node);
439
440 for &next in &adj[node] {
441 if state[next] == 1 {
442 if let Some(pos) = path.iter().position(|&n| n == next) {
444 let mut cycle = path[pos..].to_vec();
445 cycle.push(next); return Some(cycle);
447 }
448 }
449 if state[next] == 0
450 && let Some(cycle) = Self::dfs_find_cycle(next, adj, state, path)
451 {
452 return Some(cycle);
453 }
454 }
455
456 path.pop();
457 state[node] = 2; None
459 }
460
461 pub fn process_event(&mut self, event: &impl Event) -> ProcessResult {
467 let detections = self.engine.evaluate_identified(event);
468 self.correlate_identified_detections(event, detections)
469 }
470
471 pub fn correlate_detections(
481 &mut self,
482 event: &impl Event,
483 all_detections: Vec<EvaluationResult>,
484 ) -> ProcessResult {
485 let identities = all_detections
486 .iter()
487 .map(|detection| self.find_rule_identity(detection))
488 .collect();
489 self.correlate_with_identities(event, all_detections, identities)
490 }
491
492 pub(crate) fn correlate_identified_detections(
493 &mut self,
494 event: &impl Event,
495 detections: Vec<IdentifiedEvaluation>,
496 ) -> ProcessResult {
497 let (all_detections, identities) = split_identified(detections);
498 self.correlate_with_identities(event, all_detections, identities)
499 }
500
501 fn correlate_with_identities(
502 &mut self,
503 event: &impl Event,
504 all_detections: Vec<EvaluationResult>,
505 identities: Vec<RuleIdentity>,
506 ) -> ProcessResult {
507 let ts = match self.extract_event_timestamp(event) {
508 Some(ts) => ts,
509 None => match self.config.timestamp_fallback {
510 TimestampFallback::WallClock => Utc::now().timestamp(),
511 TimestampFallback::Skip => {
512 return self.filter_detections(all_detections, identities);
514 }
515 },
516 };
517 self.process_with_detection_identities(event, all_detections, identities, ts)
518 }
519
520 pub fn process_event_at(&mut self, event: &impl Event, timestamp_secs: i64) -> ProcessResult {
525 let detections = self.engine.evaluate_identified(event);
526 let (all_detections, identities) = split_identified(detections);
527 self.process_with_detection_identities(event, all_detections, identities, timestamp_secs)
528 }
529
530 pub fn process_with_detections(
542 &mut self,
543 event: &impl Event,
544 all_detections: Vec<EvaluationResult>,
545 timestamp_secs: i64,
546 ) -> ProcessResult {
547 let identities = all_detections
548 .iter()
549 .map(|detection| self.find_rule_identity(detection))
550 .collect();
551 self.process_with_detection_identities(event, all_detections, identities, timestamp_secs)
552 }
553
554 fn process_with_detection_identities(
555 &mut self,
556 event: &impl Event,
557 all_detections: Vec<EvaluationResult>,
558 identities: Vec<RuleIdentity>,
559 timestamp_secs: i64,
560 ) -> ProcessResult {
561 let timestamp_secs = timestamp_secs.clamp(0, i64::MAX / 2);
562
563 if self.state.len() >= self.config.max_state_entries {
565 self.evict_all(timestamp_secs);
566 }
567
568 let mut correlations: Vec<EvaluationResult> = Vec::new();
570 let mut fired_indices = Vec::new();
571 self.feed_detections(
572 event,
573 &identities,
574 timestamp_secs,
575 &mut correlations,
576 &mut fired_indices,
577 );
578
579 let mut chained = Vec::new();
582 let mut chained_indices = Vec::new();
583 self.chain_correlations(
584 &correlations,
585 &fired_indices,
586 timestamp_secs,
587 &mut chained,
588 &mut chained_indices,
589 );
590 correlations.extend(chained);
591 fired_indices.extend(chained_indices);
592
593 let mut out = self.filter_detections(all_detections, identities);
596 out.extend(
597 correlations
598 .into_iter()
599 .zip(fired_indices)
600 .filter(|(_, idx)| {
601 let corr = &self.correlations[*idx];
602 let identities = corr.id.as_deref().into_iter().chain(corr.name.as_deref());
603 !self.is_correlation_only(identities)
604 })
605 .map(|(result, _)| result),
606 );
607 out
608 }
609
610 pub fn evaluate(&self, event: &impl Event) -> Vec<EvaluationResult> {
617 self.engine.evaluate(event)
618 }
619
620 pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
628 let engine = &self.engine;
631 let ts_fields = &self.config.timestamp_fields;
632
633 let batch_results: Vec<(Vec<IdentifiedEvaluation>, Option<i64>)> = {
634 #[cfg(feature = "parallel")]
635 {
636 use rayon::prelude::*;
637 events
638 .par_iter()
639 .map(|e| {
640 let detections = engine.evaluate_identified(e);
641 let ts = extract_event_ts(e, ts_fields);
642 (detections, ts)
643 })
644 .collect()
645 }
646 #[cfg(not(feature = "parallel"))]
647 {
648 events
649 .iter()
650 .map(|e| {
651 let detections = engine.evaluate_identified(e);
652 let ts = extract_event_ts(e, ts_fields);
653 (detections, ts)
654 })
655 .collect()
656 }
657 };
658
659 let mut results = Vec::with_capacity(events.len());
661 for ((identified, ts_opt), event) in batch_results.into_iter().zip(events) {
662 let (detections, identities) = split_identified(identified);
663 let ts = match ts_opt {
664 Some(ts) => ts,
665 None => match self.config.timestamp_fallback {
666 TimestampFallback::WallClock => Utc::now().timestamp(),
667 TimestampFallback::Skip => {
668 results.push(self.filter_detections(detections, identities));
670 continue;
671 }
672 },
673 };
674 results.push(self.process_with_detection_identities(event, detections, identities, ts));
675 }
676 results
677 }
678
679 fn filter_detections(
684 &self,
685 all_detections: Vec<EvaluationResult>,
686 identities: Vec<RuleIdentity>,
687 ) -> Vec<EvaluationResult> {
688 if !self.config.emit_detections && !self.correlation_only_rules.is_empty() {
689 all_detections
690 .into_iter()
691 .zip(identities)
692 .filter_map(|(detection, (id, name))| {
693 let identities = id.as_deref().into_iter().chain(name.as_deref());
694 (!self.is_correlation_only(identities)).then_some(detection)
695 })
696 .collect()
697 } else {
698 all_detections
699 }
700 }
701
702 fn is_correlation_only<'a>(
706 &self,
707 mut identities: impl Iterator<Item = &'a str> + Clone,
708 ) -> bool {
709 !self.config.emit_detections
710 && identities
711 .clone()
712 .any(|identity| self.correlation_only_rules.contains(identity))
713 && !identities.any(|identity| self.generated_rules.contains(identity))
714 }
715
716 fn feed_detections(
718 &mut self,
719 event: &impl Event,
720 identities: &[RuleIdentity],
721 ts: i64,
722 out: &mut Vec<EvaluationResult>,
723 fired_indices: &mut Vec<usize>,
724 ) {
725 let mut work: Vec<(usize, Option<String>, Option<String>)> = Vec::new();
728
729 for (rule_id, rule_name) in identities {
730 let mut corr_indices = Vec::new();
732 if let Some(id) = rule_id
733 && let Some(indices) = self.rule_index.get(id)
734 {
735 corr_indices.extend(indices);
736 }
737 if let Some(name) = rule_name
738 && let Some(indices) = self.rule_index.get(name)
739 {
740 corr_indices.extend(indices);
741 }
742
743 corr_indices.sort_unstable();
744 corr_indices.dedup();
745
746 for &corr_idx in &corr_indices {
747 work.push((corr_idx, rule_id.clone(), rule_name.clone()));
748 }
749 }
750
751 for (corr_idx, rule_id, rule_name) in work {
752 let before = out.len();
753 self.update_correlation(corr_idx, event, ts, &rule_id, &rule_name, out);
754 if out.len() > before {
755 fired_indices.push(corr_idx);
756 }
757 }
758 }
759
760 fn find_rule_identity(&self, det: &EvaluationResult) -> RuleIdentity {
762 if let Some(ref match_id) = det.header.rule_id {
764 for (id, name, _) in &self.rule_ids {
765 if id.as_deref() == Some(match_id.as_str()) {
766 return (id.clone(), name.clone());
767 }
768 }
769 }
770
771 let mut title_matches = self
775 .rule_ids
776 .iter()
777 .filter(|(_, _, title)| title == &det.header.rule_title);
778 if let Some((id, name, _)) = title_matches.next()
779 && title_matches.next().is_none()
780 {
781 return (id.clone(), name.clone());
782 }
783
784 (det.header.rule_id.clone(), None)
785 }
786
787 fn resolve_event_mode(&self, corr_idx: usize) -> CorrelationEventMode {
789 let corr = &self.correlations[corr_idx];
790 corr.event_mode
791 .unwrap_or(self.config.correlation_event_mode)
792 }
793
794 fn resolve_max_events(&self, corr_idx: usize) -> usize {
796 let corr = &self.correlations[corr_idx];
797 corr.max_events
798 .unwrap_or(self.config.max_correlation_events)
799 }
800
801 fn resolve_max_group_entries(&self, corr_idx: usize) -> Option<usize> {
804 let corr = &self.correlations[corr_idx];
805 corr.max_group_entries.or(self.config.max_group_entries)
806 }
807
808 fn update_correlation(
810 &mut self,
811 corr_idx: usize,
812 event: &impl Event,
813 ts: i64,
814 rule_id: &Option<String>,
815 rule_name: &Option<String>,
816 out: &mut Vec<EvaluationResult>,
817 ) {
818 let corr = &self.correlations[corr_idx];
822 let corr_type = corr.correlation_type;
823 let timespan = corr.timespan_secs;
824 let window_mode = corr.window_mode;
825 let gap_secs = corr.gap_secs;
826 let level = corr.level;
827 let suppress_secs = corr.suppress_secs.or(self.config.suppress);
828 let action = corr.action.unwrap_or(self.config.action_on_match);
829 let event_mode = self.resolve_event_mode(corr_idx);
830 let max_events = self.resolve_max_events(corr_idx);
831 let max_group_entries = self.resolve_max_group_entries(corr_idx);
832
833 let mut ref_strs: Vec<&str> = Vec::new();
835 if let Some(id) = rule_id.as_deref() {
836 ref_strs.push(id);
837 }
838 if let Some(name) = rule_name.as_deref() {
839 ref_strs.push(name);
840 }
841 let rule_ref = ref_strs
842 .iter()
843 .copied()
844 .find(|identity| corr.rule_refs.iter().any(|rule_ref| rule_ref == identity))
845 .unwrap_or("");
846
847 let Some(group_key) = GroupKey::extract(event, &corr.group_by, &ref_strs) else {
850 return;
851 };
852
853 let state_key = (corr_idx, group_key.clone());
855 let state = self
856 .state
857 .entry(state_key.clone())
858 .or_insert_with(|| WindowState::new_for(corr_type));
859
860 let cutoff = ts - timespan as i64;
866 let decision = apply_window_open(state, ts, timespan, window_mode, gap_secs);
867 if decision == WindowDecision::Discard {
868 return;
869 }
870 let reset = decision == WindowDecision::Reset;
871
872 match corr_type {
874 CorrelationType::EventCount => {
875 state.push_event_count(ts);
876 }
877 CorrelationType::ValueCount => {
878 if let Some(ref fields) = corr.condition.field
879 && let Some(key) = composite_value_count_key(event, fields)
880 {
881 state.push_value_count(ts, key);
882 }
883 }
884 CorrelationType::Temporal | CorrelationType::TemporalOrdered => {
885 state.push_temporal(ts, rule_ref);
886 }
887 CorrelationType::ValueSum
888 | CorrelationType::ValueAvg
889 | CorrelationType::ValuePercentile
890 | CorrelationType::ValueMedian => {
891 if let Some(ref fields) = corr.condition.field
892 && let Some(field_name) = fields.first()
893 && let Some(val) = event.get_field(field_name)
894 && let Some(n) = value_to_f64_ev(&val)
895 {
896 state.push_numeric(ts, n);
897 }
898 }
899 }
900
901 if let Some(cap) = max_group_entries {
905 state.truncate_oldest(cap, window_mode == WindowMode::Session);
906 }
907
908 match event_mode {
912 CorrelationEventMode::Full => {
913 let buf = self
914 .event_buffers
915 .entry(state_key.clone())
916 .or_insert_with(|| EventBuffer::new(max_events));
917 if window_mode == rsigma_parser::WindowMode::Sliding {
918 buf.evict(cutoff);
919 } else if reset {
920 buf.clear();
921 }
922 let json = event.to_json();
923 buf.push(ts, &json);
924 }
925 CorrelationEventMode::Refs => {
926 let buf = self
927 .event_ref_buffers
928 .entry(state_key.clone())
929 .or_insert_with(|| EventRefBuffer::new(max_events));
930 if window_mode == rsigma_parser::WindowMode::Sliding {
931 buf.evict(cutoff);
932 } else if reset {
933 buf.clear();
934 }
935 let json = event.to_json();
936 buf.push(ts, &json);
937 }
938 CorrelationEventMode::None => {}
939 }
940
941 let fired = state.check_condition(
943 &corr.condition,
944 corr_type,
945 &corr.rule_refs,
946 corr.extended_expr.as_ref(),
947 );
948
949 if let Some(agg_value) = fired {
950 let alert_key = (corr_idx, group_key.clone());
951
952 let suppressed = if let Some(suppress) = suppress_secs {
954 if let Some(&last_ts) = self.last_alert.get(&alert_key) {
955 (ts - last_ts) < suppress as i64
956 } else {
957 false
958 }
959 } else {
960 false
961 };
962
963 if !suppressed {
964 let (events, event_refs) = match event_mode {
966 CorrelationEventMode::Full => {
967 let stored = self
968 .event_buffers
969 .get(&alert_key)
970 .map(|buf| buf.decompress_all())
971 .unwrap_or_default();
972 (Some(stored), None)
973 }
974 CorrelationEventMode::Refs => {
975 let stored = self
976 .event_ref_buffers
977 .get(&alert_key)
978 .map(|buf| buf.refs())
979 .unwrap_or_default();
980 (None, Some(stored))
981 }
982 CorrelationEventMode::None => (None, None),
983 };
984
985 let corr = &self.correlations[corr_idx];
987 let result = EvaluationResult {
988 header: RuleHeader {
989 rule_title: corr.title.clone(),
990 rule_id: corr.id.clone(),
991 level,
992 tags: corr.tags.clone(),
993 custom_attributes: corr.custom_attributes.clone(),
994 enrichments: None,
995 },
996 body: ResultBody::Correlation(CorrelationBody {
997 correlation_type: corr_type,
998 group_key: group_key.to_pairs(&corr.group_by),
999 aggregated_value: agg_value,
1000 timespan_secs: timespan,
1001 events,
1002 event_refs,
1003 }),
1004 };
1005 out.push(result);
1006
1007 self.last_alert.insert(alert_key.clone(), ts);
1009
1010 if action == CorrelationAction::Reset {
1012 if let Some(state) = self.state.get_mut(&alert_key) {
1013 state.clear();
1014 }
1015 if let Some(buf) = self.event_buffers.get_mut(&alert_key) {
1016 buf.clear();
1017 }
1018 if let Some(buf) = self.event_ref_buffers.get_mut(&alert_key) {
1019 buf.clear();
1020 }
1021 }
1022 }
1023 }
1024 }
1025
1026 fn chain_lookup_keys(&self, corr_idx: usize) -> Vec<String> {
1032 let corr = &self.correlations[corr_idx];
1033 let mut keys = Vec::with_capacity(2);
1034 keys.extend(corr.id.iter().cloned());
1035 if corr.name.as_ref() != corr.id.as_ref() {
1036 keys.extend(corr.name.iter().cloned());
1037 }
1038 keys
1039 }
1040
1041 fn chain_correlations(
1048 &mut self,
1049 fired: &[EvaluationResult],
1050 fired_indices: &[usize],
1051 ts: i64,
1052 out: &mut Vec<EvaluationResult>,
1053 out_indices: &mut Vec<usize>,
1054 ) {
1055 debug_assert_eq!(fired.len(), fired_indices.len());
1056 let mut pending: Vec<(usize, EvaluationResult)> = fired_indices
1057 .iter()
1058 .copied()
1059 .zip(fired.iter().cloned())
1060 .collect();
1061 let mut depth = 0;
1062
1063 while !pending.is_empty() && depth < MAX_CHAIN_DEPTH {
1064 depth += 1;
1065
1066 #[allow(clippy::type_complexity)]
1068 let mut work: Vec<(usize, Vec<(String, String)>, String)> = Vec::new();
1069 let mut seen = std::collections::HashSet::<(usize, String)>::new();
1070 for (source_idx, result) in &pending {
1071 let Some(body) = result.as_correlation() else {
1073 continue;
1074 };
1075 for key in self.chain_lookup_keys(*source_idx) {
1076 if let Some(indices) = self.rule_index.get(&key) {
1077 for &corr_idx in indices {
1078 if seen.insert((corr_idx, key.clone())) {
1079 work.push((corr_idx, body.group_key.clone(), key.clone()));
1080 }
1081 }
1082 }
1083 }
1084 }
1085
1086 let mut next_pending = Vec::new();
1087 for (corr_idx, group_key_pairs, fired_ref) in work {
1088 let corr = &self.correlations[corr_idx];
1089 let corr_type = corr.correlation_type;
1090 let timespan = corr.timespan_secs;
1091 let window_mode = corr.window_mode;
1092 let gap_secs = corr.gap_secs;
1093 let level = corr.level;
1094 let suppress_secs = corr.suppress_secs.or(self.config.suppress);
1095 let action = corr.action.unwrap_or(self.config.action_on_match);
1096
1097 let Some(group_key) = GroupKey::from_pairs(&group_key_pairs, &corr.group_by) else {
1098 continue;
1099 };
1100 let state_key = (corr_idx, group_key.clone());
1101 let state = self
1102 .state
1103 .entry(state_key.clone())
1104 .or_insert_with(|| WindowState::new_for(corr_type));
1105
1106 if apply_window_open(state, ts, timespan, window_mode, gap_secs)
1110 == WindowDecision::Discard
1111 {
1112 continue;
1113 }
1114
1115 match corr_type {
1116 CorrelationType::EventCount => {
1117 state.push_event_count(ts);
1118 }
1119 CorrelationType::Temporal | CorrelationType::TemporalOrdered => {
1120 state.push_temporal(ts, &fired_ref);
1121 }
1122 _ => {
1123 state.push_event_count(ts);
1124 }
1125 }
1126
1127 if let Some(cap) = corr.max_group_entries.or(self.config.max_group_entries) {
1130 state.truncate_oldest(cap, window_mode == WindowMode::Session);
1131 }
1132
1133 let fired = state.check_condition(
1134 &corr.condition,
1135 corr_type,
1136 &corr.rule_refs,
1137 corr.extended_expr.as_ref(),
1138 );
1139
1140 if let Some(agg_value) = fired {
1141 let alert_key = state_key;
1142 let suppressed = if let Some(suppress) = suppress_secs {
1143 self.last_alert
1144 .get(&alert_key)
1145 .is_some_and(|&last_ts| (ts - last_ts) < suppress as i64)
1146 } else {
1147 false
1148 };
1149 if suppressed {
1150 continue;
1151 }
1152
1153 let corr = &self.correlations[corr_idx];
1154 let result = EvaluationResult {
1155 header: RuleHeader {
1156 rule_title: corr.title.clone(),
1157 rule_id: corr.id.clone(),
1158 level,
1159 tags: corr.tags.clone(),
1160 custom_attributes: corr.custom_attributes.clone(),
1161 enrichments: None,
1162 },
1163 body: ResultBody::Correlation(CorrelationBody {
1164 correlation_type: corr_type,
1165 group_key: group_key.to_pairs(&corr.group_by),
1166 aggregated_value: agg_value,
1167 timespan_secs: timespan,
1168 events: None,
1172 event_refs: None,
1173 }),
1174 };
1175 next_pending.push((corr_idx, result.clone()));
1176 out.push(result);
1177 out_indices.push(corr_idx);
1178 self.last_alert.insert(alert_key.clone(), ts);
1179
1180 if action == CorrelationAction::Reset
1181 && let Some(state) = self.state.get_mut(&alert_key)
1182 {
1183 state.clear();
1184 }
1185 }
1186 }
1187
1188 pending = next_pending;
1189 }
1190
1191 if !pending.is_empty() {
1192 log::warn!(
1193 "Correlation chain depth limit reached ({MAX_CHAIN_DEPTH}); \
1194 {} pending result(s) were not propagated further. \
1195 This may indicate a cycle in correlation references.",
1196 pending.len()
1197 );
1198 }
1199 }
1200
1201 fn extract_event_timestamp(&self, event: &impl Event) -> Option<i64> {
1213 for field_name in &self.config.timestamp_fields {
1214 if let Some(val) = event.get_field(field_name)
1215 && let Some(ts) = parse_timestamp_value(&val)
1216 {
1217 return Some(ts);
1218 }
1219 }
1220 None
1221 }
1222
1223 pub fn evict_expired(&mut self, now_secs: i64) {
1229 self.evict_all(now_secs);
1230 }
1231
1232 fn evict_all(&mut self, now_secs: i64) {
1234 let specs: Vec<(u64, WindowMode, Option<u64>)> = self
1245 .correlations
1246 .iter()
1247 .map(|c| (c.timespan_secs, c.window_mode, c.gap_secs))
1248 .collect();
1249
1250 self.state.retain(|&(corr_idx, _), state| {
1251 if let Some(&(timespan, mode, gap)) = specs.get(corr_idx) {
1252 match mode {
1253 WindowMode::Sliding => {
1254 state.evict(now_secs - timespan as i64);
1255 }
1256 WindowMode::Tumbling | WindowMode::Session => {
1257 let staleness = if mode == WindowMode::Session {
1258 gap.unwrap_or(timespan)
1259 } else {
1260 timespan
1261 } as i64;
1262 if state
1263 .latest_timestamp()
1264 .is_some_and(|last| now_secs - last > staleness)
1265 {
1266 state.clear();
1267 }
1268 }
1269 }
1270 }
1271 !state.is_empty()
1272 });
1273
1274 let state = &self.state;
1278 self.event_buffers.retain(|key, buf| {
1279 if let Some(&(timespan, mode, _)) = specs.get(key.0) {
1280 match mode {
1281 WindowMode::Sliding => buf.evict(now_secs - timespan as i64),
1282 WindowMode::Tumbling | WindowMode::Session => {
1283 if !state.contains_key(key) {
1284 return false;
1285 }
1286 }
1287 }
1288 }
1289 !buf.is_empty()
1290 });
1291 self.event_ref_buffers.retain(|key, buf| {
1292 if let Some(&(timespan, mode, _)) = specs.get(key.0) {
1293 match mode {
1294 WindowMode::Sliding => buf.evict(now_secs - timespan as i64),
1295 WindowMode::Tumbling | WindowMode::Session => {
1296 if !state.contains_key(key) {
1297 return false;
1298 }
1299 }
1300 }
1301 }
1302 !buf.is_empty()
1303 });
1304
1305 if self.state.len() >= self.config.max_state_entries {
1309 let target = self.config.max_state_entries * 9 / 10;
1310 let excess = self.state.len() - target;
1311
1312 log::warn!(
1313 "Correlation state hard cap reached ({} entries, max {}); \
1314 evicting {} stalest entries to {} (90% capacity). \
1315 This indicates high-cardinality traffic; consider raising \
1316 max_state_entries or shortening correlation windows.",
1317 self.state.len(),
1318 self.config.max_state_entries,
1319 excess,
1320 target,
1321 );
1322
1323 let mut by_staleness: Vec<_> = self
1325 .state
1326 .iter()
1327 .map(|(k, v)| (k.clone(), v.latest_timestamp().unwrap_or(i64::MIN)))
1328 .collect();
1329 by_staleness.sort_unstable_by_key(|&(_, ts)| ts);
1330
1331 for (key, _) in by_staleness.into_iter().take(excess) {
1333 self.state.remove(&key);
1334 self.last_alert.remove(&key);
1335 self.event_buffers.remove(&key);
1336 self.event_ref_buffers.remove(&key);
1337 }
1338 }
1339
1340 self.last_alert.retain(|key, &mut alert_ts| {
1343 let suppress = if key.0 < self.correlations.len() {
1344 self.correlations[key.0]
1345 .suppress_secs
1346 .or(self.config.suppress)
1347 .unwrap_or(0)
1348 } else {
1349 0
1350 };
1351 (now_secs - alert_ts) < suppress as i64
1352 });
1353 }
1354
1355 pub fn state_count(&self) -> usize {
1357 self.state.len()
1358 }
1359
1360 pub fn detection_rule_count(&self) -> usize {
1362 self.engine.rule_count()
1363 }
1364
1365 pub fn correlation_rule_count(&self) -> usize {
1367 self.correlations.len()
1368 }
1369
1370 pub fn event_buffer_count(&self) -> usize {
1372 self.event_buffers.len()
1373 }
1374
1375 pub fn event_buffer_bytes(&self) -> usize {
1377 self.event_buffers
1378 .values()
1379 .map(|b| b.compressed_bytes())
1380 .sum()
1381 }
1382
1383 pub fn event_ref_buffer_count(&self) -> usize {
1385 self.event_ref_buffers.len()
1386 }
1387
1388 pub fn engine(&self) -> &Engine {
1390 &self.engine
1391 }
1392
1393 pub fn rule_metadata(&self, key: &str) -> RuleMetadataLookup {
1397 let mut variants = Vec::new();
1398 self.collect_rule_metadata(key, &mut variants);
1399 RuleMetadataLookup::from_variants(variants)
1400 }
1401
1402 pub(crate) fn collect_rule_metadata(&self, key: &str, out: &mut Vec<RuleBundleMetadata>) {
1403 self.engine.collect_rule_metadata(key, out);
1404 crate::rule_metadata::matching_correlations(&self.correlations, key, out);
1405 }
1406
1407 pub fn export_state(&self) -> CorrelationSnapshot {
1413 let mut windows: HashMap<String, Vec<(GroupKey, WindowState)>> = HashMap::new();
1414 for ((idx, gk), ws) in &self.state {
1415 let corr_id = self.correlation_stable_id(*idx);
1416 windows
1417 .entry(corr_id)
1418 .or_default()
1419 .push((gk.clone(), ws.clone()));
1420 }
1421
1422 let mut last_alert: HashMap<String, Vec<(GroupKey, i64)>> = HashMap::new();
1423 for ((idx, gk), ts) in &self.last_alert {
1424 let corr_id = self.correlation_stable_id(*idx);
1425 last_alert
1426 .entry(corr_id)
1427 .or_default()
1428 .push((gk.clone(), *ts));
1429 }
1430
1431 let mut event_buffers: HashMap<String, Vec<(GroupKey, EventBuffer)>> = HashMap::new();
1432 for ((idx, gk), buf) in &self.event_buffers {
1433 let corr_id = self.correlation_stable_id(*idx);
1434 event_buffers
1435 .entry(corr_id)
1436 .or_default()
1437 .push((gk.clone(), buf.clone()));
1438 }
1439
1440 let mut event_ref_buffers: HashMap<String, Vec<(GroupKey, EventRefBuffer)>> =
1441 HashMap::new();
1442 for ((idx, gk), buf) in &self.event_ref_buffers {
1443 let corr_id = self.correlation_stable_id(*idx);
1444 event_ref_buffers
1445 .entry(corr_id)
1446 .or_default()
1447 .push((gk.clone(), buf.clone()));
1448 }
1449
1450 CorrelationSnapshot {
1451 version: SNAPSHOT_VERSION,
1452 windows,
1453 last_alert,
1454 event_buffers,
1455 event_ref_buffers,
1456 }
1457 }
1458
1459 pub fn import_state(&mut self, snapshot: CorrelationSnapshot) -> bool {
1466 if snapshot.version != SNAPSHOT_VERSION {
1467 return false;
1468 }
1469 let id_to_idx = self.build_id_to_index_map();
1470
1471 for (corr_id, groups) in snapshot.windows {
1472 if let Some(&idx) = id_to_idx.get(&corr_id) {
1473 for (gk, ws) in groups {
1474 self.state.insert((idx, gk), ws);
1475 }
1476 }
1477 }
1478
1479 for (corr_id, groups) in snapshot.last_alert {
1480 if let Some(&idx) = id_to_idx.get(&corr_id) {
1481 for (gk, ts) in groups {
1482 self.last_alert.insert((idx, gk), ts);
1483 }
1484 }
1485 }
1486
1487 for (corr_id, groups) in snapshot.event_buffers {
1488 if let Some(&idx) = id_to_idx.get(&corr_id) {
1489 for (gk, buf) in groups {
1490 self.event_buffers.insert((idx, gk), buf);
1491 }
1492 }
1493 }
1494
1495 for (corr_id, groups) in snapshot.event_ref_buffers {
1496 if let Some(&idx) = id_to_idx.get(&corr_id) {
1497 for (gk, buf) in groups {
1498 self.event_ref_buffers.insert((idx, gk), buf);
1499 }
1500 }
1501 }
1502
1503 true
1504 }
1505
1506 fn correlation_stable_id(&self, idx: usize) -> String {
1508 let corr = &self.correlations[idx];
1509 corr.id
1510 .clone()
1511 .or_else(|| corr.name.clone())
1512 .unwrap_or_else(|| corr.title.clone())
1513 }
1514
1515 fn build_id_to_index_map(&self) -> HashMap<String, usize> {
1517 self.correlations
1518 .iter()
1519 .enumerate()
1520 .map(|(idx, _)| (self.correlation_stable_id(idx), idx))
1521 .collect()
1522 }
1523}
1524
1525impl Default for CorrelationEngine {
1526 fn default() -> Self {
1527 Self::new(CorrelationConfig::default())
1528 }
1529}
1530
1531fn split_identified(
1532 identified: Vec<IdentifiedEvaluation>,
1533) -> (Vec<EvaluationResult>, Vec<RuleIdentity>) {
1534 identified
1535 .into_iter()
1536 .map(|identified| {
1537 (
1538 identified.result,
1539 (identified.rule_id, identified.rule_name),
1540 )
1541 })
1542 .unzip()
1543}
1544
1545fn extract_event_ts(event: &impl Event, timestamp_fields: &[String]) -> Option<i64> {
1554 for field_name in timestamp_fields {
1555 if let Some(val) = event.get_field(field_name)
1556 && let Some(ts) = parse_timestamp_value(&val)
1557 {
1558 return Some(ts);
1559 }
1560 }
1561 None
1562}
1563
1564fn parse_timestamp_value(val: &EventValue) -> Option<i64> {
1566 match val {
1567 EventValue::Int(i) => Some(normalize_epoch(*i)),
1568 EventValue::Float(f) => Some(normalize_epoch(*f as i64)),
1569 EventValue::Str(s) => parse_timestamp_string(s),
1570 _ => None,
1571 }
1572}
1573
1574fn normalize_epoch(v: i64) -> i64 {
1577 if v > 1_000_000_000_000 { v / 1000 } else { v }
1578}
1579
1580fn parse_timestamp_string(s: &str) -> Option<i64> {
1582 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
1584 return Some(dt.timestamp());
1585 }
1586
1587 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
1590 return Some(Utc.from_utc_datetime(&naive).timestamp());
1591 }
1592 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
1593 return Some(Utc.from_utc_datetime(&naive).timestamp());
1594 }
1595
1596 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
1598 return Some(Utc.from_utc_datetime(&naive).timestamp());
1599 }
1600 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
1601 return Some(Utc.from_utc_datetime(&naive).timestamp());
1602 }
1603
1604 None
1605}
1606
1607fn value_to_string_for_count(v: &EventValue) -> Option<String> {
1609 match v {
1610 EventValue::Str(s) => Some(s.to_string()),
1611 EventValue::Int(n) => Some(n.to_string()),
1612 EventValue::Float(f) => Some(f.to_string()),
1613 EventValue::Bool(b) => Some(b.to_string()),
1614 EventValue::Null => Some("null".to_string()),
1615 _ => None,
1616 }
1617}
1618
1619fn composite_value_count_key(event: &impl Event, fields: &[String]) -> Option<String> {
1628 if let [field_name] = fields {
1630 let val = event.get_field(field_name)?;
1631 return value_to_string_for_count(&val);
1632 }
1633
1634 let mut parts = Vec::with_capacity(fields.len());
1635 for field_name in fields {
1636 let val = event.get_field(field_name)?;
1637 let rendered = value_to_string_for_count(&val)?;
1638 parts.push(rendered);
1639 }
1640 Some(parts.join("\u{1f}"))
1641}
1642
1643fn value_to_f64_ev(v: &EventValue) -> Option<f64> {
1645 v.as_f64()
1646}