Skip to main content

clawdstrike_ocsf/classes/
detection_finding.rs

1//! OCSF Detection Finding (class_uid = 2004, category_uid = 2 Findings).
2//!
3//! Activity IDs: 1=Create, 2=Update, 3=Close.
4
5use 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/// OCSF activity IDs for Detection Finding.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[repr(u8)]
19pub enum DetectionFindingActivity {
20    /// Create a new finding.
21    Create = 1,
22    /// Update an existing finding.
23    Update = 2,
24    /// Close a finding.
25    Close = 3,
26    /// Other (vendor-specific).
27    Other = 99,
28}
29
30impl DetectionFindingActivity {
31    /// Returns the integer representation.
32    #[must_use]
33    pub const fn as_u8(self) -> u8 {
34        self as u8
35    }
36}
37
38/// OCSF Detection Finding event (class_uid = 2004).
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct DetectionFinding {
42    // ── OCSF base fields ──
43    /// Always 2004.
44    pub class_uid: u16,
45    /// Always 2 (Findings).
46    pub category_uid: u8,
47    /// `class_uid * 100 + activity_id`.
48    pub type_uid: u32,
49    /// Activity ID (1=Create, 2=Update, 3=Close).
50    pub activity_id: u8,
51    /// Human-readable activity name.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub activity_name: Option<String>,
54    /// Event time as epoch milliseconds.
55    pub time: i64,
56    /// Severity ID (0-6, 99).
57    pub severity_id: u8,
58    /// Human-readable severity label.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub severity: Option<String>,
61    /// Status ID (0=Unknown, 1=Success, 2=Failure).
62    pub status_id: u8,
63    /// Human-readable status label.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub status: Option<String>,
66    /// Action ID (1=Allowed, 2=Denied).
67    pub action_id: u8,
68    /// Disposition ID (1=Allowed, 2=Blocked, 17=Logged).
69    pub disposition_id: u8,
70    /// Human-readable disposition label.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub disposition: Option<String>,
73    /// Human-readable event message.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub message: Option<String>,
76    /// Metadata (required).
77    pub metadata: Metadata,
78
79    // ── Detection Finding-specific fields ──
80    /// Finding information (required for Detection Finding).
81    pub finding_info: FindingInfo,
82    /// Actor who triggered the finding.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub actor: Option<Actor>,
85    /// Affected resources.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub resources: Option<Vec<ResourceDetail>>,
88    /// Observables associated with the finding.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub observables: Option<Vec<Observable>>,
91    /// Evidence supporting the finding.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub evidence: Option<Evidence>,
94    /// MITRE ATT&CK mapping.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub attacks: Option<Vec<Attack>>,
97    /// Vendor-specific unmapped data.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub unmapped: Option<serde_json::Value>,
100}
101
102impl DetectionFinding {
103    /// Create a new Detection Finding with required fields.
104    #[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    /// Set the human-readable severity label.
145    #[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    /// Set the event message.
152    #[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    /// Set the actor.
159    #[must_use]
160    pub fn with_actor(mut self, actor: Actor) -> Self {
161        self.actor = Some(actor);
162        self
163    }
164
165    /// Set resources.
166    #[must_use]
167    pub fn with_resources(mut self, resources: Vec<ResourceDetail>) -> Self {
168        self.resources = Some(resources);
169        self
170    }
171
172    /// Set observables.
173    #[must_use]
174    pub fn with_observables(mut self, observables: Vec<Observable>) -> Self {
175        self.observables = Some(observables);
176        self
177    }
178
179    /// Set evidence.
180    #[must_use]
181    pub fn with_evidence(mut self, evidence: Evidence) -> Self {
182        self.evidence = Some(evidence);
183        self
184    }
185
186    /// Set MITRE ATT&CK mappings.
187    #[must_use]
188    pub fn with_attacks(mut self, attacks: Vec<Attack>) -> Self {
189        self.attacks = Some(attacks);
190        self
191    }
192
193    /// Set unmapped vendor data.
194    #[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, // High
220            2, // Failure
221            2, // Denied
222            2, // Blocked
223            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}