fix(rust): harden provider debug redaction

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-07-31 04:27:14 +00:00
parent d5753f2d90
commit 45ec1638b7
4 changed files with 113 additions and 43 deletions

View file

@ -45,14 +45,6 @@ The folder shape follows the Python provider tree:
`core/src/providers/<provider>/<route>/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

View file

@ -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;

View file

@ -33,16 +33,7 @@ pub fn redact_headers(headers: &[(String, String)]) -> BTreeMap<String, String>
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::<String>();
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]

View file

@ -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");