mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
parent
97b7fbfc3f
commit
32425e7f9a
8 changed files with 508 additions and 10 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
6
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
6
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue