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/// Verbosity of the match detail attached to detection results.
166///
167/// Gates how much is recorded in each [`FieldMatch`]. `Off` is the default
168/// and produces the historical `{ field, value }` shape with no extra keys,
169/// so existing wire consumers are unaffected unless they opt in.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum MatchDetailLevel {
172    /// Historical behavior: only field-present matches, recorded as
173    /// `{ field, value }`. Keyword and absence matches are not reported.
174    #[default]
175    Off,
176    /// Adds the originating `selection`, the `matcher` kind, and
177    /// `case_sensitive`, and reports previously dropped keyword and
178    /// absence matches.
179    Summary,
180    /// Everything in `Summary` plus the `pattern` that fired.
181    Full,
182}
183
184impl MatchDetailLevel {
185    /// Lowercase wire name (`off` / `summary` / `full`).
186    pub fn as_str(self) -> &'static str {
187        match self {
188            MatchDetailLevel::Off => "off",
189            MatchDetailLevel::Summary => "summary",
190            MatchDetailLevel::Full => "full",
191        }
192    }
193}
194
195impl std::str::FromStr for MatchDetailLevel {
196    type Err = String;
197
198    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
199        match s.trim().to_ascii_lowercase().as_str() {
200            "off" => Ok(MatchDetailLevel::Off),
201            "summary" => Ok(MatchDetailLevel::Summary),
202            "full" => Ok(MatchDetailLevel::Full),
203            other => Err(format!(
204                "invalid match-detail level: {other:?} (expected off, summary, or full)"
205            )),
206        }
207    }
208}
209
210/// The kind of matcher that produced a [`FieldMatch`].
211///
212/// Serialized lowercase. Composite and multi-pattern matchers
213/// (`AnyOf` / `AllOf` / Aho-Corasick / regex sets) collapse to `one_of`.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
215#[serde(rename_all = "lowercase")]
216pub enum MatcherKind {
217    Exact,
218    Contains,
219    StartsWith,
220    EndsWith,
221    Regex,
222    #[serde(rename = "one_of")]
223    OneOf,
224    Cidr,
225    Numeric,
226    Exists,
227    FieldRef,
228    Null,
229    Bool,
230    Expand,
231    Timestamp,
232    Keyword,
233}
234
235/// Serde helper: skip a `bool` field when it is `false`.
236#[inline]
237fn is_false(b: &bool) -> bool {
238    !*b
239}
240
241/// A specific field match within a detection.
242///
243/// The `field` and `value` keys are always present and preserve the
244/// historical wire shape. The remaining keys are populated only when the
245/// engine runs above [`MatchDetailLevel::Off`] and are skipped on
246/// serialization when empty, so the default output is byte-identical to
247/// pre-enrichment releases.
248#[derive(Debug, Clone, Default, Serialize)]
249pub struct FieldMatch {
250    /// The field name that matched (`"keyword"` for keyword matches).
251    pub field: String,
252    /// The event value that triggered the match (`null` for absence matches).
253    pub value: serde_json::Value,
254    /// The selection (named detection) the match came from.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub selection: Option<String>,
257    /// The matcher kind that fired.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub matcher: Option<MatcherKind>,
260    /// The pattern the matcher tested against (Full level only, truncated).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub pattern: Option<String>,
263    /// Whether the match was case-sensitive, when meaningful for the matcher.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub case_sensitive: Option<bool>,
266    /// Whether the matcher was negated (`|not` / inverted).
267    #[serde(skip_serializing_if = "is_false", default)]
268    pub negated: bool,
269}
270
271impl FieldMatch {
272    /// Construct a bare match with only `field` and `value` set, matching
273    /// the historical (`MatchDetailLevel::Off`) shape.
274    pub fn new(field: impl Into<String>, value: serde_json::Value) -> Self {
275        FieldMatch {
276            field: field.into(),
277            value,
278            ..Default::default()
279        }
280    }
281}
282
283/// Convenience iterators over a slice of [`EvaluationResult`].
284///
285/// `ProcessResult` is a flat `Vec<EvaluationResult>` (detections then
286/// correlations, in evaluation order); this trait exposes by-kind views
287/// without forcing every caller to write `.iter().filter(|r| r.is_*())`.
288/// Implemented on `[EvaluationResult]` so it works for `Vec`, slices, and
289/// boxed slices alike.
290pub trait ProcessResultExt {
291    /// Iterate over detection results.
292    fn detections(&self) -> impl Iterator<Item = &EvaluationResult>;
293    /// Iterate over correlation results.
294    fn correlations(&self) -> impl Iterator<Item = &EvaluationResult>;
295    /// Number of detection results.
296    fn detection_count(&self) -> usize {
297        self.detections().count()
298    }
299    /// Number of correlation results.
300    fn correlation_count(&self) -> usize {
301        self.correlations().count()
302    }
303}
304
305impl ProcessResultExt for [EvaluationResult] {
306    fn detections(&self) -> impl Iterator<Item = &EvaluationResult> {
307        self.iter().filter(|r| r.is_detection())
308    }
309    fn correlations(&self) -> impl Iterator<Item = &EvaluationResult> {
310        self.iter().filter(|r| r.is_correlation())
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn header(title: &str) -> RuleHeader {
319        RuleHeader {
320            rule_title: title.to_string(),
321            rule_id: Some(format!("{title}-id")),
322            level: Some(Level::High),
323            tags: vec!["attack.t1059".to_string()],
324            custom_attributes: Arc::new(HashMap::new()),
325            enrichments: None,
326        }
327    }
328
329    /// Wire-shape snapshot: a detection serializes to a flat JSON object
330    /// with detection-only fields and no `correlation_type` key.
331    #[test]
332    fn detection_wire_shape_is_flat() {
333        let result = EvaluationResult {
334            header: header("Suspicious PowerShell"),
335            body: ResultBody::Detection(DetectionBody {
336                matched_selections: vec!["selection".to_string()],
337                matched_fields: vec![FieldMatch::new(
338                    "CommandLine",
339                    serde_json::json!("powershell -enc ..."),
340                )],
341                event: None,
342            }),
343        };
344
345        let json = serde_json::to_string(&result).unwrap();
346        assert_eq!(
347            json,
348            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 ..."}]}"#
349        );
350
351        // Downstream-disambiguation contract: detections must not carry
352        // a `correlation_type` key.
353        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
354        assert!(parsed.get("correlation_type").is_none());
355        assert!(parsed.get("matched_fields").is_some());
356    }
357
358    /// Wire-shape snapshot: a correlation serializes to a flat JSON object
359    /// with correlation-only fields and no `matched_fields` key.
360    #[test]
361    fn correlation_wire_shape_is_flat() {
362        let result = EvaluationResult {
363            header: header("SSH brute force"),
364            body: ResultBody::Correlation(CorrelationBody {
365                correlation_type: CorrelationType::EventCount,
366                group_key: vec![("SourceIP".to_string(), "203.0.113.4".to_string())],
367                aggregated_value: 73.0,
368                timespan_secs: 300,
369                events: None,
370                event_refs: None,
371            }),
372        };
373
374        let json = serde_json::to_string(&result).unwrap();
375        assert_eq!(
376            json,
377            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}"#
378        );
379
380        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
381        assert!(parsed.get("matched_fields").is_none());
382        assert!(parsed.get("correlation_type").is_some());
383    }
384
385    #[test]
386    fn accessors_dispatch_on_body_variant() {
387        let det = EvaluationResult {
388            header: header("Det"),
389            body: ResultBody::Detection(DetectionBody {
390                matched_selections: vec![],
391                matched_fields: vec![],
392                event: None,
393            }),
394        };
395        assert!(det.is_detection());
396        assert!(!det.is_correlation());
397        assert!(det.as_detection().is_some());
398        assert!(det.as_correlation().is_none());
399
400        let corr = EvaluationResult {
401            header: header("Corr"),
402            body: ResultBody::Correlation(CorrelationBody {
403                correlation_type: CorrelationType::EventCount,
404                group_key: vec![],
405                aggregated_value: 0.0,
406                timespan_secs: 0,
407                events: None,
408                event_refs: None,
409            }),
410        };
411        assert!(corr.is_correlation());
412        assert!(!corr.is_detection());
413        assert!(corr.as_correlation().is_some());
414        assert!(corr.as_detection().is_none());
415    }
416}