Skip to content

Commit 6302ec9

Browse files
committed
fix(l7): redact JSON-RPC params in logs
Log JSON-RPC endpoint, RPC methods, params SHA-256 digest, and policy version without recording raw params. Use <empty> when no params are present. Signed-off-by: Kris Hicks <khicks@nvidia.com>
1 parent 755ec0f commit 6302ec9

2 files changed

Lines changed: 293 additions & 18 deletions

File tree

crates/openshell-sandbox/src/l7/jsonrpc.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//! JSON-RPC 2.0 over HTTP L7 inspection.
55
66
use miette::Result;
7+
use sha2::{Digest, Sha256};
8+
use std::collections::BTreeMap;
79
use std::collections::HashMap;
810
use tokio::io::{AsyncRead, AsyncWrite};
911

@@ -44,6 +46,27 @@ pub struct JsonRpcCallInfo {
4446
pub params: HashMap<String, String>,
4547
}
4648

49+
impl JsonRpcRequestInfo {
50+
pub(crate) fn params_sha256(&self) -> Option<String> {
51+
if self.is_batch {
52+
if self.calls.is_empty() || self.calls.iter().all(|call| call.params.is_empty()) {
53+
return None;
54+
}
55+
let canonical_params = self
56+
.calls
57+
.iter()
58+
.map(|call| canonical_params_map(&call.params))
59+
.collect::<Vec<_>>();
60+
return Some(sha256_json(&canonical_params));
61+
}
62+
63+
let call = self.calls.first()?;
64+
if call.params.is_empty() {
65+
return None;
66+
}
67+
Some(sha256_json(&canonical_params_map(&call.params)))
68+
}
69+
}
4770
/// Parse a JSON-RPC 2.0 request body and extract the `method` field.
4871
///
4972
/// Returns an info struct with `method` set on success, or `error` set if the
@@ -113,6 +136,18 @@ fn flatten_jsonrpc_params(value: &serde_json::Value) -> HashMap<String, String>
113136
params
114137
}
115138

139+
fn canonical_params_map(params: &HashMap<String, String>) -> BTreeMap<String, String> {
140+
params
141+
.iter()
142+
.map(|(key, value)| (key.clone(), value.clone()))
143+
.collect()
144+
}
145+
146+
fn sha256_json(value: &impl serde::Serialize) -> String {
147+
let encoded = serde_json::to_vec(value).expect("canonical JSON-RPC params should serialize");
148+
hex::encode(Sha256::digest(&encoded))
149+
}
150+
116151
fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
117152
match value {
118153
serde_json::Value::Object(map) => {
@@ -187,4 +222,47 @@ mod tests {
187222
Some("read_status")
188223
);
189224
}
225+
226+
#[test]
227+
fn params_digest_is_canonical_and_redacted() {
228+
let first = parse_jsonrpc_body(
229+
br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"submit_report","arguments":{"scope":"workspace/main"}}}"#,
230+
);
231+
let reordered = parse_jsonrpc_body(
232+
br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments":{"scope":"workspace/main"},"name":"submit_report"}}"#,
233+
);
234+
let changed = parse_jsonrpc_body(
235+
br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"submit_report","arguments":{"scope":"workspace/other"}}}"#,
236+
);
237+
238+
let digest = first.params_sha256().expect("params digest");
239+
assert_eq!(Some(digest.as_str()), reordered.params_sha256().as_deref());
240+
assert_ne!(Some(digest.as_str()), changed.params_sha256().as_deref());
241+
assert_eq!(digest.len(), 64);
242+
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
243+
assert!(!digest.contains("workspace/main"));
244+
assert!(!digest.contains("submit_report"));
245+
}
246+
247+
#[test]
248+
fn batch_params_digest_covers_call_params_without_raw_values() {
249+
let batch = parse_jsonrpc_body(
250+
br#"[
251+
{"jsonrpc":"2.0","id":1,"method":"tools/list"},
252+
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"blocked_action"}}
253+
]"#,
254+
);
255+
let empty_batch = parse_jsonrpc_body(
256+
br#"[
257+
{"jsonrpc":"2.0","id":1,"method":"tools/list"},
258+
{"jsonrpc":"2.0","id":2,"method":"initialize"}
259+
]"#,
260+
);
261+
262+
let digest = batch.params_sha256().expect("batch params digest");
263+
assert_eq!(digest.len(), 64);
264+
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
265+
assert!(!digest.contains("blocked_action"));
266+
assert!(empty_batch.params_sha256().is_none());
267+
}
190268
}

crates/openshell-sandbox/src/l7/relay.rs

Lines changed: 215 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
13251419
fn 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

Comments
 (0)