[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>
This commit is contained in:
Ishaan Jaff
2025-11-06 16:02:28 -08:00
committed by GitHub
co-authored by Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 1fec48499f
commit 18a5c4f75a
8 changed files with 1474 additions and 1 deletions
@@ -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
<Tabs>
<TabItem label="SSN Blocked" value="ssn-blocked">
```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"
}
}
```
</TabItem>
<TabItem label="Email Masked" value="email-masked">
```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]
```
</TabItem>
</Tabs>
## 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"
```
+1
View File
@@ -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",
@@ -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,
)
@@ -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,
}
@@ -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"
)
@@ -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())
+61
View File
@@ -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,
@@ -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)