diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py index f441ce71ab9..1fddc527ec8 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py @@ -11,17 +11,35 @@ import sys sys.path.insert( 0, os.path.abspath("../..") ) # Adds the parent directory to the system path +import functools import tempfile -from typing import Optional +from contextvars import ContextVar +from typing import TYPE_CHECKING, ClassVar, Literal, Optional from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.guardrails._content_utils import walk_user_text +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import GenericGuardrailAPIInputs + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj GUARDRAIL_NAME = "hide_secrets" +GUARDRAIL_PROVIDER = "hide-secrets" + +# Per-invocation tally of redacted secrets by detect-secrets plugin type; None +# means the guardrail did not run, so _process_response records nothing. +_masked_entity_count: ContextVar[Optional[dict]] = ContextVar( + "hide_secrets_masked_entity_count", default=None +) + _custom_plugins_path = "file://" + os.path.join( os.path.dirname(os.path.abspath(__file__)), "secrets_plugins" ) @@ -422,6 +440,10 @@ _default_detect_secrets_config = { class _ENTERPRISE_SecretDetection(CustomGuardrail): + # Keeps proxied traffic on async_pre_call_hook (the unified apply_guardrail + # path skips should_run_check and never sees data["prompt"]). + use_native_lifecycle_hooks: ClassVar[bool] = True + def __init__(self, detect_secrets_config: Optional[dict] = None, **kwargs): self.user_defined_detect_secrets_config = detect_secrets_config super().__init__(**kwargs) @@ -455,6 +477,26 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail): return detected_secrets + def redact_text(self, text: str, source: str = "message") -> str: + """Replace every detected secret in ``text`` with ``[REDACTED]`` and + tally the detected types into the per-invocation masked-entity count.""" + detected_secrets = self.scan_message_for_secrets(text) + if not detected_secrets: + return text + counts = _masked_entity_count.get() + if counts is not None: + for secret in detected_secrets: + counts[secret["type"]] = counts.get(secret["type"], 0) + 1 + secret_types = [secret["type"] for secret in detected_secrets] + verbose_proxy_logger.warning( + f"Detected and redacted secrets in {source}: {secret_types}" + ) + return functools.reduce( + lambda redacted, secret: redacted.replace(secret["value"], "[REDACTED]"), + detected_secrets, + text, + ) + async def should_run_check(self, user_api_key_dict: UserAPIKeyAuth) -> bool: if user_api_key_dict.permissions is not None: if GUARDRAIL_NAME in user_api_key_dict.permissions: @@ -463,7 +505,45 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail): return True + @log_guardrail_information + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + """Unified-interface entrypoint, used by /guardrails/apply_guardrail + (the UI test playground). Proxied traffic keeps using + ``async_pre_call_hook``, see ``use_native_lifecycle_hooks``.""" + texts = inputs.get("texts") + if not texts or not any(texts): + return inputs + _masked_entity_count.set({}) + return {**inputs, "texts": [self.redact_text(text) for text in texts]} + + def _redact_prompt(self, data: dict) -> int: + """Redact ``data["prompt"]`` (the text-completion shape, which + ``walk_user_text`` does not cover) and return how many non-empty + strings were inspected.""" + prompt = data.get("prompt") + if isinstance(prompt, str): + if not prompt: + return 0 + data["prompt"] = self.redact_text(prompt, source="prompt") + return 1 + if isinstance(prompt, list): + data["prompt"] = [ # mutable-ok: data["prompt"] is a list on the wire + self.redact_text(item, source="prompt") + if isinstance(item, str) and item + else item + for item in prompt + ] + return sum(1 for item in prompt if isinstance(item, str) and item) + return 0 + #### CALL HOOKS - proxy only #### + @log_guardrail_information async def async_pre_call_hook( self, user_api_key_dict: UserAPIKeyAuth, @@ -471,53 +551,84 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail): data: dict, call_type: str, # "completion", "embeddings", "image_generation", "moderation" ): + _masked_entity_count.set(None) if await self.should_run_check(user_api_key_dict) is False: return + _masked_entity_count.set({}) + # Covers multimodal list content + Responses-API input. - def _redact_message_text(text: str) -> str: - detected_secrets = self.scan_message_for_secrets(text) - for secret in detected_secrets: - text = text.replace(secret["value"], "[REDACTED]") - if detected_secrets: - secret_types = [secret["type"] for secret in detected_secrets] - verbose_proxy_logger.warning( - f"Detected and redacted secrets in message: {secret_types}" - ) - return text + inspected = walk_user_text(data, self.redact_text) + self._redact_prompt(data) - walk_user_text(data, _redact_message_text) + if inspected == 0: + # Image-only, empty-text, and unsupported payloads inspected + # nothing, so recording "allow" would count a run that never + # looked at any content. + _masked_entity_count.set(None) - if "prompt" in data: - if isinstance(data["prompt"], str): - detected_secrets = self.scan_message_for_secrets(data["prompt"]) - for secret in detected_secrets: - data["prompt"] = data["prompt"].replace( - secret["value"], "[REDACTED]" - ) - if len(detected_secrets) > 0: - secret_types = [secret["type"] for secret in detected_secrets] - verbose_proxy_logger.warning( - f"Detected and redacted secrets in prompt: {secret_types}" - ) - elif isinstance(data["prompt"], list): - # Index back into the list — assigning to ``item`` would only - # rebind the loop variable and leave ``data["prompt"]`` - # carrying the unredacted secret. - for idx, item in enumerate(data["prompt"]): - if isinstance(item, str): - detected_secrets = self.scan_message_for_secrets(item) - for secret in detected_secrets: - item = item.replace(secret["value"], "[REDACTED]") - data["prompt"][idx] = item - if len(detected_secrets) > 0: - secret_types = [ - secret["type"] for secret in detected_secrets - ] - verbose_proxy_logger.warning( - f"Detected and redacted secrets in prompt: {secret_types}" - ) - - # ``data["input"]`` (Responses API and embeddings/moderation) is - # already covered by ``walk_user_text`` above. return + + def _process_response( + self, + response: Optional[dict], + request_data: dict, + start_time: Optional[float] = None, + end_time: Optional[float] = None, + duration: Optional[float] = None, + event_type: Optional[GuardrailEventHooks] = None, + original_inputs: Optional[dict] = None, + ): + """Record allow/mask plus the masked-entity tally for a completed run. + + Records nothing when the guardrail inspected nothing (opted-out key, + empty inputs) or when the instance has no guardrail_name (legacy + ``litellm_settings.callbacks`` deployments, which predate guardrail + telemetry and stay without it). + """ + counts = _masked_entity_count.get() + _masked_entity_count.set(None) + if counts is None or self.guardrail_name is None: + return response + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response="mask" if counts else "allow", + request_data=request_data, + guardrail_status="success", + duration=duration, + start_time=start_time, + end_time=end_time, + event_type=event_type, + guardrail_provider=GUARDRAIL_PROVIDER, + masked_entity_count=counts, + ) + return response + + def _process_error( + self, + e: Exception, + request_data: dict, + start_time: Optional[float] = None, + end_time: Optional[float] = None, + duration: Optional[float] = None, + event_type: Optional[GuardrailEventHooks] = None, + ): + """Label the failed run with this guardrail's provider so error rows + group with the successful ones in the monitor. Nameless legacy + instances record nothing, matching ``_process_response``.""" + _masked_entity_count.set(None) + if self.guardrail_name is None: + raise e + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response=e, + request_data=request_data, + guardrail_status=( + "guardrail_intervened" + if self._is_guardrail_intervention(e) + else "guardrail_failed_to_respond" + ), + duration=duration, + start_time=start_time, + end_time=end_time, + event_type=event_type, + guardrail_provider=GUARDRAIL_PROVIDER, + ) + raise e diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 372c9bf6b91..c8e5610b4a9 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -1485,7 +1485,7 @@ def log_guardrail_information(func): if func.__name__ == "apply_guardrail" and "inputs" in kwargs: original_inputs = kwargs.get("inputs") - logging_obj: Final = kwargs.get("logging_obj") + logging_obj: Final = kwargs.get("logging_obj") or request_data.get("litellm_logging_obj") self_recorded_token: Final = _guardrail_self_recorded.set(False) try: response: Final = await func(*args, **kwargs) @@ -1527,7 +1527,7 @@ def log_guardrail_information(func): if func.__name__ == "apply_guardrail" and "inputs" in kwargs: original_inputs = kwargs.get("inputs") - logging_obj: Final = kwargs.get("logging_obj") + logging_obj: Final = kwargs.get("logging_obj") or request_data.get("litellm_logging_obj") self_recorded_token: Final = _guardrail_self_recorded.set(False) try: response: Final = func(*args, **kwargs) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 3d2ed641a30..744b2959c73 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -8,7 +8,7 @@ import json import os from collections.abc import Awaitable, Callable, Mapping, Sequence from datetime import datetime, timezone -from types import UnionType +from types import MappingProxyType, UnionType from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, Union, cast, get_args, get_origin from urllib.parse import urlparse @@ -51,6 +51,9 @@ from litellm.types.guardrails import ( SupportedGuardrailIntegrations, ToolPermissionGuardrailConfigModel, ) +from litellm.types.proxy.guardrails.guardrail_hooks.hide_secrets import ( + HideSecretsGuardrailConfigModel, +) if TYPE_CHECKING: from types import CodeType @@ -1401,7 +1404,11 @@ async def get_guardrail_ui_settings(): provider: [hook.value for hook in hooks] for provider, guardrail_class in guardrail_class_registry.items() if (hooks := guardrail_class.get_supported_event_hooks()) is not None - } + } | MappingProxyType( + # hide-secrets lives in the enterprise package, not in the registry + # above; it only runs on pre_call. + {SupportedGuardrailIntegrations.HIDE_SECRETS.value: [GuardrailEventHooks.pre_call.value]} + ) return GuardrailUIAddGuardrailSettings( supported_entities=[entity.value for entity in PiiEntityType], @@ -1953,12 +1960,18 @@ async def get_provider_specific_params(): tool_permission_fields["ui_friendly_name"] = ToolPermissionGuardrailConfigModel.ui_friendly_name() + # hide-secrets lives in the enterprise package, not in the registry loop below. + hide_secrets_fields: Final = _get_fields_from_model(HideSecretsGuardrailConfigModel) + + hide_secrets_fields["ui_friendly_name"] = HideSecretsGuardrailConfigModel.ui_friendly_name() + # Return the provider-specific parameters provider_params: Final = { SupportedGuardrailIntegrations.BEDROCK.value: bedrock_fields, SupportedGuardrailIntegrations.PRESIDIO.value: presidio_fields, SupportedGuardrailIntegrations.LAKERA_V2.value: lakera_v2_fields, SupportedGuardrailIntegrations.TOOL_PERMISSION.value: tool_permission_fields, + SupportedGuardrailIntegrations.HIDE_SECRETS.value: hide_secrets_fields, } ### get the config model for the guardrail - go through the registry and get the config model for the guardrail diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/hide_secrets.py b/litellm/types/proxy/guardrails/guardrail_hooks/hide_secrets.py new file mode 100644 index 00000000000..3100968e8b1 --- /dev/null +++ b/litellm/types/proxy/guardrails/guardrail_hooks/hide_secrets.py @@ -0,0 +1,20 @@ +"""Types for the Hide Secrets guardrail.""" + +from pydantic import Field + +from .base import GuardrailConfigModel + + +class HideSecretsGuardrailConfigModel(GuardrailConfigModel): + """Configuration for the Hide Secrets guardrail. Detection runs in-process + on the detect-secrets library; ``detect_secrets_config`` overrides the + bundled plugin set.""" + + detect_secrets_config: dict | None = Field( # mutable-ok: UI type derivation maps dict to "object" + default=None, + description="Optional detect-secrets configuration (plugins_used, filters_used) overriding the bundled plugin set", + ) + + @staticmethod + def ui_friendly_name() -> str: + return "Hide Secrets" diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py b/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py new file mode 100644 index 00000000000..dc1cbb9983e --- /dev/null +++ b/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py @@ -0,0 +1,274 @@ +"""Tests for the hide-secrets guardrail (LIT-3548). + +Covers the three defects from the ticket: +- ``apply_guardrail`` (the UI test playground path) must redact, not echo. +- Guardrail runs must record ``standard_logging_guardrail_information`` so + Spend Logs / the guardrails monitor show activity, with hits ("mask" + + masked_entity_count) distinguishable from clean requests ("allow"). +- Defining ``apply_guardrail`` must NOT reroute proxied traffic off the + native ``async_pre_call_hook`` (per-key opt-out and ``data["prompt"]`` + handling live only on the native path). +""" + +import pytest + +from litellm_enterprise.enterprise_callbacks.secret_detection import ( + _ENTERPRISE_SecretDetection, +) +from litellm.caching.caching import DualCache +from litellm.proxy._types import UserAPIKeyAuth + +AWS_KEY = "AKIAIOSFODNN7EXAMPLE" + + +def _guardrail() -> _ENTERPRISE_SecretDetection: + return _ENTERPRISE_SecretDetection( + guardrail_name="hide-secrets", event_hook="pre_call", default_on=True + ) + + +def _recorded(request_data: dict) -> dict: + entries = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(entries) == 1 + return entries[0] + + +@pytest.mark.asyncio +async def test_apply_guardrail_redacts_secrets(): + """Playground path: the returned texts must carry [REDACTED], not the secret.""" + guardrail = _guardrail() + request_data: dict = {"metadata": {}} + + result = await guardrail.apply_guardrail( + inputs={"texts": [f"my key is {AWS_KEY}, keep it safe"]}, + request_data=request_data, + input_type="request", + ) + + assert result["texts"] == ["my key is [REDACTED], keep it safe"] + + recorded = _recorded(request_data) + assert recorded["guardrail_status"] == "success" + assert recorded["guardrail_response"] == "mask" + assert recorded["guardrail_provider"] == "hide-secrets" + assert recorded["masked_entity_count"] == {"AWS Access Key": 1} + + +@pytest.mark.asyncio +async def test_apply_guardrail_clean_text_records_allow(): + guardrail = _guardrail() + request_data: dict = {"metadata": {}} + + result = await guardrail.apply_guardrail( + inputs={"texts": ["nothing sensitive here"]}, + request_data=request_data, + input_type="request", + ) + + assert result["texts"] == ["nothing sensitive here"] + + recorded = _recorded(request_data) + assert recorded["guardrail_status"] == "success" + assert recorded["guardrail_response"] == "allow" + assert recorded["masked_entity_count"] == {} + + +@pytest.mark.asyncio +async def test_pre_call_hook_records_mask_with_entity_count(): + """Live-traffic path: a redaction must be visible in spend-log telemetry.""" + guardrail = _guardrail() + data = { + "messages": [{"role": "user", "content": f"use {AWS_KEY} for auth"}], + "metadata": {}, + } + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data=data, + call_type="completion", + ) + + assert data["messages"][0]["content"] == "use [REDACTED] for auth" + + recorded = _recorded(data) + assert recorded["guardrail_status"] == "success" + assert recorded["guardrail_response"] == "mask" + assert recorded["guardrail_provider"] == "hide-secrets" + assert recorded["masked_entity_count"] == {"AWS Access Key": 1} + + +@pytest.mark.asyncio +async def test_pre_call_hook_clean_request_records_allow(): + """A request with no secrets must be distinguishable from a redacted one.""" + guardrail = _guardrail() + data = { + "messages": [{"role": "user", "content": "what's the weather"}], + "metadata": {}, + } + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data=data, + call_type="completion", + ) + + recorded = _recorded(data) + assert recorded["guardrail_status"] == "success" + assert recorded["guardrail_response"] == "allow" + assert recorded["masked_entity_count"] == {} + + +@pytest.mark.asyncio +async def test_pre_call_hook_opt_out_records_nothing(): + """A key with permissions={"hide_secrets": False} skips redaction, so no + telemetry is recorded: every reader of a recorded entry (guardrail usage + tracking, compliance checks, the spend-log viewer) counts it as a run.""" + guardrail = _guardrail() + content = f"my key is {AWS_KEY}" + data = {"messages": [{"role": "user", "content": content}], "metadata": {}} + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(permissions={"hide_secrets": False}), + cache=DualCache(), + data=data, + call_type="completion", + ) + + assert data["messages"][0]["content"] == content # untouched + assert "standard_logging_guardrail_information" not in data["metadata"] + + +@pytest.mark.asyncio +async def test_pre_call_hook_still_redacts_text_completion_prompt(): + """data["prompt"] (str and list) is a native-hook-only surface; it must + keep redacting now that the class also implements apply_guardrail.""" + guardrail = _guardrail() + data = {"prompt": f"key {AWS_KEY} end", "metadata": {}} + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data=data, + call_type="completion", + ) + assert data["prompt"] == "key [REDACTED] end" + + guardrail = _guardrail() + data = {"prompt": [f"key {AWS_KEY}", "clean"], "metadata": {}} + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data=data, + call_type="completion", + ) + assert data["prompt"] == ["key [REDACTED]", "clean"] + + +def test_proxied_traffic_stays_on_native_hooks(): + """Implementing apply_guardrail must not reroute proxied requests onto the + unified path: that path skips ``should_run_check`` (per-key opt-out) and + never sees ``data["prompt"]``.""" + guardrail = _guardrail() + assert guardrail.uses_apply_guardrail_interface() is True + assert guardrail._deployment_pre_call_target() is guardrail + + +@pytest.mark.asyncio +async def test_apply_guardrail_without_texts_records_nothing(): + """No inputs means nothing was inspected, so no "allow" row is recorded. + Empty strings count as no input: there is no content to inspect.""" + guardrail = _guardrail() + + empty_variants: list[list[str]] = [[], ["", ""]] + for texts in empty_variants: + request_data: dict = {"metadata": {}} + result = await guardrail.apply_guardrail( + inputs={"texts": texts}, request_data=request_data, input_type="request" + ) + assert result == {"texts": texts} + assert "standard_logging_guardrail_information" not in request_data["metadata"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "data", + [ + pytest.param( + { + "messages": [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "https://x/y.png"}} + ], + } + ], + "metadata": {}, + }, + id="image_only", + ), + pytest.param( + {"messages": [{"role": "user", "content": ""}], "metadata": {}}, + id="empty_message", + ), + pytest.param({"prompt": "", "metadata": {}}, id="empty_prompt"), + pytest.param({"prompt": ["", ""], "metadata": {}}, id="empty_prompt_list"), + ], +) +async def test_pre_call_hook_without_inspectable_text_records_nothing(data: dict): + """A payload the guardrail could not inspect (image-only content, empty + strings) must not record an "allow" run: monitoring would count a check + that never looked at any text.""" + guardrail = _guardrail() + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data=data, + call_type="completion", + ) + + assert "standard_logging_guardrail_information" not in data["metadata"] + + +@pytest.mark.asyncio +async def test_pre_call_hook_mixed_prompt_list_still_redacts_and_records(): + """A prompt list mixing empty and real strings is inspected, so the run is + recorded and the non-empty entry is still redacted.""" + guardrail = _guardrail() + data = {"prompt": ["", f"key {AWS_KEY}"], "metadata": {}} + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data=data, + call_type="completion", + ) + + assert data["prompt"] == ["", "key [REDACTED]"] + recorded = _recorded(data) + assert recorded["guardrail_response"] == "mask" + assert recorded["masked_entity_count"] == {"AWS Access Key": 1} + + +@pytest.mark.asyncio +async def test_legacy_nameless_instance_records_nothing(): + """``litellm_settings.callbacks: ["hide_secrets"]`` builds an arg-less + instance with no guardrail_name. It still redacts, but recording a nameless + entry would flip every spend row's guardrail status with nothing to join on.""" + guardrail = _ENTERPRISE_SecretDetection() + data = { + "messages": [{"role": "user", "content": f"use {AWS_KEY} for auth"}], + "metadata": {}, + } + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data=data, + call_type="completion", + ) + + assert data["messages"][0]["content"] == "use [REDACTED] for auth" + assert "standard_logging_guardrail_information" not in data["metadata"] diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index d85c6da659b..b99dcb062b4 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -670,6 +670,37 @@ def test_get_provider_specific_params(): ) # Literal type should be select +@pytest.mark.asyncio +async def test_provider_specific_params_includes_hide_secrets(): + """hide-secrets lives in the enterprise package so it is not in + guardrail_class_registry; the endpoint must still advertise it or the + Add Guardrail UI dropdown never offers it (LIT-3548).""" + from litellm.proxy.guardrails.guardrail_endpoints import ( + get_provider_specific_params, + ) + + provider_params = await get_provider_specific_params() + + assert "hide-secrets" in provider_params + # populateGuardrailProviders() in the dashboard only lists providers whose + # entry carries a ui_friendly_name. + assert provider_params["hide-secrets"]["ui_friendly_name"] == "Hide Secrets" + assert provider_params["hide-secrets"]["detect_secrets_config"]["required"] is False + + +@pytest.mark.asyncio +async def test_add_guardrail_settings_restricts_hide_secrets_to_pre_call(): + """hide-secrets only implements async_pre_call_hook, so offering the other + modes in the UI would create configs that boot clean and never run.""" + from litellm.proxy.guardrails.guardrail_endpoints import ( + get_guardrail_ui_settings, + ) + + settings = await get_guardrail_ui_settings() + + assert settings.supported_modes_by_provider["hide-secrets"] == ["pre_call"] + + def test_optional_params_not_returned_when_not_overridden(): """Test that optional_params is not returned when the config model doesn't override it""" from typing import Optional diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx index c1f2ddcf51c..f686ff5644a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx @@ -201,6 +201,7 @@ export const guardrailLogoMap = { XecGuard: xecguardLogo.src, "LiteLLM Content Filter": litellmLogo.src, "LiteLLM LLM as a Judge": litellmLogo.src, + "Hide Secrets": litellmLogo.src, Akto: aktoLogo.src, "DeepKeep AI Firewall": deepkeepLogo.src, "Qostodian Nexus": qohashLogo.src, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.test.tsx new file mode 100644 index 00000000000..ab63b7637b1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.test.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { useForm } from "react-hook-form"; +import { renderWithProviders } from "@/../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import GuardrailProviderFields from "./guardrail_provider_fields"; +import { populateGuardrailProviderMap } from "./guardrail_info_helpers"; +import type { GuardrailFormValues } from "./GuardrailFormField"; + +vi.mock("@/lib/toast", () => ({ toast: { error: vi.fn() } })); + +const HIDE_SECRETS_PARAMS = { + "hide-secrets": { + ui_friendly_name: "Hide Secrets", + detect_secrets_config: { + param: "detect_secrets_config", + description: "Optional detect-secrets configuration", + required: false, + type: "object", + }, + }, +}; + +const Harness: React.FC<{ onValid: (values: GuardrailFormValues) => void }> = ({ onValid }) => { + const form = useForm(); + return ( +
+ + + + ); +}; + +const renderHarness = () => { + populateGuardrailProviderMap(HIDE_SECRETS_PARAMS); + const onValid = vi.fn(); + renderWithProviders(); + const textarea = screen.getByLabelText(/detect_secrets_config/) as HTMLTextAreaElement; + return { onValid, textarea }; +}; + +describe("GuardrailProviderFields object field", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("commits a valid JSON object as a parsed dict", async () => { + const { onValid, textarea } = renderHarness(); + + fireEvent.change(textarea, { target: { value: '{"plugins_used": [{"name": "AWSKeyDetector"}]}' } }); + fireEvent.blur(textarea); + fireEvent.click(screen.getByRole("button", { name: "save" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect(onValid.mock.calls[0][0].detect_secrets_config).toEqual({ + plugins_used: [{ name: "AWSKeyDetector" }], + }); + }); + + it("blocks submission while the field holds malformed JSON", async () => { + const { onValid, textarea } = renderHarness(); + + fireEvent.change(textarea, { target: { value: "{not json" } }); + fireEvent.blur(textarea); + fireEvent.click(screen.getByRole("button", { name: "save" })); + + await screen.findByText("detect_secrets_config must be a valid JSON object"); + expect(onValid).not.toHaveBeenCalled(); + expect(textarea.value).toBe("{not json"); + }); + + it.each(['["array"]', '"scalar"', "null", "42"])("blocks non-object JSON %s", async (raw) => { + const { onValid, textarea } = renderHarness(); + + fireEvent.change(textarea, { target: { value: raw } }); + fireEvent.blur(textarea); + fireEvent.click(screen.getByRole("button", { name: "save" })); + + await screen.findByText("detect_secrets_config must be a valid JSON object"); + expect(onValid).not.toHaveBeenCalled(); + }); + + it("treats a cleared field as unset and submits", async () => { + const { onValid, textarea } = renderHarness(); + + fireEvent.change(textarea, { target: { value: '{"a": 1}' } }); + fireEvent.blur(textarea); + fireEvent.change(textarea, { target: { value: "" } }); + fireEvent.blur(textarea); + fireEvent.click(screen.getByRole("button", { name: "save" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect(onValid.mock.calls[0][0].detect_secrets_config).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx index 76c91d4c176..7d48da89ebf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx @@ -13,6 +13,8 @@ import { FieldGroup } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "@/lib/toast"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { asStringArray, @@ -22,6 +24,7 @@ import { readRecord, requiredRule, type GuardrailFieldControlProps, + type GuardrailFieldRules, type GuardrailFormControl, } from "./GuardrailFormField"; @@ -60,6 +63,44 @@ const BOOLEAN_ITEMS = [ const isSecretKey = (fieldKey: string): boolean => fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key"); +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// Object fields hold the raw text while the user types, so submission must be +// blocked until the value parses to a plain JSON object (or is cleared). +const jsonObjectRule = (fieldKey: string): GuardrailFieldRules => ({ + validate: (value: unknown) => + value === undefined || isPlainObject(value) ? true : `${fieldKey} must be a valid JSON object`, +}); + +// Commits a parsed object (or undefined for a cleared field) to the form on +// blur; anything else stays as raw text so jsonObjectRule blocks submission. +const commitObjectField = (raw: string, onChange: (value: unknown) => void): void => { + const next = raw.trim(); + if (next === "") { + onChange(undefined); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(next); + } catch { + parsed = next; + } + if (isPlainObject(parsed)) { + onChange(parsed); + } else { + toast.error("Enter a valid JSON object for this configuration"); + } +}; + +const fieldRules = (field: ProviderParam, fieldKey: string): GuardrailFieldRules | undefined => { + if (field.type === "object") { + return jsonObjectRule(fieldKey); + } + return field.required ? requiredRule(`${fieldKey} is required`) : undefined; +}; + interface ProviderFieldInputProps { descriptor: ProviderParam; fieldKey: string; @@ -141,6 +182,25 @@ const ProviderFieldInput: React.FC = ({ descriptor, fie ); } + if (descriptor.type === "object") { + const objectValue = typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : asText(value); + return ( +