From c2176c786e0fc1fed274ade46bbf901fa33c34a3 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 10 Sep 2026 13:12:04 -0700 Subject: [PATCH] fix(router): redact cookies and preserve legacy classifier logs --- .../sensitive_data_masker.py | 1 + .../test_classifier_logging.py | 13 +++++ .../test_sensitive_data_masker.py | 9 +++- ui/litellm-dashboard/eslint-suppressions.json | 5 -- ... => LogDetailContent.integration.test.tsx} | 51 +++++++++++++++++++ .../LogDetailsDrawer/LogDetailContent.tsx | 15 +++--- 6 files changed, 80 insertions(+), 14 deletions(-) rename ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/{LogDetailContent.test.tsx => LogDetailContent.integration.test.tsx} (89%) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 22dd4170963..b4c1beea33e 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -15,6 +15,7 @@ _DEFAULT_SENSITIVE_PATTERNS: Final = frozenset( "token", "auth", "authorization", + "cookie", "credential", # Plural form: Vertex uses ``vertex_credentials``; segment-exact # matching otherwise misses it because "credential" != "credentials". diff --git a/tests/test_litellm/litellm_core_utils/test_classifier_logging.py b/tests/test_litellm/litellm_core_utils/test_classifier_logging.py index c923225c750..ccf14ff06c7 100644 --- a/tests/test_litellm/litellm_core_utils/test_classifier_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_classifier_logging.py @@ -33,6 +33,19 @@ def test_originating_snapshot_masks_nested_credentials_without_altering_source() assert body["metadata"]["nested"][0]["Authorization"] == "Bearer secret" +@pytest.mark.parametrize("header", ["Cookie", "cookie", "COOKIE", "sEt-CoOkIe"]) +def test_originating_snapshot_redacts_cookie_headers_shared_with_caller_metadata(header: str) -> None: + headers: Final = {header: "session=synthetic-session-credential", "content-type": "application/json"} + body: Final = {"messages": [{"role": "user", "content": "hello"}], "metadata": {"headers": headers}} + snapshot: Final = masked_originating_request({"proxy_server_request": {"body": body, "headers": headers}}) + assert snapshot == { + "messages": [{"role": "user", "content": "hello"}], + "metadata": {"headers": {header: "REDACTED", "content-type": "application/json"}}, + } + assert headers[header] == "session=synthetic-session-credential" + assert body["metadata"]["headers"][header] == "session=synthetic-session-credential" + + @pytest.mark.parametrize("value", [None, "not-json", [], {"messages": object()}]) def test_invalid_provider_payload_is_not_reported_as_captured(value: object) -> None: assert classifier_input_snapshot(value) is None diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index c8b6772d965..1551c3fd6e6 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -358,7 +358,12 @@ def test_redact_credentials_in_payload_leaves_no_fragment_of_the_secret(): "azure_ad_token": fake_token, "aws_secret_access_key": "fake-aws-secret-0000", "vertex_credentials": {"private_key": "fake-pem"}, - "extra_headers": {"Authorization": "Bearer fake-bearer-0000", "x-request-id": "abc123"}, + "extra_headers": { + "Authorization": "Bearer fake-bearer-0000", + "Cookie": "session=fake-session", + "Set-Cookie": "session=fake-session; HttpOnly", + "x-request-id": "abc123", + }, "model": "gpt-4o-mini", "max_tokens": 17, "temperature": 0.25, @@ -373,6 +378,8 @@ def test_redact_credentials_in_payload_leaves_no_fragment_of_the_secret(): assert "fake-bearer-0000" not in str(result) assert result["api_key"] == "REDACTED" assert result["extra_headers"]["Authorization"] == "REDACTED" + assert result["extra_headers"]["Cookie"] == "REDACTED" + assert result["extra_headers"]["Set-Cookie"] == "REDACTED" assert result["extra_headers"]["x-request-id"] == "abc123" assert result["model"] == "gpt-4o-mini" assert result["max_tokens"] == 17 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 76ac60a6453..e2a08a40bcb 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2325,11 +2325,6 @@ "count": 4 } }, - "src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx": { - "no-nested-ternary": { - "count": 3 - } - }, "src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": { "no-nested-ternary": { "count": 2 diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx similarity index 89% rename from ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx rename to ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx index e0d6a1d41e0..d538b16d6a2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx @@ -272,6 +272,57 @@ describe("LogDetailContent", () => { expect(screen.queryByRole("tab", { name: "Request" })).not.toBeInTheDocument(); }); + it.each(["object", "serialized", "messages only", "null captures"])( + "preserves request inspection and copying for classifier logs with %s data", + async (shape) => { + const user = userEvent.setup(); + const messages = [{ role: "user", content: "legacy classifier prompt" }]; + const request = { + messages, + temperature: 0.5, + ...(shape === "null captures" ? { classifier_input: null, originating_request_masked: null } : {}), + }; + const storedRequest = shape === "serialized" ? JSON.stringify(request) : request; + const logEntry: Partial = { + call_type: "acompletion", + messages, + proxy_server_request: shape === "messages only" ? undefined : storedRequest, + metadata: { status: "success", internal_call_origin: "autorouter_classifier" }, + }; + render(); + + expect(screen.getByText("legacy classifier prompt")).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Classifier input" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("tab", { name: "JSON", exact: true })); + await user.click(screen.getByRole("button", { name: "Copy JSON", exact: true })); + expect(await navigator.clipboard.readText()).toBe( + JSON.stringify(shape === "messages only" ? messages : request, null, 2), + ); + }, + ); + + it.each(["classifier_input", "originating_request_masked"])( + "shows partial classifier audits when only %s is captured", + (field) => { + render( + , + ); + + expect(screen.getByRole("region", { name: "Classifier input" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Originating request, credentials masked" })).toBeInTheDocument(); + expect(screen.getByText("Not captured or message logging disabled")).toBeInTheDocument(); + expect(screen.queryByText("Request & Response")).not.toBeInTheDocument(); + }, + ); + it("should display Request and Response tabs when JSON view is selected", async () => { const user = userEvent.setup(); render(); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index e29f2810926..69be5425ea1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -73,6 +73,9 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok const isClassifier = metadata.internal_call_origin === AUTOROUTER_CLASSIFIER_ORIGIN && (logEntry.call_type === "completion" || logEntry.call_type === "acompletion"); + const rawRequest = formatData(logEntry.proxy_server_request || logEntry.messages); + const hasClassifierAudit = + isClassifier && (rawRequest?.classifier_input != null || rawRequest?.originating_request_masked != null); const hasMessages = checkHasMessages(logEntry.messages); const hasResponse = checkHasResponse(logEntry.response); @@ -93,10 +96,6 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok // Vector store data const hasVectorStoreData = checkHasVectorStoreData(metadata); - const getRawRequest = () => { - return formatData(logEntry.proxy_server_request || logEntry.messages); - }; - const getFormattedResponse = () => { if (hasError && errorInfo) { return { @@ -202,14 +201,14 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok ) : null} - {!isLoadingDetails && isClassifier && ( - + {!isLoadingDetails && hasClassifierAudit && ( + )} - {!isLoadingDetails && !isClassifier && ( + {!isLoadingDetails && !hasClassifierAudit && ( rawRequest} getFormattedResponse={getFormattedResponse} logEntry={logEntry} />