1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Verdict {
15 TruePositive,
17 FalsePositive,
19 BenignTruePositive,
22}
23
24impl Verdict {
25 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum DispositionScope {
55 #[default]
57 Detection,
58 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#[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
97pub const MAX_NOTE_BYTES: usize = 2048;
99
100#[derive(Debug, Clone, PartialEq)]
106pub struct Disposition {
107 pub rule_id: Option<String>,
110 pub verdict: Verdict,
112 pub scope: DispositionScope,
114 pub fingerprint: Option<String>,
116 pub incident_id: Option<String>,
118 pub timestamp: i64,
120 pub analyst: Option<String>,
122 pub note: Option<String>,
124}
125
126impl Disposition {
127 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 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
212fn 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
221pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum DispositionError {
261 Parse(String),
263 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 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 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}