From 18a5c4f75a2b4dedfc106ea575f0e13bc300fe53 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 6 Nov 2025 16:02:28 -0800 Subject: [PATCH] [Feat] Add LiteLLM Gateway built in guardrail (#16338) * add ContentFilterAction * store pre-built regex patterns * add v0 of content filter guard * add _filter_messages * test content filter guard * init ContentFilterGuardrail * fix ContentFilterGuardrail enums * rename folder * fix litellm_content_filter * refactor content filter guard * test content filter * add streaming for ContentFilterGuardrail * test_streaming_hook_mask * add litellm_content_filter * docs show litellm content filter * docs litellm content filter * fix lnting * Potential fix for code scanning alert no. 3675: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../guardrails/litellm_content_filter.md | 419 ++++++++++++++++ docs/my-website/sidebars.js | 1 + litellm/litellm_core_utils/litellm_logging.py | 2 +- .../litellm_content_filter/__init__.py | 52 ++ .../litellm_content_filter/content_filter.py | 368 ++++++++++++++ .../litellm_content_filter/patterns.py | 103 ++++ litellm/types/guardrails.py | 61 +++ .../content_filter/test_content_filter.py | 469 ++++++++++++++++++ 8 files changed, 1474 insertions(+), 1 deletion(-) create mode 100644 docs/my-website/docs/proxy/guardrails/litellm_content_filter.md create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py create mode 100644 tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py diff --git a/docs/my-website/docs/proxy/guardrails/litellm_content_filter.md b/docs/my-website/docs/proxy/guardrails/litellm_content_filter.md new file mode 100644 index 0000000000..57a474dc17 --- /dev/null +++ b/docs/my-website/docs/proxy/guardrails/litellm_content_filter.md @@ -0,0 +1,419 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LiteLLM Content Filter + +**Built-in guardrail** for detecting and filtering sensitive information using regex patterns and keyword matching. No external dependencies required. + +## Overview + +| Property | Details | +|----------|---------| +| Description | On-device guardrail for detecting and filtering sensitive information using regex patterns and keyword matching. Built into LiteLLM with no external dependencies. | +| Guardrail Name | `litellm_content_filter` | +| Detection Methods | Prebuilt regex patterns, custom regex, keyword matching | +| Actions | `BLOCK` (reject request), `MASK` (redact content) | +| Supported Modes | `pre_call`, `post_call`, `during_call` (streaming) | +| Performance | Fast - runs locally, no external API calls | + +## Quick Start + +### 1. Define Guardrails in config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: openai/gpt-3.5-turbo + api_key: os.environ/OPENAI_API_KEY + +guardrails: + - guardrail_name: "content-filter-pre" + litellm_params: + guardrail: litellm_content_filter + mode: "pre_call" + + # Prebuilt patterns for common PII + patterns: + - pattern_type: "prebuilt" + pattern_name: "us_ssn" + action: "BLOCK" + + - pattern_type: "prebuilt" + pattern_name: "email" + action: "MASK" + + # Custom blocked keywords + blocked_words: + - keyword: "confidential" + action: "BLOCK" + description: "Sensitive internal information" +``` + +### 2. Start LiteLLM Gateway + +```shell +litellm --config config.yaml +``` + +### 3. Test Request + + + + +```shell +curl -i http://localhost:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "gpt-3.5-turbo", + "messages": [ + {"role": "user", "content": "My SSN is 123-45-6789"} + ], + "guardrails": ["content-filter-pre"] + }' +``` + +**Response: HTTP 400 Error** +```json +{ + "error": { + "message": { + "error": "Content blocked: us_ssn pattern detected", + "pattern": "us_ssn" + }, + "code": "400" + } +} +``` + + + + + +```shell +curl -i http://localhost:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "gpt-3.5-turbo", + "messages": [ + {"role": "user", "content": "Contact me at john@example.com"} + ], + "guardrails": ["content-filter-pre"] + }' +``` + +The request is sent to the LLM with the email masked: +``` +Contact me at [EMAIL_REDACTED] +``` + + + + +## Configuration + +### Supported Modes + +- **`pre_call`** - Run before LLM call, filters input messages +- **`post_call`** - Run after LLM call, filters output responses +- **`during_call`** - Run during streaming, filters each chunk in real-time + +### Actions + +- **`BLOCK`** - Reject the request with HTTP 400 error +- **`MASK`** - Replace sensitive content with redaction tags (e.g., `[EMAIL_REDACTED]`) + +## Prebuilt Patterns + +### Available Patterns + +| Pattern Name | Description | Example | +|-------------|-------------|---------| +| `us_ssn` | US Social Security Numbers | `123-45-6789` | +| `email` | Email addresses | `user@example.com` | +| `phone` | Phone numbers | `+1-555-123-4567` | +| `visa` | Visa credit cards | `4532-1234-5678-9010` | +| `mastercard` | Mastercard credit cards | `5425-2334-3010-9903` | +| `amex` | American Express cards | `3782-822463-10005` | +| `aws_access_key` | AWS access keys | `AKIAIOSFODNN7EXAMPLE` | +| `aws_secret_key` | AWS secret keys | `wJalrXUtnFEMI/K7MDENG/bPxRfi...` | +| `github_token` | GitHub tokens | `ghp_16C7e42F292c6912E7710c838347Ae178B4a` | + +### Using Prebuilt Patterns + +```yaml showLineNumbers title="config.yaml" +guardrails: + - guardrail_name: "pii-filter" + litellm_params: + guardrail: litellm_content_filter + mode: "pre_call" + patterns: + - pattern_type: "prebuilt" + pattern_name: "us_ssn" + action: "BLOCK" + + - pattern_type: "prebuilt" + pattern_name: "email" + action: "MASK" + + - pattern_type: "prebuilt" + pattern_name: "aws_access_key" + action: "BLOCK" +``` + +## Custom Regex Patterns + +Define your own regex patterns for domain-specific sensitive data: + +```yaml showLineNumbers title="config.yaml" +guardrails: + - guardrail_name: "custom-patterns" + litellm_params: + guardrail: litellm_content_filter + mode: "pre_call" + patterns: + # Custom employee ID format + - pattern_type: "regex" + pattern: '\b[A-Z]{3}-\d{4}\b' + name: "employee_id" + action: "MASK" + + # Custom project code format + - pattern_type: "regex" + pattern: 'PROJECT-\d{6}' + name: "project_code" + action: "BLOCK" +``` + +## Keyword Filtering + +Block or mask specific keywords: + +```yaml showLineNumbers title="config.yaml" +guardrails: + - guardrail_name: "keyword-filter" + litellm_params: + guardrail: litellm_content_filter + mode: "pre_call" + blocked_words: + - keyword: "confidential" + action: "BLOCK" + description: "Internal confidential information" + + - keyword: "proprietary" + action: "MASK" + description: "Proprietary company data" + + - keyword: "secret_project" + action: "BLOCK" +``` + +### Loading Keywords from File + +For large keyword lists, use a YAML file: + +```yaml showLineNumbers title="config.yaml" +guardrails: + - guardrail_name: "keyword-file-filter" + litellm_params: + guardrail: litellm_content_filter + mode: "pre_call" + blocked_words_file: "/path/to/sensitive_keywords.yaml" +``` + +```yaml showLineNumbers title="sensitive_keywords.yaml" +blocked_words: + - keyword: "project_apollo" + action: "BLOCK" + description: "Confidential project codename" + + - keyword: "internal_api" + action: "MASK" + description: "Internal API references" + + - keyword: "customer_database" + action: "BLOCK" + description: "Protected database name" +``` + +## Streaming Support + +Content filter works with streaming responses by checking each chunk: + +```yaml showLineNumbers title="config.yaml" +guardrails: + - guardrail_name: "streaming-filter" + litellm_params: + guardrail: litellm_content_filter + mode: "during_call" # Check each streaming chunk + patterns: + - pattern_type: "prebuilt" + pattern_name: "email" + action: "MASK" +``` + +```python +import openai + +client = openai.OpenAI( + api_key="sk-1234", + base_url="http://localhost:4000" +) + +response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Tell me about yourself"}], + stream=True, + extra_body={"guardrails": ["streaming-filter"]} +) + +for chunk in response: + print(chunk.choices[0].delta.content) + # Emails automatically masked in real-time +``` + +## Customizing Redaction Tags + +When using the `MASK` action, sensitive content is replaced with redaction tags. You can customize how these tags appear. + +### Default Behavior + +**Patterns:** Each pattern type gets its own tag based on the pattern name +``` +Input: "My email is john@example.com and SSN is 123-45-6789" +Output: "My email is [EMAIL_REDACTED] and SSN is [US_SSN_REDACTED]" +``` + +**Keywords:** All keywords use the same generic tag +``` +Input: "This is confidential and proprietary information" +Output: "This is [KEYWORD_REDACTED] and [KEYWORD_REDACTED] information" +``` + +### Customizing Tags + +Use `pattern_redaction_format` and `keyword_redaction_tag` to change the redaction format: + +```yaml showLineNumbers title="config.yaml" +guardrails: + - guardrail_name: "custom-redaction" + litellm_params: + guardrail: litellm_content_filter + mode: "pre_call" + pattern_redaction_format: "***{pattern_name}***" # Use {pattern_name} placeholder + keyword_redaction_tag: "***REDACTED***" + patterns: + - pattern_type: "prebuilt" + pattern_name: "email" + action: "MASK" + - pattern_type: "prebuilt" + pattern_name: "us_ssn" + action: "MASK" + blocked_words: + - keyword: "confidential" + action: "MASK" +``` + +**Output:** +``` +Input: "Email john@example.com, SSN 123-45-6789, confidential data" +Output: "Email ***EMAIL***, SSN ***US_SSN***, ***REDACTED*** data" +``` + +**Key Points:** +- `pattern_redaction_format` must include `{pattern_name}` placeholder +- Pattern names are automatically uppercased (e.g., `email` → `EMAIL`) +- `keyword_redaction_tag` is a fixed string (no placeholders) + +## Use Cases + +### 1. PII Protection +Block or mask personally identifiable information before sending to LLMs: + +```yaml +patterns: + - pattern_type: "prebuilt" + pattern_name: "us_ssn" + action: "BLOCK" + - pattern_type: "prebuilt" + pattern_name: "email" + action: "MASK" +``` + +### 2. Credential Detection +Prevent API keys and secrets from being exposed: + +```yaml +patterns: + - pattern_type: "prebuilt" + pattern_name: "aws_access_key" + action: "BLOCK" + - pattern_type: "prebuilt" + pattern_name: "github_token" + action: "BLOCK" +``` + +### 3. Sensitive Internal Data Protection +Block or mask references to confidential internal projects, codenames, or proprietary information: + +```yaml +blocked_words: + - keyword: "project_titan" + action: "BLOCK" + description: "Confidential project codename" + - keyword: "internal_api" + action: "MASK" + description: "Internal system references" +``` + +For large lists of sensitive terms, use a file: +```yaml +blocked_words_file: "/path/to/sensitive_terms.yaml" +``` + +### 4. Compliance +Ensure regulatory compliance by filtering sensitive data types: + +```yaml +patterns: + - pattern_type: "prebuilt" + pattern_name: "visa" + action: "BLOCK" + - pattern_type: "prebuilt" + pattern_name: "us_ssn" + action: "BLOCK" +``` + +## Troubleshooting + +### Pattern Not Matching + +**Issue:** Regex pattern isn't detecting expected content + +**Solution:** Test your regex pattern: +```python +import re +pattern = r'\b[A-Z]{3}-\d{4}\b' +test_text = "Employee ID: ABC-1234" +print(re.search(pattern, test_text)) # Should match +``` + +### Multiple Pattern Matches + +**Issue:** Text contains multiple sensitive patterns + +**Solution:** First matching pattern/keyword is processed. Order patterns by priority: +```yaml +patterns: + # Most critical first + - pattern_type: "prebuilt" + pattern_name: "us_ssn" + action: "BLOCK" + # Less critical + - pattern_type: "prebuilt" + pattern_name: "email" + action: "MASK" +``` + diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 0b303b8c6a..7c1d5f196b 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -41,6 +41,7 @@ const sidebars = { "proxy/guardrails/ibm_guardrails", "proxy/guardrails/grayswan", "proxy/guardrails/lasso_security", + "proxy/guardrails/litellm_content_filter", "proxy/guardrails/guardrails_ai", "proxy/guardrails/lakera_ai", "proxy/guardrails/model_armor", diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index d7be4d296b..0e361b938a 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4369,7 +4369,7 @@ class StandardLoggingPayloadSetup: s3_object_key = get_s3_object_key( s3_path=s3_path, # Use actual s3_path from logger configuration - team_alias_prefix="", # Don't split by team alias for cold storage + prefix="", # Don't split by team alias for cold storage start_time=start_time, s3_file_name=s3_file_name, ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py new file mode 100644 index 0000000000..89bb53ef72 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py @@ -0,0 +1,52 @@ +from typing import TYPE_CHECKING + +import litellm +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( + ContentFilterGuardrail, +) +from litellm.types.guardrails import SupportedGuardrailIntegrations + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"): + """ + Initialize the Content Filter Guardrail. + + Args: + litellm_params: Guardrail configuration parameters + guardrail: Guardrail metadata + + Returns: + Initialized ContentFilterGuardrail instance + """ + guardrail_name = guardrail.get("guardrail_name") + if not guardrail_name: + raise ValueError("Content Filter: guardrail_name is required") + + content_filter_guardrail = ContentFilterGuardrail( + guardrail_name=guardrail_name, + patterns=litellm_params.patterns, + blocked_words=litellm_params.blocked_words, + blocked_words_file=litellm_params.blocked_words_file, + event_hook=litellm_params.mode, # type: ignore + default_on=litellm_params.default_on or False, + ) + + litellm.logging_callback_manager.add_litellm_callback( + content_filter_guardrail + ) + + return content_filter_guardrail + + +guardrail_initializer_registry = { + SupportedGuardrailIntegrations.LITELLM_CONTENT_FILTER.value: initialize_guardrail, +} + + +guardrail_class_registry = { + SupportedGuardrailIntegrations.LITELLM_CONTENT_FILTER.value: ContentFilterGuardrail, +} + diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py new file mode 100644 index 0000000000..428b26ee16 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -0,0 +1,368 @@ +""" +Content Filter Guardrail for LiteLLM. + +This guardrail provides regex pattern matching and keyword filtering +to detect and block/mask sensitive content. +""" + +import re +from typing import Any, AsyncGenerator, Dict, List, Optional, Pattern, Tuple, Union + +import yaml +from fastapi import HTTPException + +from litellm._logging import verbose_proxy_logger +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.proxy._types import UserAPIKeyAuth +from litellm.types.guardrails import ( + BlockedWord, + ContentFilterAction, + ContentFilterPattern, + GuardrailEventHooks, + Mode, + PiiEntityType, +) +from litellm.types.utils import ModelResponseStream + +from .patterns import get_compiled_pattern + + +class ContentFilterGuardrail(CustomGuardrail): + """ + Content filter guardrail that detects sensitive information using: + - Prebuilt regex patterns (SSN, credit cards, API keys, etc.) + - Custom user-defined regex patterns + - Dictionary-based keyword matching + + Actions: + - BLOCK: Reject the request with an error + - MASK: Replace the sensitive content with a redacted placeholder + """ + + # Redaction format constants + PATTERN_REDACTION_FORMAT = "[{pattern_name}_REDACTED]" + KEYWORD_REDACTION_STR = "[KEYWORD_REDACTED]" + + def __init__( + self, + guardrail_name: Optional[str] = None, + patterns: Optional[List[ContentFilterPattern]] = None, + blocked_words: Optional[List[BlockedWord]] = None, + blocked_words_file: Optional[str] = None, + event_hook: Optional[Union[GuardrailEventHooks, List[GuardrailEventHooks], Mode]] = None, + default_on: bool = False, + pattern_redaction_format: Optional[str] = None, + keyword_redaction_tag: Optional[str] = None, + **kwargs, + ): + """ + Initialize the Content Filter Guardrail. + + Args: + guardrail_name: Name of this guardrail instance + patterns: List of ContentFilterPattern objects to detect + blocked_words: List of BlockedWord objects with keywords and actions + blocked_words_file: Path to YAML file containing blocked_words list + event_hook: When to run this guardrail (pre_call, post_call, etc.) + default_on: If True, runs on all requests by default + pattern_redaction_format: Format string for pattern redaction (use {pattern_name} placeholder) + keyword_redaction_tag: Tag to use for keyword redaction + """ + super().__init__( + guardrail_name=guardrail_name, + supported_event_hooks=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + GuardrailEventHooks.during_call, + ], + event_hook=event_hook or GuardrailEventHooks.pre_call, + default_on=default_on, + **kwargs, + ) + + self.guardrail_provider = "litellm_content_filter" + self.pattern_redaction_format = pattern_redaction_format or self.PATTERN_REDACTION_FORMAT + self.keyword_redaction_tag = keyword_redaction_tag or self.KEYWORD_REDACTION_STR + + # Compile regex patterns + self.compiled_patterns: List[Tuple[Pattern, str, ContentFilterAction]] = [] + if patterns: + for pattern_config in patterns: + self._add_pattern(pattern_config) + + # Load blocked words + self.blocked_words: Dict[str, Tuple[ContentFilterAction, Optional[str]]] = {} + if blocked_words: + for word in blocked_words: + self.blocked_words[word.keyword.lower()] = ( + word.action, + word.description, + ) + + # Load blocked words from file if provided + if blocked_words_file: + self._load_blocked_words_file(blocked_words_file) + + verbose_proxy_logger.debug( + f"ContentFilterGuardrail initialized with {len(self.compiled_patterns)} patterns " + f"and {len(self.blocked_words)} blocked words" + ) + + def _add_pattern(self, pattern_config: ContentFilterPattern) -> None: + """ + Add a pattern to the compiled patterns list. + + Args: + pattern_config: ContentFilterPattern configuration + """ + try: + if pattern_config.pattern_type == "prebuilt": + if not pattern_config.pattern_name: + raise ValueError("pattern_name is required for prebuilt patterns") + compiled = get_compiled_pattern(pattern_config.pattern_name) + pattern_name = pattern_config.pattern_name + elif pattern_config.pattern_type == "regex": + if not pattern_config.pattern: + raise ValueError("pattern is required for regex patterns") + compiled = re.compile(pattern_config.pattern, re.IGNORECASE) + pattern_name = pattern_config.name or "custom_regex" + else: + raise ValueError(f"Unknown pattern_type: {pattern_config.pattern_type}") + + self.compiled_patterns.append((compiled, pattern_name, pattern_config.action)) + verbose_proxy_logger.debug(f"Added pattern: {pattern_name} with action {pattern_config.action}") + except Exception as e: + verbose_proxy_logger.error(f"Error adding pattern {pattern_config}: {e}") + raise + + def _load_blocked_words_file(self, file_path: str) -> None: + """ + Load blocked words from a YAML file. + + Args: + file_path: Path to YAML file containing blocked_words list + + Expected format: + ```yaml + blocked_words: + - keyword: "sensitive_term" + action: "BLOCK" + description: "Optional description" + ``` + """ + try: + with open(file_path, "r") as f: + data = yaml.safe_load(f) + + if not isinstance(data, dict) or "blocked_words" not in data: + raise ValueError( + "Invalid format: file must contain 'blocked_words' key with list of words" + ) + + for word_data in data["blocked_words"]: + if not isinstance(word_data, dict) or "keyword" not in word_data or "action" not in word_data: + verbose_proxy_logger.warning(f"Skipping invalid word entry: {word_data}") + continue + + keyword = word_data["keyword"].lower() + action = ContentFilterAction(word_data["action"]) + description = word_data.get("description") + + self.blocked_words[keyword] = (action, description) + + verbose_proxy_logger.info( + f"Loaded {len(data['blocked_words'])} blocked words from {file_path}" + ) + except FileNotFoundError: + raise FileNotFoundError(f"Blocked words file not found: {file_path}") + except Exception as e: + raise Exception(f"Error loading blocked words file {file_path}: {str(e)}") + + def _check_patterns(self, text: str) -> Optional[Tuple[str, str, ContentFilterAction]]: + """ + Check text against all compiled regex patterns. + + Args: + text: Text to check + + Returns: + Tuple of (matched_text, pattern_name, action) if match found, None otherwise + """ + for compiled_pattern, pattern_name, action in self.compiled_patterns: + match = compiled_pattern.search(text) + if match: + matched_text = match.group(0) + verbose_proxy_logger.debug( + f"Pattern '{pattern_name}' matched." + ) + return (matched_text, pattern_name, action) + return None + + def _check_blocked_words(self, text: str) -> Optional[Tuple[str, ContentFilterAction, Optional[str]]]: + """ + Check text for blocked keywords. + + Args: + text: Text to check + + Returns: + Tuple of (keyword, action, description) if match found, None otherwise + """ + text_lower = text.lower() + for keyword, (action, description) in self.blocked_words.items(): + if keyword in text_lower: + verbose_proxy_logger.debug( + f"Blocked word '{keyword}' found with action {action}" + ) + return (keyword, action, description) + return None + + def _mask_content(self, text: str, pattern_name: str) -> str: + """ + Mask sensitive content in text. + + Args: + text: Text containing sensitive content + pattern_name: Name of the pattern that matched + + Returns: + Text with sensitive content masked + """ + redaction_tag = self.pattern_redaction_format.format( + pattern_name=pattern_name.upper() + ) + return redaction_tag + + async def apply_guardrail( + self, + text: str, + language: Optional[str] = None, + entities: Optional[List[PiiEntityType]] = None, + request_data: Optional[dict] = None, + ) -> str: + """ + Apply content filtering guardrail to the given text. + + This method checks for sensitive patterns and blocked keywords, + either blocking the request or masking the sensitive content. + + Args: + text: The text to apply the guardrail to + language: Optional language parameter (not used) + entities: Optional entities parameter (not used) + request_data: Optional request data dictionary for logging metadata + + Returns: + Text with sensitive content masked (if action is MASK) + + Raises: + HTTPException: If sensitive content is detected and action is BLOCK + """ + verbose_proxy_logger.debug("ContentFilterGuardrail: Applying guardrail to text") + + # Check regex patterns + pattern_match = self._check_patterns(text) + if pattern_match: + matched_text, pattern_name, action = pattern_match + + if action == ContentFilterAction.BLOCK: + error_msg = f"Content blocked: {pattern_name} pattern detected" + verbose_proxy_logger.warning(error_msg) + raise HTTPException( + status_code=400, + detail={"error": error_msg, "pattern": pattern_name}, + ) + elif action == ContentFilterAction.MASK: + # Replace the matched text with redaction tag + redaction_tag = self._mask_content(matched_text, pattern_name) + text = text.replace(matched_text, redaction_tag) + verbose_proxy_logger.info(f"Masked {pattern_name} in content") + + # Check blocked words + word_match = self._check_blocked_words(text) + if word_match: + keyword, action, description = word_match + + if action == ContentFilterAction.BLOCK: + error_msg = f"Content blocked: keyword '{keyword}' detected" + if description: + error_msg += f" ({description})" + verbose_proxy_logger.warning(error_msg) + raise HTTPException( + status_code=400, + detail={ + "error": error_msg, + "keyword": keyword, + "description": description, + }, + ) + elif action == ContentFilterAction.MASK: + # Replace keyword with redaction tag (case-insensitive) + text = re.sub( + re.escape(keyword), + self.keyword_redaction_tag, + text, + flags=re.IGNORECASE, + ) + verbose_proxy_logger.info(f"Masked keyword '{keyword}' in content") + + verbose_proxy_logger.debug("ContentFilterGuardrail: Guardrail applied successfully") + return text + + async def async_post_call_streaming_iterator_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + response: Any, + request_data: dict, + ) -> AsyncGenerator[ModelResponseStream, None]: + """ + Streaming hook to check each chunk as it's yielded. + + This implementation checks each chunk individually and yields it immediately, + allowing for low-latency streaming with content filtering. + + Args: + user_api_key_dict: User API key authentication + response: Async generator of response chunks + request_data: Original request data + + Yields: + Checked and potentially masked chunks + + Raises: + HTTPException: If chunk content should be blocked + """ + verbose_proxy_logger.debug( + "ContentFilterGuardrail: Running streaming check (per-chunk mode)" + ) + + # Process each chunk individually + async for chunk in response: + if isinstance(chunk, ModelResponseStream): + for choice in chunk.choices: + if hasattr(choice, "delta") and choice.delta.content: + if isinstance(choice.delta.content, str): + # Check the chunk content using apply_guardrail + try: + processed_content = await self.apply_guardrail( + text=choice.delta.content, + request_data=request_data, + ) + if processed_content != choice.delta.content: + choice.delta.content = processed_content + verbose_proxy_logger.debug( + "ContentFilterGuardrail: Modified streaming chunk" + ) + except HTTPException as e: + # If content should be blocked, raise immediately + verbose_proxy_logger.warning( + f"ContentFilterGuardrail: Blocked streaming chunk: {e.detail}" + ) + raise + + yield chunk + + verbose_proxy_logger.debug( + "ContentFilterGuardrail: Streaming check completed" + ) + diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py new file mode 100644 index 0000000000..89b3e5465d --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py @@ -0,0 +1,103 @@ +""" +Prebuilt regex patterns for content filtering. + +This module contains predefined regex patterns for detecting sensitive information +like SSNs, credit cards, API keys, etc. +""" + +import re +from typing import Dict, Pattern + +# US Social Security Number patterns +US_SSN_PATTERN = r"\b\d{3}-\d{2}-\d{4}\b" # Format: 123-45-6789 +US_SSN_NO_DASH_PATTERN = r"\b(?!000|666|9\d{2})\d{3}(?!00)\d{2}(?!0000)\d{4}\b" # Format: 123456789 (with validation) + +# Credit Card patterns +VISA_PATTERN = r"\b4\d{3}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b" # Visa starts with 4 +MASTERCARD_PATTERN = r"\b5[1-5]\d{2}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b" # Mastercard starts with 51-55 +AMEX_PATTERN = r"\b3[47]\d{2}[\s\-]?\d{6}[\s\-]?\d{5}\b" # Amex starts with 34 or 37 +DISCOVER_PATTERN = r"\b6(?:011|5\d{2})[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b" # Discover starts with 6011 or 65 + +# Email pattern +EMAIL_PATTERN = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" + +# Phone number patterns (US) +US_PHONE_PATTERN = r"\b(?:\+?1[\s.-]?)?\(?([0-9]{3})\)?[\s.-]?([0-9]{3})[\s.-]?([0-9]{4})\b" + +# API Key patterns (common formats) +AWS_ACCESS_KEY_PATTERN = r"\b(AKIA[0-9A-Z]{16})\b" # AWS Access Key ID +AWS_SECRET_KEY_PATTERN = r"\b([A-Za-z0-9/+=]{40})\b" # AWS Secret Access Key (generic 40 char) +GITHUB_TOKEN_PATTERN = r"\b(gh[ps]_[a-zA-Z0-9]{36})\b" # GitHub Personal Access Token +SLACK_TOKEN_PATTERN = r"\b(xox[pboa]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,32})\b" # Slack tokens +GENERIC_API_KEY_PATTERN = r"\b([Aa][Pp][Ii][-_]?[Kk][Ee][Yy][\s:=]+['\"]?[A-Za-z0-9_\-]{20,}['\"]?)\b" + +# IP Address patterns +IPV4_PATTERN = r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b" +IPV6_PATTERN = r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b" + +# URL patterns +URL_PATTERN = r"\b(?:https?://|www\.)[^\s/$.?#].[^\s]*\b" + + +PREBUILT_PATTERNS: Dict[str, str] = { + # SSN patterns + "us_ssn": US_SSN_PATTERN, + "us_ssn_no_dash": US_SSN_NO_DASH_PATTERN, + + # Credit card patterns + "visa": VISA_PATTERN, + "mastercard": MASTERCARD_PATTERN, + "amex": AMEX_PATTERN, + "discover": DISCOVER_PATTERN, + "credit_card": rf"(?:{VISA_PATTERN}|{MASTERCARD_PATTERN}|{AMEX_PATTERN}|{DISCOVER_PATTERN})", + + # Contact information + "email": EMAIL_PATTERN, + "us_phone": US_PHONE_PATTERN, + + # API keys and secrets + "aws_access_key": AWS_ACCESS_KEY_PATTERN, + "aws_secret_key": AWS_SECRET_KEY_PATTERN, + "github_token": GITHUB_TOKEN_PATTERN, + "slack_token": SLACK_TOKEN_PATTERN, + "generic_api_key": GENERIC_API_KEY_PATTERN, + + # Network identifiers + "ipv4": IPV4_PATTERN, + "ipv6": IPV6_PATTERN, + "url": URL_PATTERN, +} + + +def get_compiled_pattern(pattern_name: str) -> Pattern: + """ + Get a compiled regex pattern by name. + + Args: + pattern_name: Name of the prebuilt pattern + + Returns: + Compiled regex pattern + + Raises: + ValueError: If pattern_name is not found in PREBUILT_PATTERNS + """ + if pattern_name not in PREBUILT_PATTERNS: + available_patterns = ", ".join(PREBUILT_PATTERNS.keys()) + raise ValueError( + f"Unknown pattern name: '{pattern_name}'. " + f"Available patterns: {available_patterns}" + ) + + return re.compile(PREBUILT_PATTERNS[pattern_name], re.IGNORECASE) + + +def get_all_pattern_names(): + """ + Get a list of all available prebuilt pattern names. + + Returns: + List of pattern names + """ + return list(PREBUILT_PATTERNS.keys()) + diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index f832fdfd7d..a6359edc29 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -52,6 +52,7 @@ class SupportedGuardrailIntegrations(Enum): JAVELIN = "javelin" ENKRYPTAI = "enkryptai" IBM_GUARDRAILS = "ibm_guardrails" + LITELLM_CONTENT_FILTER = "litellm_content_filter" class Role(Enum): @@ -443,6 +444,65 @@ class JavelinGuardrailConfigModel(BaseModel): ) +class ContentFilterAction(str, Enum): + """Action to take when content filter detects a match""" + + BLOCK = "BLOCK" + MASK = "MASK" + + +class BlockedWord(BaseModel): + """Represents a blocked word with its action and optional description""" + + keyword: str = Field(description="The keyword to block or mask") + action: ContentFilterAction = Field( + description="Action to take when keyword is detected (BLOCK or MASK)" + ) + description: Optional[str] = Field( + default=None, description="Optional description explaining why this keyword is sensitive" + ) + + +class ContentFilterPattern(BaseModel): + """Represents a content filter pattern (prebuilt or custom regex)""" + + pattern_type: Literal["prebuilt", "regex"] = Field( + description="Type of pattern: 'prebuilt' for predefined patterns or 'regex' for custom" + ) + pattern_name: Optional[str] = Field( + default=None, + description="Name of prebuilt pattern (e.g., 'us_ssn', 'credit_card'). Required if pattern_type is 'prebuilt'" + ) + pattern: Optional[str] = Field( + default=None, + description="Custom regex pattern. Required if pattern_type is 'regex'" + ) + name: Optional[str] = Field( + default=None, + description="Name for this pattern (used in logging and error messages)" + ) + action: ContentFilterAction = Field( + description="Action to take when pattern matches (BLOCK or MASK)" + ) + + +class ContentFilterConfigModel(BaseModel): + """Configuration parameters for the content filter guardrail""" + + patterns: Optional[List[ContentFilterPattern]] = Field( + default=None, + description="List of patterns (prebuilt or custom regex) to detect" + ) + blocked_words: Optional[List[BlockedWord]] = Field( + default=None, + description="List of blocked words with individual actions" + ) + blocked_words_file: Optional[str] = Field( + default=None, + description="Path to YAML file containing blocked_words list" + ) + + class BaseLitellmParams(BaseModel): # works for new and patch update guardrails api_key: Optional[str] = Field( default=None, description="API key for the guardrail service" @@ -534,6 +594,7 @@ class LitellmParams( NomaGuardrailConfigModel, ToolPermissionGuardrailConfigModel, JavelinGuardrailConfigModel, + ContentFilterConfigModel, BaseLitellmParams, EnkryptAIGuardrailConfigs, IBMGuardrailsBaseConfigModel, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py new file mode 100644 index 0000000000..27530abd86 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py @@ -0,0 +1,469 @@ +""" +Tests for the Content Filter Guardrail +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../") +) # Adds the parent directory to the system path + +from fastapi import HTTPException + +import litellm +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( + ContentFilterGuardrail, +) +from litellm.types.guardrails import ( + BlockedWord, + ContentFilterAction, + ContentFilterPattern, + GuardrailEventHooks, +) + + +class TestContentFilterGuardrail: + """Test the ContentFilterGuardrail class""" + + def test_init_with_patterns(self): + """ + Test initialization with prebuilt patterns + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-content-filter", + patterns=patterns, + ) + + assert guardrail.guardrail_name == "test-content-filter" + assert len(guardrail.compiled_patterns) == 1 + + def test_init_with_blocked_words(self): + """ + Test initialization with blocked words + """ + blocked_words = [ + BlockedWord( + keyword="secret_project", + action=ContentFilterAction.BLOCK, + description="Top secret project" + ), + BlockedWord( + keyword="internal_api", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-content-filter", + blocked_words=blocked_words, + ) + + assert len(guardrail.blocked_words) == 2 + assert "secret_project" in guardrail.blocked_words + assert guardrail.blocked_words["secret_project"][0] == ContentFilterAction.BLOCK + + def test_check_patterns_ssn(self): + """ + Test SSN pattern detection + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-ssn", + patterns=patterns, + ) + + # Test with SSN + result = guardrail._check_patterns("My SSN is 123-45-6789") + assert result is not None + assert result[1] == "us_ssn" + assert result[2] == ContentFilterAction.BLOCK + + # Test without SSN + result = guardrail._check_patterns("This is a normal message") + assert result is None + + def test_check_patterns_email(self): + """ + Test email pattern detection + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-email", + patterns=patterns, + ) + + result = guardrail._check_patterns("Contact me at test@example.com") + assert result is not None + assert result[1] == "email" + assert result[2] == ContentFilterAction.MASK + + def test_check_patterns_custom_regex(self): + """ + Test custom regex pattern detection + """ + patterns = [ + ContentFilterPattern( + pattern_type="regex", + pattern=r"\b[A-Z]{3}-\d{4}\b", + name="custom_id", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-custom", + patterns=patterns, + ) + + result = guardrail._check_patterns("My ID is ABC-1234") + assert result is not None + assert result[1] == "custom_id" + + def test_check_blocked_words(self): + """ + Test blocked word detection + """ + blocked_words = [ + BlockedWord( + keyword="confidential", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-words", + blocked_words=blocked_words, + ) + + # Test with blocked word + result = guardrail._check_blocked_words("This is CONFIDENTIAL information") + assert result is not None + assert result[0] == "confidential" + assert result[1] == ContentFilterAction.BLOCK + + # Test without blocked word + result = guardrail._check_blocked_words("This is normal information") + assert result is None + + @pytest.mark.asyncio + async def test_apply_guardrail_block(self): + """ + Test apply_guardrail with BLOCK action + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-block", + patterns=patterns, + ) + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail(text="My SSN is 123-45-6789") + + assert exc_info.value.status_code == 400 + assert "us_ssn" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_apply_guardrail_mask(self): + """ + Test apply_guardrail with MASK action + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-mask", + patterns=patterns, + ) + + result = await guardrail.apply_guardrail(text="Contact me at test@example.com") + + assert result is not None + assert "[EMAIL_REDACTED]" in result + assert "test@example.com" not in result + + @pytest.mark.asyncio + async def test_apply_guardrail_blocked_word_mask(self): + """ + Test apply_guardrail with blocked word MASK action + """ + blocked_words = [ + BlockedWord( + keyword="proprietary", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-word-mask", + blocked_words=blocked_words, + ) + + result = await guardrail.apply_guardrail(text="This is PROPRIETARY information") + + assert result is not None + assert "[KEYWORD_REDACTED]" in result + assert "PROPRIETARY" not in result + + @pytest.mark.asyncio + async def test_apply_guardrail_multiple_patterns(self): + """ + Test apply_guardrail with multiple patterns in the same text + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-multiple", + patterns=patterns, + ) + + result = await guardrail.apply_guardrail( + text="Contact user@test.com or SSN: 123-45-6789" + ) + + assert result is not None + # At least one pattern should be redacted (first match wins) + assert "[EMAIL_REDACTED]" in result or "[US_SSN_REDACTED]" in result + + def test_mask_content(self): + """ + Test content masking + """ + guardrail = ContentFilterGuardrail( + guardrail_name="test-mask", + ) + + masked = guardrail._mask_content("sensitive text", "us_ssn") + assert masked == "[US_SSN_REDACTED]" + + def test_load_blocked_words_file(self): + """ + Test loading blocked words from a YAML file + """ + import tempfile + + # Create a temporary blocked words file + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("""blocked_words: + - keyword: "test_keyword" + action: "BLOCK" + description: "Test keyword" + - keyword: "another_word" + action: "MASK" +""") + temp_file = f.name + + try: + guardrail = ContentFilterGuardrail( + guardrail_name="test-file-load", + blocked_words_file=temp_file, + ) + + assert len(guardrail.blocked_words) == 2 + assert "test_keyword" in guardrail.blocked_words + assert guardrail.blocked_words["test_keyword"][0] == ContentFilterAction.BLOCK + assert guardrail.blocked_words["test_keyword"][1] == "Test keyword" + assert "another_word" in guardrail.blocked_words + assert guardrail.blocked_words["another_word"][0] == ContentFilterAction.MASK + finally: + os.unlink(temp_file) + + def test_credit_card_patterns(self): + """ + Test credit card pattern detection + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="visa", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-cc", + patterns=patterns, + ) + + # Test Visa card + result = guardrail._check_patterns("My card is 4532-1234-5678-9010") + assert result is not None + assert result[1] == "visa" + + def test_api_key_patterns(self): + """ + Test API key pattern detection + """ + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="aws_access_key", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-api-key", + patterns=patterns, + ) + + # Test AWS Access Key + result = guardrail._check_patterns("My key is AKIAIOSFODNN7EXAMPLE") + assert result is not None + assert result[1] == "aws_access_key" + + @pytest.mark.asyncio + async def test_streaming_hook_mask(self): + """ + Test streaming hook with MASK action + """ + from unittest.mock import AsyncMock + + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-streaming-mask", + patterns=patterns, + event_hook=GuardrailEventHooks.during_call, + ) + + # Create mock streaming chunks + async def mock_stream(): + # Chunk 1: contains email + chunk1 = ModelResponseStream( + id="chunk1", + choices=[StreamingChoices(delta=Delta(content="Contact me at test@example.com"), index=0)], + model="gpt-4", + ) + yield chunk1 + + # Chunk 2: normal content + chunk2 = ModelResponseStream( + id="chunk2", + choices=[StreamingChoices(delta=Delta(content=" for more info"), index=0)], + model="gpt-4", + ) + yield chunk2 + + user_api_key_dict = MagicMock() + request_data = {} + + # Process streaming response + result_chunks = [] + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=user_api_key_dict, + response=mock_stream(), + request_data=request_data, + ): + result_chunks.append(chunk) + + assert len(result_chunks) == 2 + # First chunk should have email masked + assert "[EMAIL_REDACTED]" in result_chunks[0].choices[0].delta.content + assert "test@example.com" not in result_chunks[0].choices[0].delta.content + # Second chunk should be unchanged + assert result_chunks[1].choices[0].delta.content == " for more info" + + @pytest.mark.asyncio + async def test_streaming_hook_block(self): + """ + Test streaming hook with BLOCK action + """ + from unittest.mock import AsyncMock + + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="test-streaming-block", + patterns=patterns, + event_hook=GuardrailEventHooks.during_call, + ) + + # Create mock streaming chunks with SSN + async def mock_stream(): + chunk = ModelResponseStream( + id="chunk1", + choices=[StreamingChoices(delta=Delta(content="SSN: 123-45-6789"), index=0)], + model="gpt-4", + ) + yield chunk + + user_api_key_dict = MagicMock() + request_data = {} + + # Should raise HTTPException when SSN is detected + with pytest.raises(HTTPException) as exc_info: + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=user_api_key_dict, + response=mock_stream(), + request_data=request_data, + ): + pass + + assert exc_info.value.status_code == 400 + assert "us_ssn" in str(exc_info.value.detail) +