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