diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 3143b6d68e..0cb5d906c3 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -218,7 +218,19 @@ impl OcsfEvent { (false, true) => format!(" {action}"), (false, false) => format!(" {action}{arrow}"), }; - format!("HTTP:{method} {sev}{detail}{rule_ctx}{reason_ctx}") + // Denied HTTP events surface their message through reason_ctx. + // Allowed MCP decisions also need the JSON-RPC message so the + // selected tool name is visible in the shorthand log stream. + let message_ctx = if action != "DENIED" + && e.firewall_rule + .as_ref() + .is_some_and(|rule| rule.rule_type == "l7-mcp") + { + message_tag(&e.base) + } else { + String::new() + }; + format!("HTTP:{method} {sev}{detail}{rule_ctx}{reason_ctx}{message_ctx}") } Self::SshActivity(e) => { @@ -534,6 +546,62 @@ mod tests { ); } + #[test] + fn test_http_activity_shorthand_mcp_shows_tool_for_allow_and_deny() { + let mut event_base = base(4002, "HTTP Activity", 4, "Network Activity", 0, "Other"); + event_base.set_message( + "JSONRPC_L7_REQUEST decision=allow rule_methods=tools/call tools=move_head", + ); + let event = OcsfEvent::HttpActivity(HttpActivityEvent { + base: event_base, + http_request: Some(HttpRequest::new( + "POST", + Url::new("http", "host.openshell.internal", "/mcp", 8766), + )), + http_response: None, + src_endpoint: None, + dst_endpoint: None, + proxy_endpoint: None, + actor: None, + firewall_rule: Some(FirewallRule::new("reachy-mcp", "l7-mcp")), + action: Some(ActionId::Allowed), + disposition: Some(DispositionId::Allowed), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let shorthand = event.format_shorthand(); + assert!(shorthand.contains("engine:l7-mcp")); + assert!(shorthand.contains("tools=move_head")); + + let mut denied_base = base(4002, "HTTP Activity", 4, "Network Activity", 0, "Other"); + denied_base.severity = crate::enums::SeverityId::Medium; + denied_base.set_message( + "JSONRPC_L7_REQUEST decision=deny rule_methods=tools/call tools=move_head", + ); + let denied_event = OcsfEvent::HttpActivity(HttpActivityEvent { + base: denied_base, + http_request: Some(HttpRequest::new( + "POST", + Url::new("http", "host.openshell.internal", "/mcp", 8766), + )), + http_response: None, + src_endpoint: None, + dst_endpoint: None, + proxy_endpoint: None, + actor: None, + firewall_rule: Some(FirewallRule::new("reachy-mcp", "l7-mcp")), + action: Some(ActionId::Denied), + disposition: Some(DispositionId::Blocked), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let denied_shorthand = denied_event.format_shorthand(); + assert!(denied_shorthand.contains("[reason:")); + assert!(denied_shorthand.contains("tools=move_head")); + } + #[test] fn test_network_activity_shorthand_denied_shows_reason() { let mut b = base(4001, "Network Activity", 4, "Network Activity", 1, "Open"); diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index ed2bde1135..c43fb35f9d 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -571,7 +571,11 @@ fn l7_protocol_log_summary( } if let Some(info) = jsonrpc_info { - return format!(" rule_methods={}", rule_method_names_for_log(info)); + return format!( + " rule_methods={} tools={}", + rule_method_names_for_log(info), + tool_names_for_log(info) + ); } String::new() @@ -1428,8 +1432,9 @@ pub(crate) fn jsonrpc_log_message( reason: &str, ) -> String { let rule_methods = rule_method_names_for_log(info); + let tools = tool_names_for_log(info); format!( - "JSONRPC_L7_REQUEST decision={decision} http_method={http_method} endpoint={endpoint} rule_methods={rule_methods} policy_version={policy_version} reason={reason}" + "JSONRPC_L7_REQUEST decision={decision} rule_methods={rule_methods} tools={tools} http_method={http_method} endpoint={endpoint} policy_version={policy_version} reason={reason}" ) } @@ -1444,6 +1449,20 @@ pub(crate) fn rule_method_names_for_log(info: &crate::l7::jsonrpc::JsonRpcReques .join(",") } +pub(crate) fn tool_names_for_log(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> String { + let tools = info + .calls + .iter() + .filter_map(|call| call.tool.as_deref()) + .map(sanitize_log_token) + .collect::>(); + if tools.is_empty() { + "-".to_string() + } else { + tools.join(",") + } +} + fn sanitize_log_token(value: &str) -> String { value .chars() @@ -2538,7 +2557,6 @@ network_policies: let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); assert!(allowed, "{reason}"); - request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( br#"{"jsonrpc":"2.0","id":1,"method":"reports.search","params":["ignored",{"nested":true}]}"#, crate::l7::jsonrpc::JsonRpcInspectionMode::JsonRpc, @@ -2604,6 +2622,17 @@ network_policies: let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); assert!(allowed, "{reason}"); + let allowed_info = request.jsonrpc.as_ref().expect("parsed MCP request"); + let allowed_message = jsonrpc_log_message( + "allow", + "POST", + "api.example.test:443/mcp", + allowed_info, + 42, + &reason, + ); + assert!(allowed_message.contains("rule_methods=tools/call")); + assert!(allowed_message.contains("tools=read_status")); request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource","arguments":{"scope":"workspace/main"}}}"#, @@ -2625,6 +2654,17 @@ network_policies: reason.contains("deny rule"), "deny reason should identify policy denial: {reason}" ); + let denied_message = jsonrpc_log_message( + "deny", + "POST", + "api.example.test:443/mcp", + parsed, + 42, + &reason, + ); + assert!(denied_message.contains("rule_methods=tools/call")); + assert!(denied_message.contains("tools=delete_resource")); + assert!(!denied_message.contains("workspace/main")); } #[test] @@ -2644,6 +2684,7 @@ network_policies: assert!(message.contains("endpoint=jsonrpc.example.com:443/rpc")); assert!(message.contains("rule_methods=reports.archive")); + assert!(message.contains("tools=-")); assert!(message.contains("policy_version=42")); assert!(!message.contains("delete_resource")); assert!(!message.contains("secret-scope"));