Skip to main content

rsigma_runtime/dispositions/
record.rs

1//! Disposition records: the analyst verdict carried back to the rule that fired.
2//!
3//! A disposition is a single JSON object. The wire shape is deliberately
4//! minimal: a rule identity, a verdict, and a few optional fields for
5//! traceability and rolling-window placement. Parsing accepts either a single
6//! object or an array (a `POST` body), and validation produces a normalized
7//! [`Disposition`] with a pointed error pointing at the offending field.
8
9use serde::{Deserialize, Serialize};
10
11/// The analyst's verdict on an alert.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Verdict {
15    /// A real detection of the targeted behavior.
16    TruePositive,
17    /// A misfire: the rule fired on benign or unrelated activity.
18    FalsePositive,
19    /// The activity is real and correctly detected, but benign in context
20    /// (still triage noise that a tuning program may want to count).
21    BenignTruePositive,
22}
23
24impl Verdict {
25    /// Parse a verdict from its wire string, returning a pointed error.
26    pub fn parse(s: &str) -> Result<Self, DispositionError> {
27        match s {
28            "true_positive" => Ok(Self::TruePositive),
29            "false_positive" => Ok(Self::FalsePositive),
30            "benign_true_positive" => Ok(Self::BenignTruePositive),
31            other => Err(DispositionError::field(
32                "verdict",
33                format!(
34                    "unknown verdict '{other}' (expected 'true_positive', 'false_positive', or \
35                     'benign_true_positive')"
36                ),
37            )),
38        }
39    }
40
41    /// The wire string for this verdict.
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Self::TruePositive => "true_positive",
45            Self::FalsePositive => "false_positive",
46            Self::BenignTruePositive => "benign_true_positive",
47        }
48    }
49}
50
51/// Whether a disposition is keyed to a single detection or a whole incident.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum DispositionScope {
55    /// The verdict applies to one rule's detection (the default).
56    #[default]
57    Detection,
58    /// The verdict applies to an incident; the daemon resolves the incident to
59    /// its contributing rules through the live alert-pipeline incident map.
60    Incident,
61}
62
63impl DispositionScope {
64    fn parse(s: &str) -> Result<Self, DispositionError> {
65        match s {
66            "detection" => Ok(Self::Detection),
67            "incident" => Ok(Self::Incident),
68            other => Err(DispositionError::field(
69                "scope",
70                format!("unknown scope '{other}' (expected 'detection' or 'incident')"),
71            )),
72        }
73    }
74}
75
76/// The raw disposition as it arrives on the wire, before validation.
77#[derive(Debug, Clone, Deserialize)]
78pub struct RawDisposition {
79    #[serde(default)]
80    pub rule_id: Option<String>,
81    #[serde(default)]
82    pub verdict: Option<String>,
83    #[serde(default)]
84    pub scope: Option<String>,
85    #[serde(default)]
86    pub fingerprint: Option<String>,
87    #[serde(default)]
88    pub incident_id: Option<String>,
89    #[serde(default)]
90    pub timestamp: Option<String>,
91    #[serde(default)]
92    pub analyst: Option<String>,
93    #[serde(default)]
94    pub note: Option<String>,
95}
96
97/// Maximum accepted length of the free-text `note` field, in bytes.
98pub const MAX_NOTE_BYTES: usize = 2048;
99
100/// A validated, normalized disposition ready for the store.
101///
102/// `rule_id` is `None` only for an `incident`-scoped record that the daemon has
103/// not yet resolved to its contributing rules; the store rejects such a record
104/// until a `rule_id` is supplied.
105#[derive(Debug, Clone, PartialEq)]
106pub struct Disposition {
107    /// The rule identity the verdict accounts against (the rule's id, with the
108    /// title as the fallback the per-rule metrics already use).
109    pub rule_id: Option<String>,
110    /// The analyst verdict.
111    pub verdict: Verdict,
112    /// Detection- or incident-scoped.
113    pub scope: DispositionScope,
114    /// The alert-pipeline dedup fingerprint, when carried.
115    pub fingerprint: Option<String>,
116    /// The alert-pipeline incident id, required for `incident` scope.
117    pub incident_id: Option<String>,
118    /// Epoch seconds for rolling-window placement (defaults to ingest time).
119    pub timestamp: i64,
120    /// Optional analyst identity, recorded for traceability.
121    pub analyst: Option<String>,
122    /// Optional bounded free-text note, recorded for traceability.
123    pub note: Option<String>,
124}
125
126impl Disposition {
127    /// Validate and normalize a [`RawDisposition`], using `now` (epoch seconds)
128    /// as the default timestamp when none is supplied.
129    pub fn from_raw(raw: RawDisposition, now: i64) -> Result<Self, DispositionError> {
130        let verdict = match raw.verdict.as_deref() {
131            Some(v) => Verdict::parse(v)?,
132            None => return Err(DispositionError::field("verdict", "missing required field")),
133        };
134
135        let scope = match raw.scope.as_deref() {
136            Some(s) => DispositionScope::parse(s)?,
137            None => DispositionScope::Detection,
138        };
139
140        let rule_id = raw.rule_id.filter(|s| !s.is_empty());
141        let incident_id = raw.incident_id.filter(|s| !s.is_empty());
142        let fingerprint = raw.fingerprint.filter(|s| !s.is_empty());
143
144        match scope {
145            DispositionScope::Detection => {
146                if rule_id.is_none() {
147                    return Err(DispositionError::field(
148                        "rule_id",
149                        "missing required field for a 'detection'-scoped disposition",
150                    ));
151                }
152            }
153            DispositionScope::Incident => {
154                if incident_id.is_none() {
155                    return Err(DispositionError::field(
156                        "incident_id",
157                        "required when 'scope' is 'incident'",
158                    ));
159                }
160            }
161        }
162
163        let timestamp = match raw.timestamp.as_deref() {
164            Some(ts) => parse_rfc3339(ts)?,
165            None => now,
166        };
167
168        if let Some(note) = raw.note.as_deref()
169            && note.len() > MAX_NOTE_BYTES
170        {
171            return Err(DispositionError::field(
172                "note",
173                format!("exceeds the {MAX_NOTE_BYTES}-byte limit"),
174            ));
175        }
176
177        Ok(Self {
178            rule_id,
179            verdict,
180            scope,
181            fingerprint,
182            incident_id,
183            timestamp,
184            analyst: raw.analyst.filter(|s| !s.is_empty()),
185            note: raw.note,
186        })
187    }
188
189    /// The idempotency key for redelivery dedup: `(fingerprint or incident_id,
190    /// verdict, rule_id)` when an alert identity is present, otherwise
191    /// `(rule_id, timestamp, analyst)`.
192    ///
193    /// The `rule_id` is always part of the key. It is redundant for a
194    /// fingerprint (which already identifies a single rule's alert) but
195    /// essential for an `incident_id`, which fans out to every contributing
196    /// rule: without it, the per-rule records an incident expands into would
197    /// collapse to one and only the first rule would be counted.
198    pub fn dedup_key(&self) -> String {
199        let rule = self.rule_id.as_deref().unwrap_or("");
200        if let Some(id) = self.fingerprint.as_deref().or(self.incident_id.as_deref()) {
201            format!("id\u{1}{id}\u{1}{}\u{1}{rule}", self.verdict.as_str())
202        } else {
203            format!(
204                "rt\u{1}{rule}\u{1}{}\u{1}{}",
205                self.timestamp,
206                self.analyst.as_deref().unwrap_or(""),
207            )
208        }
209    }
210}
211
212/// Parse an RFC 3339 timestamp into epoch seconds.
213fn parse_rfc3339(ts: &str) -> Result<i64, DispositionError> {
214    chrono::DateTime::parse_from_rfc3339(ts)
215        .map(|dt| dt.timestamp())
216        .map_err(|e| {
217            DispositionError::field("timestamp", format!("not a valid RFC 3339 time: {e}"))
218        })
219}
220
221/// Parse a `POST` body or source payload into a vector of raw dispositions.
222///
223/// Accepts a single JSON object, a JSON array of objects, or newline-delimited
224/// JSON (one object per line; blank lines are skipped). This is the single
225/// untrusted-input surface and never panics on malformed input.
226pub fn parse_dispositions(input: &str) -> Result<Vec<RawDisposition>, DispositionError> {
227    let trimmed = input.trim_start();
228    if trimmed.is_empty() {
229        return Ok(Vec::new());
230    }
231
232    // A leading `[` is a JSON array; a single-line leading `{` is one JSON
233    // object; anything else is NDJSON (one object per non-blank line).
234    if trimmed.starts_with('[') {
235        return serde_json::from_str::<Vec<RawDisposition>>(trimmed)
236            .map_err(|e| DispositionError::parse(format!("invalid disposition array: {e}")));
237    }
238    if trimmed.starts_with('{') && !trimmed.contains('\n') {
239        return serde_json::from_str::<RawDisposition>(trimmed)
240            .map(|d| vec![d])
241            .map_err(|e| DispositionError::parse(format!("invalid disposition object: {e}")));
242    }
243
244    let mut out = Vec::new();
245    for (i, line) in input.lines().enumerate() {
246        let line = line.trim();
247        if line.is_empty() {
248            continue;
249        }
250        let rec = serde_json::from_str::<RawDisposition>(line).map_err(|e| {
251            DispositionError::parse(format!("invalid disposition on line {}: {e}", i + 1))
252        })?;
253        out.push(rec);
254    }
255    Ok(out)
256}
257
258/// An error parsing or validating a disposition.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum DispositionError {
261    /// The JSON could not be parsed.
262    Parse(String),
263    /// A field was missing or invalid; carries the field name and reason.
264    Field { field: String, reason: String },
265}
266
267impl DispositionError {
268    fn field(field: &str, reason: impl Into<String>) -> Self {
269        Self::Field {
270            field: field.to_string(),
271            reason: reason.into(),
272        }
273    }
274
275    fn parse(msg: impl Into<String>) -> Self {
276        Self::Parse(msg.into())
277    }
278}
279
280impl std::fmt::Display for DispositionError {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        match self {
283            Self::Parse(msg) => write!(f, "{msg}"),
284            Self::Field { field, reason } => write!(f, "field '{field}': {reason}"),
285        }
286    }
287}
288
289impl std::error::Error for DispositionError {}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn raw(json: &str) -> RawDisposition {
296        serde_json::from_str(json).unwrap()
297    }
298
299    #[test]
300    fn verdict_round_trips() {
301        for v in [
302            Verdict::TruePositive,
303            Verdict::FalsePositive,
304            Verdict::BenignTruePositive,
305        ] {
306            assert_eq!(Verdict::parse(v.as_str()).unwrap(), v);
307        }
308        assert!(Verdict::parse("nope").is_err());
309    }
310
311    #[test]
312    fn detection_requires_rule_id() {
313        let err = Disposition::from_raw(raw(r#"{"verdict": "false_positive"}"#), 100).unwrap_err();
314        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "rule_id"));
315    }
316
317    #[test]
318    fn incident_requires_incident_id() {
319        let err = Disposition::from_raw(
320            raw(r#"{"verdict": "true_positive", "scope": "incident"}"#),
321            100,
322        )
323        .unwrap_err();
324        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "incident_id"));
325    }
326
327    #[test]
328    fn incident_scope_allows_missing_rule_id() {
329        let d = Disposition::from_raw(
330            raw(r#"{"verdict": "true_positive", "scope": "incident", "incident_id": "abc"}"#),
331            100,
332        )
333        .unwrap();
334        assert_eq!(d.rule_id, None);
335        assert_eq!(d.scope, DispositionScope::Incident);
336        assert_eq!(d.incident_id.as_deref(), Some("abc"));
337    }
338
339    #[test]
340    fn missing_verdict_is_rejected() {
341        let err = Disposition::from_raw(raw(r#"{"rule_id": "r1"}"#), 100).unwrap_err();
342        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "verdict"));
343    }
344
345    #[test]
346    fn timestamp_defaults_to_now_else_parses_rfc3339() {
347        let d = Disposition::from_raw(raw(r#"{"rule_id": "r", "verdict": "true_positive"}"#), 42)
348            .unwrap();
349        assert_eq!(d.timestamp, 42);
350
351        let d = Disposition::from_raw(
352            raw(r#"{"rule_id": "r", "verdict": "true_positive", "timestamp": "2026-01-01T00:00:00Z"}"#),
353            42,
354        )
355        .unwrap();
356        assert_eq!(d.timestamp, 1_767_225_600);
357
358        let err = Disposition::from_raw(
359            raw(r#"{"rule_id": "r", "verdict": "true_positive", "timestamp": "not-a-time"}"#),
360            42,
361        )
362        .unwrap_err();
363        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "timestamp"));
364    }
365
366    #[test]
367    fn oversized_note_is_rejected() {
368        let note = "x".repeat(MAX_NOTE_BYTES + 1);
369        let json = format!(r#"{{"rule_id": "r", "verdict": "true_positive", "note": "{note}"}}"#);
370        let err = Disposition::from_raw(raw(&json), 1).unwrap_err();
371        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "note"));
372    }
373
374    #[test]
375    fn dedup_key_prefers_alert_identity() {
376        let with_fp = Disposition::from_raw(
377            raw(r#"{"rule_id": "r", "verdict": "false_positive", "fingerprint": "fp1"}"#),
378            1,
379        )
380        .unwrap();
381        assert!(with_fp.dedup_key().contains("fp1"));
382
383        // Same fingerprint + verdict collapses regardless of timestamp/analyst.
384        let again = Disposition::from_raw(
385            raw(r#"{"rule_id": "r", "verdict": "false_positive", "fingerprint": "fp1", "analyst": "x"}"#),
386            999,
387        )
388        .unwrap();
389        assert_eq!(with_fp.dedup_key(), again.dedup_key());
390
391        // Without an alert identity, the key falls back to rule/time/analyst.
392        let no_id =
393            Disposition::from_raw(raw(r#"{"rule_id": "r", "verdict": "false_positive"}"#), 5)
394                .unwrap();
395        assert!(no_id.dedup_key().contains("\u{1}5\u{1}"));
396    }
397
398    #[test]
399    fn parse_accepts_object_array_and_ndjson() {
400        assert_eq!(parse_dispositions("").unwrap().len(), 0);
401        assert_eq!(
402            parse_dispositions(r#"{"rule_id":"r","verdict":"true_positive"}"#)
403                .unwrap()
404                .len(),
405            1
406        );
407        assert_eq!(
408            parse_dispositions(
409                r#"[{"rule_id":"r","verdict":"true_positive"},{"rule_id":"s","verdict":"false_positive"}]"#
410            )
411            .unwrap()
412            .len(),
413            2
414        );
415        let ndjson = "{\"rule_id\":\"r\",\"verdict\":\"true_positive\"}\n\n{\"rule_id\":\"s\",\"verdict\":\"false_positive\"}\n";
416        assert_eq!(parse_dispositions(ndjson).unwrap().len(), 2);
417    }
418
419    #[test]
420    fn parse_reports_malformed_input() {
421        assert!(matches!(
422            parse_dispositions("[not json"),
423            Err(DispositionError::Parse(_))
424        ));
425        assert!(matches!(
426            parse_dispositions("{bad}\n{also bad}"),
427            Err(DispositionError::Parse(_))
428        ));
429    }
430}