@@ -955,10 +955,12 @@ where
955955 . as_deref ( )
956956 . map ( |e| format ! ( "JSON-RPC request rejected: {e}" ) ) ;
957957 let force_deny = parse_error_reason. is_some ( ) ;
958- let ( allowed, reason) = if let Some ( reason) = parse_error_reason {
959- ( false , reason)
958+ let ( allowed, reason, jsonrpc_log_info ) = if let Some ( reason) = parse_error_reason {
959+ ( false , reason, jsonrpc_info . clone ( ) )
960960 } else {
961- evaluate_l7_request ( engine, ctx, & request_info) ?
961+ let evaluation =
962+ evaluate_jsonrpc_l7_request_for_log ( engine, ctx, & request_info, & jsonrpc_info) ?;
963+ ( evaluation. allowed , evaluation. reason , evaluation. log_info )
962964 } ;
963965
964966 if close_if_stale ( engine. generation_guard ( ) , ctx) {
@@ -981,11 +983,11 @@ where
981983 SeverityId :: Informational ,
982984 ) ,
983985 } ;
984- let rpc_method = jsonrpc_info
985- . calls
986- . first ( )
987- . map ( |call| call . method . as_str ( ) )
988- . unwrap_or ( "-" ) ;
986+ let endpoint = format ! ( "{}:{}{}" , ctx . host , ctx . port , redacted_target ) ;
987+ let params_sha256 = jsonrpc_log_info
988+ . params_sha256 ( )
989+ . unwrap_or_else ( || "<empty>" . to_string ( ) ) ;
990+ let policy_version = engine . captured_generation ( ) ;
989991 let event = HttpActivityBuilder :: new ( crate :: ocsf_ctx ( ) )
990992 . activity ( ActivityId :: Other )
991993 . action ( action_id)
@@ -997,9 +999,14 @@ where
997999 ) )
9981000 . dst_endpoint ( Endpoint :: from_domain ( & ctx. host , ctx. port ) )
9991001 . firewall_rule ( & ctx. policy_name , "l7-jsonrpc" )
1000- . message ( format ! (
1001- "JSONRPC_L7_REQUEST {decision_str} {} {}:{}{} rpc_method={rpc_method} reason={}" ,
1002- request_info. action, ctx. host, ctx. port, redacted_target, reason,
1002+ . message ( jsonrpc_log_message (
1003+ decision_str,
1004+ & request_info. action ,
1005+ & endpoint,
1006+ & jsonrpc_log_info,
1007+ & params_sha256,
1008+ policy_version,
1009+ & reason,
10031010 ) )
10041011 . build ( ) ;
10051012 ocsf_emit ! ( event) ;
@@ -1274,6 +1281,38 @@ fn graphql_log_summary(info: &crate::l7::graphql::GraphqlRequestInfo) -> String
12741281 format ! ( "graphql_ops={}" , ops. join( ";" ) )
12751282}
12761283
1284+ fn jsonrpc_log_message (
1285+ decision : & str ,
1286+ http_method : & str ,
1287+ endpoint : & str ,
1288+ info : & crate :: l7:: jsonrpc:: JsonRpcRequestInfo ,
1289+ params_sha256 : & str ,
1290+ policy_version : u64 ,
1291+ reason : & str ,
1292+ ) -> String {
1293+ let rpc_methods = jsonrpc_methods_for_log ( info) ;
1294+ format ! (
1295+ "JSONRPC_L7_REQUEST decision={decision} http_method={http_method} endpoint={endpoint} rpc_methods={rpc_methods} params_sha256={params_sha256} policy_version={policy_version} reason={reason}"
1296+ )
1297+ }
1298+
1299+ fn jsonrpc_methods_for_log ( info : & crate :: l7:: jsonrpc:: JsonRpcRequestInfo ) -> String {
1300+ if info. calls . is_empty ( ) {
1301+ return "-" . to_string ( ) ;
1302+ }
1303+ info. calls
1304+ . iter ( )
1305+ . map ( |call| call. method . as_str ( ) )
1306+ . collect :: < Vec < _ > > ( )
1307+ . join ( "," )
1308+ }
1309+
1310+ struct JsonRpcEvaluation {
1311+ allowed : bool ,
1312+ reason : String ,
1313+ log_info : crate :: l7:: jsonrpc:: JsonRpcRequestInfo ,
1314+ }
1315+
12771316/// Check if a miette error represents a benign connection close.
12781317///
12791318/// TLS handshake EOF, missing `close_notify`, connection resets, and broken
@@ -1305,12 +1344,7 @@ pub fn evaluate_l7_request(
13051344 && !jsonrpc. calls . is_empty ( )
13061345 {
13071346 for call in & jsonrpc. calls {
1308- let mut item_request = request. clone ( ) ;
1309- item_request. jsonrpc = Some ( crate :: l7:: jsonrpc:: JsonRpcRequestInfo {
1310- calls : vec ! [ call. clone( ) ] ,
1311- is_batch : false ,
1312- error : None ,
1313- } ) ;
1347+ let item_request = jsonrpc_request_for_call ( request, call) ;
13141348 let ( allowed, reason) = evaluate_l7_request_once ( engine, ctx, & item_request) ?;
13151349 if !allowed {
13161350 return Ok ( ( false , reason) ) ;
@@ -1322,6 +1356,66 @@ pub fn evaluate_l7_request(
13221356 evaluate_l7_request_once ( engine, ctx, request)
13231357}
13241358
1359+ fn evaluate_jsonrpc_l7_request_for_log (
1360+ engine : & TunnelPolicyEngine ,
1361+ ctx : & L7EvalContext ,
1362+ request : & L7RequestInfo ,
1363+ jsonrpc : & crate :: l7:: jsonrpc:: JsonRpcRequestInfo ,
1364+ ) -> Result < JsonRpcEvaluation > {
1365+ if jsonrpc. is_batch && !jsonrpc. calls . is_empty ( ) {
1366+ let mut denied_calls = Vec :: new ( ) ;
1367+ let mut first_denied_reason = None ;
1368+ for call in & jsonrpc. calls {
1369+ let item_request = jsonrpc_request_for_call ( request, call) ;
1370+ let ( allowed, reason) = evaluate_l7_request_once ( engine, ctx, & item_request) ?;
1371+ if !allowed {
1372+ if first_denied_reason. is_none ( ) {
1373+ first_denied_reason = Some ( reason) ;
1374+ }
1375+ denied_calls. push ( call. clone ( ) ) ;
1376+ }
1377+ }
1378+
1379+ if denied_calls. is_empty ( ) {
1380+ return Ok ( JsonRpcEvaluation {
1381+ allowed : true ,
1382+ reason : String :: new ( ) ,
1383+ log_info : jsonrpc. clone ( ) ,
1384+ } ) ;
1385+ }
1386+
1387+ return Ok ( JsonRpcEvaluation {
1388+ allowed : false ,
1389+ reason : first_denied_reason. unwrap_or_else ( || "request denied by policy" . to_string ( ) ) ,
1390+ log_info : crate :: l7:: jsonrpc:: JsonRpcRequestInfo {
1391+ calls : denied_calls,
1392+ is_batch : true ,
1393+ error : None ,
1394+ } ,
1395+ } ) ;
1396+ }
1397+
1398+ let ( allowed, reason) = evaluate_l7_request_once ( engine, ctx, request) ?;
1399+ Ok ( JsonRpcEvaluation {
1400+ allowed,
1401+ reason,
1402+ log_info : jsonrpc. clone ( ) ,
1403+ } )
1404+ }
1405+
1406+ fn jsonrpc_request_for_call (
1407+ request : & L7RequestInfo ,
1408+ call : & crate :: l7:: jsonrpc:: JsonRpcCallInfo ,
1409+ ) -> L7RequestInfo {
1410+ let mut item_request = request. clone ( ) ;
1411+ item_request. jsonrpc = Some ( crate :: l7:: jsonrpc:: JsonRpcRequestInfo {
1412+ calls : vec ! [ call. clone( ) ] ,
1413+ is_batch : false ,
1414+ error : None ,
1415+ } ) ;
1416+ item_request
1417+ }
1418+
13251419fn evaluate_l7_request_once (
13261420 engine : & TunnelPolicyEngine ,
13271421 ctx : & L7EvalContext ,
@@ -2026,6 +2120,7 @@ network_policies:
20262120 - rpc_method: "tools/call"
20272121 params:
20282122 name: blocked_action
2123+ - rpc_method: "tools/delete"
20292124 binaries:
20302125 - { path: /usr/bin/node }
20312126"# ;
@@ -2064,11 +2159,113 @@ network_policies:
20642159 request. jsonrpc = Some ( crate :: l7:: jsonrpc:: parse_jsonrpc_body (
20652160 br#"[
20662161 {"jsonrpc":"2.0","id":1,"method":"tools/list"},
2067- {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"blocked_action"}}
2162+ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"blocked_action"}},
2163+ {"jsonrpc":"2.0","id":3,"method":"tools/delete","params":{"name":"purge_cache"}}
20682164 ]"# ,
20692165 ) ) ;
20702166 let ( allowed, _) = evaluate_l7_request ( & tunnel_engine, & ctx, & request) . unwrap ( ) ;
20712167 assert ! ( !allowed) ;
2168+
2169+ let jsonrpc = request. jsonrpc . as_ref ( ) . expect ( "jsonrpc request" ) ;
2170+ let evaluation =
2171+ evaluate_jsonrpc_l7_request_for_log ( & tunnel_engine, & ctx, & request, jsonrpc) . unwrap ( ) ;
2172+ assert ! ( !evaluation. allowed) ;
2173+ assert ! ( evaluation. log_info. is_batch) ;
2174+ assert_eq ! (
2175+ jsonrpc_methods_for_log( & evaluation. log_info) ,
2176+ "tools/call,tools/delete"
2177+ ) ;
2178+
2179+ let full_params_sha256 = jsonrpc. params_sha256 ( ) . expect ( "full batch params digest" ) ;
2180+ let log_params_sha256 = evaluation
2181+ . log_info
2182+ . params_sha256 ( )
2183+ . expect ( "logged batch params digest" ) ;
2184+ assert_ne ! ( full_params_sha256, log_params_sha256) ;
2185+ let message = jsonrpc_log_message (
2186+ "deny" ,
2187+ "POST" ,
2188+ "api.example.test:443/mcp" ,
2189+ & evaluation. log_info ,
2190+ & log_params_sha256,
2191+ 42 ,
2192+ & evaluation. reason ,
2193+ ) ;
2194+ assert ! ( message. contains( "rpc_methods=tools/call,tools/delete" ) ) ;
2195+ assert ! ( message. contains( "params_sha256=" ) ) ;
2196+ assert ! ( !message. contains( "params_sha256=sha256:" ) ) ;
2197+ assert ! ( message. contains( "policy_version=42" ) ) ;
2198+ assert ! ( !message. contains( "tools/list" ) ) ;
2199+ assert ! ( !message. contains( "blocked_action" ) ) ;
2200+ assert ! ( !message. contains( "purge_cache" ) ) ;
2201+ }
2202+
2203+ #[ test]
2204+ fn jsonrpc_log_records_digest_not_args ( ) {
2205+ let info = crate :: l7:: jsonrpc:: parse_jsonrpc_body (
2206+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_resource","arguments":{"scope":"secret-scope"}}}"# ,
2207+ ) ;
2208+ let params_sha256 = info. params_sha256 ( ) . expect ( "params digest" ) ;
2209+ let message = jsonrpc_log_message (
2210+ "deny" ,
2211+ "POST" ,
2212+ "mcp.example.com:443/mcp" ,
2213+ & info,
2214+ & params_sha256,
2215+ 42 ,
2216+ "request denied by policy" ,
2217+ ) ;
2218+
2219+ assert ! ( message. contains( "endpoint=mcp.example.com:443/mcp" ) ) ;
2220+ assert ! ( message. contains( "rpc_methods=tools/call" ) ) ;
2221+ assert ! ( message. contains( "params_sha256=" ) ) ;
2222+ assert ! ( !message. contains( "params_sha256=sha256:" ) ) ;
2223+ assert ! ( message. contains( "policy_version=42" ) ) ;
2224+ assert ! ( !message. contains( "delete_resource" ) ) ;
2225+ assert ! ( !message. contains( "secret-scope" ) ) ;
2226+
2227+ let batch = crate :: l7:: jsonrpc:: parse_jsonrpc_body (
2228+ br#"[
2229+ {"jsonrpc":"2.0","id":1,"method":"tools/list"},
2230+ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource"}}
2231+ ]"# ,
2232+ ) ;
2233+ let batch_params_sha256 = batch. params_sha256 ( ) . expect ( "batch params digest" ) ;
2234+ let batch_message = jsonrpc_log_message (
2235+ "allow" ,
2236+ "POST" ,
2237+ "mcp.example.com:443/mcp" ,
2238+ & batch,
2239+ & batch_params_sha256,
2240+ 43 ,
2241+ "" ,
2242+ ) ;
2243+
2244+ assert ! ( batch_message. starts_with( "JSONRPC_L7_REQUEST " ) ) ;
2245+ assert ! ( batch_message. contains( "rpc_methods=tools/list,tools/call" ) ) ;
2246+ assert ! ( batch_message. contains( "params_sha256=" ) ) ;
2247+ assert ! ( !batch_message. contains( "params_sha256=sha256:" ) ) ;
2248+ assert ! ( batch_message. contains( "policy_version=43" ) ) ;
2249+ assert ! ( !batch_message. contains( "rpc_method=" ) ) ;
2250+ assert ! ( !batch_message. contains( "delete_resource" ) ) ;
2251+
2252+ let no_params = crate :: l7:: jsonrpc:: parse_jsonrpc_body (
2253+ br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"# ,
2254+ ) ;
2255+ let no_params_sha256 = no_params
2256+ . params_sha256 ( )
2257+ . unwrap_or_else ( || "<empty>" . to_string ( ) ) ;
2258+ let no_params_message = jsonrpc_log_message (
2259+ "allow" ,
2260+ "POST" ,
2261+ "mcp.example.com:443/mcp" ,
2262+ & no_params,
2263+ & no_params_sha256,
2264+ 44 ,
2265+ "" ,
2266+ ) ;
2267+ assert ! ( no_params_message. contains( "rpc_methods=initialize" ) ) ;
2268+ assert ! ( no_params_message. contains( "params_sha256=<empty>" ) ) ;
20722269 }
20732270
20742271 #[ tokio:: test]
0 commit comments