Skip to main content

rsigma_runtime/risk/
incident.rs

1//! The risk-incident wire shape and the open-entity admin view.
2
3use serde::Serialize;
4use serde_json::Value;
5
6/// How much contributing-detection detail to embed in a [`RiskIncidentResult`].
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum IncludeMode {
9    /// Lightweight references (rule, level, score, timestamp) only.
10    Refs,
11    /// Full (event-stripped) contributing results.
12    Results,
13}
14
15/// A lightweight reference to a contributing detection.
16#[derive(Debug, Clone, Serialize)]
17pub struct RiskRef {
18    /// Rule id, falling back to the rule title.
19    pub rule: String,
20    /// Severity, lowercased.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub level: Option<String>,
23    /// The risk score this firing contributed.
24    pub score: i64,
25    /// Contributing-detection timestamp (unix seconds).
26    pub timestamp: i64,
27}
28
29/// The wire shape emitted when an entity crosses a risk threshold. One flat
30/// NDJSON object, disambiguated downstream by the presence of `risk_incident_id`.
31#[derive(Debug, Clone, Serialize)]
32pub struct RiskIncidentResult {
33    /// Surrogate UUIDv4 identity for this incident.
34    pub risk_incident_id: String,
35    /// The risk-object type, e.g. `user`.
36    pub entity_type: String,
37    /// The entity value, e.g. `alice`.
38    pub entity_value: String,
39    /// What crossed the threshold: `score` or `tactic_count`.
40    pub trigger: &'static str,
41    /// The accumulated risk score over the window.
42    pub score: i64,
43    /// The configured score threshold, when set.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub score_threshold: Option<i64>,
46    /// The distinct ATT&CK tactic count over the window.
47    pub tactic_count: u64,
48    /// The configured tactic-count threshold, when set.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub tactic_count_threshold: Option<u64>,
51    /// The distinct ATT&CK tactics contributing over the window.
52    pub tactics: Vec<String>,
53    /// The distinct contributing sources (rule identities) over the window,
54    /// bounded by `max_sources_per_entity`.
55    pub sources: Vec<String>,
56    /// The distinct contributing-source count over the window.
57    pub source_count: u64,
58    /// First and last contributing-detection timestamps (unix seconds).
59    pub window_start: i64,
60    pub window_end: i64,
61    /// Number of contributing detections retained over the window.
62    pub result_count: u64,
63    /// Contributing references (`include: refs`), bounded by
64    /// `max_results_per_incident`.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub refs: Option<Vec<RiskRef>>,
67    /// Contributing results (`include: results`), event payloads stripped and
68    /// stored as serialized JSON values, bounded by `max_results_per_incident`.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub results: Option<Vec<Value>>,
71}
72
73/// A read-only view of one open entity, served by `GET /api/v1/risk`.
74#[derive(Debug, Clone, Serialize)]
75pub struct RiskEntityView {
76    /// The risk-object type.
77    pub entity_type: String,
78    /// The entity value.
79    pub entity_value: String,
80    /// The accumulated risk score over the window.
81    pub score: i64,
82    /// The distinct ATT&CK tactic count over the window.
83    pub tactic_count: u64,
84    /// The distinct contributing-source count over the window.
85    pub source_count: u64,
86    /// Number of contributing detections retained over the window.
87    pub result_count: u64,
88    /// First and last contributing-detection timestamps (unix seconds).
89    pub window_start: i64,
90    pub window_end: i64,
91    /// When this entity last fired an incident, if ever (unix seconds).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub last_fired: Option<i64>,
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn risk_incident_wire_shape_is_stable() {
102        let incident = RiskIncidentResult {
103            risk_incident_id: "11111111-1111-4111-8111-111111111111".to_string(),
104            entity_type: "user".to_string(),
105            entity_value: "alice".to_string(),
106            trigger: "score",
107            score: 120,
108            score_threshold: Some(100),
109            tactic_count: 2,
110            tactic_count_threshold: None,
111            tactics: vec!["execution".to_string(), "persistence".to_string()],
112            sources: vec!["rule-1".to_string(), "rule-2".to_string()],
113            source_count: 2,
114            window_start: 1000,
115            window_end: 1010,
116            result_count: 2,
117            refs: Some(vec![RiskRef {
118                rule: "rule-1".to_string(),
119                level: Some("high".to_string()),
120                score: 60,
121                timestamp: 1000,
122            }]),
123            results: None,
124        };
125        let json = serde_json::to_string(&incident).unwrap();
126        let expected = concat!(
127            r#"{"risk_incident_id":"11111111-1111-4111-8111-111111111111","#,
128            r#""entity_type":"user","entity_value":"alice","trigger":"score","#,
129            r#""score":120,"score_threshold":100,"tactic_count":2,"#,
130            r#""tactics":["execution","persistence"],"sources":["rule-1","rule-2"],"#,
131            r#""source_count":2,"window_start":1000,"window_end":1010,"result_count":2,"#,
132            r#""refs":[{"rule":"rule-1","level":"high","score":60,"timestamp":1000}]}"#,
133        );
134        assert_eq!(json, expected);
135    }
136
137    #[test]
138    fn risk_entity_view_omits_unset_last_fired() {
139        let view = RiskEntityView {
140            entity_type: "host".to_string(),
141            entity_value: "dc01".to_string(),
142            score: 40,
143            tactic_count: 1,
144            source_count: 1,
145            result_count: 1,
146            window_start: 5,
147            window_end: 5,
148            last_fired: None,
149        };
150        let json = serde_json::to_string(&view).unwrap();
151        assert!(!json.contains("last_fired"));
152        assert!(json.contains(r#""entity_value":"dc01""#));
153    }
154}