1use serde::Serialize;
4use serde_json::Value;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum IncludeMode {
9 Refs,
11 Results,
13}
14
15#[derive(Debug, Clone, Serialize)]
17pub struct RiskRef {
18 pub rule: String,
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub level: Option<String>,
23 pub score: i64,
25 pub timestamp: i64,
27}
28
29#[derive(Debug, Clone, Serialize)]
32pub struct RiskIncidentResult {
33 pub risk_incident_id: String,
35 pub entity_type: String,
37 pub entity_value: String,
39 pub trigger: &'static str,
41 pub score: i64,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub score_threshold: Option<i64>,
46 pub tactic_count: u64,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub tactic_count_threshold: Option<u64>,
51 pub tactics: Vec<String>,
53 pub sources: Vec<String>,
56 pub source_count: u64,
58 pub window_start: i64,
60 pub window_end: i64,
61 pub result_count: u64,
63 #[serde(skip_serializing_if = "Option::is_none")]
66 pub refs: Option<Vec<RiskRef>>,
67 #[serde(skip_serializing_if = "Option::is_none")]
70 pub results: Option<Vec<Value>>,
71}
72
73#[derive(Debug, Clone, Serialize)]
75pub struct RiskEntityView {
76 pub entity_type: String,
78 pub entity_value: String,
80 pub score: i64,
82 pub tactic_count: u64,
84 pub source_count: u64,
86 pub result_count: u64,
88 pub window_start: i64,
90 pub window_end: i64,
91 #[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}