Skip to main content

clawdstrike_ocsf/
validate.rs

1//! Runtime validation of OCSF required fields on serialized JSON.
2//!
3//! Intended for integration tests and debug builds. Production code uses
4//! the typed structs which enforce required fields at compile time.
5
6use serde_json::Value;
7
8/// Errors found during OCSF field validation.
9#[derive(Clone, Debug, thiserror::Error)]
10pub enum OcsfValidationError {
11    /// A required field is missing.
12    #[error("missing required OCSF field: {field}")]
13    MissingField { field: &'static str },
14    /// A field has an unexpected type.
15    #[error("invalid type for OCSF field {field}: expected {expected}")]
16    InvalidType {
17        field: &'static str,
18        expected: &'static str,
19    },
20    /// The type_uid does not match class_uid * 100 + activity_id.
21    #[error("type_uid mismatch: expected {expected}, got {actual}")]
22    TypeUidMismatch { expected: u64, actual: u64 },
23    /// Severity ID out of range.
24    #[error("severity_id {value} is not a valid OCSF severity (0-6, 99)")]
25    InvalidSeverity { value: u64 },
26}
27
28/// Validate that a serialized OCSF event JSON contains all required base fields.
29///
30/// Returns a list of all validation errors found (empty = valid).
31#[must_use]
32pub fn validate_ocsf_json(json: &Value) -> Vec<OcsfValidationError> {
33    let mut errors = Vec::new();
34
35    // Required numeric fields
36    let class_uid = check_u64(json, "class_uid", &mut errors);
37    let activity_id = check_u64(json, "activity_id", &mut errors);
38    let type_uid = check_u64(json, "type_uid", &mut errors);
39    check_u64(json, "severity_id", &mut errors);
40    check_u64(json, "status_id", &mut errors);
41
42    // Required field: time (epoch ms)
43    check_i64(json, "time", &mut errors);
44
45    // Required field: category_uid
46    check_u64(json, "category_uid", &mut errors);
47
48    // Metadata
49    if let Some(metadata) = json.get("metadata") {
50        if metadata.get("version").and_then(|v| v.as_str()).is_none() {
51            errors.push(OcsfValidationError::MissingField {
52                field: "metadata.version",
53            });
54        }
55        if metadata.get("product").is_none() {
56            errors.push(OcsfValidationError::MissingField {
57                field: "metadata.product",
58            });
59        } else {
60            let product = &metadata["product"];
61            if product.get("name").and_then(|v| v.as_str()).is_none() {
62                errors.push(OcsfValidationError::MissingField {
63                    field: "metadata.product.name",
64                });
65            }
66            if product
67                .get("vendor_name")
68                .and_then(|v| v.as_str())
69                .is_none()
70            {
71                errors.push(OcsfValidationError::MissingField {
72                    field: "metadata.product.vendor_name",
73                });
74            }
75        }
76    } else {
77        errors.push(OcsfValidationError::MissingField { field: "metadata" });
78    }
79
80    // type_uid invariant
81    if let (Some(c), Some(a), Some(t)) = (class_uid, activity_id, type_uid) {
82        let expected = c * 100 + a;
83        if t != expected {
84            errors.push(OcsfValidationError::TypeUidMismatch {
85                expected,
86                actual: t,
87            });
88        }
89    }
90
91    // severity_id range check
92    if let Some(sev) = json.get("severity_id").and_then(|v| v.as_u64()) {
93        if sev > 6 && sev != 99 {
94            errors.push(OcsfValidationError::InvalidSeverity { value: sev });
95        }
96    }
97
98    // Detection Finding-specific: finding_info
99    if let Some(class) = class_uid {
100        if class == 2004 {
101            validate_detection_finding(json, &mut errors);
102        }
103    }
104
105    errors
106}
107
108fn validate_detection_finding(json: &Value, errors: &mut Vec<OcsfValidationError>) {
109    if let Some(fi) = json.get("finding_info") {
110        if fi.get("uid").and_then(|v| v.as_str()).is_none() {
111            errors.push(OcsfValidationError::MissingField {
112                field: "finding_info.uid",
113            });
114        }
115        if fi.get("title").and_then(|v| v.as_str()).is_none() {
116            errors.push(OcsfValidationError::MissingField {
117                field: "finding_info.title",
118            });
119        }
120        if fi.get("analytic").is_none() {
121            errors.push(OcsfValidationError::MissingField {
122                field: "finding_info.analytic",
123            });
124        }
125    } else {
126        errors.push(OcsfValidationError::MissingField {
127            field: "finding_info",
128        });
129    }
130
131    // action_id and disposition_id are required for Detection Finding
132    check_u64(json, "action_id", errors);
133    check_u64(json, "disposition_id", errors);
134}
135
136fn check_u64(
137    json: &Value,
138    field: &'static str,
139    errors: &mut Vec<OcsfValidationError>,
140) -> Option<u64> {
141    match json.get(field) {
142        Some(v) => match v.as_u64() {
143            Some(n) => Some(n),
144            None => {
145                errors.push(OcsfValidationError::InvalidType {
146                    field,
147                    expected: "unsigned integer",
148                });
149                None
150            }
151        },
152        None => {
153            errors.push(OcsfValidationError::MissingField { field });
154            None
155        }
156    }
157}
158
159fn check_i64(
160    json: &Value,
161    field: &'static str,
162    errors: &mut Vec<OcsfValidationError>,
163) -> Option<i64> {
164    match json.get(field) {
165        Some(v) => match v.as_i64() {
166            Some(n) => Some(n),
167            None => {
168                errors.push(OcsfValidationError::InvalidType {
169                    field,
170                    expected: "integer",
171                });
172                None
173            }
174        },
175        None => {
176            errors.push(OcsfValidationError::MissingField { field });
177            None
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde_json::json;
186
187    fn valid_detection_finding() -> Value {
188        json!({
189            "class_uid": 2004,
190            "category_uid": 2,
191            "type_uid": 200401,
192            "activity_id": 1,
193            "time": 1709366400000_i64,
194            "severity_id": 4,
195            "status_id": 2,
196            "action_id": 2,
197            "disposition_id": 2,
198            "metadata": {
199                "version": "1.4.0",
200                "product": {
201                    "name": "ClawdStrike",
202                    "uid": "clawdstrike",
203                    "vendor_name": "Backbay Labs",
204                    "version": "0.1.3"
205                }
206            },
207            "finding_info": {
208                "uid": "finding-001",
209                "title": "Forbidden path",
210                "analytic": {
211                    "name": "ForbiddenPathGuard",
212                    "type_id": 1,
213                    "type": "Rule"
214                }
215            }
216        })
217    }
218
219    #[test]
220    fn valid_event_passes() {
221        let errors = validate_ocsf_json(&valid_detection_finding());
222        assert!(errors.is_empty(), "unexpected errors: {:?}", errors);
223    }
224
225    #[test]
226    fn missing_class_uid() {
227        let mut v = valid_detection_finding();
228        v.as_object_mut().unwrap().remove("class_uid");
229        let errors = validate_ocsf_json(&v);
230        assert!(errors
231            .iter()
232            .any(|e| matches!(e, OcsfValidationError::MissingField { field: "class_uid" })));
233    }
234
235    #[test]
236    fn wrong_type_uid() {
237        let mut v = valid_detection_finding();
238        v["type_uid"] = json!(999999);
239        let errors = validate_ocsf_json(&v);
240        assert!(errors
241            .iter()
242            .any(|e| matches!(e, OcsfValidationError::TypeUidMismatch { .. })));
243    }
244
245    #[test]
246    fn invalid_severity() {
247        let mut v = valid_detection_finding();
248        v["severity_id"] = json!(7);
249        let errors = validate_ocsf_json(&v);
250        assert!(errors
251            .iter()
252            .any(|e| matches!(e, OcsfValidationError::InvalidSeverity { value: 7 })));
253    }
254
255    #[test]
256    fn severity_99_is_valid() {
257        let mut v = valid_detection_finding();
258        v["severity_id"] = json!(99);
259        let errors = validate_ocsf_json(&v);
260        assert!(!errors
261            .iter()
262            .any(|e| matches!(e, OcsfValidationError::InvalidSeverity { .. })));
263    }
264
265    #[test]
266    fn missing_metadata() {
267        let mut v = valid_detection_finding();
268        v.as_object_mut().unwrap().remove("metadata");
269        let errors = validate_ocsf_json(&v);
270        assert!(errors
271            .iter()
272            .any(|e| matches!(e, OcsfValidationError::MissingField { field: "metadata" })));
273    }
274
275    #[test]
276    fn missing_finding_info_for_2004() {
277        let mut v = valid_detection_finding();
278        v.as_object_mut().unwrap().remove("finding_info");
279        let errors = validate_ocsf_json(&v);
280        assert!(errors.iter().any(|e| matches!(
281            e,
282            OcsfValidationError::MissingField {
283                field: "finding_info"
284            }
285        )));
286    }
287
288    #[test]
289    fn non_detection_finding_needs_no_finding_info() {
290        let v = json!({
291            "class_uid": 1007,
292            "category_uid": 1,
293            "type_uid": 100701,
294            "activity_id": 1,
295            "time": 1709366400000_i64,
296            "severity_id": 1,
297            "status_id": 1,
298            "metadata": {
299                "version": "1.4.0",
300                "product": {
301                    "name": "ClawdStrike",
302                    "uid": "clawdstrike",
303                    "vendor_name": "Backbay Labs",
304                    "version": "0.1.3"
305                }
306            }
307        });
308        let errors = validate_ocsf_json(&v);
309        assert!(errors.is_empty(), "unexpected errors: {:?}", errors);
310    }
311}