Skip to main content

rsigma_eval/
result.rs

1//! Unified result type for rule evaluation and correlation.
2//!
3//! `EvaluationResult` is the single output type produced by both detection
4//! and correlation. Fields shared across kinds (rule metadata, custom
5//! attributes, optional enrichments) live in [`RuleHeader`]; kind-specific
6//! fields live in [`ResultBody`]. Both are merged into one flat top-level
7//! JSON object via `#[serde(flatten)]` on the struct and `#[serde(untagged)]`
8//! on the body enum.
9//!
10//! Downstream JSON consumers distinguish detection from correlation by the
11//! presence of `correlation_type` (correlation-only) and `matched_fields`
12//! (detection-only). The field set, values, and `skip_serializing_if`
13//! behavior match the pre-unification `MatchResult` / `CorrelationResult`
14//! layout; the only visible difference is that a non-empty
15//! `custom_attributes` map is now emitted between header and body fields
16//! rather than at the end of the line, which is invisible to compliant
17//! JSON consumers (objects are unordered per spec). The wire-shape golden
18//! tests under `crates/rsigma-eval/tests/wire_shape_golden.rs` pin the
19//! new ordering for both kinds.
20
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use rsigma_parser::{CorrelationType, Level};
25use serde::Serialize;
26
27use crate::correlation::EventRef;
28
29/// A single evaluation result.
30///
31/// Wraps a detection match ([`ResultBody::Detection`]) or a correlation
32/// firing ([`ResultBody::Correlation`]) behind one shared [`RuleHeader`].
33/// Serialize emits a single flat JSON object combining header and body
34/// fields.
35#[derive(Debug, Clone, Serialize)]
36pub struct EvaluationResult {
37    #[serde(flatten)]
38    pub header: RuleHeader,
39    #[serde(flatten)]
40    pub body: ResultBody,
41}
42
43impl EvaluationResult {
44    /// True when this result was produced by detection rule matching.
45    pub fn is_detection(&self) -> bool {
46        matches!(self.body, ResultBody::Detection(_))
47    }
48
49    /// True when this result was produced by a correlation firing.
50    pub fn is_correlation(&self) -> bool {
51        matches!(self.body, ResultBody::Correlation(_))
52    }
53
54    /// Read the detection-specific body, if this result is a detection.
55    pub fn as_detection(&self) -> Option<&DetectionBody> {
56        match &self.body {
57            ResultBody::Detection(d) => Some(d),
58            ResultBody::Correlation(_) => None,
59        }
60    }
61
62    /// Read the correlation-specific body, if this result is a correlation.
63    pub fn as_correlation(&self) -> Option<&CorrelationBody> {
64        match &self.body {
65            ResultBody::Correlation(c) => Some(c),
66            ResultBody::Detection(_) => None,
67        }
68    }
69
70    /// Mutable accessor for the detection-specific body.
71    pub fn as_detection_mut(&mut self) -> Option<&mut DetectionBody> {
72        match &mut self.body {
73            ResultBody::Detection(d) => Some(d),
74            ResultBody::Correlation(_) => None,
75        }
76    }
77
78    /// Mutable accessor for the correlation-specific body.
79    pub fn as_correlation_mut(&mut self) -> Option<&mut CorrelationBody> {
80        match &mut self.body {
81            ResultBody::Correlation(c) => Some(c),
82            ResultBody::Detection(_) => None,
83        }
84    }
85}
86
87/// Fields shared between detection and correlation results.
88///
89/// The optional `enrichments` map is `None` for results emitted directly
90/// by the engine; downstream middleware can populate it with arbitrary
91/// JSON values to ride along with each result.
92#[derive(Debug, Clone, Serialize)]
93pub struct RuleHeader {
94    /// Title of the matched rule.
95    pub rule_title: String,
96    /// ID of the matched rule (if present).
97    pub rule_id: Option<String>,
98    /// Severity level.
99    pub level: Option<Level>,
100    /// Tags from the matched rule.
101    pub tags: Vec<String>,
102    /// Custom attributes from the rule (merged with pipeline overrides).
103    ///
104    /// Wrapped in `Arc` so per-match cloning is a pointer bump.
105    #[serde(skip_serializing_if = "HashMap::is_empty")]
106    pub custom_attributes: Arc<HashMap<String, serde_json::Value>>,
107    /// Optional map of arbitrary enrichment values, written by downstream
108    /// middleware. `None` for engine-emitted results; skipped on serialize.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub enrichments: Option<serde_json::Map<String, serde_json::Value>>,
111}
112
113/// Kind-specific payload of an [`EvaluationResult`].
114///
115/// Serialized as an untagged enum so the variant fields flatten directly
116/// into the parent JSON object. Downstream consumers disambiguate variants
117/// by the kind-unique fields each variant carries (`matched_fields` for
118/// detection, `correlation_type` for correlation).
119///
120/// Invariant: each variant must keep at least one required, kind-unique
121/// field. This is what lets the untagged enum disambiguate on a future
122/// `Deserialize` and keeps the `correlation_type`-presence rule reliable
123/// for existing consumers.
124#[derive(Debug, Clone, Serialize)]
125#[serde(untagged)]
126pub enum ResultBody {
127    /// Detection rule match (stateless, immediate).
128    Detection(DetectionBody),
129    /// Correlation rule firing (stateful, time-windowed).
130    Correlation(CorrelationBody),
131}
132
133/// Detection-specific result fields.
134#[derive(Debug, Clone, Serialize)]
135pub struct DetectionBody {
136    /// Which named detections (selections) matched.
137    pub matched_selections: Vec<String>,
138    /// Specific field matches that triggered the detection.
139    pub matched_fields: Vec<FieldMatch>,
140    /// The full event that triggered the match, included when the rule
141    /// sets `rsigma.include_event: "true"`.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub event: Option<serde_json::Value>,
144}
145
146/// Correlation-specific result fields.
147#[derive(Debug, Clone, Serialize)]
148pub struct CorrelationBody {
149    /// Type of correlation.
150    pub correlation_type: CorrelationType,
151    /// Group-by field names and their values for this match.
152    pub group_key: Vec<(String, String)>,
153    /// The aggregated value that triggered the condition (count, sum, avg, ...).
154    pub aggregated_value: f64,
155    /// The time window in seconds.
156    pub timespan_secs: u64,
157    /// Full event bodies, included when `correlation_event_mode` is `Full`.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub events: Option<Vec<serde_json::Value>>,
160    /// Lightweight event references, included when `correlation_event_mode` is `Refs`.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub event_refs: Option<Vec<EventRef>>,
163}
164
165/// A specific field match within a detection.
166#[derive(Debug, Clone, Serialize)]
167pub struct FieldMatch {
168    /// The field name that matched.
169    pub field: String,
170    /// The event value that triggered the match.
171    pub value: serde_json::Value,
172}
173
174/// Convenience iterators over a slice of [`EvaluationResult`].
175///
176/// `ProcessResult` is a flat `Vec<EvaluationResult>` (detections then
177/// correlations, in evaluation order); this trait exposes by-kind views
178/// without forcing every caller to write `.iter().filter(|r| r.is_*())`.
179/// Implemented on `[EvaluationResult]` so it works for `Vec`, slices, and
180/// boxed slices alike.
181pub trait ProcessResultExt {
182    /// Iterate over detection results.
183    fn detections(&self) -> impl Iterator<Item = &EvaluationResult>;
184    /// Iterate over correlation results.
185    fn correlations(&self) -> impl Iterator<Item = &EvaluationResult>;
186    /// Number of detection results.
187    fn detection_count(&self) -> usize {
188        self.detections().count()
189    }
190    /// Number of correlation results.
191    fn correlation_count(&self) -> usize {
192        self.correlations().count()
193    }
194}
195
196impl ProcessResultExt for [EvaluationResult] {
197    fn detections(&self) -> impl Iterator<Item = &EvaluationResult> {
198        self.iter().filter(|r| r.is_detection())
199    }
200    fn correlations(&self) -> impl Iterator<Item = &EvaluationResult> {
201        self.iter().filter(|r| r.is_correlation())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn header(title: &str) -> RuleHeader {
210        RuleHeader {
211            rule_title: title.to_string(),
212            rule_id: Some(format!("{title}-id")),
213            level: Some(Level::High),
214            tags: vec!["attack.t1059".to_string()],
215            custom_attributes: Arc::new(HashMap::new()),
216            enrichments: None,
217        }
218    }
219
220    /// Wire-shape snapshot: a detection serializes to a flat JSON object
221    /// with detection-only fields and no `correlation_type` key.
222    #[test]
223    fn detection_wire_shape_is_flat() {
224        let result = EvaluationResult {
225            header: header("Suspicious PowerShell"),
226            body: ResultBody::Detection(DetectionBody {
227                matched_selections: vec!["selection".to_string()],
228                matched_fields: vec![FieldMatch {
229                    field: "CommandLine".to_string(),
230                    value: serde_json::json!("powershell -enc ..."),
231                }],
232                event: None,
233            }),
234        };
235
236        let json = serde_json::to_string(&result).unwrap();
237        assert_eq!(
238            json,
239            r#"{"rule_title":"Suspicious PowerShell","rule_id":"Suspicious PowerShell-id","level":"high","tags":["attack.t1059"],"matched_selections":["selection"],"matched_fields":[{"field":"CommandLine","value":"powershell -enc ..."}]}"#
240        );
241
242        // Downstream-disambiguation contract: detections must not carry
243        // a `correlation_type` key.
244        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
245        assert!(parsed.get("correlation_type").is_none());
246        assert!(parsed.get("matched_fields").is_some());
247    }
248
249    /// Wire-shape snapshot: a correlation serializes to a flat JSON object
250    /// with correlation-only fields and no `matched_fields` key.
251    #[test]
252    fn correlation_wire_shape_is_flat() {
253        let result = EvaluationResult {
254            header: header("SSH brute force"),
255            body: ResultBody::Correlation(CorrelationBody {
256                correlation_type: CorrelationType::EventCount,
257                group_key: vec![("SourceIP".to_string(), "203.0.113.4".to_string())],
258                aggregated_value: 73.0,
259                timespan_secs: 300,
260                events: None,
261                event_refs: None,
262            }),
263        };
264
265        let json = serde_json::to_string(&result).unwrap();
266        assert_eq!(
267            json,
268            r#"{"rule_title":"SSH brute force","rule_id":"SSH brute force-id","level":"high","tags":["attack.t1059"],"correlation_type":"event_count","group_key":[["SourceIP","203.0.113.4"]],"aggregated_value":73.0,"timespan_secs":300}"#
269        );
270
271        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
272        assert!(parsed.get("matched_fields").is_none());
273        assert!(parsed.get("correlation_type").is_some());
274    }
275
276    #[test]
277    fn accessors_dispatch_on_body_variant() {
278        let det = EvaluationResult {
279            header: header("Det"),
280            body: ResultBody::Detection(DetectionBody {
281                matched_selections: vec![],
282                matched_fields: vec![],
283                event: None,
284            }),
285        };
286        assert!(det.is_detection());
287        assert!(!det.is_correlation());
288        assert!(det.as_detection().is_some());
289        assert!(det.as_correlation().is_none());
290
291        let corr = EvaluationResult {
292            header: header("Corr"),
293            body: ResultBody::Correlation(CorrelationBody {
294                correlation_type: CorrelationType::EventCount,
295                group_key: vec![],
296                aggregated_value: 0.0,
297                timespan_secs: 0,
298                events: None,
299                event_refs: None,
300            }),
301        };
302        assert!(corr.is_correlation());
303        assert!(!corr.is_detection());
304        assert!(corr.as_correlation().is_some());
305        assert!(corr.as_detection().is_none());
306    }
307}