Skip to content

Commit e7ba676

Browse files
[Experimental]: Test integration of secrets detection plugin with rust bindings provided by cpex with the rust data plane (#70)
* spike: wire secrets detection runtime plugin Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test: add secrets detection binary e2e Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * docs: remove secrets detection spike readme section Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test: cover clean secrets detection payloads Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * ci: checkout secrets detection spike dependency Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * build: update secrets detection spike dependencies Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * build: use pinned secrets detection git dependency Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
1 parent ba44692 commit e7ba676

8 files changed

Lines changed: 772 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 57 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ rmp-serde = "1.3.1"
5252
async-trait = "0.1.89"
5353
reqwest = "0.13"
5454
rustls = { version = "0.23", features = ["ring"] }
55-
cpex = "0.2.0"
55+
cpex = "=0.2.2"
5656
uuid = { version = "1.23.1", features = ["v4"] }
57+
axum = "0.8"
58+
openport = { version = "0.4.0", features = ["rand"] }
59+
secrets_detection_rust = { git = "https://github.com/IBM/cpex-plugins", rev = "6ff7af74587574fe6115ce87427519b63f6062da", package = "secrets_detection_rust" }
5760

5861
[profile.release]
5962
codegen-units = 1

crates/contextforge-gateway-rs-lib/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ tracing-opentelemetry.workspace = true
1919
opentelemetry.workspace = true
2020
tokio.workspace = true
2121
tokio-util = "0.7"
22-
axum = "0.8"
22+
axum.workspace = true
2323
axum-otel-metrics = "0.14"
2424
tower-http = { version = "0.7.0", features = ["full"] }
2525
tower = "0.5.3"
@@ -52,7 +52,8 @@ with_tools = []
5252
[dev-dependencies]
5353
opentelemetry_sdk.workspace = true
5454
cpex.workspace = true
55-
openport = { version = "0.4.0", features = ["rand"] }
55+
openport.workspace = true
56+
secrets_detection_rust.workspace = true
5657
test-log = "0.2.20"
5758
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
5859

crates/contextforge-gateway-rs-lib/tests/gateway_plugins.rs

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex as StdMutex};
44

55
use contextforge_gateway_rs_cpex::CpexRuntimeRegistry;
66
use cpex::cpex_core::cmf::Role;
7+
use cpex::cpex_core::config::CpexConfig;
78
use cpex::cpex_core::hooks::types::cmf_hook_names;
89
use rmcp::{
910
ClientHandler,
@@ -13,7 +14,7 @@ use rmcp::{
1314
},
1415
service::{NotificationContext, PeerRequestOptions, RequestHandle, RoleClient, RunningService},
1516
};
16-
use serde_json::Value;
17+
use serde_json::{Map, Value, json};
1718

1819
use support::{
1920
POST_DENY_ERROR_CODE, PRE_DENY_ERROR_CODE, REWRITTEN_SUM_A, REWRITTEN_SUM_B, RunningGateway, TEST_USER_ID,
@@ -124,6 +125,43 @@ fn raw_tool_call(tool_name: &str, request_id: i64, progress_token: &str) -> Valu
124125
})
125126
}
126127

128+
fn fake_aws_access_key(suffix: &str) -> String {
129+
["AKIA", suffix].concat()
130+
}
131+
132+
fn sum_request_with_secret(secret_field: &str, secret: String) -> CallToolRequestParams {
133+
let mut request = sum_request("sum", 1, 2);
134+
request
135+
.arguments
136+
.as_mut()
137+
.expect("sum request has arguments")
138+
.insert(secret_field.to_owned(), Value::String(secret));
139+
request
140+
}
141+
142+
fn reflect_text_request(text: String) -> CallToolRequestParams {
143+
CallToolRequestParams::new("reflect_text")
144+
.with_arguments(Map::from_iter([("text".to_owned(), Value::String(text))]))
145+
}
146+
147+
async fn runtime_with_secrets_detection(hooks: Vec<&'static str>, plugin_config: Value) -> Arc<CpexRuntimeRegistry> {
148+
let mut runtime = CpexRuntimeRegistry::default();
149+
runtime
150+
.register_factory(secrets_detection_rust::KIND, Box::new(secrets_detection_rust::SecretsDetectionFactory))
151+
.expect("secrets detection factory registers");
152+
let config: CpexConfig = serde_json::from_value(json!({
153+
"plugins": [{
154+
"name": "secrets-detection",
155+
"kind": secrets_detection_rust::KIND,
156+
"hooks": hooks,
157+
"config": plugin_config,
158+
}]
159+
}))
160+
.expect("secrets detection CPEX config parses");
161+
runtime.apply_config(Some(config)).await.expect("secrets detection runtime applies");
162+
Arc::new(runtime)
163+
}
164+
127165
fn sse_data_values(body: &str) -> Vec<Value> {
128166
let values = body
129167
.lines()
@@ -333,6 +371,139 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() {
333371
assert_eq!(0, post_observations.lock().expect("observations lock poisoned").post_calls);
334372
}
335373

374+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
375+
async fn secrets_detection_pre_hook_redacts_tool_arguments_before_backend_call() {
376+
let runtime = runtime_with_secrets_detection(
377+
vec![cmf_hook_names::TOOL_PRE_INVOKE],
378+
json!({
379+
"redact": true,
380+
"redaction_text": "[redacted]",
381+
"block_on_detection": false,
382+
}),
383+
)
384+
.await;
385+
let gateway = start_gateway("admin@example.com", true, runtime).await;
386+
let service = gateway.connect("admin@example.com").await;
387+
388+
let result = service
389+
.call_tool(sum_request_with_secret("credential", fake_aws_access_key("1111111111111111")))
390+
.await
391+
.expect("secret argument is redacted and call succeeds");
392+
393+
assert_eq!("3", text(&result));
394+
let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned");
395+
assert_eq!(1, backend_calls.len());
396+
assert_eq!(
397+
Some(&Value::from("[redacted]")),
398+
backend_calls[0].args.as_ref().and_then(|args| args.get("credential"))
399+
);
400+
}
401+
402+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
403+
async fn secrets_detection_clean_tool_payload_passes_through_unchanged() {
404+
let runtime = runtime_with_secrets_detection(
405+
vec![cmf_hook_names::TOOL_PRE_INVOKE, cmf_hook_names::TOOL_POST_INVOKE],
406+
json!({
407+
"redact": true,
408+
"redaction_text": "[redacted]",
409+
"block_on_detection": true,
410+
}),
411+
)
412+
.await;
413+
let gateway = start_gateway("admin@example.com", true, runtime).await;
414+
let service = gateway.connect("admin@example.com").await;
415+
416+
let result =
417+
service.call_tool(sum_request("sum", 1, 2)).await.expect("clean argument payload passes through unchanged");
418+
419+
let result_text = text(&result);
420+
assert_eq!("3", result_text.as_str());
421+
assert!(!result_text.contains("[redacted]"));
422+
let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned");
423+
assert_eq!(1, backend_calls.len());
424+
assert_eq!("sum", backend_calls[0].tool_name);
425+
let args = backend_calls[0].args.as_ref().expect("backend call has args");
426+
assert_eq!(2, args.len());
427+
assert_eq!(Some(&Value::from(1)), args.get("a"));
428+
assert_eq!(Some(&Value::from(2)), args.get("b"));
429+
}
430+
431+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
432+
async fn secrets_detection_pre_hook_blocks_tool_arguments_before_backend_call() {
433+
let runtime = runtime_with_secrets_detection(
434+
vec![cmf_hook_names::TOOL_PRE_INVOKE],
435+
json!({
436+
"redact": false,
437+
"block_on_detection": true,
438+
}),
439+
)
440+
.await;
441+
let gateway = start_gateway("admin@example.com", true, runtime).await;
442+
let service = gateway.connect("admin@example.com").await;
443+
444+
let error = service
445+
.call_tool(sum_request_with_secret("credential", fake_aws_access_key("2222222222222222")))
446+
.await
447+
.expect_err("secret argument blocks the call");
448+
449+
assert_eq!(ErrorCode::INVALID_REQUEST, error_code(error));
450+
assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty());
451+
}
452+
453+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
454+
async fn secrets_detection_post_hook_redacts_tool_result_before_client_response() {
455+
let runtime = runtime_with_secrets_detection(
456+
vec![cmf_hook_names::TOOL_POST_INVOKE],
457+
json!({
458+
"redact": true,
459+
"redaction_text": "[redacted]",
460+
"block_on_detection": false,
461+
}),
462+
)
463+
.await;
464+
let gateway = start_gateway("admin@example.com", true, runtime).await;
465+
let service = gateway.connect("admin@example.com").await;
466+
467+
let result = service
468+
.call_tool(reflect_text_request(fake_aws_access_key("3333333333333333")))
469+
.await
470+
.expect("secret result is redacted and call succeeds");
471+
472+
assert_eq!("[redacted]", text(&result));
473+
assert_eq!(1, gateway.backend_state.calls.lock().expect("backend calls lock poisoned").len());
474+
}
475+
476+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
477+
async fn secrets_detection_pre_hook_respects_field_allowlist() {
478+
let runtime = runtime_with_secrets_detection(
479+
vec![cmf_hook_names::TOOL_PRE_INVOKE],
480+
json!({
481+
"redact": true,
482+
"redaction_text": "[redacted]",
483+
"block_on_detection": false,
484+
"field_allowlist": ["credential"],
485+
}),
486+
)
487+
.await;
488+
let gateway = start_gateway("admin@example.com", true, runtime).await;
489+
let service = gateway.connect("admin@example.com").await;
490+
let ignored_secret = fake_aws_access_key("4444444444444444");
491+
let mut request = sum_request_with_secret("credential", fake_aws_access_key("5555555555555555"));
492+
request
493+
.arguments
494+
.as_mut()
495+
.expect("sum request has arguments")
496+
.insert("ignored".to_owned(), Value::String(ignored_secret.clone()));
497+
498+
let result = service.call_tool(request).await.expect("allowed field is redacted");
499+
500+
assert_eq!("3", text(&result));
501+
let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned");
502+
let args = backend_calls[0].args.as_ref().expect("backend call has args");
503+
assert_eq!(Some(&Value::from("[redacted]")), args.get("credential"));
504+
assert_eq!(Some(&Value::from(ignored_secret)), args.get("ignored"));
505+
}
506+
336507
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
337508
async fn pre_hook_modifies_backend_arguments_without_rerouting_tool() {
338509
let plugin = Arc::new(TestPlugin::new("pre", vec![cmf_hook_names::TOOL_PRE_INVOKE]).with_pre_rewrite());

crates/contextforge-gateway-rs-lib/tests/support/plugin_gateway.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ impl ServerHandler for TestBackend {
127127
}
128128
Ok(CallToolResult::success(vec![ContentBlock::text("completed 4 packages")]))
129129
},
130+
"reflect_text" => {
131+
let text = request
132+
.arguments
133+
.as_ref()
134+
.and_then(|args| args.get("text"))
135+
.and_then(Value::as_str)
136+
.ok_or_else(|| ErrorData::invalid_params("reflect_text requires text", None))?;
137+
Ok(CallToolResult::success(vec![ContentBlock::text(text.to_owned())]))
138+
},
130139
"wait_for_cancellation" => {
131140
cx.ct.cancelled().await;
132141
self.state

crates/contextforge-gateway-rs/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ contextforge-gateway-rs-lib = { path = "../contextforge-gateway-rs-lib" }
1313
cpex-payload-marker = { git = "https://github.com/contextforge-gateway-rs/cpex-plugins-rs", rev = "ab47801daccfbba44ea07b033034a347e7b5afdd", optional = true }
1414
cpex-text-prefixer = { git = "https://github.com/contextforge-gateway-rs/cpex-plugins-rs", rev = "ab47801daccfbba44ea07b033034a347e7b5afdd", optional = true }
1515
cpex-tool-namespace = { git = "https://github.com/contextforge-gateway-rs/cpex-plugins-rs", rev = "ab47801daccfbba44ea07b033034a347e7b5afdd", optional = true }
16+
secrets_detection_rust = { workspace = true, optional = true }
1617
clap.workspace = true
1718
tracing.workspace = true
1819
tracing-appender = "0.2.3"
@@ -29,7 +30,19 @@ tikv-jemallocator = "0.7.0"
2930
rustls.workspace = true
3031

3132
[features]
33+
secrets-detection-plugin = ["dep:secrets_detection_rust"]
3234
test-plugins = ["dep:cpex-payload-marker", "dep:cpex-text-prefixer", "dep:cpex-tool-namespace"]
3335

36+
[dev-dependencies]
37+
axum.workspace = true
38+
contextforge-gateway-rs-apis.workspace = true
39+
http.workspace = true
40+
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
41+
openport.workspace = true
42+
redis.workspace = true
43+
reqwest.workspace = true
44+
rmp-serde.workspace = true
45+
serde_json.workspace = true
46+
3447
[lints]
3548
workspace = true

0 commit comments

Comments
 (0)