mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
fix(proxy): scope the PyNaCl-free encryption scan to MCP fields encrypted at rest
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
33834c679b
commit
f162996af3
4 changed files with 126 additions and 11 deletions
|
|
@ -714,20 +714,22 @@ async def _db_find_user_env_var_rows(
|
|||
return await _user_env_var_actions(prisma_client).find_many(where=where)
|
||||
|
||||
|
||||
MCP_CREDENTIAL_SECRET_FIELDS: Final = (
|
||||
"auth_value",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"client_private_key",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
)
|
||||
|
||||
|
||||
def decrypt_credentials(
|
||||
credentials: MCPCredentials,
|
||||
) -> MCPCredentials:
|
||||
"""Decrypt all secret fields in an MCPCredentials dict using the global salt key."""
|
||||
secret_fields: Final = [
|
||||
"auth_value",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"client_private_key",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
]
|
||||
for field in secret_fields:
|
||||
for field in MCP_CREDENTIAL_SECRET_FIELDS:
|
||||
value = credentials.get(field)
|
||||
if value is not None and isinstance(value, str):
|
||||
credentials[field] = decrypt_value_helper(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ config rows, and the SSO config table.
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast
|
||||
|
||||
|
|
@ -465,6 +466,7 @@ _COVERED_TABLE_SPECS: Final = [
|
|||
("mcp_oauth_client", "litellm_mcpserveroauthclient", ("credentials",), ()),
|
||||
("sso_identity_assertion", "litellm_ssoidentityassertion", (), ("assertion_b64",)),
|
||||
]
|
||||
_MCP_CREDENTIAL_TABLES: Final = frozenset({"litellm_mcpservertable", "litellm_mcpserveroauthclient"})
|
||||
|
||||
|
||||
def _iter_encrypted_strings(obj: object):
|
||||
|
|
@ -485,6 +487,31 @@ def _iter_encrypted_strings(obj: object):
|
|||
stack.extend(cur)
|
||||
|
||||
|
||||
def _mcp_encrypted_leaves(col: str, raw: object) -> Iterator[str]:
|
||||
"""Only the strings an MCP column encrypts at rest: a credentials blob keeps auth_type, scopes and urls in
|
||||
plaintext and env_vars keeps every name and every per user placeholder, so without PyNaCl those must not be
|
||||
mistaken for legacy ciphertext and refuse the scan"""
|
||||
from litellm.proxy._experimental.mcp_server.db import MCP_CREDENTIAL_SECRET_FIELDS, _is_global_env_var_scope
|
||||
|
||||
if col == "credentials":
|
||||
if not isinstance(raw, dict):
|
||||
return iter(())
|
||||
return (v for k, v in raw.items() if k in MCP_CREDENTIAL_SECRET_FIELDS and isinstance(v, str))
|
||||
if not isinstance(raw, list):
|
||||
return iter(())
|
||||
return (
|
||||
e["value"]
|
||||
for e in raw
|
||||
if isinstance(e, dict) and _is_global_env_var_scope(e.get("scope")) and isinstance(e.get("value"), str)
|
||||
)
|
||||
|
||||
|
||||
def _encrypted_leaves(db_attr: str, col: str, raw: object) -> Iterator[str]:
|
||||
if db_attr in _MCP_CREDENTIAL_TABLES and col in ("credentials", "env_vars"):
|
||||
return _mcp_encrypted_leaves(col, raw)
|
||||
return _iter_encrypted_strings(raw)
|
||||
|
||||
|
||||
def _classify_into_report(report: LocationReport, value: str) -> None:
|
||||
"""Classify one stored string and bump the matching read-only counter.
|
||||
|
||||
|
|
@ -548,7 +575,7 @@ async def _scan_one_table(
|
|||
raw = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for s in _iter_encrypted_strings(raw):
|
||||
for s in _encrypted_leaves(db_attr, col, raw):
|
||||
_classify_into_report(report, s)
|
||||
for col in scalar_columns:
|
||||
v = getattr(row, col, None)
|
||||
|
|
|
|||
|
|
@ -193,6 +193,49 @@ async def test_check_refuses_without_pynacl_for_every_rotation_rewritten_locatio
|
|||
await cm.check_encryption(prisma_client=client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_without_pynacl_ignores_plaintext_mcp_metadata_next_to_v3_secrets(salt_key, monkeypatch):
|
||||
_enable_aes(monkeypatch)
|
||||
v3_secret: Final = encrypt_value_helper("dcr-secret")
|
||||
client: Final = MagicMock()
|
||||
_empty_covered_tables(client)
|
||||
client.db.litellm_teamtable.find_many = AsyncMock(return_value=[])
|
||||
client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
|
||||
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
|
||||
client.db.litellm_mcpserveroauthclient.find_many = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
server_id="s1",
|
||||
credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"},
|
||||
)
|
||||
]
|
||||
)
|
||||
client.db.litellm_mcpservertable.find_many = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
server_id="s1",
|
||||
credentials={"auth_value": v3_secret, "auth_type": "api_key", "token_url": "https://idp/token"},
|
||||
env_vars=[
|
||||
{"name": "TOKEN", "scope": "global", "value": v3_secret},
|
||||
{"name": "USER_TOKEN", "scope": "user", "value": "{{user.token}}"},
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "nacl", None)
|
||||
monkeypatch.setitem(sys.modules, "nacl.secret", None)
|
||||
|
||||
report: Final = await cm.check_encryption(prisma_client=client)
|
||||
|
||||
by_location: Final = report.as_dict()["locations"]
|
||||
assert report.residual_legacy == 0, by_location
|
||||
assert by_location["mcp_oauth_client"]["already_v2"] == 2, by_location
|
||||
assert by_location["mcp_oauth_client"]["scanned"] == 2, by_location
|
||||
assert by_location["mcp_server"]["already_v2"] == 2, by_location
|
||||
assert by_location["mcp_server"]["scanned"] == 2, by_location
|
||||
|
||||
|
||||
# --------------------------- config-row walker ---------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9496,6 +9496,49 @@ async def test_rotate_master_key_refuses_without_pynacl_before_touching_any_row(
|
|||
mock_prisma_client.db.tx.assert_not_called()
|
||||
|
||||
|
||||
async def test_rotate_master_key_preflight_without_pynacl_passes_migrated_oauth_client_with_plaintext_scopes(
|
||||
monkeypatch,
|
||||
):
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_require_legacy_reader_for_stored_values,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-rotate")
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {})
|
||||
v3_secret = encrypt_value_helper("dcr-secret")
|
||||
monkeypatch.setitem(sys.modules, "nacl", None)
|
||||
monkeypatch.setitem(sys.modules, "nacl.secret", None)
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db = MagicMock()
|
||||
mock_prisma_client.db.litellm_config.find_unique = AsyncMock(return_value=None)
|
||||
for table in (
|
||||
"litellm_proxymodeltable",
|
||||
"litellm_credentialstable",
|
||||
"litellm_mcpservertable",
|
||||
"litellm_mcpusercredentials",
|
||||
"litellm_mcpuserenvvars",
|
||||
"litellm_ssoidentityassertion",
|
||||
):
|
||||
getattr(mock_prisma_client.db, table).find_many = AsyncMock(return_value=[])
|
||||
mock_prisma_client.db.litellm_mcpserveroauthclient.find_many = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
server_id="s1",
|
||||
credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
await _require_legacy_reader_for_stored_values(mock_prisma_client)
|
||||
|
||||
assert mock_prisma_client.db.litellm_mcpserveroauthclient.find_many.await_count == 1
|
||||
|
||||
|
||||
async def test_default_key_generate_params_duration(monkeypatch):
|
||||
"""
|
||||
Test that default_key_generate_params with 'duration' is applied
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue