diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py index 287dc38a1fb..602732d5d37 100644 --- a/litellm/proxy/management_helpers/utils.py +++ b/litellm/proxy/management_helpers/utils.py @@ -436,6 +436,33 @@ async def send_management_endpoint_alert( ) +def _redact_env_var_values(response: dict) -> None: + """Blank ``env_vars[].value`` in a management response before telemetry. + + MCP create/update endpoints return decrypted ``scope="global"`` env var + values so the admin UI can pre-fill the edit form; those values are + upstream credentials and must not be serialized verbatim into OTEL spans, + where an observability user could read them. Names, scopes, and + descriptions are kept so traces stay useful. + """ + env_vars = response.get("env_vars") + if not isinstance(env_vars, list): + return + + def _redacted(entry: Any) -> dict: + get = ( + entry.get if isinstance(entry, dict) else lambda k: getattr(entry, k, None) + ) + return { + "name": get("name"), + "scope": get("scope"), + "description": get("description"), + "value": "", + } + + response["env_vars"] = [_redacted(entry) for entry in env_vars] + + async def _emit_management_endpoint_otel_span( func: Callable, kwargs: dict, @@ -497,6 +524,7 @@ async def _emit_management_endpoint_otel_span( try: raw = dict(result) _response = {k: v for k, v in raw.items() if k not in _CREDENTIAL_FIELDS} + _redact_env_var_values(_response) except Exception: _response = None diff --git a/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py b/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py index 459072cf9d3..6a4801a699b 100644 --- a/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py @@ -19,6 +19,79 @@ from litellm.proxy._types import ( from litellm.proxy.management_helpers.utils import add_new_member +@pytest.mark.asyncio +async def test_management_otel_span_redacts_mcp_global_env_var_secrets(monkeypatch): + """A decrypted MCP global env var secret must never reach telemetry. + + MCP create/update endpoints return the server with decrypted + ``scope="global"`` env var values so the admin UI can pre-fill the edit + form. ``management_endpoint_wrapper`` serializes the response into an OTEL + span, and that span is readable by observability users, so the secret value + must be blanked there while names/scopes stay for usefulness. The endpoint's + own return value must keep the decrypted value for the admin. + """ + import datetime + + from litellm.proxy._types import ( + LiteLLM_MCPServerTable, + MCPEnvVar, + MCPEnvVarScope, + ) + from litellm.proxy.management_helpers import utils as mgmt_utils + + captured = {} + + class _FakeOtelLogger: + async def async_management_endpoint_success_hook( + self, logging_payload, parent_otel_span + ): + captured["response"] = logging_payload.response + + import litellm.proxy.proxy_server as proxy_server + + monkeypatch.setattr(proxy_server, "open_telemetry_logger", _FakeOtelLogger()) + monkeypatch.setattr(mgmt_utils, "is_otel_v2_enabled", lambda: False) + + secret = "s3cr3t-p@ss" + result = LiteLLM_MCPServerTable( + server_id="srv-1", + alias="echo", + url="http://localhost:8765/mcp", + transport="http", + env_vars=[ + MCPEnvVar(name="DB_PASSWORD", value=secret, scope=MCPEnvVarScope.global_), + MCPEnvVar( + name="CORP_USER", + value="", + scope=MCPEnvVarScope.user, + description="Your DB username", + ), + ], + created_at=datetime.datetime.now(), + updated_at=datetime.datetime.now(), + ) + + await mgmt_utils._emit_management_endpoint_otel_span( + func=lambda: None, + kwargs={}, + parent_otel_span=object(), + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + result=result, + ) + + serialized = captured["response"]["env_vars"] + # The secret must not appear anywhere the span serializer would stringify. + assert secret not in str(captured["response"]) + assert all(entry["value"] == "" for entry in serialized) + # Names and scopes survive so the trace stays useful. + assert {entry["name"] for entry in serialized} == {"DB_PASSWORD", "CORP_USER"} + assert any(entry["scope"] == MCPEnvVarScope.global_ for entry in serialized) + # The endpoint's own return value is untouched: the admin still gets the + # decrypted value to pre-fill the edit form. + assert result.env_vars[0].value == secret + + @pytest.mark.asyncio async def test_add_new_member_clones_default_team_budget_id(): """