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/// Stateful correlation engine.
48///
49/// Wraps the stateless `Engine` for detection rules and adds time-windowed
50/// correlation on top. Supports all 7 Sigma correlation types and chaining.
51pub struct CorrelationEngine {
52    /// Inner stateless detection engine.
53    engine: Engine,
54    /// Compiled correlation rules.
55    correlations: Vec<CompiledCorrelation>,
56    /// Maps rule ID/name -> indices into `correlations` that reference it.
57    /// This allows quick lookup: "which correlations care about rule X?"
58    rule_index: HashMap<String, Vec<usize>>,
59    /// Maps detection rule index -> (rule_id, rule_name) for reverse lookup.
60    /// Used to find which correlations a detection match triggers.
61    rule_ids: Vec<(Option<String>, Option<String>)>,
62    /// Per-(correlation_index, group_key) window state.
63    state: HashMap<(usize, GroupKey), WindowState>,
64    /// Last alert timestamp per (correlation_index, group_key) for suppression.
65    last_alert: HashMap<(usize, GroupKey), i64>,
66    /// Per-(correlation_index, group_key) compressed event buffer (`Full` mode).
67    event_buffers: HashMap<(usize, GroupKey), EventBuffer>,
68    /// Per-(correlation_index, group_key) event reference buffer (`Refs` mode).
69    event_ref_buffers: HashMap<(usize, GroupKey), EventRefBuffer>,
70    /// Set of detection rule IDs/names that are "correlation-only"
71    /// (referenced by correlations where `generate == false`).
72    /// Used to filter detection output when `config.emit_detections == false`.
73    correlation_only_rules: std::collections::HashSet<String>,
74    /// Configuration.
75    config: CorrelationConfig,
76    /// Processing pipelines applied to rules during add_rule.
77    pipelines: Vec<Pipeline>,
78}
79
80impl CorrelationEngine {
81    /// Create a new correlation engine with the given configuration.
82    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    /// Add a pipeline to the engine.
99    ///
100    /// Pipelines are applied to rules during `add_rule` / `add_collection`.
101    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    /// Set global `include_event` on the inner detection engine.
107    pub fn set_include_event(&mut self, include: bool) {
108        self.engine.set_include_event(include);
109    }
110
111    /// Forward to [`crate::Engine::set_match_detail`] on the inner detection
112    /// engine. Correlation detections inherit the level set here.
113    pub fn set_match_detail(&mut self, level: crate::result::MatchDetailLevel) {
114        self.engine.set_match_detail(level);
115    }
116
117    /// Forward to [`crate::Engine::set_bloom_prefilter`] on the inner
118    /// detection engine. Off by default; the optimization helps only on
119    /// substring-heavy rule sets paired with mostly-non-matching events.
120    pub fn set_bloom_prefilter(&mut self, enabled: bool) {
121        self.engine.set_bloom_prefilter(enabled);
122    }
123
124    /// Forward to [`crate::Engine::set_logsource_extractor`] on the inner
125    /// detection engine. Correlation inherits logsource pruning, since
126    /// `process_event` evaluates through this engine.
127    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    /// Total rule candidates pruned by logsource on the inner engine.
135    pub fn logsource_pruned_total(&self) -> u64 {
136        self.engine.logsource_pruned_total()
137    }
138
139    /// Total evaluate calls with no extractable event logsource (fail-open).
140    pub fn logsource_absent_total(&self) -> u64 {
141        self.engine.logsource_absent_total()
142    }
143
144    /// Forward to [`crate::Engine::set_bloom_max_bytes`] on the inner
145    /// detection engine.
146    pub fn set_bloom_max_bytes(&mut self, max_bytes: usize) {
147        self.engine.set_bloom_max_bytes(max_bytes);
148    }
149
150    /// Forward to [`crate::Engine::set_cross_rule_ac`] on the inner
151    /// detection engine. Off by default. Available behind the
152    /// `daachorse-index` Cargo feature.
153    #[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    /// Set the global correlation event mode.
159    ///
160    /// - `None`: no event storage (default)
161    /// - `Full`: compressed event bodies
162    /// - `Refs`: lightweight timestamp + ID references
163    pub fn set_correlation_event_mode(&mut self, mode: CorrelationEventMode) {
164        self.config.correlation_event_mode = mode;
165    }
166
167    /// Set the maximum number of events to store per correlation window group.
168    /// Only meaningful when `correlation_event_mode` is not `None`.
169    pub fn set_max_correlation_events(&mut self, max: usize) {
170        self.config.max_correlation_events = max;
171    }
172
173    /// Add a single detection rule.
174    ///
175    /// If pipelines are set, the rule is cloned and transformed before compilation.
176    /// The inner engine receives the already-transformed rule directly (not through
177    /// its own pipeline, to avoid double transformation).
178    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            // Use compile_rule + add_compiled_rule to bypass inner engine's pipelines
190            let compiled = crate::compiler::compile_rule(&transformed)?;
191            self.engine.add_compiled_rule(compiled);
192        }
193        Ok(())
194    }
195
196    /// Read `rsigma.*` custom attributes from a rule and apply them to the
197    /// engine configuration.  This allows pipelines to influence engine
198    /// behaviour via `SetCustomAttribute` transformations — the same pattern
199    /// used by pySigma backends (e.g. pySigma-backend-loki).
200    ///
201    /// Supported attributes:
202    /// - `rsigma.timestamp_field` — prepends a field name to the timestamp
203    ///   extraction priority list so the correlation engine can find the
204    ///   event timestamp in non-standard field names.
205    /// - `rsigma.suppress` — sets the default suppression window (e.g. `5m`).
206    ///   Only applied when the CLI did not already set `--suppress`.
207    /// - `rsigma.action` — sets the default post-fire action (`alert`/`reset`).
208    ///   Only applied when the CLI did not already set `--action`.
209    fn apply_custom_attributes(
210        &mut self,
211        attrs: &std::collections::HashMap<String, yaml_serde::Value>,
212    ) {
213        // rsigma.timestamp_field — prepend to priority list, skip duplicates
214        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        // rsigma.suppress — only when CLI didn't already set one
221        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        // rsigma.action — only when CLI left it at the default (Alert)
229        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    /// Add a single correlation rule.
238    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        // Apply engine-level custom attributes from the (possibly transformed)
252        // correlation rule (e.g. rsigma.timestamp_field).
253        self.apply_custom_attributes(&effective.custom_attributes);
254
255        let compiled = compile_correlation(effective)?;
256        let idx = self.correlations.len();
257
258        // Index by each referenced rule ID/name
259        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        // Track correlation-only rules (generate == false is the default)
267        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    /// Add all rules and correlations from a parsed collection.
278    ///
279    /// Detection rules are added first (so they're available for correlation
280    /// references), then correlation rules. Detection rules are compiled
281    /// sequentially and then pushed to the inner engine in a single batch,
282    /// so the inverted index and bloom filter are rebuilt exactly once for
283    /// the whole collection. Without this batching, large rule sets
284    /// (multi-thousand rules) hit an O(N²) rebuild cost on load.
285    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                // Bypass the inner engine's pipelines (would double-transform)
301                compiled_batch.push(crate::compiler::compile_rule(&transformed)?);
302            }
303        }
304        self.engine.extend_compiled_rules(compiled_batch);
305        // Apply filter rules to the inner engine's detection rules
306        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    /// Validate that every correlation's `rule_refs` resolve to at least one
318    /// known detection rule (by ID or name) or another correlation (by ID or name).
319    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    /// Detect cycles in the correlation reference graph.
350    ///
351    /// Builds a directed graph where each correlation (identified by its id/name)
352    /// has edges to the correlations it references via `rule_refs`. Uses DFS with
353    /// a "gray/black" coloring scheme to detect back-edges (cycles).
354    ///
355    /// Returns `Err(EvalError::CorrelationCycle)` if a cycle is found.
356    fn detect_correlation_cycles(&self) -> Result<()> {
357        // Build a set of all correlation identifiers (id and/or name)
358        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        // Build adjacency list: corr index → set of corr indices it references
369        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        // DFS cycle detection with three states: white (unvisited), gray (in stack), black (done)
379        let mut state = vec![0u8; self.correlations.len()]; // 0=white, 1=gray, 2=black
380        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    /// DFS helper that returns the cycle path if a back-edge is found.
406    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; // gray
413        path.push(node);
414
415        for &next in &adj[node] {
416            if state[next] == 1 {
417                // Back-edge found — extract cycle from path
418                if let Some(pos) = path.iter().position(|&n| n == next) {
419                    let mut cycle = path[pos..].to_vec();
420                    cycle.push(next); // close the cycle
421                    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; // black
433        None
434    }
435
436    /// Process an event, extracting the timestamp from configured event fields.
437    ///
438    /// When no timestamp field is found, the `timestamp_fallback` policy applies:
439    /// - `WallClock`: use `Utc::now()` (good for real-time streaming)
440    /// - `Skip`: return detections only, skip correlation state updates
441    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    /// Run the correlation layer over externally-produced detections.
447    ///
448    /// Like [`process_event`](Self::process_event) but the detections are
449    /// supplied by the caller instead of computed by this engine's inner
450    /// detection engine. This lets a multi-engine router run detection in a
451    /// per-schema engine and still feed every detection into one shared
452    /// correlation store. Timestamp extraction and the `timestamp_fallback`
453    /// policy match `process_event`.
454    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                    // Still surface detections, but skip correlation state.
465                    return self.filter_detections(all_detections);
466                }
467            },
468        };
469        self.process_with_detections(event, all_detections, ts)
470    }
471
472    /// Process an event with an explicit Unix epoch timestamp (seconds).
473    ///
474    /// The timestamp is clamped to `[0, i64::MAX / 2]` to prevent overflow
475    /// when adding timespan durations internally.
476    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    /// Process an event with pre-computed detection results.
482    ///
483    /// Enables external parallelism: callers can run detection (via
484    /// [`evaluate`](Self::evaluate)) in parallel, then feed results here
485    /// sequentially for stateful correlation.
486    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        // Memory management — evict before adding new state to enforce limit
495        if self.state.len() >= self.config.max_state_entries {
496            self.evict_all(timestamp_secs);
497        }
498
499        // Feed detection matches into correlations
500        let mut correlations: Vec<EvaluationResult> = Vec::new();
501        self.feed_detections(event, &all_detections, timestamp_secs, &mut correlations);
502
503        // Chain — correlation results may trigger higher-level correlations
504        self.chain_correlations(&correlations, timestamp_secs);
505
506        // Filter detections by generate flag, then append the correlations.
507        let mut out = self.filter_detections(all_detections);
508        out.extend(correlations);
509        out
510    }
511
512    /// Run stateless detection only (no correlation), delegating to the inner engine.
513    ///
514    /// Returns one [`EvaluationResult`] per matched detection. Takes `&self`
515    /// so it can be called concurrently from multiple threads (e.g. via
516    /// `rayon::par_iter`) while the mutable correlation phase runs
517    /// sequentially afterwards.
518    pub fn evaluate(&self, event: &impl Event) -> Vec<EvaluationResult> {
519        self.engine.evaluate(event)
520    }
521
522    /// Process a batch of events: parallel detection, then sequential correlation.
523    ///
524    /// When the `parallel` feature is enabled, the stateless detection phase runs
525    /// concurrently via rayon. Timestamp extraction also runs in the parallel
526    /// phase (it borrows `&self.config` immutably). After `collect()` releases the
527    /// immutable borrows, each event's pre-computed detections are fed into the
528    /// stateful correlation engine sequentially.
529    pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
530        // Borrow split: take immutable refs to fields needed for the parallel phase.
531        // These are released by collect() before the sequential &mut self phase.
532        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        // Sequential correlation phase
562        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                        // Still return detection results, but skip correlation
575                        results.push(self.filter_detections(detections));
576                    }
577                },
578            }
579        }
580        results
581    }
582
583    /// Filter detections by the `generate` flag / `emit_detections` config.
584    ///
585    /// If `emit_detections` is false and some rules are correlation-only,
586    /// their detection output is suppressed.
587    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    /// Feed detection matches into correlation window states.
606    fn feed_detections(
607        &mut self,
608        event: &impl Event,
609        detections: &[EvaluationResult],
610        ts: i64,
611        out: &mut Vec<EvaluationResult>,
612    ) {
613        // Collect all (corr_idx, rule_id, rule_name) tuples upfront to avoid
614        // borrow conflicts between self.rule_ids and self.update_correlation.
615        let mut work: Vec<(usize, Option<String>, Option<String>)> = Vec::new();
616
617        for det in detections {
618            // Use the MatchResult's rule_id to find the original rule's ID/name.
619            // We also look up by rule_id in our rule_ids table for the name.
620            let (rule_id, rule_name) = self.find_rule_identity(det);
621
622            // Collect correlation indices that reference this rule
623            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    /// Find the (id, name) for a detection match by searching our rule_ids table.
649    fn find_rule_identity(&self, det: &EvaluationResult) -> (Option<String>, Option<String>) {
650        // First, try to find by matching rule_id in our table
651        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        // Fall back to using just the EvaluationResult's rule_id
659        (det.header.rule_id.clone(), None)
660    }
661
662    /// Resolve the event mode for a given correlation.
663    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    /// Resolve the max events cap for a given correlation.
670    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    /// Resolve the per-group window-state entry cap for a given correlation.
677    /// `None` means unbounded.
678    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    /// Update a single correlation's state and check its condition.
684    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        // Borrow the correlation by reference — no cloning needed.  Rust allows
694        // simultaneous &self.correlations and &mut self.state / &mut self.last_alert
695        // because they are disjoint struct fields.
696        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        // Determine the rule_ref strings for alias resolution and temporal tracking.
709        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        // Extract group key
719        let group_key = GroupKey::extract(event, &corr.group_by, &ref_strs);
720
721        // Get or create window state
722        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        // Apply the window's pre-insert maintenance (sliding evict, tumbling
729        // bucket reset/late-event discard, or session gap/cap reset). On
730        // `Reset` the event buffers below are cleared in sync; on `Discard`
731        // (a late arrival in an earlier tumbling bucket) the event is dropped
732        // without touching the state or buffers.
733        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        // Push the new event into the state
741        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        // Enforce the per-group entry cap. Session windows keep their oldest
770        // entry as the span anchor so truncation cannot silently extend the
771        // `timespan` cap.
772        if let Some(cap) = max_group_entries {
773            state.truncate_oldest(cap, window_mode == WindowMode::Session);
774        }
775
776        // Push event into buffer based on event mode. Keep the buffer's retained
777        // events aligned with the window state: sliding evicts by the trailing
778        // cutoff, while tumbling/session clear the buffer when the window reset.
779        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        // Check condition — after this, `state` is no longer used (NLL drops the borrow).
810        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            // Suppression check: skip if we've already alerted within the suppress window
821            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                // Retrieve stored events / refs based on mode
833                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                // Only clone title/id/tags when we actually produce output
854                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                // Record alert time for suppression
876                self.last_alert.insert(alert_key.clone(), ts);
877
878                // Action on match
879                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    /// Propagate correlation results to higher-level correlations (chaining).
895    ///
896    /// When a correlation fires, any correlation that references it (by ID or name)
897    /// is updated. Limits chain depth to 10 to prevent infinite loops.
898    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            // Collect work items: (corr_idx, group_key_pairs, fired_ref)
907            #[allow(clippy::type_complexity)]
908            let mut work: Vec<(usize, Vec<(String, String)>, String)> = Vec::new();
909            for result in &pending {
910                // Only correlation results chain. Detections never reach here.
911                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                // Late arrivals in an earlier tumbling bucket are discarded;
946                // chained correlations keep no event buffers, so `Reset` needs
947                // no extra bookkeeping here.
948                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                // Same per-group cap as the direct path; session windows
967                // keep the span anchor.
968                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                            // Chained correlations don't include events
996                            // (they aggregate over correlation results, not
997                            // raw events)
998                            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    // =========================================================================
1019    // Timestamp extraction
1020    // =========================================================================
1021
1022    /// Extract a Unix epoch timestamp (seconds) from an event.
1023    ///
1024    /// Tries each configured timestamp field in order. Supports:
1025    /// - Numeric values (epoch seconds, or epoch millis if > 1e12)
1026    /// - ISO 8601 strings (e.g., "2024-07-10T12:30:00Z")
1027    ///
1028    /// Returns `None` if no field yields a valid timestamp.
1029    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    // =========================================================================
1041    // State management
1042    // =========================================================================
1043
1044    /// Manually evict all expired state entries.
1045    pub fn evict_expired(&mut self, now_secs: i64) {
1046        self.evict_all(now_secs);
1047    }
1048
1049    /// Evict expired entries and remove empty states.
1050    fn evict_all(&mut self, now_secs: i64) {
1051        // Phase 1: Time-based eviction — remove entries outside their correlation
1052        // window. Eviction is window-mode aware:
1053        //
1054        // - Sliding: trim entries older than the trailing cutoff, as always.
1055        // - Tumbling/session: never trim from the front — that would forget the
1056        //   bucket/session start and silently weaken the `timespan` cap (the
1057        //   window would drift toward sliding semantics). Instead, drop the
1058        //   whole group once it is stale (no event within its bucket span /
1059        //   session gap), which is exactly the point at which the next arrival
1060        //   would reset it anyway.
1061        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        // Evict event buffers in sync with window state: sliding buffers trim by
1092        // the trailing cutoff, tumbling/session buffers live and die with their
1093        // window state (dropped above when the group went stale).
1094        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        // Phase 2: Hard cap — if still over limit after time-based eviction (e.g.
1123        // high-cardinality traffic with long windows), drop the stalest entries
1124        // until we're at 90% capacity to avoid evicting on every single event.
1125        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            // Collect keys with their latest timestamp, sort by oldest first
1141            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            // Drop the oldest entries (and their associated event buffers)
1149            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        // Phase 3: Evict stale last_alert entries — remove if the suppress window
1158        // has passed or if the corresponding window state no longer exists.
1159        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    /// Number of active state entries (for monitoring).
1173    pub fn state_count(&self) -> usize {
1174        self.state.len()
1175    }
1176
1177    /// Number of detection rules loaded.
1178    pub fn detection_rule_count(&self) -> usize {
1179        self.engine.rule_count()
1180    }
1181
1182    /// Number of correlation rules loaded.
1183    pub fn correlation_rule_count(&self) -> usize {
1184        self.correlations.len()
1185    }
1186
1187    /// Number of active event buffers (for monitoring).
1188    pub fn event_buffer_count(&self) -> usize {
1189        self.event_buffers.len()
1190    }
1191
1192    /// Total compressed bytes across all event buffers (for monitoring).
1193    pub fn event_buffer_bytes(&self) -> usize {
1194        self.event_buffers
1195            .values()
1196            .map(|b| b.compressed_bytes())
1197            .sum()
1198    }
1199
1200    /// Number of active event ref buffers — `Refs` mode (for monitoring).
1201    pub fn event_ref_buffer_count(&self) -> usize {
1202        self.event_ref_buffers.len()
1203    }
1204
1205    /// Access the inner stateless engine.
1206    pub fn engine(&self) -> &Engine {
1207        &self.engine
1208    }
1209
1210    /// Resolve a rule key (an id, or a title for a rule without one) to the
1211    /// documentation of every loaded rule that carries it, across both the
1212    /// detection rules and the correlations.
1213    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    /// Export all mutable correlation state as a serializable snapshot.
1225    ///
1226    /// The snapshot uses stable correlation identifiers (id > name > title)
1227    /// instead of internal indices, so it survives rule reloads as long as
1228    /// the correlation rules keep the same identifiers.
1229    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    /// Import previously exported state, mapping stable identifiers back to
1277    /// current correlation indices. Entries whose identifiers no longer match
1278    /// any loaded correlation are silently dropped.
1279    ///
1280    /// Returns `false` (and imports nothing) if the snapshot version is
1281    /// incompatible with the current schema.
1282    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    /// Stable identifier for a correlation rule: prefers id, then name, then title.
1324    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    /// Build a reverse map from stable id → current correlation index.
1333    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
1348// =============================================================================
1349// Timestamp parsing helpers
1350// =============================================================================
1351
1352/// Extract a timestamp from an event using the given field names.
1353///
1354/// Standalone version of `CorrelationEngine::extract_event_timestamp` for use
1355/// in contexts where borrowing `&self` is not possible (e.g. rayon closures).
1356fn 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
1367/// Parse an [`EventValue`] as a Unix epoch timestamp in seconds.
1368fn 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
1377/// Normalize an epoch value: if it looks like milliseconds (> year 33658),
1378/// convert to seconds.
1379fn normalize_epoch(v: i64) -> i64 {
1380    if v > 1_000_000_000_000 { v / 1000 } else { v }
1381}
1382
1383/// Parse a timestamp string. Tries ISO 8601 with timezone, then without.
1384fn parse_timestamp_string(s: &str) -> Option<i64> {
1385    // Try RFC 3339 / ISO 8601 with timezone
1386    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
1387        return Some(dt.timestamp());
1388    }
1389
1390    // Try ISO 8601 without timezone (assume UTC)
1391    // Common formats: "2024-07-10T12:30:00", "2024-07-10 12:30:00"
1392    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    // Try with fractional seconds
1400    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
1410/// Convert an [`EventValue`] to a string for value_count purposes.
1411fn 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
1422/// Build a composite distinct-key for `value_count` over one or more fields.
1423///
1424/// Each field's value is rendered with [`value_to_string_for_count`] and the
1425/// rendered parts are joined with `\u{1f}` (the ASCII Unit Separator), which
1426/// is unlikely to occur in normal log data. If any field is missing or has a
1427/// type that does not produce a stable string representation, the event is
1428/// excluded from the distinct count (return `None`), matching the historical
1429/// single-field behavior.
1430fn composite_value_count_key(event: &impl Event, fields: &[String]) -> Option<String> {
1431    // Common case: exactly one field. Avoid the separator overhead.
1432    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
1446/// Convert an [`EventValue`] to f64 for numeric aggregation.
1447fn value_to_f64_ev(v: &EventValue) -> Option<f64> {
1448    v.as_f64()
1449}