From 45ec1638b732a96132d6d0e07e8b76bc068f4e59 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:27:14 +0000 Subject: [PATCH] fix(rust): harden provider debug redaction Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- litellm-rust/README.md | 8 -- litellm-rust/crates/core/src/logging/mod.rs | 5 + .../crates/core/src/logging/redaction.rs | 107 ++++++++++++------ .../crates/core/src/messages/tests.rs | 36 ++++++ 4 files changed, 113 insertions(+), 43 deletions(-) diff --git a/litellm-rust/README.md b/litellm-rust/README.md index 06afda7774a..bcccf93300b 100644 --- a/litellm-rust/README.md +++ b/litellm-rust/README.md @@ -45,14 +45,6 @@ The folder shape follows the Python provider tree: `core/src/providers///transformation.rs`. The bridge exposes one function per top-level route, mirroring the core entrypoints. -## Provider debug logging - -The typed provider debug contract, `CallLogger`, and console renderer live in -`crates/core/src/logging/`. The gateway and Python bridge own activation: -Python enables the injected sink with `litellm._turn_on_debug()`, while the standalone gateway uses -`LITELLM_LOG=DEBUG`. `JSON_LOGS=true` selects compact JSON; terminal pretty -output honors `NO_COLOR`. - ## Checks Run these before pushing Rust changes. GitHub Actions runs the same checks for diff --git a/litellm-rust/crates/core/src/logging/mod.rs b/litellm-rust/crates/core/src/logging/mod.rs index 900b10fd065..ba6913c412a 100644 --- a/litellm-rust/crates/core/src/logging/mod.rs +++ b/litellm-rust/crates/core/src/logging/mod.rs @@ -1,3 +1,8 @@ +//! Provider debug events are enabled by `litellm._turn_on_debug()` in Python +//! or `LITELLM_LOG=DEBUG` in the standalone gateway. `JSON_LOGS` selects compact +//! output and `NO_COLOR` disables terminal colors. Prompt and response content +//! remains visible and may contain sensitive application data. + mod redaction; pub mod console; diff --git a/litellm-rust/crates/core/src/logging/redaction.rs b/litellm-rust/crates/core/src/logging/redaction.rs index 22e07ffbc7e..8019bb6f56c 100644 --- a/litellm-rust/crates/core/src/logging/redaction.rs +++ b/litellm-rust/crates/core/src/logging/redaction.rs @@ -33,16 +33,7 @@ pub fn redact_headers(headers: &[(String, String)]) -> BTreeMap headers .iter() .map(|(name, value)| { - let value = if matches!( - name.to_ascii_lowercase().as_str(), - "authorization" - | "proxy-authorization" - | "x-api-key" - | "api-key" - | "x-amz-security-token" - | "cookie" - | "set-cookie" - ) { + let value = if is_credential_name(name) { "[REDACTED]".to_string() } else { value.clone() @@ -56,22 +47,19 @@ pub fn redact_url(url: &str) -> String { let Ok(mut parsed) = url::Url::parse(url) else { return url.to_string(); }; + if !parsed.username().is_empty() { + let _ = parsed.set_username("[REDACTED]"); + } + if parsed.password().is_some() { + let _ = parsed.set_password(Some("[REDACTED]")); + } let Some(_) = parsed.query() else { return parsed.to_string(); }; let pairs = parsed .query_pairs() .map(|(key, value)| { - let value = if matches!( - key.to_ascii_lowercase().as_str(), - "x-amz-signature" - | "x-amz-credential" - | "x-amz-security-token" - | "api-key" - | "key" - | "access_token" - | "signature" - ) { + let value = if is_credential_name(&key) { "[REDACTED]" } else { value.as_ref() @@ -102,20 +90,47 @@ fn redact_value(value: Value) -> Value { } fn is_secret_key(key: &str) -> bool { + is_credential_name(key) +} + +fn is_credential_name(name: &str) -> bool { + let normalized = name + .chars() + .filter(|character| *character != '-' && *character != '_') + .flat_map(char::to_lowercase) + .collect::(); matches!( - key.to_ascii_lowercase().as_str(), - "api_key" + normalized.as_str(), + "authorization" + | "proxyauthorization" + | "xapikey" | "apikey" + | "xamzsecuritytoken" + | "cookie" + | "setcookie" + | "xamzsignature" + | "xamzcredential" + | "key" + | "accesstoken" + | "signature" | "secret" | "password" | "token" - | "access_token" - | "client_secret" - | "aws_secret_access_key" - | "aws_access_key_id" - | "aws_session_token" - | "x-amz-security-token" - ) + | "clientsecret" + | "awssecretaccesskey" + | "awsaccesskeyid" + | "awssessiontoken" + ) || [ + "apikey", + "secret", + "token", + "password", + "credential", + "signature", + "authorization", + ] + .iter() + .any(|marker| normalized.contains(marker)) } #[cfg(test)] @@ -125,24 +140,46 @@ mod tests { use super::{PROVIDER_DEBUG_BODY_MAX_BYTES, redact_headers, redact_url, snapshot_json}; #[test] - fn redacts_sensitive_headers() { + fn redacts_explicit_sensitive_headers() { let headers = redact_headers(&[ ("authorization".to_string(), "Bearer secret".to_string()), + ("cookie".to_string(), "session-secret".to_string()), ("content-type".to_string(), "application/json".to_string()), ]); assert_eq!(headers["authorization"], "[REDACTED]"); + assert_eq!(headers["cookie"], "[REDACTED]"); assert_eq!(headers["content-type"], "application/json"); } #[test] - fn redacts_sensitive_query_parameters() { + fn redacts_credential_marker_headers_and_preserves_ordinary_headers() { + let headers = redact_headers(&[ + ("x-goog-api-key".to_string(), "google-secret".to_string()), + ("X_Custom_Token".to_string(), "custom-secret".to_string()), + ("content-type".to_string(), "application/json".to_string()), + ]); + assert_eq!(headers["x-goog-api-key"], "[REDACTED]"); + assert_eq!(headers["X_Custom_Token"], "[REDACTED]"); + assert_eq!(headers["content-type"], "application/json"); + } + + #[test] + fn redacts_credential_marker_query_parameters_and_userinfo() { let url = redact_url( - "https://example.test/v1%3A0/invoke?X-Amz-Signature=secret&keep=value&key=hidden", + "https://user:password@example.test/invoke?token=secret&client_secret=hidden&keep=value", ); - assert!(url.contains("X-Amz-Signature=%5BREDACTED%5D")); - assert!(url.contains("key=%5BREDACTED%5D")); + assert!(url.contains("%5BREDACTED%5D:%5BREDACTED%5D@example.test")); + assert!(url.contains("token=%5BREDACTED%5D")); + assert!(url.contains("client_secret=%5BREDACTED%5D")); assert!(url.contains("keep=value")); - assert!(url.contains("/v1%3A0/invoke")); + } + + #[test] + fn redact_url_preserves_queryless_urls_without_trailing_question_mark() { + assert_eq!( + redact_url("https://example.test/v1%3A0/invoke"), + "https://example.test/v1%3A0/invoke" + ); } #[test] diff --git a/litellm-rust/crates/core/src/messages/tests.rs b/litellm-rust/crates/core/src/messages/tests.rs index 69cfd8a7cbe..0fbffc73807 100644 --- a/litellm-rust/crates/core/src/messages/tests.rs +++ b/litellm-rust/crates/core/src/messages/tests.rs @@ -534,6 +534,42 @@ async fn messages_debug_logs_transformed_request_and_redacted_response() { assert!(response_event.duration_ms < 10_000); } +#[tokio::test] +async fn messages_debug_wrong_shape_emits_response_then_failure() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); + let addr = listener.local_addr().expect("addr"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts request"); + let _ = read_http_request(&mut socket).await; + socket + .write_all(write_response(r#"{"id":"wrong-shape"}"#).as_bytes()) + .await + .expect("writes response"); + }); + let sink = RecordingSink::default(); + let err = messages(MessagesRequest { + model: "claude", + body: json!({"model": "claude", "max_tokens": 8, "messages": []}), + api_key: Some("request-secret"), + api_base: Some(&format!("http://{addr}")), + custom_llm_provider: Some("anthropic"), + extra_headers: None, + timeout: Some(Duration::from_secs(5)), + litellm_call_id: Some("wrong-shape"), + logging_sink: Some(Arc::new(sink.clone())), + }) + .await + .expect_err("wrong response shape errors"); + assert!(matches!(err, CoreError::InvalidResponse(_))); + + server.await.expect("server task"); + let events = sink.0.lock().expect("recording lock"); + assert!(matches!(events[0], LogEvent::Request(_))); + assert!(matches!(events[1], LogEvent::Response(_))); + assert!(matches!(events[2], LogEvent::Error(_))); + assert_eq!(events.len(), 3); +} + #[tokio::test] async fn messages_debug_http_failure_emits_one_error_and_none_emits_nothing() { let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");