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;
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
40const SNAPSHOT_VERSION: u32 = 1;
46
47pub struct CorrelationEngine {
52 engine: Engine,
54 correlations: Vec<CompiledCorrelation>,
56 rule_index: HashMap<String, Vec<usize>>,
59 rule_ids: Vec<(Option<String>, Option<String>)>,
62 state: HashMap<(usize, GroupKey), WindowState>,
64 last_alert: HashMap<(usize, GroupKey), i64>,
66 event_buffers: HashMap<(usize, GroupKey), EventBuffer>,
68 event_ref_buffers: HashMap<(usize, GroupKey), EventRefBuffer>,
70 correlation_only_rules: std::collections::HashSet<String>,
74 config: CorrelationConfig,
76 pipelines: Vec<Pipeline>,
78}
79
80impl CorrelationEngine {
81 pub fn new(config: CorrelationConfig) -> Self {
83 CorrelationEngine {
84 engine: Engine::new(),
85 correlations: Vec::new(),
86 rule_index: HashMap::new(),
87 rule_ids: Vec::new(),
88 state: HashMap::new(),
89 last_alert: HashMap::new(),
90 event_buffers: HashMap::new(),
91 event_ref_buffers: HashMap::new(),
92 correlation_only_rules: std::collections::HashSet::new(),
93 config,
94 pipelines: Vec::new(),
95 }
96 }
97
98 pub fn add_pipeline(&mut self, pipeline: Pipeline) {
102 self.pipelines.push(pipeline);
103 self.pipelines.sort_by_key(|p| p.priority);
104 }
105
106 pub fn set_include_event(&mut self, include: bool) {
108 self.engine.set_include_event(include);
109 }
110
111 pub fn set_match_detail(&mut self, level: crate::result::MatchDetailLevel) {
114 self.engine.set_match_detail(level);
115 }
116
117 pub fn set_bloom_prefilter(&mut self, enabled: bool) {
121 self.engine.set_bloom_prefilter(enabled);
122 }
123
124 pub fn set_logsource_extractor(
128 &mut self,
129 extractor: Option<crate::logsource::LogSourceExtractor>,
130 ) {
131 self.engine.set_logsource_extractor(extractor);
132 }
133
134 pub fn logsource_pruned_total(&self) -> u64 {
136 self.engine.logsource_pruned_total()
137 }
138
139 pub fn logsource_absent_total(&self) -> u64 {
141 self.engine.logsource_absent_total()
142 }
143
144 pub fn set_bloom_max_bytes(&mut self, max_bytes: usize) {
147 self.engine.set_bloom_max_bytes(max_bytes);
148 }
149
150 #[cfg(feature = "daachorse-index")]
154 pub fn set_cross_rule_ac(&mut self, enabled: bool) {
155 self.engine.set_cross_rule_ac(enabled);
156 }
157
158 pub fn set_correlation_event_mode(&mut self, mode: CorrelationEventMode) {
164 self.config.correlation_event_mode = mode;
165 }
166
167 pub fn set_max_correlation_events(&mut self, max: usize) {
170 self.config.max_correlation_events = max;
171 }
172
173 pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
179 if self.pipelines.is_empty() {
180 self.apply_custom_attributes(&rule.custom_attributes);
181 self.rule_ids.push((rule.id.clone(), rule.name.clone()));
182 self.engine.add_rule(rule)?;
183 } else {
184 let mut transformed = rule.clone();
185 apply_pipelines(&self.pipelines, &mut transformed)?;
186 self.apply_custom_attributes(&transformed.custom_attributes);
187 self.rule_ids
188 .push((transformed.id.clone(), transformed.name.clone()));
189 let compiled = crate::compiler::compile_rule(&transformed)?;
191 self.engine.add_compiled_rule(compiled);
192 }
193 Ok(())
194 }
195
196 fn apply_custom_attributes(
210 &mut self,
211 attrs: &std::collections::HashMap<String, yaml_serde::Value>,
212 ) {
213 if let Some(field) = attrs.get("rsigma.timestamp_field").and_then(|v| v.as_str())
215 && !self.config.timestamp_fields.iter().any(|f| f == field)
216 {
217 self.config.timestamp_fields.insert(0, field.to_string());
218 }
219
220 if let Some(val) = attrs.get("rsigma.suppress").and_then(|v| v.as_str())
222 && self.config.suppress.is_none()
223 && let Ok(ts) = rsigma_parser::Timespan::parse(val)
224 {
225 self.config.suppress = Some(ts.seconds);
226 }
227
228 if let Some(val) = attrs.get("rsigma.action").and_then(|v| v.as_str())
230 && self.config.action_on_match == CorrelationAction::Alert
231 && let Ok(a) = val.parse::<CorrelationAction>()
232 {
233 self.config.action_on_match = a;
234 }
235 }
236
237 pub fn add_correlation(&mut self, corr: &CorrelationRule) -> Result<()> {
239 let owned;
240 let effective = if self.pipelines.is_empty() {
241 corr
242 } else {
243 owned = {
244 let mut c = corr.clone();
245 apply_pipelines_to_correlation(&self.pipelines, &mut c)?;
246 c
247 };
248 &owned
249 };
250
251 self.apply_custom_attributes(&effective.custom_attributes);
254
255 let compiled = compile_correlation(effective)?;
256 let idx = self.correlations.len();
257
258 for rule_ref in &compiled.rule_refs {
260 self.rule_index
261 .entry(rule_ref.clone())
262 .or_default()
263 .push(idx);
264 }
265
266 if !compiled.generate {
268 for rule_ref in &compiled.rule_refs {
269 self.correlation_only_rules.insert(rule_ref.clone());
270 }
271 }
272
273 self.correlations.push(compiled);
274 Ok(())
275 }
276
277 pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
286 let mut compiled_batch = Vec::with_capacity(collection.rules.len());
287 if self.pipelines.is_empty() {
288 for rule in &collection.rules {
289 self.apply_custom_attributes(&rule.custom_attributes);
290 self.rule_ids.push((rule.id.clone(), rule.name.clone()));
291 compiled_batch.push(crate::compiler::compile_rule(rule)?);
292 }
293 } else {
294 for rule in &collection.rules {
295 let mut transformed = rule.clone();
296 apply_pipelines(&self.pipelines, &mut transformed)?;
297 self.apply_custom_attributes(&transformed.custom_attributes);
298 self.rule_ids
299 .push((transformed.id.clone(), transformed.name.clone()));
300 compiled_batch.push(crate::compiler::compile_rule(&transformed)?);
302 }
303 }
304 self.engine.extend_compiled_rules(compiled_batch);
305 for filter in &collection.filters {
307 self.engine.apply_filter(filter)?;
308 }
309 for corr in &collection.correlations {
310 self.add_correlation(corr)?;
311 }
312 self.validate_rule_refs()?;
313 self.detect_correlation_cycles()?;
314 Ok(())
315 }
316
317 fn validate_rule_refs(&self) -> Result<()> {
320 let mut known: std::collections::HashSet<&str> = std::collections::HashSet::new();
321
322 for (id, name) in &self.rule_ids {
323 if let Some(id) = id {
324 known.insert(id.as_str());
325 }
326 if let Some(name) = name {
327 known.insert(name.as_str());
328 }
329 }
330 for corr in &self.correlations {
331 if let Some(ref id) = corr.id {
332 known.insert(id.as_str());
333 }
334 if let Some(ref name) = corr.name {
335 known.insert(name.as_str());
336 }
337 }
338
339 for corr in &self.correlations {
340 for rule_ref in &corr.rule_refs {
341 if !known.contains(rule_ref.as_str()) {
342 return Err(EvalError::UnknownRuleRef(rule_ref.clone()));
343 }
344 }
345 }
346 Ok(())
347 }
348
349 fn detect_correlation_cycles(&self) -> Result<()> {
357 let mut corr_identifiers: HashMap<&str, usize> = HashMap::new();
359 for (idx, corr) in self.correlations.iter().enumerate() {
360 if let Some(ref id) = corr.id {
361 corr_identifiers.insert(id.as_str(), idx);
362 }
363 if let Some(ref name) = corr.name {
364 corr_identifiers.insert(name.as_str(), idx);
365 }
366 }
367
368 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); self.correlations.len()];
370 for (idx, corr) in self.correlations.iter().enumerate() {
371 for rule_ref in &corr.rule_refs {
372 if let Some(&target_idx) = corr_identifiers.get(rule_ref.as_str()) {
373 adj[idx].push(target_idx);
374 }
375 }
376 }
377
378 let mut state = vec![0u8; self.correlations.len()]; let mut path: Vec<usize> = Vec::new();
381
382 for start in 0..self.correlations.len() {
383 if state[start] == 0
384 && let Some(cycle) = Self::dfs_find_cycle(start, &adj, &mut state, &mut path)
385 {
386 let names: Vec<String> = cycle
387 .iter()
388 .map(|&i| {
389 self.correlations[i]
390 .id
391 .as_deref()
392 .or(self.correlations[i].name.as_deref())
393 .unwrap_or(&self.correlations[i].title)
394 .to_string()
395 })
396 .collect();
397 return Err(crate::error::EvalError::CorrelationCycle(
398 names.join(" -> "),
399 ));
400 }
401 }
402 Ok(())
403 }
404
405 fn dfs_find_cycle(
407 node: usize,
408 adj: &[Vec<usize>],
409 state: &mut [u8],
410 path: &mut Vec<usize>,
411 ) -> Option<Vec<usize>> {
412 state[node] = 1; path.push(node);
414
415 for &next in &adj[node] {
416 if state[next] == 1 {
417 if let Some(pos) = path.iter().position(|&n| n == next) {
419 let mut cycle = path[pos..].to_vec();
420 cycle.push(next); return Some(cycle);
422 }
423 }
424 if state[next] == 0
425 && let Some(cycle) = Self::dfs_find_cycle(next, adj, state, path)
426 {
427 return Some(cycle);
428 }
429 }
430
431 path.pop();
432 state[node] = 2; None
434 }
435
436 pub fn process_event(&mut self, event: &impl Event) -> ProcessResult {
442 let all_detections = self.engine.evaluate(event);
443 self.correlate_detections(event, all_detections)
444 }
445
446 pub fn correlate_detections(
455 &mut self,
456 event: &impl Event,
457 all_detections: Vec<EvaluationResult>,
458 ) -> ProcessResult {
459 let ts = match self.extract_event_timestamp(event) {
460 Some(ts) => ts,
461 None => match self.config.timestamp_fallback {
462 TimestampFallback::WallClock => Utc::now().timestamp(),
463 TimestampFallback::Skip => {
464 return self.filter_detections(all_detections);
466 }
467 },
468 };
469 self.process_with_detections(event, all_detections, ts)
470 }
471
472 pub fn process_event_at(&mut self, event: &impl Event, timestamp_secs: i64) -> ProcessResult {
477 let all_detections = self.engine.evaluate(event);
478 self.process_with_detections(event, all_detections, timestamp_secs)
479 }
480
481 pub fn process_with_detections(
487 &mut self,
488 event: &impl Event,
489 all_detections: Vec<EvaluationResult>,
490 timestamp_secs: i64,
491 ) -> ProcessResult {
492 let timestamp_secs = timestamp_secs.clamp(0, i64::MAX / 2);
493
494 if self.state.len() >= self.config.max_state_entries {
496 self.evict_all(timestamp_secs);
497 }
498
499 let mut correlations: Vec<EvaluationResult> = Vec::new();
501 self.feed_detections(event, &all_detections, timestamp_secs, &mut correlations);
502
503 self.chain_correlations(&correlations, timestamp_secs);
505
506 let mut out = self.filter_detections(all_detections);
508 out.extend(correlations);
509 out
510 }
511
512 pub fn evaluate(&self, event: &impl Event) -> Vec<EvaluationResult> {
519 self.engine.evaluate(event)
520 }
521
522 pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
530 let engine = &self.engine;
533 let ts_fields = &self.config.timestamp_fields;
534
535 let batch_results: Vec<(Vec<EvaluationResult>, Option<i64>)> = {
536 #[cfg(feature = "parallel")]
537 {
538 use rayon::prelude::*;
539 events
540 .par_iter()
541 .map(|e| {
542 let detections = engine.evaluate(e);
543 let ts = extract_event_ts(e, ts_fields);
544 (detections, ts)
545 })
546 .collect()
547 }
548 #[cfg(not(feature = "parallel"))]
549 {
550 events
551 .iter()
552 .map(|e| {
553 let detections = engine.evaluate(e);
554 let ts = extract_event_ts(e, ts_fields);
555 (detections, ts)
556 })
557 .collect()
558 }
559 };
560
561 let mut results = Vec::with_capacity(events.len());
563 for ((detections, ts_opt), event) in batch_results.into_iter().zip(events) {
564 match ts_opt {
565 Some(ts) => {
566 results.push(self.process_with_detections(event, detections, ts));
567 }
568 None => match self.config.timestamp_fallback {
569 TimestampFallback::WallClock => {
570 let ts = Utc::now().timestamp();
571 results.push(self.process_with_detections(event, detections, ts));
572 }
573 TimestampFallback::Skip => {
574 results.push(self.filter_detections(detections));
576 }
577 },
578 }
579 }
580 results
581 }
582
583 fn filter_detections(&self, all_detections: Vec<EvaluationResult>) -> Vec<EvaluationResult> {
588 if !self.config.emit_detections && !self.correlation_only_rules.is_empty() {
589 all_detections
590 .into_iter()
591 .filter(|m| {
592 let id_match = m
593 .header
594 .rule_id
595 .as_ref()
596 .is_some_and(|id| self.correlation_only_rules.contains(id));
597 !id_match
598 })
599 .collect()
600 } else {
601 all_detections
602 }
603 }
604
605 fn feed_detections(
607 &mut self,
608 event: &impl Event,
609 detections: &[EvaluationResult],
610 ts: i64,
611 out: &mut Vec<EvaluationResult>,
612 ) {
613 let mut work: Vec<(usize, Option<String>, Option<String>)> = Vec::new();
616
617 for det in detections {
618 let (rule_id, rule_name) = self.find_rule_identity(det);
621
622 let mut corr_indices = Vec::new();
624 if let Some(ref id) = rule_id
625 && let Some(indices) = self.rule_index.get(id)
626 {
627 corr_indices.extend(indices);
628 }
629 if let Some(ref name) = rule_name
630 && let Some(indices) = self.rule_index.get(name)
631 {
632 corr_indices.extend(indices);
633 }
634
635 corr_indices.sort_unstable();
636 corr_indices.dedup();
637
638 for &corr_idx in &corr_indices {
639 work.push((corr_idx, rule_id.clone(), rule_name.clone()));
640 }
641 }
642
643 for (corr_idx, rule_id, rule_name) in work {
644 self.update_correlation(corr_idx, event, ts, &rule_id, &rule_name, out);
645 }
646 }
647
648 fn find_rule_identity(&self, det: &EvaluationResult) -> (Option<String>, Option<String>) {
650 if let Some(ref match_id) = det.header.rule_id {
652 for (id, name) in &self.rule_ids {
653 if id.as_deref() == Some(match_id.as_str()) {
654 return (id.clone(), name.clone());
655 }
656 }
657 }
658 (det.header.rule_id.clone(), None)
660 }
661
662 fn resolve_event_mode(&self, corr_idx: usize) -> CorrelationEventMode {
664 let corr = &self.correlations[corr_idx];
665 corr.event_mode
666 .unwrap_or(self.config.correlation_event_mode)
667 }
668
669 fn resolve_max_events(&self, corr_idx: usize) -> usize {
671 let corr = &self.correlations[corr_idx];
672 corr.max_events
673 .unwrap_or(self.config.max_correlation_events)
674 }
675
676 fn resolve_max_group_entries(&self, corr_idx: usize) -> Option<usize> {
679 let corr = &self.correlations[corr_idx];
680 corr.max_group_entries.or(self.config.max_group_entries)
681 }
682
683 fn update_correlation(
685 &mut self,
686 corr_idx: usize,
687 event: &impl Event,
688 ts: i64,
689 rule_id: &Option<String>,
690 rule_name: &Option<String>,
691 out: &mut Vec<EvaluationResult>,
692 ) {
693 let corr = &self.correlations[corr_idx];
697 let corr_type = corr.correlation_type;
698 let timespan = corr.timespan_secs;
699 let window_mode = corr.window_mode;
700 let gap_secs = corr.gap_secs;
701 let level = corr.level;
702 let suppress_secs = corr.suppress_secs.or(self.config.suppress);
703 let action = corr.action.unwrap_or(self.config.action_on_match);
704 let event_mode = self.resolve_event_mode(corr_idx);
705 let max_events = self.resolve_max_events(corr_idx);
706 let max_group_entries = self.resolve_max_group_entries(corr_idx);
707
708 let mut ref_strs: Vec<&str> = Vec::new();
710 if let Some(id) = rule_id.as_deref() {
711 ref_strs.push(id);
712 }
713 if let Some(name) = rule_name.as_deref() {
714 ref_strs.push(name);
715 }
716 let rule_ref = rule_id.as_deref().or(rule_name.as_deref()).unwrap_or("");
717
718 let group_key = GroupKey::extract(event, &corr.group_by, &ref_strs);
720
721 let state_key = (corr_idx, group_key.clone());
723 let state = self
724 .state
725 .entry(state_key.clone())
726 .or_insert_with(|| WindowState::new_for(corr_type));
727
728 let cutoff = ts - timespan as i64;
734 let decision = apply_window_open(state, ts, timespan, window_mode, gap_secs);
735 if decision == WindowDecision::Discard {
736 return;
737 }
738 let reset = decision == WindowDecision::Reset;
739
740 match corr_type {
742 CorrelationType::EventCount => {
743 state.push_event_count(ts);
744 }
745 CorrelationType::ValueCount => {
746 if let Some(ref fields) = corr.condition.field
747 && let Some(key) = composite_value_count_key(event, fields)
748 {
749 state.push_value_count(ts, key);
750 }
751 }
752 CorrelationType::Temporal | CorrelationType::TemporalOrdered => {
753 state.push_temporal(ts, rule_ref);
754 }
755 CorrelationType::ValueSum
756 | CorrelationType::ValueAvg
757 | CorrelationType::ValuePercentile
758 | CorrelationType::ValueMedian => {
759 if let Some(ref fields) = corr.condition.field
760 && let Some(field_name) = fields.first()
761 && let Some(val) = event.get_field(field_name)
762 && let Some(n) = value_to_f64_ev(&val)
763 {
764 state.push_numeric(ts, n);
765 }
766 }
767 }
768
769 if let Some(cap) = max_group_entries {
773 state.truncate_oldest(cap, window_mode == WindowMode::Session);
774 }
775
776 match event_mode {
780 CorrelationEventMode::Full => {
781 let buf = self
782 .event_buffers
783 .entry(state_key.clone())
784 .or_insert_with(|| EventBuffer::new(max_events));
785 if window_mode == rsigma_parser::WindowMode::Sliding {
786 buf.evict(cutoff);
787 } else if reset {
788 buf.clear();
789 }
790 let json = event.to_json();
791 buf.push(ts, &json);
792 }
793 CorrelationEventMode::Refs => {
794 let buf = self
795 .event_ref_buffers
796 .entry(state_key.clone())
797 .or_insert_with(|| EventRefBuffer::new(max_events));
798 if window_mode == rsigma_parser::WindowMode::Sliding {
799 buf.evict(cutoff);
800 } else if reset {
801 buf.clear();
802 }
803 let json = event.to_json();
804 buf.push(ts, &json);
805 }
806 CorrelationEventMode::None => {}
807 }
808
809 let fired = state.check_condition(
811 &corr.condition,
812 corr_type,
813 &corr.rule_refs,
814 corr.extended_expr.as_ref(),
815 );
816
817 if let Some(agg_value) = fired {
818 let alert_key = (corr_idx, group_key.clone());
819
820 let suppressed = if let Some(suppress) = suppress_secs {
822 if let Some(&last_ts) = self.last_alert.get(&alert_key) {
823 (ts - last_ts) < suppress as i64
824 } else {
825 false
826 }
827 } else {
828 false
829 };
830
831 if !suppressed {
832 let (events, event_refs) = match event_mode {
834 CorrelationEventMode::Full => {
835 let stored = self
836 .event_buffers
837 .get(&alert_key)
838 .map(|buf| buf.decompress_all())
839 .unwrap_or_default();
840 (Some(stored), None)
841 }
842 CorrelationEventMode::Refs => {
843 let stored = self
844 .event_ref_buffers
845 .get(&alert_key)
846 .map(|buf| buf.refs())
847 .unwrap_or_default();
848 (None, Some(stored))
849 }
850 CorrelationEventMode::None => (None, None),
851 };
852
853 let corr = &self.correlations[corr_idx];
855 let result = EvaluationResult {
856 header: RuleHeader {
857 rule_title: corr.title.clone(),
858 rule_id: corr.id.clone(),
859 level,
860 tags: corr.tags.clone(),
861 custom_attributes: corr.custom_attributes.clone(),
862 enrichments: None,
863 },
864 body: ResultBody::Correlation(CorrelationBody {
865 correlation_type: corr_type,
866 group_key: group_key.to_pairs(&corr.group_by),
867 aggregated_value: agg_value,
868 timespan_secs: timespan,
869 events,
870 event_refs,
871 }),
872 };
873 out.push(result);
874
875 self.last_alert.insert(alert_key.clone(), ts);
877
878 if action == CorrelationAction::Reset {
880 if let Some(state) = self.state.get_mut(&alert_key) {
881 state.clear();
882 }
883 if let Some(buf) = self.event_buffers.get_mut(&alert_key) {
884 buf.clear();
885 }
886 if let Some(buf) = self.event_ref_buffers.get_mut(&alert_key) {
887 buf.clear();
888 }
889 }
890 }
891 }
892 }
893
894 fn chain_correlations(&mut self, fired: &[EvaluationResult], ts: i64) {
899 const MAX_CHAIN_DEPTH: usize = 10;
900 let mut pending: Vec<EvaluationResult> = fired.to_vec();
901 let mut depth = 0;
902
903 while !pending.is_empty() && depth < MAX_CHAIN_DEPTH {
904 depth += 1;
905
906 #[allow(clippy::type_complexity)]
908 let mut work: Vec<(usize, Vec<(String, String)>, String)> = Vec::new();
909 for result in &pending {
910 let Some(body) = result.as_correlation() else {
912 continue;
913 };
914 if let Some(ref id) = result.header.rule_id
915 && let Some(indices) = self.rule_index.get(id)
916 {
917 let fired_ref = result
918 .header
919 .rule_id
920 .as_deref()
921 .unwrap_or(&result.header.rule_title)
922 .to_string();
923 for &corr_idx in indices {
924 work.push((corr_idx, body.group_key.clone(), fired_ref.clone()));
925 }
926 }
927 }
928
929 let mut next_pending = Vec::new();
930 for (corr_idx, group_key_pairs, fired_ref) in work {
931 let corr = &self.correlations[corr_idx];
932 let corr_type = corr.correlation_type;
933 let timespan = corr.timespan_secs;
934 let window_mode = corr.window_mode;
935 let gap_secs = corr.gap_secs;
936 let level = corr.level;
937
938 let group_key = GroupKey::from_pairs(&group_key_pairs, &corr.group_by);
939 let state_key = (corr_idx, group_key.clone());
940 let state = self
941 .state
942 .entry(state_key)
943 .or_insert_with(|| WindowState::new_for(corr_type));
944
945 if apply_window_open(state, ts, timespan, window_mode, gap_secs)
949 == WindowDecision::Discard
950 {
951 continue;
952 }
953
954 match corr_type {
955 CorrelationType::EventCount => {
956 state.push_event_count(ts);
957 }
958 CorrelationType::Temporal | CorrelationType::TemporalOrdered => {
959 state.push_temporal(ts, &fired_ref);
960 }
961 _ => {
962 state.push_event_count(ts);
963 }
964 }
965
966 if let Some(cap) = corr.max_group_entries.or(self.config.max_group_entries) {
969 state.truncate_oldest(cap, window_mode == WindowMode::Session);
970 }
971
972 let fired = state.check_condition(
973 &corr.condition,
974 corr_type,
975 &corr.rule_refs,
976 corr.extended_expr.as_ref(),
977 );
978
979 if let Some(agg_value) = fired {
980 let corr = &self.correlations[corr_idx];
981 next_pending.push(EvaluationResult {
982 header: RuleHeader {
983 rule_title: corr.title.clone(),
984 rule_id: corr.id.clone(),
985 level,
986 tags: corr.tags.clone(),
987 custom_attributes: corr.custom_attributes.clone(),
988 enrichments: None,
989 },
990 body: ResultBody::Correlation(CorrelationBody {
991 correlation_type: corr_type,
992 group_key: group_key.to_pairs(&corr.group_by),
993 aggregated_value: agg_value,
994 timespan_secs: timespan,
995 events: None,
999 event_refs: None,
1000 }),
1001 });
1002 }
1003 }
1004
1005 pending = next_pending;
1006 }
1007
1008 if !pending.is_empty() {
1009 log::warn!(
1010 "Correlation chain depth limit reached ({MAX_CHAIN_DEPTH}); \
1011 {} pending result(s) were not propagated further. \
1012 This may indicate a cycle in correlation references.",
1013 pending.len()
1014 );
1015 }
1016 }
1017
1018 fn extract_event_timestamp(&self, event: &impl Event) -> Option<i64> {
1030 for field_name in &self.config.timestamp_fields {
1031 if let Some(val) = event.get_field(field_name)
1032 && let Some(ts) = parse_timestamp_value(&val)
1033 {
1034 return Some(ts);
1035 }
1036 }
1037 None
1038 }
1039
1040 pub fn evict_expired(&mut self, now_secs: i64) {
1046 self.evict_all(now_secs);
1047 }
1048
1049 fn evict_all(&mut self, now_secs: i64) {
1051 let specs: Vec<(u64, WindowMode, Option<u64>)> = self
1062 .correlations
1063 .iter()
1064 .map(|c| (c.timespan_secs, c.window_mode, c.gap_secs))
1065 .collect();
1066
1067 self.state.retain(|&(corr_idx, _), state| {
1068 if let Some(&(timespan, mode, gap)) = specs.get(corr_idx) {
1069 match mode {
1070 WindowMode::Sliding => {
1071 state.evict(now_secs - timespan as i64);
1072 }
1073 WindowMode::Tumbling | WindowMode::Session => {
1074 let staleness = if mode == WindowMode::Session {
1075 gap.unwrap_or(timespan)
1076 } else {
1077 timespan
1078 } as i64;
1079 if state
1080 .latest_timestamp()
1081 .is_some_and(|last| now_secs - last > staleness)
1082 {
1083 state.clear();
1084 }
1085 }
1086 }
1087 }
1088 !state.is_empty()
1089 });
1090
1091 let state = &self.state;
1095 self.event_buffers.retain(|key, buf| {
1096 if let Some(&(timespan, mode, _)) = specs.get(key.0) {
1097 match mode {
1098 WindowMode::Sliding => buf.evict(now_secs - timespan as i64),
1099 WindowMode::Tumbling | WindowMode::Session => {
1100 if !state.contains_key(key) {
1101 return false;
1102 }
1103 }
1104 }
1105 }
1106 !buf.is_empty()
1107 });
1108 self.event_ref_buffers.retain(|key, buf| {
1109 if let Some(&(timespan, mode, _)) = specs.get(key.0) {
1110 match mode {
1111 WindowMode::Sliding => buf.evict(now_secs - timespan as i64),
1112 WindowMode::Tumbling | WindowMode::Session => {
1113 if !state.contains_key(key) {
1114 return false;
1115 }
1116 }
1117 }
1118 }
1119 !buf.is_empty()
1120 });
1121
1122 if self.state.len() >= self.config.max_state_entries {
1126 let target = self.config.max_state_entries * 9 / 10;
1127 let excess = self.state.len() - target;
1128
1129 log::warn!(
1130 "Correlation state hard cap reached ({} entries, max {}); \
1131 evicting {} stalest entries to {} (90% capacity). \
1132 This indicates high-cardinality traffic; consider raising \
1133 max_state_entries or shortening correlation windows.",
1134 self.state.len(),
1135 self.config.max_state_entries,
1136 excess,
1137 target,
1138 );
1139
1140 let mut by_staleness: Vec<_> = self
1142 .state
1143 .iter()
1144 .map(|(k, v)| (k.clone(), v.latest_timestamp().unwrap_or(i64::MIN)))
1145 .collect();
1146 by_staleness.sort_unstable_by_key(|&(_, ts)| ts);
1147
1148 for (key, _) in by_staleness.into_iter().take(excess) {
1150 self.state.remove(&key);
1151 self.last_alert.remove(&key);
1152 self.event_buffers.remove(&key);
1153 self.event_ref_buffers.remove(&key);
1154 }
1155 }
1156
1157 self.last_alert.retain(|key, &mut alert_ts| {
1160 let suppress = if key.0 < self.correlations.len() {
1161 self.correlations[key.0]
1162 .suppress_secs
1163 .or(self.config.suppress)
1164 .unwrap_or(0)
1165 } else {
1166 0
1167 };
1168 (now_secs - alert_ts) < suppress as i64
1169 });
1170 }
1171
1172 pub fn state_count(&self) -> usize {
1174 self.state.len()
1175 }
1176
1177 pub fn detection_rule_count(&self) -> usize {
1179 self.engine.rule_count()
1180 }
1181
1182 pub fn correlation_rule_count(&self) -> usize {
1184 self.correlations.len()
1185 }
1186
1187 pub fn event_buffer_count(&self) -> usize {
1189 self.event_buffers.len()
1190 }
1191
1192 pub fn event_buffer_bytes(&self) -> usize {
1194 self.event_buffers
1195 .values()
1196 .map(|b| b.compressed_bytes())
1197 .sum()
1198 }
1199
1200 pub fn event_ref_buffer_count(&self) -> usize {
1202 self.event_ref_buffers.len()
1203 }
1204
1205 pub fn engine(&self) -> &Engine {
1207 &self.engine
1208 }
1209
1210 pub fn rule_metadata(&self, key: &str) -> RuleMetadataLookup {
1214 let mut variants = Vec::new();
1215 self.collect_rule_metadata(key, &mut variants);
1216 RuleMetadataLookup::from_variants(variants)
1217 }
1218
1219 pub(crate) fn collect_rule_metadata(&self, key: &str, out: &mut Vec<RuleBundleMetadata>) {
1220 self.engine.collect_rule_metadata(key, out);
1221 crate::rule_metadata::matching_correlations(&self.correlations, key, out);
1222 }
1223
1224 pub fn export_state(&self) -> CorrelationSnapshot {
1230 let mut windows: HashMap<String, Vec<(GroupKey, WindowState)>> = HashMap::new();
1231 for ((idx, gk), ws) in &self.state {
1232 let corr_id = self.correlation_stable_id(*idx);
1233 windows
1234 .entry(corr_id)
1235 .or_default()
1236 .push((gk.clone(), ws.clone()));
1237 }
1238
1239 let mut last_alert: HashMap<String, Vec<(GroupKey, i64)>> = HashMap::new();
1240 for ((idx, gk), ts) in &self.last_alert {
1241 let corr_id = self.correlation_stable_id(*idx);
1242 last_alert
1243 .entry(corr_id)
1244 .or_default()
1245 .push((gk.clone(), *ts));
1246 }
1247
1248 let mut event_buffers: HashMap<String, Vec<(GroupKey, EventBuffer)>> = HashMap::new();
1249 for ((idx, gk), buf) in &self.event_buffers {
1250 let corr_id = self.correlation_stable_id(*idx);
1251 event_buffers
1252 .entry(corr_id)
1253 .or_default()
1254 .push((gk.clone(), buf.clone()));
1255 }
1256
1257 let mut event_ref_buffers: HashMap<String, Vec<(GroupKey, EventRefBuffer)>> =
1258 HashMap::new();
1259 for ((idx, gk), buf) in &self.event_ref_buffers {
1260 let corr_id = self.correlation_stable_id(*idx);
1261 event_ref_buffers
1262 .entry(corr_id)
1263 .or_default()
1264 .push((gk.clone(), buf.clone()));
1265 }
1266
1267 CorrelationSnapshot {
1268 version: SNAPSHOT_VERSION,
1269 windows,
1270 last_alert,
1271 event_buffers,
1272 event_ref_buffers,
1273 }
1274 }
1275
1276 pub fn import_state(&mut self, snapshot: CorrelationSnapshot) -> bool {
1283 if snapshot.version != SNAPSHOT_VERSION {
1284 return false;
1285 }
1286 let id_to_idx = self.build_id_to_index_map();
1287
1288 for (corr_id, groups) in snapshot.windows {
1289 if let Some(&idx) = id_to_idx.get(&corr_id) {
1290 for (gk, ws) in groups {
1291 self.state.insert((idx, gk), ws);
1292 }
1293 }
1294 }
1295
1296 for (corr_id, groups) in snapshot.last_alert {
1297 if let Some(&idx) = id_to_idx.get(&corr_id) {
1298 for (gk, ts) in groups {
1299 self.last_alert.insert((idx, gk), ts);
1300 }
1301 }
1302 }
1303
1304 for (corr_id, groups) in snapshot.event_buffers {
1305 if let Some(&idx) = id_to_idx.get(&corr_id) {
1306 for (gk, buf) in groups {
1307 self.event_buffers.insert((idx, gk), buf);
1308 }
1309 }
1310 }
1311
1312 for (corr_id, groups) in snapshot.event_ref_buffers {
1313 if let Some(&idx) = id_to_idx.get(&corr_id) {
1314 for (gk, buf) in groups {
1315 self.event_ref_buffers.insert((idx, gk), buf);
1316 }
1317 }
1318 }
1319
1320 true
1321 }
1322
1323 fn correlation_stable_id(&self, idx: usize) -> String {
1325 let corr = &self.correlations[idx];
1326 corr.id
1327 .clone()
1328 .or_else(|| corr.name.clone())
1329 .unwrap_or_else(|| corr.title.clone())
1330 }
1331
1332 fn build_id_to_index_map(&self) -> HashMap<String, usize> {
1334 self.correlations
1335 .iter()
1336 .enumerate()
1337 .map(|(idx, _)| (self.correlation_stable_id(idx), idx))
1338 .collect()
1339 }
1340}
1341
1342impl Default for CorrelationEngine {
1343 fn default() -> Self {
1344 Self::new(CorrelationConfig::default())
1345 }
1346}
1347
1348fn extract_event_ts(event: &impl Event, timestamp_fields: &[String]) -> Option<i64> {
1357 for field_name in timestamp_fields {
1358 if let Some(val) = event.get_field(field_name)
1359 && let Some(ts) = parse_timestamp_value(&val)
1360 {
1361 return Some(ts);
1362 }
1363 }
1364 None
1365}
1366
1367fn parse_timestamp_value(val: &EventValue) -> Option<i64> {
1369 match val {
1370 EventValue::Int(i) => Some(normalize_epoch(*i)),
1371 EventValue::Float(f) => Some(normalize_epoch(*f as i64)),
1372 EventValue::Str(s) => parse_timestamp_string(s),
1373 _ => None,
1374 }
1375}
1376
1377fn normalize_epoch(v: i64) -> i64 {
1380 if v > 1_000_000_000_000 { v / 1000 } else { v }
1381}
1382
1383fn parse_timestamp_string(s: &str) -> Option<i64> {
1385 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
1387 return Some(dt.timestamp());
1388 }
1389
1390 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
1393 return Some(Utc.from_utc_datetime(&naive).timestamp());
1394 }
1395 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
1396 return Some(Utc.from_utc_datetime(&naive).timestamp());
1397 }
1398
1399 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
1401 return Some(Utc.from_utc_datetime(&naive).timestamp());
1402 }
1403 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
1404 return Some(Utc.from_utc_datetime(&naive).timestamp());
1405 }
1406
1407 None
1408}
1409
1410fn value_to_string_for_count(v: &EventValue) -> Option<String> {
1412 match v {
1413 EventValue::Str(s) => Some(s.to_string()),
1414 EventValue::Int(n) => Some(n.to_string()),
1415 EventValue::Float(f) => Some(f.to_string()),
1416 EventValue::Bool(b) => Some(b.to_string()),
1417 EventValue::Null => Some("null".to_string()),
1418 _ => None,
1419 }
1420}
1421
1422fn composite_value_count_key(event: &impl Event, fields: &[String]) -> Option<String> {
1431 if let [field_name] = fields {
1433 let val = event.get_field(field_name)?;
1434 return value_to_string_for_count(&val);
1435 }
1436
1437 let mut parts = Vec::with_capacity(fields.len());
1438 for field_name in fields {
1439 let val = event.get_field(field_name)?;
1440 let rendered = value_to_string_for_count(&val)?;
1441 parts.push(rendered);
1442 }
1443 Some(parts.join("\u{1f}"))
1444}
1445
1446fn value_to_f64_ev(v: &EventValue) -> Option<f64> {
1448 v.as_f64()
1449}