Skip to main content

rsigma_eval/correlation_engine/
mod.rs

1//! Stateful correlation engine with time-windowed aggregation.
2//!
3//! `CorrelationEngine` wraps the stateless `Engine` and adds support for
4//! Sigma correlation rules: `event_count`, `value_count`, `temporal`,
5//! `temporal_ordered`, `value_sum`, `value_avg`, `value_percentile`,
6//! and `value_median`.
7//!
8//! # Architecture
9//!
10//! 1. Events are first evaluated against detection rules (stateless)
11//! 2. Detection matches update correlation window state (stateful)
12//! 3. When a correlation condition is met, a `CorrelationResult` is emitted
13//! 4. Correlation results can chain into higher-level correlations
14
15mod 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
40// =============================================================================
41// Correlation Engine
42// =============================================================================
43
44/// Current snapshot schema version. Bump when the serialized format changes.
45const SNAPSHOT_VERSION: u32 = 1;
46
47/// Parent hops evaluated after a first-level correlation fire in one event.
48/// The first-level result is produced by `feed_detections`; each loop
49/// iteration here walks one parent. A chain longer than this is updated
50/// and emitted through this many hops, then leftover parents are dropped
51/// with a `WARN`.
52const MAX_CHAIN_DEPTH: usize = 10;
53
54/// Stateful correlation engine.
55///
56/// Wraps the stateless `Engine` for detection rules and adds time-windowed
57/// correlation on top. Supports all 7 Sigma correlation types and chaining.
58pub struct CorrelationEngine {
59    /// Inner stateless detection engine.
60    engine: Engine,
61    /// Compiled correlation rules.
62    correlations: Vec<CompiledCorrelation>,
63    /// Maps rule ID/name -> indices into `correlations` that reference it.
64    /// This allows quick lookup: "which correlations care about rule X?"
65    rule_index: HashMap<String, Vec<usize>>,
66    /// Maps detection rule index -> (rule_id, rule_name) for reverse lookup.
67    /// Used to find which correlations a detection match triggers.
68    rule_ids: Vec<(Option<String>, Option<String>)>,
69    /// Per-(correlation_index, group_key) window state.
70    state: HashMap<(usize, GroupKey), WindowState>,
71    /// Last alert timestamp per (correlation_index, group_key) for suppression.
72    last_alert: HashMap<(usize, GroupKey), i64>,
73    /// Per-(correlation_index, group_key) compressed event buffer (`Full` mode).
74    event_buffers: HashMap<(usize, GroupKey), EventBuffer>,
75    /// Per-(correlation_index, group_key) event reference buffer (`Refs` mode).
76    event_ref_buffers: HashMap<(usize, GroupKey), EventRefBuffer>,
77    /// Set of detection rule IDs/names that are "correlation-only"
78    /// (referenced by correlations where `generate == false`).
79    /// Used to filter detection output when `config.emit_detections == false`.
80    correlation_only_rules: std::collections::HashSet<String>,
81    /// Configuration.
82    config: CorrelationConfig,
83    /// Processing pipelines applied to rules during add_rule.
84    pipelines: Vec<Pipeline>,
85}
86
87impl CorrelationEngine {
88    /// Create a new correlation engine with the given configuration.
89    pub fn new(config: CorrelationConfig) -> Self {
90        CorrelationEngine {
91            engine: Engine::new(),
92            correlations: Vec::new(),
93            rule_index: HashMap::new(),
94            rule_ids: Vec::new(),
95            state: HashMap::new(),
96            last_alert: HashMap::new(),
97            event_buffers: HashMap::new(),
98            event_ref_buffers: HashMap::new(),
99            correlation_only_rules: std::collections::HashSet::new(),
100            config,
101            pipelines: Vec::new(),
102        }
103    }
104
105    /// Add a pipeline to the engine.
106    ///
107    /// Pipelines are applied to rules during `add_rule` / `add_collection`.
108    pub fn add_pipeline(&mut self, pipeline: Pipeline) {
109        self.pipelines.push(pipeline);
110        self.pipelines.sort_by_key(|p| p.priority);
111    }
112
113    /// Set global `include_event` on the inner detection engine.
114    pub fn set_include_event(&mut self, include: bool) {
115        self.engine.set_include_event(include);
116    }
117
118    /// Forward to [`crate::Engine::set_match_detail`] on the inner detection
119    /// engine. Correlation detections inherit the level set here.
120    pub fn set_match_detail(&mut self, level: crate::result::MatchDetailLevel) {
121        self.engine.set_match_detail(level);
122    }
123
124    /// Forward to [`crate::Engine::set_bloom_prefilter`] on the inner
125    /// detection engine. Off by default; the optimization helps only on
126    /// substring-heavy rule sets paired with mostly-non-matching events.
127    pub fn set_bloom_prefilter(&mut self, enabled: bool) {
128        self.engine.set_bloom_prefilter(enabled);
129    }
130
131    /// Forward to [`crate::Engine::set_logsource_extractor`] on the inner
132    /// detection engine. Correlation inherits logsource pruning, since
133    /// `process_event` evaluates through this engine.
134    pub fn set_logsource_extractor(
135        &mut self,
136        extractor: Option<crate::logsource::LogSourceExtractor>,
137    ) {
138        self.engine.set_logsource_extractor(extractor);
139    }
140
141    /// Total rule candidates pruned by logsource on the inner engine.
142    pub fn logsource_pruned_total(&self) -> u64 {
143        self.engine.logsource_pruned_total()
144    }
145
146    /// Total evaluate calls with no extractable event logsource (fail-open).
147    pub fn logsource_absent_total(&self) -> u64 {
148        self.engine.logsource_absent_total()
149    }
150
151    /// Forward to [`crate::Engine::set_bloom_max_bytes`] on the inner
152    /// detection engine.
153    pub fn set_bloom_max_bytes(&mut self, max_bytes: usize) {
154        self.engine.set_bloom_max_bytes(max_bytes);
155    }
156
157    /// Forward to [`crate::Engine::set_cross_rule_ac`] on the inner
158    /// detection engine. Off by default. Available behind the
159    /// `daachorse-index` Cargo feature.
160    #[cfg(feature = "daachorse-index")]
161    pub fn set_cross_rule_ac(&mut self, enabled: bool) {
162        self.engine.set_cross_rule_ac(enabled);
163    }
164
165    /// Set the global correlation event mode.
166    ///
167    /// - `None`: no event storage (default)
168    /// - `Full`: compressed event bodies
169    /// - `Refs`: lightweight timestamp + ID references
170    pub fn set_correlation_event_mode(&mut self, mode: CorrelationEventMode) {
171        self.config.correlation_event_mode = mode;
172    }
173
174    /// Set the maximum number of events to store per correlation window group.
175    /// Only meaningful when `correlation_event_mode` is not `None`.
176    pub fn set_max_correlation_events(&mut self, max: usize) {
177        self.config.max_correlation_events = max;
178    }
179
180    /// Add a single detection rule.
181    ///
182    /// If pipelines are set, the rule is cloned and transformed before compilation.
183    /// The inner engine receives the already-transformed rule directly (not through
184    /// its own pipeline, to avoid double transformation).
185    pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
186        if self.pipelines.is_empty() {
187            self.apply_custom_attributes(&rule.custom_attributes);
188            self.rule_ids.push((rule.id.clone(), rule.name.clone()));
189            self.engine.add_rule(rule)?;
190        } else {
191            let mut transformed = rule.clone();
192            apply_pipelines(&self.pipelines, &mut transformed)?;
193            self.apply_custom_attributes(&transformed.custom_attributes);
194            self.rule_ids
195                .push((transformed.id.clone(), transformed.name.clone()));
196            // Use compile_rule + add_compiled_rule to bypass inner engine's pipelines
197            let compiled = crate::compiler::compile_rule(&transformed)?;
198            self.engine.add_compiled_rule(compiled);
199        }
200        Ok(())
201    }
202
203    /// Read `rsigma.*` custom attributes from a rule and apply them to the
204    /// engine configuration.  This allows pipelines to influence engine
205    /// behaviour via `SetCustomAttribute` transformations — the same pattern
206    /// used by pySigma backends (e.g. pySigma-backend-loki).
207    ///
208    /// Supported attributes:
209    /// - `rsigma.timestamp_field` — prepends a field name to the timestamp
210    ///   extraction priority list so the correlation engine can find the
211    ///   event timestamp in non-standard field names.
212    /// - `rsigma.suppress` — sets the default suppression window (e.g. `5m`).
213    ///   Only applied when the CLI did not already set `--suppress`.
214    /// - `rsigma.action` — sets the default post-fire action (`alert`/`reset`).
215    ///   Only applied when the CLI did not already set `--action`.
216    fn apply_custom_attributes(
217        &mut self,
218        attrs: &std::collections::HashMap<String, yaml_serde::Value>,
219    ) {
220        // rsigma.timestamp_field — prepend to priority list, skip duplicates
221        if let Some(field) = attrs.get("rsigma.timestamp_field").and_then(|v| v.as_str())
222            && !self.config.timestamp_fields.iter().any(|f| f == field)
223        {
224            self.config.timestamp_fields.insert(0, field.to_string());
225        }
226
227        // rsigma.suppress — only when CLI didn't already set one
228        if let Some(val) = attrs.get("rsigma.suppress").and_then(|v| v.as_str())
229            && self.config.suppress.is_none()
230            && let Ok(ts) = rsigma_parser::Timespan::parse(val)
231        {
232            self.config.suppress = Some(ts.seconds);
233        }
234
235        // rsigma.action — only when CLI left it at the default (Alert)
236        if let Some(val) = attrs.get("rsigma.action").and_then(|v| v.as_str())
237            && self.config.action_on_match == CorrelationAction::Alert
238            && let Ok(a) = val.parse::<CorrelationAction>()
239        {
240            self.config.action_on_match = a;
241        }
242    }
243
244    /// Add a single correlation rule.
245    pub fn add_correlation(&mut self, corr: &CorrelationRule) -> Result<()> {
246        let owned;
247        let effective = if self.pipelines.is_empty() {
248            corr
249        } else {
250            owned = {
251                let mut c = corr.clone();
252                apply_pipelines_to_correlation(&self.pipelines, &mut c)?;
253                c
254            };
255            &owned
256        };
257
258        // Apply engine-level custom attributes from the (possibly transformed)
259        // correlation rule (e.g. rsigma.timestamp_field).
260        self.apply_custom_attributes(&effective.custom_attributes);
261
262        let compiled = compile_correlation(effective)?;
263        let idx = self.correlations.len();
264
265        // Index by each referenced rule ID/name
266        for rule_ref in &compiled.rule_refs {
267            self.rule_index
268                .entry(rule_ref.clone())
269                .or_default()
270                .push(idx);
271        }
272
273        // Track correlation-only rules (generate == false is the default)
274        if !compiled.generate {
275            for rule_ref in &compiled.rule_refs {
276                self.correlation_only_rules.insert(rule_ref.clone());
277            }
278        }
279
280        self.correlations.push(compiled);
281        Ok(())
282    }
283
284    /// Add all rules and correlations from a parsed collection.
285    ///
286    /// Detection rules are added first (so they're available for correlation
287    /// references), then correlation rules. Detection rules are compiled
288    /// sequentially and then pushed to the inner engine in a single batch,
289    /// so the inverted index and bloom filter are rebuilt exactly once for
290    /// the whole collection. Without this batching, large rule sets
291    /// (multi-thousand rules) hit an O(N²) rebuild cost on load.
292    pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
293        let mut compiled_batch = Vec::with_capacity(collection.rules.len());
294        if self.pipelines.is_empty() {
295            for rule in &collection.rules {
296                self.apply_custom_attributes(&rule.custom_attributes);
297                self.rule_ids.push((rule.id.clone(), rule.name.clone()));
298                compiled_batch.push(crate::compiler::compile_rule(rule)?);
299            }
300        } else {
301            for rule in &collection.rules {
302                let mut transformed = rule.clone();
303                apply_pipelines(&self.pipelines, &mut transformed)?;
304                self.apply_custom_attributes(&transformed.custom_attributes);
305                self.rule_ids
306                    .push((transformed.id.clone(), transformed.name.clone()));
307                // Bypass the inner engine's pipelines (would double-transform)
308                compiled_batch.push(crate::compiler::compile_rule(&transformed)?);
309            }
310        }
311        self.engine.extend_compiled_rules(compiled_batch);
312        // Apply filter rules to the inner engine's detection rules
313        for filter in &collection.filters {
314            self.engine.apply_filter(filter)?;
315        }
316        for corr in &collection.correlations {
317            self.add_correlation(corr)?;
318        }
319        self.validate_rule_refs()?;
320        self.detect_correlation_cycles()?;
321        Ok(())
322    }
323
324    /// Validate that every correlation's `rule_refs` resolve to at least one
325    /// known detection rule (by ID or name) or another correlation (by ID or name).
326    fn validate_rule_refs(&self) -> Result<()> {
327        let mut known: std::collections::HashSet<&str> = std::collections::HashSet::new();
328
329        for (id, name) in &self.rule_ids {
330            if let Some(id) = id {
331                known.insert(id.as_str());
332            }
333            if let Some(name) = name {
334                known.insert(name.as_str());
335            }
336        }
337        for corr in &self.correlations {
338            if let Some(ref id) = corr.id {
339                known.insert(id.as_str());
340            }
341            if let Some(ref name) = corr.name {
342                known.insert(name.as_str());
343            }
344        }
345
346        for corr in &self.correlations {
347            for rule_ref in &corr.rule_refs {
348                if !known.contains(rule_ref.as_str()) {
349                    return Err(EvalError::UnknownRuleRef(rule_ref.clone()));
350                }
351            }
352        }
353        Ok(())
354    }
355
356    /// Detect cycles in the correlation reference graph.
357    ///
358    /// Builds a directed graph where each correlation (identified by its id/name)
359    /// has edges to the correlations it references via `rule_refs`. Uses DFS with
360    /// a "gray/black" coloring scheme to detect back-edges (cycles).
361    ///
362    /// Returns `Err(EvalError::CorrelationCycle)` if a cycle is found.
363    fn detect_correlation_cycles(&self) -> Result<()> {
364        // Build a set of all correlation identifiers (id and/or name)
365        let mut corr_identifiers: HashMap<&str, usize> = HashMap::new();
366        for (idx, corr) in self.correlations.iter().enumerate() {
367            if let Some(ref id) = corr.id {
368                corr_identifiers.insert(id.as_str(), idx);
369            }
370            if let Some(ref name) = corr.name {
371                corr_identifiers.insert(name.as_str(), idx);
372            }
373        }
374
375        // Build adjacency list: corr index → set of corr indices it references
376        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); self.correlations.len()];
377        for (idx, corr) in self.correlations.iter().enumerate() {
378            for rule_ref in &corr.rule_refs {
379                if let Some(&target_idx) = corr_identifiers.get(rule_ref.as_str()) {
380                    adj[idx].push(target_idx);
381                }
382            }
383        }
384
385        // DFS cycle detection with three states: white (unvisited), gray (in stack), black (done)
386        let mut state = vec![0u8; self.correlations.len()]; // 0=white, 1=gray, 2=black
387        let mut path: Vec<usize> = Vec::new();
388
389        for start in 0..self.correlations.len() {
390            if state[start] == 0
391                && let Some(cycle) = Self::dfs_find_cycle(start, &adj, &mut state, &mut path)
392            {
393                let names: Vec<String> = cycle
394                    .iter()
395                    .map(|&i| {
396                        self.correlations[i]
397                            .id
398                            .as_deref()
399                            .or(self.correlations[i].name.as_deref())
400                            .unwrap_or(&self.correlations[i].title)
401                            .to_string()
402                    })
403                    .collect();
404                return Err(crate::error::EvalError::CorrelationCycle(
405                    names.join(" -> "),
406                ));
407            }
408        }
409        Ok(())
410    }
411
412    /// DFS helper that returns the cycle path if a back-edge is found.
413    fn dfs_find_cycle(
414        node: usize,
415        adj: &[Vec<usize>],
416        state: &mut [u8],
417        path: &mut Vec<usize>,
418    ) -> Option<Vec<usize>> {
419        state[node] = 1; // gray
420        path.push(node);
421
422        for &next in &adj[node] {
423            if state[next] == 1 {
424                // Back-edge found — extract cycle from path
425                if let Some(pos) = path.iter().position(|&n| n == next) {
426                    let mut cycle = path[pos..].to_vec();
427                    cycle.push(next); // close the cycle
428                    return Some(cycle);
429                }
430            }
431            if state[next] == 0
432                && let Some(cycle) = Self::dfs_find_cycle(next, adj, state, path)
433            {
434                return Some(cycle);
435            }
436        }
437
438        path.pop();
439        state[node] = 2; // black
440        None
441    }
442
443    /// Process an event, extracting the timestamp from configured event fields.
444    ///
445    /// When no timestamp field is found, the `timestamp_fallback` policy applies:
446    /// - `WallClock`: use `Utc::now()` (good for real-time streaming)
447    /// - `Skip`: return detections only, skip correlation state updates
448    pub fn process_event(&mut self, event: &impl Event) -> ProcessResult {
449        let all_detections = self.engine.evaluate(event);
450        self.correlate_detections(event, all_detections)
451    }
452
453    /// Run the correlation layer over externally-produced detections.
454    ///
455    /// Like [`process_event`](Self::process_event) but the detections are
456    /// supplied by the caller instead of computed by this engine's inner
457    /// detection engine. This lets a multi-engine router run detection in a
458    /// per-schema engine and still feed every detection into one shared
459    /// correlation store. Timestamp extraction and the `timestamp_fallback`
460    /// policy match `process_event`.
461    pub fn correlate_detections(
462        &mut self,
463        event: &impl Event,
464        all_detections: Vec<EvaluationResult>,
465    ) -> ProcessResult {
466        let ts = match self.extract_event_timestamp(event) {
467            Some(ts) => ts,
468            None => match self.config.timestamp_fallback {
469                TimestampFallback::WallClock => Utc::now().timestamp(),
470                TimestampFallback::Skip => {
471                    // Still surface detections, but skip correlation state.
472                    return self.filter_detections(all_detections);
473                }
474            },
475        };
476        self.process_with_detections(event, all_detections, ts)
477    }
478
479    /// Process an event with an explicit Unix epoch timestamp (seconds).
480    ///
481    /// The timestamp is clamped to `[0, i64::MAX / 2]` to prevent overflow
482    /// when adding timespan durations internally.
483    pub fn process_event_at(&mut self, event: &impl Event, timestamp_secs: i64) -> ProcessResult {
484        let all_detections = self.engine.evaluate(event);
485        self.process_with_detections(event, all_detections, timestamp_secs)
486    }
487
488    /// Process an event with pre-computed detection results.
489    ///
490    /// Enables external parallelism: callers can run detection (via
491    /// [`evaluate`](Self::evaluate)) in parallel, then feed results here
492    /// sequentially for stateful correlation.
493    pub fn process_with_detections(
494        &mut self,
495        event: &impl Event,
496        all_detections: Vec<EvaluationResult>,
497        timestamp_secs: i64,
498    ) -> ProcessResult {
499        let timestamp_secs = timestamp_secs.clamp(0, i64::MAX / 2);
500
501        // Memory management — evict before adding new state to enforce limit
502        if self.state.len() >= self.config.max_state_entries {
503            self.evict_all(timestamp_secs);
504        }
505
506        // Feed detection matches into correlations
507        let mut correlations: Vec<EvaluationResult> = Vec::new();
508        self.feed_detections(event, &all_detections, timestamp_secs, &mut correlations);
509
510        // Chain — parent firings go into a separate vec so we do not
511        // alias `correlations` as both the input slice and the output.
512        let mut chained = Vec::new();
513        self.chain_correlations(&correlations, timestamp_secs, &mut chained);
514        correlations.extend(chained);
515
516        // Filter detections by generate flag, then append the correlations.
517        let mut out = self.filter_detections(all_detections);
518        out.extend(correlations);
519        out
520    }
521
522    /// Run stateless detection only (no correlation), delegating to the inner engine.
523    ///
524    /// Returns one [`EvaluationResult`] per matched detection. Takes `&self`
525    /// so it can be called concurrently from multiple threads (e.g. via
526    /// `rayon::par_iter`) while the mutable correlation phase runs
527    /// sequentially afterwards.
528    pub fn evaluate(&self, event: &impl Event) -> Vec<EvaluationResult> {
529        self.engine.evaluate(event)
530    }
531
532    /// Process a batch of events: parallel detection, then sequential correlation.
533    ///
534    /// When the `parallel` feature is enabled, the stateless detection phase runs
535    /// concurrently via rayon. Timestamp extraction also runs in the parallel
536    /// phase (it borrows `&self.config` immutably). After `collect()` releases the
537    /// immutable borrows, each event's pre-computed detections are fed into the
538    /// stateful correlation engine sequentially.
539    pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
540        // Borrow split: take immutable refs to fields needed for the parallel phase.
541        // These are released by collect() before the sequential &mut self phase.
542        let engine = &self.engine;
543        let ts_fields = &self.config.timestamp_fields;
544
545        let batch_results: Vec<(Vec<EvaluationResult>, Option<i64>)> = {
546            #[cfg(feature = "parallel")]
547            {
548                use rayon::prelude::*;
549                events
550                    .par_iter()
551                    .map(|e| {
552                        let detections = engine.evaluate(e);
553                        let ts = extract_event_ts(e, ts_fields);
554                        (detections, ts)
555                    })
556                    .collect()
557            }
558            #[cfg(not(feature = "parallel"))]
559            {
560                events
561                    .iter()
562                    .map(|e| {
563                        let detections = engine.evaluate(e);
564                        let ts = extract_event_ts(e, ts_fields);
565                        (detections, ts)
566                    })
567                    .collect()
568            }
569        };
570
571        // Sequential correlation phase
572        let mut results = Vec::with_capacity(events.len());
573        for ((detections, ts_opt), event) in batch_results.into_iter().zip(events) {
574            match ts_opt {
575                Some(ts) => {
576                    results.push(self.process_with_detections(event, detections, ts));
577                }
578                None => match self.config.timestamp_fallback {
579                    TimestampFallback::WallClock => {
580                        let ts = Utc::now().timestamp();
581                        results.push(self.process_with_detections(event, detections, ts));
582                    }
583                    TimestampFallback::Skip => {
584                        // Still return detection results, but skip correlation
585                        results.push(self.filter_detections(detections));
586                    }
587                },
588            }
589        }
590        results
591    }
592
593    /// Filter detections by the `generate` flag / `emit_detections` config.
594    ///
595    /// If `emit_detections` is false and some rules are correlation-only,
596    /// their detection output is suppressed.
597    fn filter_detections(&self, all_detections: Vec<EvaluationResult>) -> Vec<EvaluationResult> {
598        if !self.config.emit_detections && !self.correlation_only_rules.is_empty() {
599            all_detections
600                .into_iter()
601                .filter(|m| {
602                    let id_match = m
603                        .header
604                        .rule_id
605                        .as_ref()
606                        .is_some_and(|id| self.correlation_only_rules.contains(id));
607                    !id_match
608                })
609                .collect()
610        } else {
611            all_detections
612        }
613    }
614
615    /// Feed detection matches into correlation window states.
616    fn feed_detections(
617        &mut self,
618        event: &impl Event,
619        detections: &[EvaluationResult],
620        ts: i64,
621        out: &mut Vec<EvaluationResult>,
622    ) {
623        // Collect all (corr_idx, rule_id, rule_name) tuples upfront to avoid
624        // borrow conflicts between self.rule_ids and self.update_correlation.
625        let mut work: Vec<(usize, Option<String>, Option<String>)> = Vec::new();
626
627        for det in detections {
628            // Use the MatchResult's rule_id to find the original rule's ID/name.
629            // We also look up by rule_id in our rule_ids table for the name.
630            let (rule_id, rule_name) = self.find_rule_identity(det);
631
632            // Collect correlation indices that reference this rule
633            let mut corr_indices = Vec::new();
634            if let Some(ref id) = rule_id
635                && let Some(indices) = self.rule_index.get(id)
636            {
637                corr_indices.extend(indices);
638            }
639            if let Some(ref name) = rule_name
640                && let Some(indices) = self.rule_index.get(name)
641            {
642                corr_indices.extend(indices);
643            }
644
645            corr_indices.sort_unstable();
646            corr_indices.dedup();
647
648            for &corr_idx in &corr_indices {
649                work.push((corr_idx, rule_id.clone(), rule_name.clone()));
650            }
651        }
652
653        for (corr_idx, rule_id, rule_name) in work {
654            self.update_correlation(corr_idx, event, ts, &rule_id, &rule_name, out);
655        }
656    }
657
658    /// Find the (id, name) for a detection match by searching our rule_ids table.
659    fn find_rule_identity(&self, det: &EvaluationResult) -> (Option<String>, Option<String>) {
660        // First, try to find by matching rule_id in our table
661        if let Some(ref match_id) = det.header.rule_id {
662            for (id, name) in &self.rule_ids {
663                if id.as_deref() == Some(match_id.as_str()) {
664                    return (id.clone(), name.clone());
665                }
666            }
667        }
668        // Fall back to using just the EvaluationResult's rule_id
669        (det.header.rule_id.clone(), None)
670    }
671
672    /// Resolve the event mode for a given correlation.
673    fn resolve_event_mode(&self, corr_idx: usize) -> CorrelationEventMode {
674        let corr = &self.correlations[corr_idx];
675        corr.event_mode
676            .unwrap_or(self.config.correlation_event_mode)
677    }
678
679    /// Resolve the max events cap for a given correlation.
680    fn resolve_max_events(&self, corr_idx: usize) -> usize {
681        let corr = &self.correlations[corr_idx];
682        corr.max_events
683            .unwrap_or(self.config.max_correlation_events)
684    }
685
686    /// Resolve the per-group window-state entry cap for a given correlation.
687    /// `None` means unbounded.
688    fn resolve_max_group_entries(&self, corr_idx: usize) -> Option<usize> {
689        let corr = &self.correlations[corr_idx];
690        corr.max_group_entries.or(self.config.max_group_entries)
691    }
692
693    /// Update a single correlation's state and check its condition.
694    fn update_correlation(
695        &mut self,
696        corr_idx: usize,
697        event: &impl Event,
698        ts: i64,
699        rule_id: &Option<String>,
700        rule_name: &Option<String>,
701        out: &mut Vec<EvaluationResult>,
702    ) {
703        // Borrow the correlation by reference — no cloning needed.  Rust allows
704        // simultaneous &self.correlations and &mut self.state / &mut self.last_alert
705        // because they are disjoint struct fields.
706        let corr = &self.correlations[corr_idx];
707        let corr_type = corr.correlation_type;
708        let timespan = corr.timespan_secs;
709        let window_mode = corr.window_mode;
710        let gap_secs = corr.gap_secs;
711        let level = corr.level;
712        let suppress_secs = corr.suppress_secs.or(self.config.suppress);
713        let action = corr.action.unwrap_or(self.config.action_on_match);
714        let event_mode = self.resolve_event_mode(corr_idx);
715        let max_events = self.resolve_max_events(corr_idx);
716        let max_group_entries = self.resolve_max_group_entries(corr_idx);
717
718        // Determine the rule_ref strings for alias resolution and temporal tracking.
719        let mut ref_strs: Vec<&str> = Vec::new();
720        if let Some(id) = rule_id.as_deref() {
721            ref_strs.push(id);
722        }
723        if let Some(name) = rule_name.as_deref() {
724            ref_strs.push(name);
725        }
726        let rule_ref = ref_strs
727            .iter()
728            .copied()
729            .find(|identity| corr.rule_refs.iter().any(|rule_ref| rule_ref == identity))
730            .unwrap_or("");
731
732        // Extract group key
733        let group_key = GroupKey::extract(event, &corr.group_by, &ref_strs);
734
735        // Get or create window state
736        let state_key = (corr_idx, group_key.clone());
737        let state = self
738            .state
739            .entry(state_key.clone())
740            .or_insert_with(|| WindowState::new_for(corr_type));
741
742        // Apply the window's pre-insert maintenance (sliding evict, tumbling
743        // bucket reset/late-event discard, or session gap/cap reset). On
744        // `Reset` the event buffers below are cleared in sync; on `Discard`
745        // (a late arrival in an earlier tumbling bucket) the event is dropped
746        // without touching the state or buffers.
747        let cutoff = ts - timespan as i64;
748        let decision = apply_window_open(state, ts, timespan, window_mode, gap_secs);
749        if decision == WindowDecision::Discard {
750            return;
751        }
752        let reset = decision == WindowDecision::Reset;
753
754        // Push the new event into the state
755        match corr_type {
756            CorrelationType::EventCount => {
757                state.push_event_count(ts);
758            }
759            CorrelationType::ValueCount => {
760                if let Some(ref fields) = corr.condition.field
761                    && let Some(key) = composite_value_count_key(event, fields)
762                {
763                    state.push_value_count(ts, key);
764                }
765            }
766            CorrelationType::Temporal | CorrelationType::TemporalOrdered => {
767                state.push_temporal(ts, rule_ref);
768            }
769            CorrelationType::ValueSum
770            | CorrelationType::ValueAvg
771            | CorrelationType::ValuePercentile
772            | CorrelationType::ValueMedian => {
773                if let Some(ref fields) = corr.condition.field
774                    && let Some(field_name) = fields.first()
775                    && let Some(val) = event.get_field(field_name)
776                    && let Some(n) = value_to_f64_ev(&val)
777                {
778                    state.push_numeric(ts, n);
779                }
780            }
781        }
782
783        // Enforce the per-group entry cap. Session windows keep their oldest
784        // entry as the span anchor so truncation cannot silently extend the
785        // `timespan` cap.
786        if let Some(cap) = max_group_entries {
787            state.truncate_oldest(cap, window_mode == WindowMode::Session);
788        }
789
790        // Push event into buffer based on event mode. Keep the buffer's retained
791        // events aligned with the window state: sliding evicts by the trailing
792        // cutoff, while tumbling/session clear the buffer when the window reset.
793        match event_mode {
794            CorrelationEventMode::Full => {
795                let buf = self
796                    .event_buffers
797                    .entry(state_key.clone())
798                    .or_insert_with(|| EventBuffer::new(max_events));
799                if window_mode == rsigma_parser::WindowMode::Sliding {
800                    buf.evict(cutoff);
801                } else if reset {
802                    buf.clear();
803                }
804                let json = event.to_json();
805                buf.push(ts, &json);
806            }
807            CorrelationEventMode::Refs => {
808                let buf = self
809                    .event_ref_buffers
810                    .entry(state_key.clone())
811                    .or_insert_with(|| EventRefBuffer::new(max_events));
812                if window_mode == rsigma_parser::WindowMode::Sliding {
813                    buf.evict(cutoff);
814                } else if reset {
815                    buf.clear();
816                }
817                let json = event.to_json();
818                buf.push(ts, &json);
819            }
820            CorrelationEventMode::None => {}
821        }
822
823        // Check condition — after this, `state` is no longer used (NLL drops the borrow).
824        let fired = state.check_condition(
825            &corr.condition,
826            corr_type,
827            &corr.rule_refs,
828            corr.extended_expr.as_ref(),
829        );
830
831        if let Some(agg_value) = fired {
832            let alert_key = (corr_idx, group_key.clone());
833
834            // Suppression check: skip if we've already alerted within the suppress window
835            let suppressed = if let Some(suppress) = suppress_secs {
836                if let Some(&last_ts) = self.last_alert.get(&alert_key) {
837                    (ts - last_ts) < suppress as i64
838                } else {
839                    false
840                }
841            } else {
842                false
843            };
844
845            if !suppressed {
846                // Retrieve stored events / refs based on mode
847                let (events, event_refs) = match event_mode {
848                    CorrelationEventMode::Full => {
849                        let stored = self
850                            .event_buffers
851                            .get(&alert_key)
852                            .map(|buf| buf.decompress_all())
853                            .unwrap_or_default();
854                        (Some(stored), None)
855                    }
856                    CorrelationEventMode::Refs => {
857                        let stored = self
858                            .event_ref_buffers
859                            .get(&alert_key)
860                            .map(|buf| buf.refs())
861                            .unwrap_or_default();
862                        (None, Some(stored))
863                    }
864                    CorrelationEventMode::None => (None, None),
865                };
866
867                // Only clone title/id/tags when we actually produce output
868                let corr = &self.correlations[corr_idx];
869                let result = EvaluationResult {
870                    header: RuleHeader {
871                        rule_title: corr.title.clone(),
872                        rule_id: corr.id.clone(),
873                        level,
874                        tags: corr.tags.clone(),
875                        custom_attributes: corr.custom_attributes.clone(),
876                        enrichments: None,
877                    },
878                    body: ResultBody::Correlation(CorrelationBody {
879                        correlation_type: corr_type,
880                        group_key: group_key.to_pairs(&corr.group_by),
881                        aggregated_value: agg_value,
882                        timespan_secs: timespan,
883                        events,
884                        event_refs,
885                    }),
886                };
887                out.push(result);
888
889                // Record alert time for suppression
890                self.last_alert.insert(alert_key.clone(), ts);
891
892                // Action on match
893                if action == CorrelationAction::Reset {
894                    if let Some(state) = self.state.get_mut(&alert_key) {
895                        state.clear();
896                    }
897                    if let Some(buf) = self.event_buffers.get_mut(&alert_key) {
898                        buf.clear();
899                    }
900                    if let Some(buf) = self.event_ref_buffers.get_mut(&alert_key) {
901                        buf.clear();
902                    }
903                }
904            }
905        }
906    }
907
908    /// IDs and names a fired correlation can be referenced by.
909    ///
910    /// Parents index `rule_refs` as written in YAML (id or name). The
911    /// emitted result only carries `rule_id`, so name-based parents need
912    /// this extra key or they never see the child.
913    fn chain_lookup_keys(&self, result: &EvaluationResult) -> Vec<String> {
914        let Some(id) = result.header.rule_id.as_deref() else {
915            return Vec::new();
916        };
917        let mut keys = vec![id.to_string()];
918        if let Some(name) = self
919            .correlations
920            .iter()
921            .find(|c| c.id.as_deref() == Some(id))
922            .and_then(|c| c.name.as_deref())
923            && name != id
924        {
925            keys.push(name.to_string());
926        }
927        keys
928    }
929
930    /// Propagate correlation results to higher-level correlations (chaining).
931    ///
932    /// When a correlation fires, any correlation that references it (by ID or name)
933    /// is updated. Newly fired parents are appended to `out` and become the next
934    /// `pending` set. Limits chain depth to 10 to prevent infinite loops.
935    fn chain_correlations(
936        &mut self,
937        fired: &[EvaluationResult],
938        ts: i64,
939        out: &mut Vec<EvaluationResult>,
940    ) {
941        let mut pending: Vec<EvaluationResult> = fired.to_vec();
942        let mut depth = 0;
943
944        while !pending.is_empty() && depth < MAX_CHAIN_DEPTH {
945            depth += 1;
946
947            // Collect work items: (corr_idx, group_key_pairs, fired_ref)
948            #[allow(clippy::type_complexity)]
949            let mut work: Vec<(usize, Vec<(String, String)>, String)> = Vec::new();
950            let mut seen = std::collections::HashSet::<(usize, String)>::new();
951            for result in &pending {
952                // Only correlation results chain. Detections never reach here.
953                let Some(body) = result.as_correlation() else {
954                    continue;
955                };
956                for key in self.chain_lookup_keys(result) {
957                    if let Some(indices) = self.rule_index.get(&key) {
958                        for &corr_idx in indices {
959                            if seen.insert((corr_idx, key.clone())) {
960                                work.push((corr_idx, body.group_key.clone(), key.clone()));
961                            }
962                        }
963                    }
964                }
965            }
966
967            let mut next_pending = Vec::new();
968            for (corr_idx, group_key_pairs, fired_ref) in work {
969                let corr = &self.correlations[corr_idx];
970                let corr_type = corr.correlation_type;
971                let timespan = corr.timespan_secs;
972                let window_mode = corr.window_mode;
973                let gap_secs = corr.gap_secs;
974                let level = corr.level;
975                let suppress_secs = corr.suppress_secs.or(self.config.suppress);
976                let action = corr.action.unwrap_or(self.config.action_on_match);
977
978                let group_key = GroupKey::from_pairs(&group_key_pairs, &corr.group_by);
979                let state_key = (corr_idx, group_key.clone());
980                let state = self
981                    .state
982                    .entry(state_key.clone())
983                    .or_insert_with(|| WindowState::new_for(corr_type));
984
985                // Late arrivals in an earlier tumbling bucket are discarded;
986                // chained correlations keep no event buffers, so `Reset` needs
987                // no extra bookkeeping here.
988                if apply_window_open(state, ts, timespan, window_mode, gap_secs)
989                    == WindowDecision::Discard
990                {
991                    continue;
992                }
993
994                match corr_type {
995                    CorrelationType::EventCount => {
996                        state.push_event_count(ts);
997                    }
998                    CorrelationType::Temporal | CorrelationType::TemporalOrdered => {
999                        state.push_temporal(ts, &fired_ref);
1000                    }
1001                    _ => {
1002                        state.push_event_count(ts);
1003                    }
1004                }
1005
1006                // Same per-group cap as the direct path; session windows
1007                // keep the span anchor.
1008                if let Some(cap) = corr.max_group_entries.or(self.config.max_group_entries) {
1009                    state.truncate_oldest(cap, window_mode == WindowMode::Session);
1010                }
1011
1012                let fired = state.check_condition(
1013                    &corr.condition,
1014                    corr_type,
1015                    &corr.rule_refs,
1016                    corr.extended_expr.as_ref(),
1017                );
1018
1019                if let Some(agg_value) = fired {
1020                    let alert_key = state_key;
1021                    let suppressed = if let Some(suppress) = suppress_secs {
1022                        self.last_alert
1023                            .get(&alert_key)
1024                            .is_some_and(|&last_ts| (ts - last_ts) < suppress as i64)
1025                    } else {
1026                        false
1027                    };
1028                    if suppressed {
1029                        continue;
1030                    }
1031
1032                    let corr = &self.correlations[corr_idx];
1033                    let result = EvaluationResult {
1034                        header: RuleHeader {
1035                            rule_title: corr.title.clone(),
1036                            rule_id: corr.id.clone(),
1037                            level,
1038                            tags: corr.tags.clone(),
1039                            custom_attributes: corr.custom_attributes.clone(),
1040                            enrichments: None,
1041                        },
1042                        body: ResultBody::Correlation(CorrelationBody {
1043                            correlation_type: corr_type,
1044                            group_key: group_key.to_pairs(&corr.group_by),
1045                            aggregated_value: agg_value,
1046                            timespan_secs: timespan,
1047                            // Chained correlations don't include events
1048                            // (they aggregate over correlation results, not
1049                            // raw events)
1050                            events: None,
1051                            event_refs: None,
1052                        }),
1053                    };
1054                    next_pending.push(result.clone());
1055                    out.push(result);
1056                    self.last_alert.insert(alert_key.clone(), ts);
1057
1058                    if action == CorrelationAction::Reset
1059                        && let Some(state) = self.state.get_mut(&alert_key)
1060                    {
1061                        state.clear();
1062                    }
1063                }
1064            }
1065
1066            pending = next_pending;
1067        }
1068
1069        if !pending.is_empty() {
1070            log::warn!(
1071                "Correlation chain depth limit reached ({MAX_CHAIN_DEPTH}); \
1072                 {} pending result(s) were not propagated further. \
1073                 This may indicate a cycle in correlation references.",
1074                pending.len()
1075            );
1076        }
1077    }
1078
1079    // =========================================================================
1080    // Timestamp extraction
1081    // =========================================================================
1082
1083    /// Extract a Unix epoch timestamp (seconds) from an event.
1084    ///
1085    /// Tries each configured timestamp field in order. Supports:
1086    /// - Numeric values (epoch seconds, or epoch millis if > 1e12)
1087    /// - ISO 8601 strings (e.g., "2024-07-10T12:30:00Z")
1088    ///
1089    /// Returns `None` if no field yields a valid timestamp.
1090    fn extract_event_timestamp(&self, event: &impl Event) -> Option<i64> {
1091        for field_name in &self.config.timestamp_fields {
1092            if let Some(val) = event.get_field(field_name)
1093                && let Some(ts) = parse_timestamp_value(&val)
1094            {
1095                return Some(ts);
1096            }
1097        }
1098        None
1099    }
1100
1101    // =========================================================================
1102    // State management
1103    // =========================================================================
1104
1105    /// Manually evict all expired state entries.
1106    pub fn evict_expired(&mut self, now_secs: i64) {
1107        self.evict_all(now_secs);
1108    }
1109
1110    /// Evict expired entries and remove empty states.
1111    fn evict_all(&mut self, now_secs: i64) {
1112        // Phase 1: Time-based eviction — remove entries outside their correlation
1113        // window. Eviction is window-mode aware:
1114        //
1115        // - Sliding: trim entries older than the trailing cutoff, as always.
1116        // - Tumbling/session: never trim from the front — that would forget the
1117        //   bucket/session start and silently weaken the `timespan` cap (the
1118        //   window would drift toward sliding semantics). Instead, drop the
1119        //   whole group once it is stale (no event within its bucket span /
1120        //   session gap), which is exactly the point at which the next arrival
1121        //   would reset it anyway.
1122        let specs: Vec<(u64, WindowMode, Option<u64>)> = self
1123            .correlations
1124            .iter()
1125            .map(|c| (c.timespan_secs, c.window_mode, c.gap_secs))
1126            .collect();
1127
1128        self.state.retain(|&(corr_idx, _), state| {
1129            if let Some(&(timespan, mode, gap)) = specs.get(corr_idx) {
1130                match mode {
1131                    WindowMode::Sliding => {
1132                        state.evict(now_secs - timespan as i64);
1133                    }
1134                    WindowMode::Tumbling | WindowMode::Session => {
1135                        let staleness = if mode == WindowMode::Session {
1136                            gap.unwrap_or(timespan)
1137                        } else {
1138                            timespan
1139                        } as i64;
1140                        if state
1141                            .latest_timestamp()
1142                            .is_some_and(|last| now_secs - last > staleness)
1143                        {
1144                            state.clear();
1145                        }
1146                    }
1147                }
1148            }
1149            !state.is_empty()
1150        });
1151
1152        // Evict event buffers in sync with window state: sliding buffers trim by
1153        // the trailing cutoff, tumbling/session buffers live and die with their
1154        // window state (dropped above when the group went stale).
1155        let state = &self.state;
1156        self.event_buffers.retain(|key, buf| {
1157            if let Some(&(timespan, mode, _)) = specs.get(key.0) {
1158                match mode {
1159                    WindowMode::Sliding => buf.evict(now_secs - timespan as i64),
1160                    WindowMode::Tumbling | WindowMode::Session => {
1161                        if !state.contains_key(key) {
1162                            return false;
1163                        }
1164                    }
1165                }
1166            }
1167            !buf.is_empty()
1168        });
1169        self.event_ref_buffers.retain(|key, buf| {
1170            if let Some(&(timespan, mode, _)) = specs.get(key.0) {
1171                match mode {
1172                    WindowMode::Sliding => buf.evict(now_secs - timespan as i64),
1173                    WindowMode::Tumbling | WindowMode::Session => {
1174                        if !state.contains_key(key) {
1175                            return false;
1176                        }
1177                    }
1178                }
1179            }
1180            !buf.is_empty()
1181        });
1182
1183        // Phase 2: Hard cap — if still over limit after time-based eviction (e.g.
1184        // high-cardinality traffic with long windows), drop the stalest entries
1185        // until we're at 90% capacity to avoid evicting on every single event.
1186        if self.state.len() >= self.config.max_state_entries {
1187            let target = self.config.max_state_entries * 9 / 10;
1188            let excess = self.state.len() - target;
1189
1190            log::warn!(
1191                "Correlation state hard cap reached ({} entries, max {}); \
1192                 evicting {} stalest entries to {} (90% capacity). \
1193                 This indicates high-cardinality traffic; consider raising \
1194                 max_state_entries or shortening correlation windows.",
1195                self.state.len(),
1196                self.config.max_state_entries,
1197                excess,
1198                target,
1199            );
1200
1201            // Collect keys with their latest timestamp, sort by oldest first
1202            let mut by_staleness: Vec<_> = self
1203                .state
1204                .iter()
1205                .map(|(k, v)| (k.clone(), v.latest_timestamp().unwrap_or(i64::MIN)))
1206                .collect();
1207            by_staleness.sort_unstable_by_key(|&(_, ts)| ts);
1208
1209            // Drop the oldest entries (and their associated event buffers)
1210            for (key, _) in by_staleness.into_iter().take(excess) {
1211                self.state.remove(&key);
1212                self.last_alert.remove(&key);
1213                self.event_buffers.remove(&key);
1214                self.event_ref_buffers.remove(&key);
1215            }
1216        }
1217
1218        // Phase 3: Evict stale last_alert entries — remove if the suppress window
1219        // has passed or if the corresponding window state no longer exists.
1220        self.last_alert.retain(|key, &mut alert_ts| {
1221            let suppress = if key.0 < self.correlations.len() {
1222                self.correlations[key.0]
1223                    .suppress_secs
1224                    .or(self.config.suppress)
1225                    .unwrap_or(0)
1226            } else {
1227                0
1228            };
1229            (now_secs - alert_ts) < suppress as i64
1230        });
1231    }
1232
1233    /// Number of active state entries (for monitoring).
1234    pub fn state_count(&self) -> usize {
1235        self.state.len()
1236    }
1237
1238    /// Number of detection rules loaded.
1239    pub fn detection_rule_count(&self) -> usize {
1240        self.engine.rule_count()
1241    }
1242
1243    /// Number of correlation rules loaded.
1244    pub fn correlation_rule_count(&self) -> usize {
1245        self.correlations.len()
1246    }
1247
1248    /// Number of active event buffers (for monitoring).
1249    pub fn event_buffer_count(&self) -> usize {
1250        self.event_buffers.len()
1251    }
1252
1253    /// Total compressed bytes across all event buffers (for monitoring).
1254    pub fn event_buffer_bytes(&self) -> usize {
1255        self.event_buffers
1256            .values()
1257            .map(|b| b.compressed_bytes())
1258            .sum()
1259    }
1260
1261    /// Number of active event ref buffers — `Refs` mode (for monitoring).
1262    pub fn event_ref_buffer_count(&self) -> usize {
1263        self.event_ref_buffers.len()
1264    }
1265
1266    /// Access the inner stateless engine.
1267    pub fn engine(&self) -> &Engine {
1268        &self.engine
1269    }
1270
1271    /// Resolve a rule key (an id, or a title for a rule without one) to the
1272    /// documentation of every loaded rule that carries it, across both the
1273    /// detection rules and the correlations.
1274    pub fn rule_metadata(&self, key: &str) -> RuleMetadataLookup {
1275        let mut variants = Vec::new();
1276        self.collect_rule_metadata(key, &mut variants);
1277        RuleMetadataLookup::from_variants(variants)
1278    }
1279
1280    pub(crate) fn collect_rule_metadata(&self, key: &str, out: &mut Vec<RuleBundleMetadata>) {
1281        self.engine.collect_rule_metadata(key, out);
1282        crate::rule_metadata::matching_correlations(&self.correlations, key, out);
1283    }
1284
1285    /// Export all mutable correlation state as a serializable snapshot.
1286    ///
1287    /// The snapshot uses stable correlation identifiers (id > name > title)
1288    /// instead of internal indices, so it survives rule reloads as long as
1289    /// the correlation rules keep the same identifiers.
1290    pub fn export_state(&self) -> CorrelationSnapshot {
1291        let mut windows: HashMap<String, Vec<(GroupKey, WindowState)>> = HashMap::new();
1292        for ((idx, gk), ws) in &self.state {
1293            let corr_id = self.correlation_stable_id(*idx);
1294            windows
1295                .entry(corr_id)
1296                .or_default()
1297                .push((gk.clone(), ws.clone()));
1298        }
1299
1300        let mut last_alert: HashMap<String, Vec<(GroupKey, i64)>> = HashMap::new();
1301        for ((idx, gk), ts) in &self.last_alert {
1302            let corr_id = self.correlation_stable_id(*idx);
1303            last_alert
1304                .entry(corr_id)
1305                .or_default()
1306                .push((gk.clone(), *ts));
1307        }
1308
1309        let mut event_buffers: HashMap<String, Vec<(GroupKey, EventBuffer)>> = HashMap::new();
1310        for ((idx, gk), buf) in &self.event_buffers {
1311            let corr_id = self.correlation_stable_id(*idx);
1312            event_buffers
1313                .entry(corr_id)
1314                .or_default()
1315                .push((gk.clone(), buf.clone()));
1316        }
1317
1318        let mut event_ref_buffers: HashMap<String, Vec<(GroupKey, EventRefBuffer)>> =
1319            HashMap::new();
1320        for ((idx, gk), buf) in &self.event_ref_buffers {
1321            let corr_id = self.correlation_stable_id(*idx);
1322            event_ref_buffers
1323                .entry(corr_id)
1324                .or_default()
1325                .push((gk.clone(), buf.clone()));
1326        }
1327
1328        CorrelationSnapshot {
1329            version: SNAPSHOT_VERSION,
1330            windows,
1331            last_alert,
1332            event_buffers,
1333            event_ref_buffers,
1334        }
1335    }
1336
1337    /// Import previously exported state, mapping stable identifiers back to
1338    /// current correlation indices. Entries whose identifiers no longer match
1339    /// any loaded correlation are silently dropped.
1340    ///
1341    /// Returns `false` (and imports nothing) if the snapshot version is
1342    /// incompatible with the current schema.
1343    pub fn import_state(&mut self, snapshot: CorrelationSnapshot) -> bool {
1344        if snapshot.version != SNAPSHOT_VERSION {
1345            return false;
1346        }
1347        let id_to_idx = self.build_id_to_index_map();
1348
1349        for (corr_id, groups) in snapshot.windows {
1350            if let Some(&idx) = id_to_idx.get(&corr_id) {
1351                for (gk, ws) in groups {
1352                    self.state.insert((idx, gk), ws);
1353                }
1354            }
1355        }
1356
1357        for (corr_id, groups) in snapshot.last_alert {
1358            if let Some(&idx) = id_to_idx.get(&corr_id) {
1359                for (gk, ts) in groups {
1360                    self.last_alert.insert((idx, gk), ts);
1361                }
1362            }
1363        }
1364
1365        for (corr_id, groups) in snapshot.event_buffers {
1366            if let Some(&idx) = id_to_idx.get(&corr_id) {
1367                for (gk, buf) in groups {
1368                    self.event_buffers.insert((idx, gk), buf);
1369                }
1370            }
1371        }
1372
1373        for (corr_id, groups) in snapshot.event_ref_buffers {
1374            if let Some(&idx) = id_to_idx.get(&corr_id) {
1375                for (gk, buf) in groups {
1376                    self.event_ref_buffers.insert((idx, gk), buf);
1377                }
1378            }
1379        }
1380
1381        true
1382    }
1383
1384    /// Stable identifier for a correlation rule: prefers id, then name, then title.
1385    fn correlation_stable_id(&self, idx: usize) -> String {
1386        let corr = &self.correlations[idx];
1387        corr.id
1388            .clone()
1389            .or_else(|| corr.name.clone())
1390            .unwrap_or_else(|| corr.title.clone())
1391    }
1392
1393    /// Build a reverse map from stable id → current correlation index.
1394    fn build_id_to_index_map(&self) -> HashMap<String, usize> {
1395        self.correlations
1396            .iter()
1397            .enumerate()
1398            .map(|(idx, _)| (self.correlation_stable_id(idx), idx))
1399            .collect()
1400    }
1401}
1402
1403impl Default for CorrelationEngine {
1404    fn default() -> Self {
1405        Self::new(CorrelationConfig::default())
1406    }
1407}
1408
1409// =============================================================================
1410// Timestamp parsing helpers
1411// =============================================================================
1412
1413/// Extract a timestamp from an event using the given field names.
1414///
1415/// Standalone version of `CorrelationEngine::extract_event_timestamp` for use
1416/// in contexts where borrowing `&self` is not possible (e.g. rayon closures).
1417fn extract_event_ts(event: &impl Event, timestamp_fields: &[String]) -> Option<i64> {
1418    for field_name in timestamp_fields {
1419        if let Some(val) = event.get_field(field_name)
1420            && let Some(ts) = parse_timestamp_value(&val)
1421        {
1422            return Some(ts);
1423        }
1424    }
1425    None
1426}
1427
1428/// Parse an [`EventValue`] as a Unix epoch timestamp in seconds.
1429fn parse_timestamp_value(val: &EventValue) -> Option<i64> {
1430    match val {
1431        EventValue::Int(i) => Some(normalize_epoch(*i)),
1432        EventValue::Float(f) => Some(normalize_epoch(*f as i64)),
1433        EventValue::Str(s) => parse_timestamp_string(s),
1434        _ => None,
1435    }
1436}
1437
1438/// Normalize an epoch value: if it looks like milliseconds (> year 33658),
1439/// convert to seconds.
1440fn normalize_epoch(v: i64) -> i64 {
1441    if v > 1_000_000_000_000 { v / 1000 } else { v }
1442}
1443
1444/// Parse a timestamp string. Tries ISO 8601 with timezone, then without.
1445fn parse_timestamp_string(s: &str) -> Option<i64> {
1446    // Try RFC 3339 / ISO 8601 with timezone
1447    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
1448        return Some(dt.timestamp());
1449    }
1450
1451    // Try ISO 8601 without timezone (assume UTC)
1452    // Common formats: "2024-07-10T12:30:00", "2024-07-10 12:30:00"
1453    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
1454        return Some(Utc.from_utc_datetime(&naive).timestamp());
1455    }
1456    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
1457        return Some(Utc.from_utc_datetime(&naive).timestamp());
1458    }
1459
1460    // Try with fractional seconds
1461    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
1462        return Some(Utc.from_utc_datetime(&naive).timestamp());
1463    }
1464    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
1465        return Some(Utc.from_utc_datetime(&naive).timestamp());
1466    }
1467
1468    None
1469}
1470
1471/// Convert an [`EventValue`] to a string for value_count purposes.
1472fn value_to_string_for_count(v: &EventValue) -> Option<String> {
1473    match v {
1474        EventValue::Str(s) => Some(s.to_string()),
1475        EventValue::Int(n) => Some(n.to_string()),
1476        EventValue::Float(f) => Some(f.to_string()),
1477        EventValue::Bool(b) => Some(b.to_string()),
1478        EventValue::Null => Some("null".to_string()),
1479        _ => None,
1480    }
1481}
1482
1483/// Build a composite distinct-key for `value_count` over one or more fields.
1484///
1485/// Each field's value is rendered with [`value_to_string_for_count`] and the
1486/// rendered parts are joined with `\u{1f}` (the ASCII Unit Separator), which
1487/// is unlikely to occur in normal log data. If any field is missing or has a
1488/// type that does not produce a stable string representation, the event is
1489/// excluded from the distinct count (return `None`), matching the historical
1490/// single-field behavior.
1491fn composite_value_count_key(event: &impl Event, fields: &[String]) -> Option<String> {
1492    // Common case: exactly one field. Avoid the separator overhead.
1493    if let [field_name] = fields {
1494        let val = event.get_field(field_name)?;
1495        return value_to_string_for_count(&val);
1496    }
1497
1498    let mut parts = Vec::with_capacity(fields.len());
1499    for field_name in fields {
1500        let val = event.get_field(field_name)?;
1501        let rendered = value_to_string_for_count(&val)?;
1502        parts.push(rendered);
1503    }
1504    Some(parts.join("\u{1f}"))
1505}
1506
1507/// Convert an [`EventValue`] to f64 for numeric aggregation.
1508fn value_to_f64_ev(v: &EventValue) -> Option<f64> {
1509    v.as_f64()
1510}