rsigma-runtime 0.18.0

Streaming runtime for rsigma — event sources, sinks, and log processing pipeline
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Disposition records: the analyst verdict carried back to the rule that fired.
//!
//! A disposition is a single JSON object. The wire shape is deliberately
//! minimal: a rule identity, a verdict, and a few optional fields for
//! traceability and rolling-window placement. Parsing accepts either a single
//! object or an array (a `POST` body), and validation produces a normalized
//! [`Disposition`] with a pointed error pointing at the offending field.

use serde::{Deserialize, Serialize};

/// The analyst's verdict on an alert.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
    /// A real detection of the targeted behavior.
    TruePositive,
    /// A misfire: the rule fired on benign or unrelated activity.
    FalsePositive,
    /// The activity is real and correctly detected, but benign in context
    /// (still triage noise that a tuning program may want to count).
    BenignTruePositive,
}

impl Verdict {
    /// Parse a verdict from its wire string, returning a pointed error.
    pub fn parse(s: &str) -> Result<Self, DispositionError> {
        match s {
            "true_positive" => Ok(Self::TruePositive),
            "false_positive" => Ok(Self::FalsePositive),
            "benign_true_positive" => Ok(Self::BenignTruePositive),
            other => Err(DispositionError::field(
                "verdict",
                format!(
                    "unknown verdict '{other}' (expected 'true_positive', 'false_positive', or \
                     'benign_true_positive')"
                ),
            )),
        }
    }

    /// The wire string for this verdict.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::TruePositive => "true_positive",
            Self::FalsePositive => "false_positive",
            Self::BenignTruePositive => "benign_true_positive",
        }
    }
}

/// Whether a disposition is keyed to a single detection or a whole incident.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DispositionScope {
    /// The verdict applies to one rule's detection (the default).
    #[default]
    Detection,
    /// The verdict applies to an incident; the daemon resolves the incident to
    /// its contributing rules through the live alert-pipeline incident map.
    Incident,
}

impl DispositionScope {
    fn parse(s: &str) -> Result<Self, DispositionError> {
        match s {
            "detection" => Ok(Self::Detection),
            "incident" => Ok(Self::Incident),
            other => Err(DispositionError::field(
                "scope",
                format!("unknown scope '{other}' (expected 'detection' or 'incident')"),
            )),
        }
    }
}

/// The raw disposition as it arrives on the wire, before validation.
#[derive(Debug, Clone, Deserialize)]
pub struct RawDisposition {
    #[serde(default)]
    pub rule_id: Option<String>,
    #[serde(default)]
    pub verdict: Option<String>,
    #[serde(default)]
    pub scope: Option<String>,
    #[serde(default)]
    pub fingerprint: Option<String>,
    #[serde(default)]
    pub incident_id: Option<String>,
    #[serde(default)]
    pub timestamp: Option<String>,
    #[serde(default)]
    pub analyst: Option<String>,
    #[serde(default)]
    pub note: Option<String>,
}

/// Maximum accepted length of the free-text `note` field, in bytes.
pub const MAX_NOTE_BYTES: usize = 2048;

/// A validated, normalized disposition ready for the store.
///
/// `rule_id` is `None` only for an `incident`-scoped record that the daemon has
/// not yet resolved to its contributing rules; the store rejects such a record
/// until a `rule_id` is supplied.
#[derive(Debug, Clone, PartialEq)]
pub struct Disposition {
    /// The rule identity the verdict accounts against (the rule's id, with the
    /// title as the fallback the per-rule metrics already use).
    pub rule_id: Option<String>,
    /// The analyst verdict.
    pub verdict: Verdict,
    /// Detection- or incident-scoped.
    pub scope: DispositionScope,
    /// The alert-pipeline dedup fingerprint, when carried.
    pub fingerprint: Option<String>,
    /// The alert-pipeline incident id, required for `incident` scope.
    pub incident_id: Option<String>,
    /// Epoch seconds for rolling-window placement (defaults to ingest time).
    pub timestamp: i64,
    /// Optional analyst identity, recorded for traceability.
    pub analyst: Option<String>,
    /// Optional bounded free-text note, recorded for traceability.
    pub note: Option<String>,
}

impl Disposition {
    /// Validate and normalize a [`RawDisposition`], using `now` (epoch seconds)
    /// as the default timestamp when none is supplied.
    pub fn from_raw(raw: RawDisposition, now: i64) -> Result<Self, DispositionError> {
        let verdict = match raw.verdict.as_deref() {
            Some(v) => Verdict::parse(v)?,
            None => return Err(DispositionError::field("verdict", "missing required field")),
        };

        let scope = match raw.scope.as_deref() {
            Some(s) => DispositionScope::parse(s)?,
            None => DispositionScope::Detection,
        };

        let rule_id = raw.rule_id.filter(|s| !s.is_empty());
        let incident_id = raw.incident_id.filter(|s| !s.is_empty());
        let fingerprint = raw.fingerprint.filter(|s| !s.is_empty());

        match scope {
            DispositionScope::Detection => {
                if rule_id.is_none() {
                    return Err(DispositionError::field(
                        "rule_id",
                        "missing required field for a 'detection'-scoped disposition",
                    ));
                }
            }
            DispositionScope::Incident => {
                if incident_id.is_none() {
                    return Err(DispositionError::field(
                        "incident_id",
                        "required when 'scope' is 'incident'",
                    ));
                }
            }
        }

        let timestamp = match raw.timestamp.as_deref() {
            Some(ts) => parse_rfc3339(ts)?,
            None => now,
        };

        if let Some(note) = raw.note.as_deref()
            && note.len() > MAX_NOTE_BYTES
        {
            return Err(DispositionError::field(
                "note",
                format!("exceeds the {MAX_NOTE_BYTES}-byte limit"),
            ));
        }

        Ok(Self {
            rule_id,
            verdict,
            scope,
            fingerprint,
            incident_id,
            timestamp,
            analyst: raw.analyst.filter(|s| !s.is_empty()),
            note: raw.note,
        })
    }

    /// The idempotency key for redelivery dedup: `(fingerprint or incident_id,
    /// verdict, rule_id)` when an alert identity is present, otherwise
    /// `(rule_id, timestamp, analyst)`.
    ///
    /// The `rule_id` is always part of the key. It is redundant for a
    /// fingerprint (which already identifies a single rule's alert) but
    /// essential for an `incident_id`, which fans out to every contributing
    /// rule: without it, the per-rule records an incident expands into would
    /// collapse to one and only the first rule would be counted.
    pub fn dedup_key(&self) -> String {
        let rule = self.rule_id.as_deref().unwrap_or("");
        if let Some(id) = self.fingerprint.as_deref().or(self.incident_id.as_deref()) {
            format!("id\u{1}{id}\u{1}{}\u{1}{rule}", self.verdict.as_str())
        } else {
            format!(
                "rt\u{1}{rule}\u{1}{}\u{1}{}",
                self.timestamp,
                self.analyst.as_deref().unwrap_or(""),
            )
        }
    }
}

/// Parse an RFC 3339 timestamp into epoch seconds.
fn parse_rfc3339(ts: &str) -> Result<i64, DispositionError> {
    chrono::DateTime::parse_from_rfc3339(ts)
        .map(|dt| dt.timestamp())
        .map_err(|e| {
            DispositionError::field("timestamp", format!("not a valid RFC 3339 time: {e}"))
        })
}

/// Parse a `POST` body or source payload into a vector of raw dispositions.
///
/// Accepts a single JSON object, a JSON array of objects, or newline-delimited
/// JSON (one object per line; blank lines are skipped). This is the single
/// untrusted-input surface and never panics on malformed input.
pub fn parse_dispositions(input: &str) -> Result<Vec<RawDisposition>, DispositionError> {
    let trimmed = input.trim_start();
    if trimmed.is_empty() {
        return Ok(Vec::new());
    }

    // A leading `[` is a JSON array; a single-line leading `{` is one JSON
    // object; anything else is NDJSON (one object per non-blank line).
    if trimmed.starts_with('[') {
        return serde_json::from_str::<Vec<RawDisposition>>(trimmed)
            .map_err(|e| DispositionError::parse(format!("invalid disposition array: {e}")));
    }
    if trimmed.starts_with('{') && !trimmed.contains('\n') {
        return serde_json::from_str::<RawDisposition>(trimmed)
            .map(|d| vec![d])
            .map_err(|e| DispositionError::parse(format!("invalid disposition object: {e}")));
    }

    let mut out = Vec::new();
    for (i, line) in input.lines().enumerate() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let rec = serde_json::from_str::<RawDisposition>(line).map_err(|e| {
            DispositionError::parse(format!("invalid disposition on line {}: {e}", i + 1))
        })?;
        out.push(rec);
    }
    Ok(out)
}

/// An error parsing or validating a disposition.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispositionError {
    /// The JSON could not be parsed.
    Parse(String),
    /// A field was missing or invalid; carries the field name and reason.
    Field { field: String, reason: String },
}

impl DispositionError {
    fn field(field: &str, reason: impl Into<String>) -> Self {
        Self::Field {
            field: field.to_string(),
            reason: reason.into(),
        }
    }

    fn parse(msg: impl Into<String>) -> Self {
        Self::Parse(msg.into())
    }
}

impl std::fmt::Display for DispositionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Parse(msg) => write!(f, "{msg}"),
            Self::Field { field, reason } => write!(f, "field '{field}': {reason}"),
        }
    }
}

impl std::error::Error for DispositionError {}

#[cfg(test)]
mod tests {
    use super::*;

    fn raw(json: &str) -> RawDisposition {
        serde_json::from_str(json).unwrap()
    }

    #[test]
    fn verdict_round_trips() {
        for v in [
            Verdict::TruePositive,
            Verdict::FalsePositive,
            Verdict::BenignTruePositive,
        ] {
            assert_eq!(Verdict::parse(v.as_str()).unwrap(), v);
        }
        assert!(Verdict::parse("nope").is_err());
    }

    #[test]
    fn detection_requires_rule_id() {
        let err = Disposition::from_raw(raw(r#"{"verdict": "false_positive"}"#), 100).unwrap_err();
        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "rule_id"));
    }

    #[test]
    fn incident_requires_incident_id() {
        let err = Disposition::from_raw(
            raw(r#"{"verdict": "true_positive", "scope": "incident"}"#),
            100,
        )
        .unwrap_err();
        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "incident_id"));
    }

    #[test]
    fn incident_scope_allows_missing_rule_id() {
        let d = Disposition::from_raw(
            raw(r#"{"verdict": "true_positive", "scope": "incident", "incident_id": "abc"}"#),
            100,
        )
        .unwrap();
        assert_eq!(d.rule_id, None);
        assert_eq!(d.scope, DispositionScope::Incident);
        assert_eq!(d.incident_id.as_deref(), Some("abc"));
    }

    #[test]
    fn missing_verdict_is_rejected() {
        let err = Disposition::from_raw(raw(r#"{"rule_id": "r1"}"#), 100).unwrap_err();
        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "verdict"));
    }

    #[test]
    fn timestamp_defaults_to_now_else_parses_rfc3339() {
        let d = Disposition::from_raw(raw(r#"{"rule_id": "r", "verdict": "true_positive"}"#), 42)
            .unwrap();
        assert_eq!(d.timestamp, 42);

        let d = Disposition::from_raw(
            raw(r#"{"rule_id": "r", "verdict": "true_positive", "timestamp": "2026-01-01T00:00:00Z"}"#),
            42,
        )
        .unwrap();
        assert_eq!(d.timestamp, 1_767_225_600);

        let err = Disposition::from_raw(
            raw(r#"{"rule_id": "r", "verdict": "true_positive", "timestamp": "not-a-time"}"#),
            42,
        )
        .unwrap_err();
        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "timestamp"));
    }

    #[test]
    fn oversized_note_is_rejected() {
        let note = "x".repeat(MAX_NOTE_BYTES + 1);
        let json = format!(r#"{{"rule_id": "r", "verdict": "true_positive", "note": "{note}"}}"#);
        let err = Disposition::from_raw(raw(&json), 1).unwrap_err();
        assert!(matches!(err, DispositionError::Field { ref field, .. } if field == "note"));
    }

    #[test]
    fn dedup_key_prefers_alert_identity() {
        let with_fp = Disposition::from_raw(
            raw(r#"{"rule_id": "r", "verdict": "false_positive", "fingerprint": "fp1"}"#),
            1,
        )
        .unwrap();
        assert!(with_fp.dedup_key().contains("fp1"));

        // Same fingerprint + verdict collapses regardless of timestamp/analyst.
        let again = Disposition::from_raw(
            raw(r#"{"rule_id": "r", "verdict": "false_positive", "fingerprint": "fp1", "analyst": "x"}"#),
            999,
        )
        .unwrap();
        assert_eq!(with_fp.dedup_key(), again.dedup_key());

        // Without an alert identity, the key falls back to rule/time/analyst.
        let no_id =
            Disposition::from_raw(raw(r#"{"rule_id": "r", "verdict": "false_positive"}"#), 5)
                .unwrap();
        assert!(no_id.dedup_key().contains("\u{1}5\u{1}"));
    }

    #[test]
    fn parse_accepts_object_array_and_ndjson() {
        assert_eq!(parse_dispositions("").unwrap().len(), 0);
        assert_eq!(
            parse_dispositions(r#"{"rule_id":"r","verdict":"true_positive"}"#)
                .unwrap()
                .len(),
            1
        );
        assert_eq!(
            parse_dispositions(
                r#"[{"rule_id":"r","verdict":"true_positive"},{"rule_id":"s","verdict":"false_positive"}]"#
            )
            .unwrap()
            .len(),
            2
        );
        let ndjson = "{\"rule_id\":\"r\",\"verdict\":\"true_positive\"}\n\n{\"rule_id\":\"s\",\"verdict\":\"false_positive\"}\n";
        assert_eq!(parse_dispositions(ndjson).unwrap().len(), 2);
    }

    #[test]
    fn parse_reports_malformed_input() {
        assert!(matches!(
            parse_dispositions("[not json"),
            Err(DispositionError::Parse(_))
        ));
        assert!(matches!(
            parse_dispositions("{bad}\n{also bad}"),
            Err(DispositionError::Parse(_))
        ));
    }
}