clawdstrike_ocsf/classes/
detection_finding.rs1use serde::{Deserialize, Serialize};
6
7use crate::base::{category_for_class, compute_type_uid, ClassUid};
8use crate::objects::actor::Actor;
9use crate::objects::attack::Attack;
10use crate::objects::evidence::Evidence;
11use crate::objects::finding_info::FindingInfo;
12use crate::objects::metadata::Metadata;
13use crate::objects::observable::Observable;
14use crate::objects::resource::ResourceDetail;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[repr(u8)]
19pub enum DetectionFindingActivity {
20 Create = 1,
22 Update = 2,
24 Close = 3,
26 Other = 99,
28}
29
30impl DetectionFindingActivity {
31 #[must_use]
33 pub const fn as_u8(self) -> u8 {
34 self as u8
35 }
36}
37
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct DetectionFinding {
42 pub class_uid: u16,
45 pub category_uid: u8,
47 pub type_uid: u32,
49 pub activity_id: u8,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub activity_name: Option<String>,
54 pub time: i64,
56 pub severity_id: u8,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub severity: Option<String>,
61 pub status_id: u8,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub status: Option<String>,
66 pub action_id: u8,
68 pub disposition_id: u8,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub disposition: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub message: Option<String>,
76 pub metadata: Metadata,
78
79 pub finding_info: FindingInfo,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub actor: Option<Actor>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub resources: Option<Vec<ResourceDetail>>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub observables: Option<Vec<Observable>>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub evidence: Option<Evidence>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub attacks: Option<Vec<Attack>>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub unmapped: Option<serde_json::Value>,
100}
101
102impl DetectionFinding {
103 #[must_use]
105 #[allow(clippy::too_many_arguments)]
106 pub fn new(
107 activity: DetectionFindingActivity,
108 time: i64,
109 severity_id: u8,
110 status_id: u8,
111 action_id: u8,
112 disposition_id: u8,
113 metadata: Metadata,
114 finding_info: FindingInfo,
115 ) -> Self {
116 let class_uid = ClassUid::DetectionFinding;
117 let activity_id = activity.as_u8();
118 Self {
119 class_uid: class_uid.as_u16(),
120 category_uid: category_for_class(class_uid).as_u8(),
121 type_uid: compute_type_uid(class_uid.as_u16(), activity_id),
122 activity_id,
123 activity_name: Some(detection_finding_activity_name(activity).to_string()),
124 time,
125 severity_id,
126 severity: None,
127 status_id,
128 status: None,
129 action_id,
130 disposition_id,
131 disposition: None,
132 message: None,
133 metadata,
134 finding_info,
135 actor: None,
136 resources: None,
137 observables: None,
138 evidence: None,
139 attacks: None,
140 unmapped: None,
141 }
142 }
143
144 #[must_use]
146 pub fn with_severity_label(mut self, label: &str) -> Self {
147 self.severity = Some(label.to_string());
148 self
149 }
150
151 #[must_use]
153 pub fn with_message(mut self, msg: impl Into<String>) -> Self {
154 self.message = Some(msg.into());
155 self
156 }
157
158 #[must_use]
160 pub fn with_actor(mut self, actor: Actor) -> Self {
161 self.actor = Some(actor);
162 self
163 }
164
165 #[must_use]
167 pub fn with_resources(mut self, resources: Vec<ResourceDetail>) -> Self {
168 self.resources = Some(resources);
169 self
170 }
171
172 #[must_use]
174 pub fn with_observables(mut self, observables: Vec<Observable>) -> Self {
175 self.observables = Some(observables);
176 self
177 }
178
179 #[must_use]
181 pub fn with_evidence(mut self, evidence: Evidence) -> Self {
182 self.evidence = Some(evidence);
183 self
184 }
185
186 #[must_use]
188 pub fn with_attacks(mut self, attacks: Vec<Attack>) -> Self {
189 self.attacks = Some(attacks);
190 self
191 }
192
193 #[must_use]
195 pub fn with_unmapped(mut self, unmapped: serde_json::Value) -> Self {
196 self.unmapped = Some(unmapped);
197 self
198 }
199}
200
201fn detection_finding_activity_name(activity: DetectionFindingActivity) -> &'static str {
202 match activity {
203 DetectionFindingActivity::Create => "Create",
204 DetectionFindingActivity::Update => "Update",
205 DetectionFindingActivity::Close => "Close",
206 DetectionFindingActivity::Other => "Other",
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::objects::finding_info::Analytic;
214
215 fn sample_finding() -> DetectionFinding {
216 DetectionFinding::new(
217 DetectionFindingActivity::Create,
218 1_709_366_400_000,
219 4, 2, 2, 2, Metadata::clawdstrike("0.1.3"),
224 FindingInfo {
225 uid: "finding-001".to_string(),
226 title: "Forbidden path access".to_string(),
227 analytic: Analytic::rule("ForbiddenPathGuard"),
228 desc: None,
229 related_analytics: None,
230 },
231 )
232 }
233
234 #[test]
235 fn class_uid_is_2004() {
236 let f = sample_finding();
237 assert_eq!(f.class_uid, 2004);
238 }
239
240 #[test]
241 fn category_uid_is_2() {
242 let f = sample_finding();
243 assert_eq!(f.category_uid, 2);
244 }
245
246 #[test]
247 fn type_uid_for_create() {
248 let f = sample_finding();
249 assert_eq!(f.type_uid, 200401);
250 }
251
252 #[test]
253 fn type_uid_for_update() {
254 let f = DetectionFinding::new(
255 DetectionFindingActivity::Update,
256 0,
257 0,
258 0,
259 0,
260 0,
261 Metadata::clawdstrike("0.1.3"),
262 FindingInfo {
263 uid: "f".to_string(),
264 title: "t".to_string(),
265 analytic: Analytic::rule("g"),
266 desc: None,
267 related_analytics: None,
268 },
269 );
270 assert_eq!(f.type_uid, 200402);
271 }
272
273 #[test]
274 fn type_uid_for_close() {
275 let f = DetectionFinding::new(
276 DetectionFindingActivity::Close,
277 0,
278 0,
279 0,
280 0,
281 0,
282 Metadata::clawdstrike("0.1.3"),
283 FindingInfo {
284 uid: "f".to_string(),
285 title: "t".to_string(),
286 analytic: Analytic::rule("g"),
287 desc: None,
288 related_analytics: None,
289 },
290 );
291 assert_eq!(f.type_uid, 200403);
292 }
293
294 #[test]
295 fn serialization_roundtrip() {
296 let f = sample_finding()
297 .with_message("Blocked access to /etc/shadow")
298 .with_severity_label("High");
299 let json = serde_json::to_string(&f).unwrap();
300 let f2: DetectionFinding = serde_json::from_str(&json).unwrap();
301 assert_eq!(f.class_uid, f2.class_uid);
302 assert_eq!(f.type_uid, f2.type_uid);
303 assert_eq!(f.finding_info.uid, f2.finding_info.uid);
304 }
305
306 #[test]
307 fn json_contains_required_ocsf_fields() {
308 let f = sample_finding();
309 let v = serde_json::to_value(&f).unwrap();
310 assert!(v.get("class_uid").is_some());
311 assert!(v.get("category_uid").is_some());
312 assert!(v.get("type_uid").is_some());
313 assert!(v.get("activity_id").is_some());
314 assert!(v.get("time").is_some());
315 assert!(v.get("severity_id").is_some());
316 assert!(v.get("status_id").is_some());
317 assert!(v.get("action_id").is_some());
318 assert!(v.get("disposition_id").is_some());
319 assert!(v.get("metadata").is_some());
320 assert!(v.get("finding_info").is_some());
321 assert!(v["metadata"].get("version").is_some());
322 assert!(v["metadata"].get("product").is_some());
323 assert!(v["finding_info"].get("uid").is_some());
324 assert!(v["finding_info"].get("title").is_some());
325 assert!(v["finding_info"].get("analytic").is_some());
326 assert_eq!(v["finding_info"]["analytic"]["type_id"], 1);
327 }
328}