mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(rust): harden provider debug redaction
Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
parent
d5753f2d90
commit
45ec1638b7
4 changed files with 113 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue