fix(spend-logs): mask token-shaped secrets in stored request and resp… (#48968)

* 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 <krrishdholakia@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Moe Khalil <moe@berri.ai>
This commit is contained in:
Krrish Dholakia 2026-11-12 10:45:06 -08:00 • committed by GitHub
parent 97b7fbfc3f
commit 32425e7f9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 508 additions and 10 deletions

View file

@ -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"(?:(?<!\w)|(?<=\\[nrt])|(?<=\\u[0-9A-Fa-f]{4}))"
_TOKEN_PEM_PRIVATE_KEY_BLOCK: Final = (
r"-----BEGIN(?:[A-Z ]|-(?!----))*?PRIVATE KEY-----"
r"(?:[^-]|-(?!----))*?-----END(?:[A-Z ]|-(?!----))*?PRIVATE KEY-----"
)
_NON_JWT_TOKEN_RE: Final = re.compile(
f"{_TOKEN_PEM_PRIVATE_KEY_BLOCK}|{_TOKEN_START}(?:{'|'.join(_TOKEN_SHAPES[:-1])})"
)
_DOTTED_TOKEN: Final = f"{_TOKEN_START}(?:{_TOKEN_SHAPES[0]}|{_TOKEN_SHAPES[-2]})"
# Consume failed JWT headers once, including dotted keys and their boundary so the second pass sees them whole.
_UNMATCHED_JWT: Final = (
rf"(?P<unmatched_jwt>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 = (

View file

@ -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.",

View file

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

View file

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

View file

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

View file

@ -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 = {

View file

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

View file

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