Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion crates/openshell-ocsf/src/format/shorthand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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");
Expand Down
47 changes: 44 additions & 3 deletions crates/openshell-supervisor-network/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}"
)
}

Expand All @@ -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::<Vec<_>>();
if tools.is_empty() {
"-".to_string()
} else {
tools.join(",")
}
}

fn sanitize_log_token(value: &str) -> String {
value
.chars()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"}}}"#,
Expand All @@ -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]
Expand All @@ -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"));
Expand Down
Loading