From 32425e7f9a64b495853f8a342585d8739a32c8f1 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Thu, 12 Nov 2026 10:45:06 -0800 Subject: [PATCH] =?UTF-8?q?fix(spend-logs):=20mask=20token-shaped=20secret?= =?UTF-8?q?s=20in=20stored=20request=20and=20resp=E2=80=A6=20(#48968)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(spend-logs): mask token-shaped secrets in stored request and response content Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(spend-logs): mask Linear API keys in spend logs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(spend): assert stored spend row masks a github token inside tool output Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(spend-logs): gate token redaction behind redact_tokens_in_spend_logs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(spend-logs): redact tokens by default with explicit opt-out Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(spend-logs): bound token scanning and offload large payloads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Krrish Dholakia Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Moe Khalil --- .../litellm_core_utils/secret_redaction.py | 55 ++++- litellm/proxy/_types.py | 4 + litellm/proxy/db/db_spend_update_writer.py | 6 +- .../spend_tracking/spend_tracking_utils.py | 61 ++++- .../src/lib/http/schema.d.ts | 6 + .../test_spend_log_tool_payload_content.py | 65 +++++ .../test_spend_tracking_utils.py | 225 ++++++++++++++++++ tests/unit/test_secret_redaction.py | 96 ++++++++ 8 files changed, 508 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/secret_redaction.py b/litellm/litellm_core_utils/secret_redaction.py index 2b782f0a4b9..f4648fafb47 100644 --- a/litellm/litellm_core_utils/secret_redaction.py +++ b/litellm/litellm_core_utils/secret_redaction.py @@ -14,11 +14,13 @@ from litellm.rust_bridge import diagnostics REDACTED: Final = "REDACTED" +_PEM_PRIVATE_KEY_BLOCK: Final = r"-----BEGIN[A-Z \-]*PRIVATE KEY-----[\s\S]*?-----END[A-Z \-]*PRIVATE KEY-----" + def _build_secret_patterns() -> "re.Pattern[str]": patterns: Final[list[str]] = [ # PEM private key / certificate blocks - r"-----BEGIN[A-Z \-]*PRIVATE KEY-----[\s\S]*?-----END[A-Z \-]*PRIVATE KEY-----", + _PEM_PRIVATE_KEY_BLOCK, # GCP OAuth2 access tokens (ya29.*) r"\bya29\.[A-Za-z0-9_.~+/-]+", # Credential %s formatting (space separator, no key= prefix) @@ -100,6 +102,57 @@ def redact_string(value: str) -> str: return diagnostics.run(lambda native: native.redact_text(value), lambda: python_redact_string(value)) +_TOKEN_SHAPES: Final = ( + r"ya29\.[A-Za-z0-9_.~+/-]{20,}", + r"(?:AKIA|ASIA)[0-9A-Z]{16}\b", + rf"sk-[A-Za-z0-9\-_]{{{MINIMUM_CUSTOM_KEY_LENGTH - len('sk-')},}}", + r"(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}", + r"AIza[0-9A-Za-z\-_]{35}", + r"dapi[0-9a-f]{32}\b", + r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}", + r"github_pat_[A-Za-z0-9_]{82}", + r"glpat-[A-Za-z0-9\-_]{20,}", + r"lin_api_[A-Za-z0-9]{40}\b", + r"xox[abposr]-[A-Za-z0-9-]{10,}", + r"hf_[A-Za-z0-9]{34,}", + r"npm_[A-Za-z0-9]{36}\b", + r"pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\-_]{50,}", + r"SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])", + r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", +) +# Tokens start at a word boundary or right after a JSON escape like "\n", since tool output is often serialized JSON +_TOKEN_START: Final = r"(?:(?eyJ(?:(?!-----BEGIN|{_DOTTED_TOKEN})[A-Za-z0-9_-])*" + rf"(?:{_DOTTED_TOKEN})?[^A-Za-z0-9_-]?)" +) +_TOKEN_RE: Final = re.compile(f"{_NON_JWT_TOKEN_RE.pattern}|{_TOKEN_START}(?:{_TOKEN_SHAPES[-1]}|{_UNMATCHED_JWT})") + + +def redact_tokens(value: str, replacement: str = REDACTED) -> str: + """Replace substrings shaped like a credential (provider key prefixes, JWTs, PEM private keys). + + Unlike redact_string() this never matches on surrounding words such as ``password:`` or ``Basic``, + so it is safe to run over free-form prompt and tool content. + """ + + def replace(match: re.Match[str]) -> str: + if match.lastgroup == "unmatched_jwt": + return _NON_JWT_TOKEN_RE.sub(replacement, match.group()) + return match.expand(replacement) + + return _TOKEN_RE.sub(replace, value) + + _UNIX_SYSTEM_PATH: Final = r"/(?:etc|var|opt|usr|home|root|private|Users|tmp|mnt|srv)/[^\s'\"\)\]}>,]+" _WINDOWS_DRIVE_PATH: Final = r"[A-Za-z]:\\[^\s'\"\)\]}>,]+" _PRIVATE_OR_LOOPBACK_IPV4: Final = ( diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d703c378ce8..d4b6579523c 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2938,6 +2938,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="If True, stores request messages and responses in spend logs. Default is False.", ) + redact_tokens_in_spend_logs: bool = Field( + True, + description="Masks token-shaped secrets in stored spend log request and response content by default. Set False to opt out.", + ) spend_logs_prompt_storage: Literal["latest_turn"] | None = Field( None, description="With store_prompts_in_spend_logs on, 'latest_turn' stores only the current turn of a stateless request (/chat/completions, /v1/messages, and /v1/responses without previous_response_id): the model and the latest user message, with the system prompt, tool definitions and earlier turns dropped. Requests that continue a stored conversation and the response are stored as they are today. Unset stores the full request.", diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 5a9a52447b7..6eb7b6373f7 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -792,13 +792,13 @@ class DBSpendUpdateWriter: ## CREATE SPEND LOG PAYLOAD ## from litellm.proxy.spend_tracking.spend_tracking_utils import ( - get_logging_payload, + get_logging_payload_async, get_request_model_access_groups, ) llm_router: Final = get_llm_router() - payload: Final = get_logging_payload( - kwargs=kwargs, + payload: Final = await get_logging_payload_async( + kwargs=_SPEND_METADATA_ADAPTER.validate_python(kwargs) if kwargs is not None else None, response_obj=completion_response, start_time=start_time, end_time=end_time, diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index f431763c049..8cbc7d5fb16 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -1,12 +1,13 @@ +import asyncio import ipaddress import json import os import re import secrets -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from datetime import datetime, timezone from datetime import datetime as dt -from functools import reduce +from functools import partial, reduce from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Protocol, cast, runtime_checkable @@ -72,6 +73,7 @@ from litellm.litellm_core_utils.litellm_logging import ( from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field, parts_of from litellm.litellm_core_utils.ptu_pricing import azure_spillover from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes +from litellm.litellm_core_utils.secret_redaction import redact_tokens from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.llms.anthropic.common_utils import resolve_used_client_oauth_token from litellm.proxy._types import ( @@ -609,6 +611,45 @@ def _scrub_raw_model_from_error_information( ) +def _spend_log_worker_kwargs(kwargs: Mapping[str, object]) -> Mapping[str, object]: + params: Final = as_str_mapping(kwargs.get("litellm_params")) + if params is None: + return kwargs + metadata: Final = as_str_mapping(params.get("litellm_metadata")) + return { + **kwargs, + "litellm_params": { + **params, + **({"litellm_metadata": dict(metadata)} if metadata is not None else {}), + }, + } + + +async def get_logging_payload_async( + kwargs: Mapping[str, object] | None, + response_obj: object, + start_time: datetime, + end_time: datetime, + llm_router: "Router | None" = None, + run_in_thread: Callable[[Callable[[], SpendLogsPayload]], Awaitable[SpendLogsPayload]] = asyncio.to_thread, +) -> SpendLogsPayload: + from litellm.proxy.proxy_server import general_settings_view + + call_kwargs: Final[Mapping[str, object]] = kwargs if kwargs is not None else MappingProxyType({}) + offload: Final = general_settings_view().get("redact_tokens_in_spend_logs") is not False + build: Final = partial( + get_logging_payload, + kwargs=dict(_spend_log_worker_kwargs(call_kwargs)) if offload else dict(call_kwargs), + response_obj=response_obj, + start_time=start_time, + end_time=end_time, + llm_router=llm_router, + ) + if offload: + return await run_in_thread(build) + return build() + + def get_logging_payload( kwargs: dict | None, response_obj: object, @@ -1286,7 +1327,7 @@ def _get_messages_for_spend_logs_payload( messages: Final = standard_logging_payload.get("messages") if messages is not None: try: - return safe_dumps(messages, non_finite_as_null=True) + return _without_tokens(safe_dumps(messages, non_finite_as_null=True)) except Exception: return "{}" return "{}" @@ -1459,6 +1500,14 @@ def _is_spend_log_content(parent: Mapping[str, object], key: str) -> bool: ) +def _without_tokens(value: str) -> str: + from litellm.proxy.proxy_server import general_settings_view + + if general_settings_view().get("redact_tokens_in_spend_logs") is False: + return value + return redact_tokens(strip_null_bytes(value), REDACTED_BY_LITELM_STRING) + + def _truncated_for_db(value: str, max_length: int) -> str: if len(value) <= max_length: return value @@ -1474,7 +1523,7 @@ def _truncated_for_db(value: str, max_length: int) -> str: def _compaction_summary_for_db(summary: str) -> str: return _truncated_for_db( - summary, max(_get_max_string_length_prompt_in_db(), MAX_STRING_LENGTH_COMPACTION_SUMMARY_IN_DB) + _without_tokens(summary), max(_get_max_string_length_prompt_in_db(), MAX_STRING_LENGTH_COMPACTION_SUMMARY_IN_DB) ) @@ -1503,7 +1552,7 @@ def _sanitize_request_body_for_spend_logs_payload( At every nesting level, also strips keys listed in _SENSITIVE_REQUEST_BODY_KEYS (e.g. secret_fields, which holds raw HTTP headers including Authorization tokens), and replaces string values under keys SensitiveDataMasker classifies as credentials with REDACTED_BY_LITELM_STRING, except inside tool payloads - and logprobs. + and logprobs. Token-shaped substrings (e.g. a GitHub token in a tool result) are masked everywhere. """ if visited is None: visited = set() @@ -1524,7 +1573,7 @@ def _sanitize_request_body_for_spend_logs_payload( elif isinstance(value, list): return [_sanitize_value(item, mask_credentials) for item in value] elif isinstance(value, str): - return _truncated_for_db(value, max_string_length_prompt_in_db) + return _truncated_for_db(_without_tokens(value), max_string_length_prompt_in_db) return value def _stored_value(key: str, value: object) -> object: diff --git a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3053ec54592..f2048ca3ba9 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -31753,6 +31753,12 @@ export interface components { * @default 30 */ proxy_config_reload_interval_seconds: number; + /** + * Redact Tokens In Spend Logs + * @description Masks token-shaped secrets in stored spend log request and response content by default. Set False to opt out. + * @default true + */ + redact_tokens_in_spend_logs: boolean; /** * Reject Clientside Metadata Tags * @description When set to True, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata. diff --git a/tests/integration/spend/test_spend_log_tool_payload_content.py b/tests/integration/spend/test_spend_log_tool_payload_content.py index 226accdbf54..d4f124172c0 100644 --- a/tests/integration/spend/test_spend_log_tool_payload_content.py +++ b/tests/integration/spend/test_spend_log_tool_payload_content.py @@ -481,6 +481,71 @@ def test_stored_messages_keep_tool_use_input(gateway: Gateway, tmp_path: Path) - assert _json_object(observed[0].body)["messages"][1]["content"][0]["input"] == tool_input +def test_stored_messages_mask_a_github_token_inside_tool_output(gateway: Gateway, tmp_path: Path) -> None: + github_token: Final = f"ghp_{uuid4().hex}Ab12" + tool_output: Final = json.dumps( + {"stdout": f"Cloning into 'r'...\nremote: https://x-access-token:{github_token}@github.com/o/r.git\ndone"} + ) + clone_command: Final = f"git push https://{github_token}@github.com/o/r HEAD" + response_body: Final = { + "id": f"msg-github-token-{uuid4()}", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "tool_use", "id": "toolu_02", "name": "bash", "input": {"cmd": clone_command}}], + "stop_reason": "tool_use", + "stop_sequence": None, + "usage": {"input_tokens": 8, "output_tokens": 4}, + } + + def respond(request: Request) -> Reply: + assert request.method == "POST" + return Reply(body=json.dumps(response_body).encode()) + + with ( + wire_server(_answering_model_listing(respond)) as wire, + owned_proxy(gateway, tmp_path, {}, config=_prompt_storage_config(tmp_path)) as isolated, + isolated.scenario() as scenario, + ): + model: Final = scenario.model(model=ANTHROPIC_MODEL, api_base=wire.url, api_key="synthetic-anthropic-key") + response: Final = isolated.request( + "POST", + "/v1/messages", + { + "model": model, + "max_tokens": 64, + "messages": [ + {"role": "user", "content": "Clone the repo."}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_01", "name": "bash", "input": {"cmd": "git clone"}} + ], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_output}], + }, + ], + }, + ) + assert response.status_code == 200, response.text + caller_response: Final = _json_object(response.content) + assert _objects(caller_response["content"])[0]["input"] == {"cmd": clone_command} + observed: Final = _provider_calls(wire.drain()) + assert len(observed) == 1 + assert _json_object(observed[0].body)["messages"][2]["content"][0]["content"] == tool_output + row: Final = _stored_row(string_value(caller_response["id"])) + stored_request: Final = object_value(row["proxy_server_request"]) + stored_response: Final = object_value(row["response"]) + assert github_token not in json.dumps(row) + assert json.loads(string_value(stored_request["messages"][2]["content"][0]["content"])) == { + "stdout": f"Cloning into 'r'...\nremote: https://x-access-token:{REDACTED}@github.com/o/r.git\ndone" + } + stored_arguments: Final = stored_response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] + assert json.loads(string_value(stored_arguments)) == {"cmd": f"git push https://{REDACTED}@github.com/o/r HEAD"} + + def test_previous_response_id_replay_sends_real_tool_payloads(gateway: Gateway, tmp_path: Path) -> None: function_arguments: Final = {"sort_key": "created_at", "access_level": "admin"} function_output: Final = { diff --git a/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py index af8bc254e2c..338f3086e6c 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py @@ -1,7 +1,9 @@ import asyncio import datetime import json +import threading from collections.abc import Callable, Mapping +from copy import deepcopy from datetime import timezone from types import MappingProxyType from typing import Any, Final, cast @@ -60,6 +62,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import ( _scrub_raw_model_from_error_information, configured_spend_logs_metadata_fields, get_logging_payload, + get_logging_payload_async, get_spend_logs_id, _stored_request_without_requester_ip_address, requester_ip_address_logging_disabled, @@ -1612,6 +1615,228 @@ def test_get_response_for_spend_logs_payload_strips_null_bytes(mock_should_store assert json.loads(response_json)["content"] == "answerhere" +_GITHUB_TOKEN: Final = "ghp_" + "a1B2c3D4e5" * 3 + "F6g7H8" + + +@pytest.mark.parametrize("prefix,serialized", (("\u2022", True), ("\u00e9", True), ("\U0001f600", True), ("\u2022", False))) +def test_payload_masks_tokens_after_json_unicode_escapes( + monkeypatch: pytest.MonkeyPatch, prefix: str, serialized: bool +) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True}) + raw: Final = prefix + "ghp_" + "A" * 36 + content: Final = json.dumps({"stdout": raw}) if serialized else raw + messages: Final = [{"role": "tool", "content": content}] + kwargs: Final[dict[str, object]] = { + "model": "gpt-4o-mini", + "litellm_params": {"proxy_server_request": {"body": {"messages": messages}}, "metadata": {}}, + "standard_logging_object": { + "call_type": "_arealtime", + "model_map_information": None, + "metadata": {}, + "messages": messages, + "response": {"content": content}, + }, + } + now: Final = datetime.datetime(2026, 1, 1, tzinfo=timezone.utc) + payload: Final = get_logging_payload(kwargs, litellm.ModelResponse(choices=[]), now, now) + masked_text: Final = prefix + "REDACTED_BY_LITELM" + masked: Final = json.dumps({"stdout": masked_text}) if serialized else masked_text + + assert json.loads(payload["proxy_server_request"])["messages"] == [{"role": "tool", "content": masked}] + assert json.loads(payload["messages"]) == [{"role": "tool", "content": masked}] + assert json.loads(payload["response"]) == {"content": masked} + assert messages == [{"role": "tool", "content": content}] + + +def test_payload_masks_tokens_joined_by_null_byte_removal(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True}) + secret: Final = "ghp_\x00" + "A" * 36 + messages: Final = [{"role": "tool", "content": secret}] + compaction: Final = {"type": "compaction", "content": secret} + kwargs: Final[dict[str, object]] = { + "model": "gpt-4o-mini", + "litellm_params": { + "proxy_server_request": {"body": {"messages": messages, "input": [compaction]}}, + "metadata": {}, + }, + "standard_logging_object": { + "call_type": "_arealtime", + "model_map_information": None, + "metadata": {}, + "messages": messages, + "response": {"content": secret, "output": [compaction]}, + }, + } + now: Final = datetime.datetime(2026, 1, 1, tzinfo=timezone.utc) + payload: Final = get_logging_payload(kwargs, litellm.ModelResponse(choices=[]), now, now) + masked_messages: Final = [{"role": "tool", "content": "REDACTED_BY_LITELM"}] + masked_compaction: Final = {"type": "compaction", "content": "REDACTED_BY_LITELM"} + + assert json.loads(payload["proxy_server_request"]) == {"messages": masked_messages, "input": [masked_compaction]} + assert json.loads(payload["messages"]) == masked_messages + assert json.loads(payload["response"]) == {"content": "REDACTED_BY_LITELM", "output": [masked_compaction]} + assert messages == [{"role": "tool", "content": secret}] + assert compaction == {"type": "compaction", "content": secret} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("redact", (None, False, True)) +async def test_token_redaction_thread_and_inline_payloads_match( + monkeypatch: pytest.MonkeyPatch, redact: bool | None +) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr( + proxy_server, + "general_settings", + {"store_prompts_in_spend_logs": True, **({"redact_tokens_in_spend_logs": redact} if redact is not None else {})}, + ) + loop_thread: Final = threading.get_ident() + monkeypatch.setattr(litellm, "cache", litellm.Cache(type="local")) + worker_threads: Final[list[int]] = [] + text: Final = f"TOKEN={_GITHUB_TOKEN} ordinary tool output" + body: Final = {"messages": [{"role": "tool", "content": text}]} + kwargs: Final[dict[str, object]] = { + "model": "gpt-4o-mini", + "litellm_trace_id": "trace-1", + "litellm_params": { + "proxy_server_request": {"body": body}, + "metadata": {"user_api_key_alias": "key-alias"}, + "litellm_metadata": {"session_id": "session-1"}, + }, + } + original: Final = deepcopy(kwargs) + response: Final = litellm.ModelResponse(id="chatcmpl-thread-test", choices=[]) + now: Final = datetime.datetime(2026, 1, 1, tzinfo=timezone.utc) + + async def run_in_thread(build: Callable[[], SpendLogsPayload]) -> SpendLogsPayload: + thread_id, payload = await asyncio.to_thread(lambda: (threading.get_ident(), build())) + worker_threads.append(thread_id) + return payload + + payload: Final = await get_logging_payload_async(kwargs, response, now, now, run_in_thread=run_in_thread) + expected: Final = get_logging_payload(deepcopy(original), response, now, now) + + assert payload == expected + assert redact is False or kwargs == original + assert (len(worker_threads) == 1) is (redact is not False) + assert all(thread_id != loop_thread for thread_id in worker_threads) + stored: Final = json.loads(payload["proxy_server_request"])["messages"][0]["content"] + assert stored == ("TOKEN=REDACTED_BY_LITELM ordinary tool output" if redact is not False else text) + assert body["messages"][0]["content"] == text + + +_TOKEN_SHAPED_SECRETS: Final = ( + _GITHUB_TOKEN, + "github_pat_" + "A1b2C3d4E5" * 8 + "Z9", + "sk-ant-api03-" + "Qw3rTy" * 7, + "AKIAIOSFODNN7EXAMPLE", + "xoxb-1234567890-0987654321-AbCdEfGhIjKl", + "glpat-AbCdEfGhIj1234567890", + "lin_api_" + "AbCdEfGhIj" * 4, + "hf_" + "AbCdEfGhIj" * 3 + "KlMn", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----", +) + + +@pytest.mark.parametrize("secret", _TOKEN_SHAPED_SECRETS) +def test_stored_request_masks_a_token_returned_in_an_anthropic_tool_result( + monkeypatch: pytest.MonkeyPatch, secret: str +) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True}) + tool_output: Final = json.dumps({"stdout": f"export TOKEN={secret}\ndone"}) + request_body: Final = { + "model": "claude-sonnet-5-5", + "messages": [ + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": tool_output}]} + ], + } + + stored: Final = json.dumps(_sanitize_request_body_for_spend_logs_payload(request_body)) + + assert secret not in stored + assert secret[:12] not in stored + assert "export TOKEN=REDACTED_BY_LITELM" in json.loads(json.loads(stored)["messages"][0]["content"][0]["content"])[ + "stdout" + ] + + +def test_stored_response_masks_a_token_in_a_tool_call_argument(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr( + proxy_server, "general_settings", {"store_prompts_in_spend_logs": True, "redact_tokens_in_spend_logs": True} + ) + payload: Final = cast( + StandardLoggingPayload, + { + "response": { + "content": [ + {"type": "text", "text": "Cloning now"}, + {"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {"cmd": f"git clone https://{_GITHUB_TOKEN}@github.com/o/r"}}, + ] + } + }, + ) + + stored: Final = json.loads(_get_response_for_spend_logs_payload(payload)) + + assert stored["content"][0]["text"] == "Cloning now" + assert stored["content"][1]["input"]["cmd"] == "git clone https://REDACTED_BY_LITELM@github.com/o/r" + + +def test_stored_request_masks_a_token_kept_in_the_head_of_a_truncated_string(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {}) + long_text: Final = f"{_GITHUB_TOKEN} " + "x" * 10_000 + + stored: Final = _sanitize_request_body_for_spend_logs_payload({"text": long_text}, max_string_length_prompt_in_db=200) + + assert stored["text"].startswith("REDACTED_BY_LITELM x") + assert LITELLM_TRUNCATED_PAYLOAD_FIELD in stored["text"] + + +@pytest.mark.parametrize( + "text", + ( + "Use basic auth with password: hunter2 and a Bearer header", + "The task-management-system and risk-assessment-framework docs", + "See https://github.com/BerriAI/litellm and ghp_short", + "A" * 5000, + ), +) +def test_stored_request_keeps_text_that_only_resembles_credentials(monkeypatch: pytest.MonkeyPatch, text: str) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "general_settings", {}) + stored: Final = _sanitize_request_body_for_spend_logs_payload({"text": text}, max_string_length_prompt_in_db=10_000) + + assert stored["text"] == text + + +def test_stored_request_preserves_a_token_in_a_tool_result_without_redaction(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server + + monkeypatch.setattr( + proxy_server, "general_settings", {"store_prompts_in_spend_logs": True, "redact_tokens_in_spend_logs": False} + ) + request_body: Final = { + "messages": [{"role": "tool", "tool_call_id": "call_1", "content": f"TOKEN={_GITHUB_TOKEN}\ndone"}] + } + + stored: Final = _sanitize_request_body_for_spend_logs_payload(request_body) + + assert stored == request_body + + @patch("litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs") def test_get_response_for_spend_logs_payload_truncates_large_embedding( mock_should_store, diff --git a/tests/unit/test_secret_redaction.py b/tests/unit/test_secret_redaction.py index cce9c6c558d..b135fad78fb 100644 --- a/tests/unit/test_secret_redaction.py +++ b/tests/unit/test_secret_redaction.py @@ -22,11 +22,106 @@ from litellm._logging import ( from litellm.litellm_core_utils.secret_redaction import ( redact_internal_details, redact_structured_value, + redact_tokens, ) SECRET = "sk-proj-abc123def456ghi789jklmnopqrst" +@pytest.mark.timeout(10) +@pytest.mark.parametrize("header", ("-----BEGIN PRIVATE KEY-----\n", "-----BEGIN PRIVATE KEY----- ", "-----BEGIN A")) +def test_token_redaction_preserves_many_unterminated_pem_blocks(header: str) -> None: + text: Final = header * 24_000 + assert redact_tokens(text) == text + + +@pytest.mark.timeout(10) +def test_token_redaction_preserves_many_failed_jwt_starts_and_finds_an_inner_key() -> None: + text: Final = "eyJaaaaaaaaaa-" * 24_000 + assert redact_tokens(text + "sk-" + "a" * 20) == text + "REDACTED" + + +@pytest.mark.parametrize( + "token", + ( + "ya29." + "a" * 20, + "AKIA" + "A" * 16, + "sk-" + "a" * 50_000, + "sk_live_" + "a" * 24, + "rk_test_" + "a" * 24, + "AIza" + "a" * 35, + "dapi" + "a" * 32, + "ghp_" + "a" * 36, + "github_pat_" + "a" * 82, + "glpat-" + "a" * 20, + "lin_api_" + "a" * 40, + "xoxb-" + "a" * 10, + "hf_" + "a" * 34, + "npm_" + "a" * 36, + "pypi-AgEIcHlwaS5vcmc" + "a" * 50, + "SG." + "a" * 22 + "." + "a" * 43, + "eyJ" + "a" * 50_000 + ".eyJ" + "b" * 50_000 + ".c", + "-----BEGIN " + "A" * 50_000 + " PRIVATE KEY-----\nbody\n-----END PRIVATE KEY-----", + ), +) +def test_token_redaction_masks_known_token_shapes(token: str) -> None: + assert redact_tokens("credential=" + token + " !") == "credential=REDACTED !" + + +@pytest.mark.parametrize( + "text,expected", + ( + ("", ""), + ("password: ordinary text", "password: ordinary text"), + ("sk-short", "sk-short"), + ("eyJ" + "a" * 12 + ".e30.fake_signature", "REDACTED"), + ("eyJ" + "a" * 12 + ".x.fake_signature", "REDACTED"), + ("not_a_json_header.e30.fake_signature", "not_a_json_header.e30.fake_signature"), + ("eyJ" + "a" * 12 + "..fake_signature", "eyJ" + "a" * 12 + "..fake_signature"), + ("SG." + "a" * 22 + "." + "b" * 42 + "- ", "REDACTED "), + ("SG." + "a" * 22 + "." + "b" * 42 + "_ ", "REDACTED "), + ("SG." + "a" * 22 + "." + "b" * 43 + "-x", "SG." + "a" * 22 + "." + "b" * 43 + "-x"), + ("eyJ" + "a" * 12 + "-SG." + "a" * 22 + "." + "b" * 43, "REDACTED"), + ("wordghp_" + "a" * 36, "wordghp_" + "a" * 36), + ("éghp_" + "a" * 36, "éghp_" + "a" * 36), + (r"\nghp_" + "a" * 36, r"\nREDACTED"), + ("AKIA" + "A" * 17, "AKIA" + "A" * 17), + ("-----BEGIN PRIVATE KEY-----\nmissing end", "-----BEGIN PRIVATE KEY-----\nmissing end"), + ("-----BEGIN PRIVATE KEY----- -----END PRIVATE KEY-----", "REDACTED"), + ("-----BEGIN PRIVATE KEY-----\n-----END RSA PRIVATE KEY-----", "REDACTED"), + ( + "-----BEGIN PRIVATE KEY-----\n-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----\nREDACTED", + ), + ("-----BEGIN BAD-----\n-----END PRIVATE KEY-----", "-----BEGIN BAD-----\n-----END PRIVATE KEY-----"), + ), +) +def test_token_redaction_preserves_matching_rules(text: str, expected: str) -> None: + assert redact_tokens(text) == expected + + +@pytest.mark.parametrize( + "content,expected", + ( + ("-----BEGIN RSA PRIVATE KEY-----\nbody\n-----END RSA PRIVATE KEY-----", "REDACTED"), + ("ya29." + "a" * 20, "REDACTED"), + ("SG." + "a" * 22 + "." + "b" * 42 + "-", "REDACTED"), + ("AKIA" + "A" * 16 + "é", "AKIA" + "A" * 16 + "é"), + ( + "SG." + "a" * 22 + "." + "b" * 43 + "!eyJ" + "a" * 12 + ".eyJ" + "b" * 12 + ".c", + "REDACTED!REDACTED", + ), + ( + "sk-" + "a" * 20, + "REDACTED", + ), + ), +) +def test_failed_jwt_header_does_not_hide_other_tokens(content: str, expected: str) -> None: + prefix: Final = "eyJa-" + assert redact_tokens(prefix + content) == prefix + expected + + @pytest.fixture(autouse=True) def _enable_redaction(): """Ensure secret redaction is on (the default) for all tests in this module.""" @@ -473,6 +568,7 @@ def test_vertex_error_message_no_credential_leak(): VertexCredentialsInlineNotJson, raise_vertex_credentials_failure, ) + path = "/etc/litellm/vertexai.json" failures = ( VertexCredentialsFileUnreadable(path, "No such file or directory (FileNotFoundError)"),