|
7 | 7 |
|
8 | 8 | use crate::events::OcsfEvent; |
9 | 9 | use crate::events::base_event::BaseEventData; |
10 | | -use crate::objects::Url; |
| 10 | +use crate::objects::{Evidence, Url}; |
| 11 | + |
| 12 | +fn finding_evidence_value<'a>(evidences: Option<&'a [Evidence]>, key: &str) -> Option<&'a str> { |
| 13 | + evidences? |
| 14 | + .iter() |
| 15 | + .filter_map(|evidence| evidence.data.as_ref()?.as_object()) |
| 16 | + .find_map(|data| data.get(key)?.as_str()) |
| 17 | +} |
| 18 | + |
| 19 | +fn message_bool_value<'a>(message: Option<&'a str>, key: &str) -> Option<&'a str> { |
| 20 | + let prefix = format!("{key}="); |
| 21 | + message? |
| 22 | + .split_ascii_whitespace() |
| 23 | + .find_map(|field| field.strip_prefix(&prefix)) |
| 24 | + .filter(|value| matches!(*value, "true" | "false")) |
| 25 | +} |
11 | 26 |
|
12 | 27 | /// Format a timestamp (ms since epoch) as `HH:MM:SS.mmm`. |
13 | 28 | /// |
@@ -195,10 +210,32 @@ impl OcsfEvent { |
195 | 210 | .and_then(|r| r.url.as_ref()) |
196 | 211 | .map(Url::to_display_string) |
197 | 212 | .unwrap_or_default(); |
| 213 | + let transformed = e |
| 214 | + .firewall_rule |
| 215 | + .as_ref() |
| 216 | + .filter(|rule| rule.rule_type == "middleware") |
| 217 | + .and_then(|_| message_bool_value(e.base.message.as_deref(), "transformed")); |
| 218 | + let failed = e |
| 219 | + .firewall_rule |
| 220 | + .as_ref() |
| 221 | + .filter(|rule| rule.rule_type == "middleware") |
| 222 | + .and_then(|_| message_bool_value(e.base.message.as_deref(), "failed")); |
198 | 223 | let rule_ctx = e |
199 | 224 | .firewall_rule |
200 | 225 | .as_ref() |
201 | | - .map(|r| format!(" [policy:{} engine:{}]", r.name, r.rule_type)) |
| 226 | + .map(|r| { |
| 227 | + let mut context = vec![ |
| 228 | + format!("policy:{}", r.name), |
| 229 | + format!("engine:{}", r.rule_type), |
| 230 | + ]; |
| 231 | + if let Some(value) = transformed { |
| 232 | + context.push(format!("transformed:{value}")); |
| 233 | + } |
| 234 | + if let Some(value) = failed { |
| 235 | + context.push(format!("failed:{value}")); |
| 236 | + } |
| 237 | + format!(" [{}]", context.join(" ")) |
| 238 | + }) |
202 | 239 | .unwrap_or_default(); |
203 | 240 | // For denied events, surface the reason from status_detail |
204 | 241 | let reason_ctx = if action == "DENIED" { |
@@ -280,16 +317,26 @@ impl OcsfEvent { |
280 | 317 | } |
281 | 318 |
|
282 | 319 | Self::DetectionFinding(e) => { |
283 | | - let disposition = e |
284 | | - .disposition |
285 | | - .map_or_else(|| "UNKNOWN".to_string(), |d| d.label().to_uppercase()); |
| 320 | + let disposition = e.disposition.map_or_else( |
| 321 | + || e.base.activity_name.to_uppercase(), |
| 322 | + |d| d.label().to_uppercase(), |
| 323 | + ); |
286 | 324 | let title = &e.finding_info.title; |
287 | | - let confidence_ctx = e |
288 | | - .confidence |
289 | | - .map(|c| format!(" [confidence:{}]", c.label().to_lowercase())) |
290 | | - .unwrap_or_default(); |
291 | | - |
292 | | - format!("FINDING:{disposition} {sev} \"{title}\"{confidence_ctx}") |
| 325 | + let mut context = vec![format!("type:{}", e.finding_info.uid)]; |
| 326 | + if let Some(middleware) = |
| 327 | + finding_evidence_value(e.evidences.as_deref(), "middleware") |
| 328 | + { |
| 329 | + context.push(format!("middleware:{middleware}")); |
| 330 | + } |
| 331 | + if let Some(count) = finding_evidence_value(e.evidences.as_deref(), "count") { |
| 332 | + context.push(format!("count:{count}")); |
| 333 | + } |
| 334 | + if let Some(confidence) = e.confidence { |
| 335 | + context.push(format!("confidence:{}", confidence.label().to_lowercase())); |
| 336 | + } |
| 337 | + let context = format!(" [{}]", context.join(" ")); |
| 338 | + |
| 339 | + format!("FINDING:{disposition} {sev} \"{title}\"{context}") |
293 | 340 | } |
294 | 341 |
|
295 | 342 | Self::ApplicationLifecycle(e) => { |
@@ -534,6 +581,37 @@ mod tests { |
534 | 581 | ); |
535 | 582 | } |
536 | 583 |
|
| 584 | + #[test] |
| 585 | + fn test_http_activity_shorthand_includes_middleware_outcome() { |
| 586 | + let mut base = base(4002, "HTTP Activity", 4, "Network Activity", 99, "Other"); |
| 587 | + base.set_message( |
| 588 | + "MIDDLEWARE prototype-content-guard example/content-guard decision=Allow transformed=false failed=true", |
| 589 | + ); |
| 590 | + let event = OcsfEvent::HttpActivity(HttpActivityEvent { |
| 591 | + base, |
| 592 | + http_request: Some(HttpRequest::new( |
| 593 | + "POST", |
| 594 | + Url::new("http", "httpbin.org", "/anything", 443), |
| 595 | + )), |
| 596 | + http_response: None, |
| 597 | + src_endpoint: None, |
| 598 | + dst_endpoint: None, |
| 599 | + proxy_endpoint: None, |
| 600 | + actor: None, |
| 601 | + firewall_rule: Some(FirewallRule::new("httpbin", "middleware")), |
| 602 | + action: Some(ActionId::Allowed), |
| 603 | + disposition: Some(DispositionId::Allowed), |
| 604 | + observation_point_id: None, |
| 605 | + is_src_dst_assignment_known: None, |
| 606 | + }); |
| 607 | + |
| 608 | + let shorthand = event.format_shorthand(); |
| 609 | + assert_eq!( |
| 610 | + shorthand, |
| 611 | + "HTTP:POST [INFO] ALLOWED POST http://httpbin.org:443/anything [policy:httpbin engine:middleware transformed:false failed:true]" |
| 612 | + ); |
| 613 | + } |
| 614 | + |
537 | 615 | #[test] |
538 | 616 | fn test_network_activity_shorthand_denied_shows_reason() { |
539 | 617 | let mut b = base(4001, "Network Activity", 4, "Network Activity", 1, "Open"); |
@@ -863,8 +941,35 @@ mod tests { |
863 | 941 | let shorthand = event.format_shorthand(); |
864 | 942 | assert_eq!( |
865 | 943 | shorthand, |
866 | | - "FINDING:BLOCKED [HIGH] \"NSSH1 Nonce Replay Attack\" [confidence:high]" |
| 944 | + "FINDING:BLOCKED [HIGH] \"NSSH1 Nonce Replay Attack\" [type:nssh1-replay-abc confidence:high]" |
| 945 | + ); |
| 946 | + } |
| 947 | + |
| 948 | + #[test] |
| 949 | + fn test_detection_finding_shorthand_uses_activity_and_safe_evidence() { |
| 950 | + let event = OcsfEvent::DetectionFinding(DetectionFindingEvent { |
| 951 | + base: base(2004, "Detection Finding", 2, "Findings", 1, "Create"), |
| 952 | + finding_info: FindingInfo::new("content_guard.match", "configured content matched"), |
| 953 | + evidences: Some(vec![Evidence::from_pairs(&[ |
| 954 | + ("middleware", "prototype-content-guard"), |
| 955 | + ("count", "1"), |
| 956 | + ("matched_content", "must-not-appear"), |
| 957 | + ])]), |
| 958 | + attacks: None, |
| 959 | + remediation: None, |
| 960 | + is_alert: None, |
| 961 | + confidence: None, |
| 962 | + risk_level: None, |
| 963 | + action: None, |
| 964 | + disposition: None, |
| 965 | + }); |
| 966 | + |
| 967 | + let shorthand = event.format_shorthand(); |
| 968 | + assert_eq!( |
| 969 | + shorthand, |
| 970 | + "FINDING:CREATE [INFO] \"configured content matched\" [type:content_guard.match middleware:prototype-content-guard count:1]" |
867 | 971 | ); |
| 972 | + assert!(!shorthand.contains("must-not-appear")); |
868 | 973 | } |
869 | 974 |
|
870 | 975 | #[test] |
|
0 commit comments