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}