1use serde_json::Value;
7
8#[derive(Clone, Debug, thiserror::Error)]
10pub enum OcsfValidationError {
11 #[error("missing required OCSF field: {field}")]
13 MissingField { field: &'static str },
14 #[error("invalid type for OCSF field {field}: expected {expected}")]
16 InvalidType {
17 field: &'static str,
18 expected: &'static str,
19 },
20 #[error("type_uid mismatch: expected {expected}, got {actual}")]
22 TypeUidMismatch { expected: u64, actual: u64 },
23 #[error("severity_id {value} is not a valid OCSF severity (0-6, 99)")]
25 InvalidSeverity { value: u64 },
26}
27
28#[must_use]
32pub fn validate_ocsf_json(json: &Value) -> Vec<OcsfValidationError> {
33 let mut errors = Vec::new();
34
35 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 check_i64(json, "time", &mut errors);
44
45 check_u64(json, "category_uid", &mut errors);
47
48 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 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 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 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 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}