mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-21 06:27:00 +00:00
[Feat] UI - Allow configuring LiteLLM Custom Guardrail (#16339)
* 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 * use ENUM for PrebuiltPatternName * fix validate_blocked_words_file * add validateBlockedWordsFile * LitellmContentFilterGuardrailConfigModel * add get_config_model * add litellm content filter * refactored ui * fix config * tests for new components
This commit is contained in:
@@ -693,7 +693,13 @@ async def get_guardrail_ui_settings():
|
||||
- Supported entities for guardrails
|
||||
- Supported modes for guardrails
|
||||
- PII entity categories for UI organization
|
||||
- Content filter settings (patterns and categories)
|
||||
"""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
|
||||
PATTERN_CATEGORIES,
|
||||
get_pattern_metadata,
|
||||
)
|
||||
|
||||
# Convert the PII_ENTITY_CATEGORIES_MAP to the format expected by the UI
|
||||
category_maps = []
|
||||
for category, entities in PII_ENTITY_CATEGORIES_MAP.items():
|
||||
@@ -707,9 +713,111 @@ async def get_guardrail_ui_settings():
|
||||
supported_actions=[action.value for action in PiiAction],
|
||||
supported_modes=[mode.value for mode in GuardrailEventHooks],
|
||||
pii_entity_categories=category_maps,
|
||||
content_filter_settings={
|
||||
"prebuilt_patterns": get_pattern_metadata(),
|
||||
"pattern_categories": list(PATTERN_CATEGORIES.keys()),
|
||||
"supported_actions": ["BLOCK", "MASK"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/guardrails/validate_blocked_words_file",
|
||||
tags=["Guardrails"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def validate_blocked_words_file(request: Dict[str, str]):
|
||||
"""
|
||||
Validate a blocked_words YAML file content.
|
||||
|
||||
Args:
|
||||
request: Dictionary with 'file_content' key containing the YAML string
|
||||
|
||||
Returns:
|
||||
Dictionary with 'valid' boolean and either 'message'/'errors' depending on result
|
||||
|
||||
Example Request:
|
||||
```json
|
||||
{
|
||||
"file_content": "blocked_words:\\n - keyword: \\"test\\"\\n action: \\"BLOCK\\""
|
||||
}
|
||||
```
|
||||
|
||||
Example Success Response:
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"message": "Valid YAML file with 2 blocked words"
|
||||
}
|
||||
```
|
||||
|
||||
Example Error Response:
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"errors": ["Entry 0: missing 'action' field"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
import yaml
|
||||
|
||||
try:
|
||||
file_content = request.get("file_content", "")
|
||||
if not file_content:
|
||||
return {
|
||||
"valid": False,
|
||||
"error": "No file content provided"
|
||||
}
|
||||
|
||||
data = yaml.safe_load(file_content)
|
||||
|
||||
if not isinstance(data, dict) or "blocked_words" not in data:
|
||||
return {
|
||||
"valid": False,
|
||||
"error": "Invalid format: file must contain 'blocked_words' key with a list"
|
||||
}
|
||||
|
||||
blocked_words_list = data["blocked_words"]
|
||||
if not isinstance(blocked_words_list, list):
|
||||
return {
|
||||
"valid": False,
|
||||
"error": "'blocked_words' must be a list"
|
||||
}
|
||||
|
||||
# Validate each entry
|
||||
errors = []
|
||||
for idx, word_data in enumerate(blocked_words_list):
|
||||
if not isinstance(word_data, dict):
|
||||
errors.append(f"Entry {idx}: must be an object")
|
||||
continue
|
||||
|
||||
if "keyword" not in word_data:
|
||||
errors.append(f"Entry {idx}: missing 'keyword' field")
|
||||
elif not isinstance(word_data["keyword"], str):
|
||||
errors.append(f"Entry {idx}: 'keyword' must be a string")
|
||||
|
||||
if "action" not in word_data:
|
||||
errors.append(f"Entry {idx}: missing 'action' field")
|
||||
elif word_data["action"] not in ["BLOCK", "MASK"]:
|
||||
errors.append(f"Entry {idx}: action must be 'BLOCK' or 'MASK', got '{word_data['action']}'")
|
||||
|
||||
if "description" in word_data and not isinstance(word_data["description"], str):
|
||||
errors.append(f"Entry {idx}: 'description' must be a string")
|
||||
|
||||
if errors:
|
||||
return {"valid": False, "errors": errors}
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"message": f"Valid YAML file with {len(blocked_words_list)} blocked word(s)"
|
||||
}
|
||||
except yaml.YAMLError as e:
|
||||
return {"valid": False, "error": f"Invalid YAML syntax: {str(e)}"}
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error validating blocked words file")
|
||||
return {"valid": False, "error": f"Validation error: {str(e)}"}
|
||||
|
||||
|
||||
def _get_field_type_from_annotation(field_annotation: Any) -> str:
|
||||
"""
|
||||
Convert a Python type annotation to a UI-friendly type string
|
||||
|
||||
@@ -193,7 +193,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
if match:
|
||||
matched_text = match.group(0)
|
||||
verbose_proxy_logger.debug(
|
||||
f"Pattern '{pattern_name}' matched."
|
||||
f"Pattern '{pattern_name}' matched: {matched_text[:20]}..."
|
||||
)
|
||||
return (matched_text, pattern_name, action)
|
||||
return None
|
||||
@@ -365,4 +365,12 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
verbose_proxy_logger.debug(
|
||||
"ContentFilterGuardrail: Streaming check completed"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_config_model():
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
|
||||
LitellmContentFilterGuardrailConfigModel,
|
||||
)
|
||||
|
||||
return LitellmContentFilterGuardrailConfigModel
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ like SSNs, credit cards, API keys, etc.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, Pattern
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Pattern
|
||||
|
||||
# US Social Security Number patterns
|
||||
US_SSN_PATTERN = r"\b\d{3}-\d{2}-\d{4}\b" # Format: 123-45-6789
|
||||
@@ -39,33 +40,63 @@ IPV6_PATTERN = r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b"
|
||||
URL_PATTERN = r"\b(?:https?://|www\.)[^\s/$.?#].[^\s]*\b"
|
||||
|
||||
|
||||
PREBUILT_PATTERNS: Dict[str, str] = {
|
||||
class PrebuiltPatternName(str, Enum):
|
||||
"""Enum for prebuilt pattern names"""
|
||||
# SSN patterns
|
||||
"us_ssn": US_SSN_PATTERN,
|
||||
"us_ssn_no_dash": US_SSN_NO_DASH_PATTERN,
|
||||
US_SSN = "us_ssn"
|
||||
US_SSN_NO_DASH = "us_ssn_no_dash"
|
||||
|
||||
# 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})",
|
||||
VISA = "visa"
|
||||
MASTERCARD = "mastercard"
|
||||
AMEX = "amex"
|
||||
DISCOVER = "discover"
|
||||
CREDIT_CARD = "credit_card"
|
||||
|
||||
# Contact information
|
||||
"email": EMAIL_PATTERN,
|
||||
"us_phone": US_PHONE_PATTERN,
|
||||
EMAIL = "email"
|
||||
US_PHONE = "us_phone"
|
||||
|
||||
# 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,
|
||||
AWS_ACCESS_KEY = "aws_access_key"
|
||||
AWS_SECRET_KEY = "aws_secret_key"
|
||||
GITHUB_TOKEN = "github_token"
|
||||
SLACK_TOKEN = "slack_token"
|
||||
GENERIC_API_KEY = "generic_api_key"
|
||||
|
||||
# Network identifiers
|
||||
"ipv4": IPV4_PATTERN,
|
||||
"ipv6": IPV6_PATTERN,
|
||||
"url": URL_PATTERN,
|
||||
IPV4 = "ipv4"
|
||||
IPV6 = "ipv6"
|
||||
URL = "url"
|
||||
|
||||
|
||||
PREBUILT_PATTERNS: Dict[str, str] = {
|
||||
# SSN patterns
|
||||
PrebuiltPatternName.US_SSN.value: US_SSN_PATTERN,
|
||||
PrebuiltPatternName.US_SSN_NO_DASH.value: US_SSN_NO_DASH_PATTERN,
|
||||
|
||||
# Credit card patterns
|
||||
PrebuiltPatternName.VISA.value: VISA_PATTERN,
|
||||
PrebuiltPatternName.MASTERCARD.value: MASTERCARD_PATTERN,
|
||||
PrebuiltPatternName.AMEX.value: AMEX_PATTERN,
|
||||
PrebuiltPatternName.DISCOVER.value: DISCOVER_PATTERN,
|
||||
PrebuiltPatternName.CREDIT_CARD.value: rf"(?:{VISA_PATTERN}|{MASTERCARD_PATTERN}|{AMEX_PATTERN}|{DISCOVER_PATTERN})",
|
||||
|
||||
# Contact information
|
||||
PrebuiltPatternName.EMAIL.value: EMAIL_PATTERN,
|
||||
PrebuiltPatternName.US_PHONE.value: US_PHONE_PATTERN,
|
||||
|
||||
# API keys and secrets
|
||||
PrebuiltPatternName.AWS_ACCESS_KEY.value: AWS_ACCESS_KEY_PATTERN,
|
||||
PrebuiltPatternName.AWS_SECRET_KEY.value: AWS_SECRET_KEY_PATTERN,
|
||||
PrebuiltPatternName.GITHUB_TOKEN.value: GITHUB_TOKEN_PATTERN,
|
||||
PrebuiltPatternName.SLACK_TOKEN.value: SLACK_TOKEN_PATTERN,
|
||||
PrebuiltPatternName.GENERIC_API_KEY.value: GENERIC_API_KEY_PATTERN,
|
||||
|
||||
# Network identifiers
|
||||
PrebuiltPatternName.IPV4.value: IPV4_PATTERN,
|
||||
PrebuiltPatternName.IPV6.value: IPV6_PATTERN,
|
||||
PrebuiltPatternName.URL.value: URL_PATTERN,
|
||||
}
|
||||
|
||||
|
||||
@@ -101,3 +132,76 @@ def get_all_pattern_names():
|
||||
"""
|
||||
return list(PREBUILT_PATTERNS.keys())
|
||||
|
||||
|
||||
# Pattern categories for UI organization
|
||||
PATTERN_CATEGORIES: Dict[str, List[str]] = {
|
||||
"PII Patterns": [
|
||||
PrebuiltPatternName.US_SSN.value,
|
||||
PrebuiltPatternName.EMAIL.value,
|
||||
PrebuiltPatternName.US_PHONE.value,
|
||||
],
|
||||
"Payment Card Patterns": [
|
||||
PrebuiltPatternName.VISA.value,
|
||||
PrebuiltPatternName.MASTERCARD.value,
|
||||
PrebuiltPatternName.AMEX.value,
|
||||
PrebuiltPatternName.DISCOVER.value,
|
||||
PrebuiltPatternName.CREDIT_CARD.value,
|
||||
],
|
||||
"Credential Patterns": [
|
||||
PrebuiltPatternName.AWS_ACCESS_KEY.value,
|
||||
PrebuiltPatternName.AWS_SECRET_KEY.value,
|
||||
PrebuiltPatternName.GITHUB_TOKEN.value,
|
||||
PrebuiltPatternName.SLACK_TOKEN.value,
|
||||
PrebuiltPatternName.GENERIC_API_KEY.value,
|
||||
],
|
||||
"Network Patterns": [
|
||||
PrebuiltPatternName.IPV4.value,
|
||||
PrebuiltPatternName.IPV6.value,
|
||||
PrebuiltPatternName.URL.value,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Pattern descriptions for UI display
|
||||
PATTERN_DESCRIPTIONS: Dict[str, str] = {
|
||||
PrebuiltPatternName.US_SSN.value: "Detects US Social Security Numbers (XXX-XX-XXXX format)",
|
||||
PrebuiltPatternName.US_SSN_NO_DASH.value: "Detects US SSN without dashes (XXXXXXXXX format)",
|
||||
PrebuiltPatternName.EMAIL.value: "Detects email addresses",
|
||||
PrebuiltPatternName.US_PHONE.value: "Detects US phone numbers in various formats",
|
||||
PrebuiltPatternName.VISA.value: "Detects Visa credit card numbers",
|
||||
PrebuiltPatternName.MASTERCARD.value: "Detects Mastercard credit card numbers",
|
||||
PrebuiltPatternName.AMEX.value: "Detects American Express credit card numbers",
|
||||
PrebuiltPatternName.DISCOVER.value: "Detects Discover credit card numbers",
|
||||
PrebuiltPatternName.CREDIT_CARD.value: "Detects any major credit card number",
|
||||
PrebuiltPatternName.AWS_ACCESS_KEY.value: "Detects AWS access keys (AKIA...)",
|
||||
PrebuiltPatternName.AWS_SECRET_KEY.value: "Detects AWS secret keys (40 characters)",
|
||||
PrebuiltPatternName.GITHUB_TOKEN.value: "Detects GitHub personal access tokens",
|
||||
PrebuiltPatternName.SLACK_TOKEN.value: "Detects Slack API tokens",
|
||||
PrebuiltPatternName.GENERIC_API_KEY.value: "Detects generic API key patterns",
|
||||
PrebuiltPatternName.IPV4.value: "Detects IPv4 addresses",
|
||||
PrebuiltPatternName.IPV6.value: "Detects IPv6 addresses",
|
||||
PrebuiltPatternName.URL.value: "Detects URLs (http/https)",
|
||||
}
|
||||
|
||||
|
||||
def get_pattern_metadata() -> List[Dict[str, str]]:
|
||||
"""
|
||||
Return pattern metadata for UI display.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing pattern name, category, and description
|
||||
"""
|
||||
prebuilt_patterns = []
|
||||
for category, pattern_names in PATTERN_CATEGORIES.items():
|
||||
for pattern_name in pattern_names:
|
||||
if pattern_name in PREBUILT_PATTERNS:
|
||||
prebuilt_patterns.append({
|
||||
"name": pattern_name,
|
||||
"category": category,
|
||||
"description": PATTERN_DESCRIPTIONS.get(
|
||||
pattern_name,
|
||||
f"Detects {pattern_name.replace('_', ' ').title()}"
|
||||
),
|
||||
})
|
||||
return prebuilt_patterns
|
||||
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
general_settings:
|
||||
key_management_system: "cyberark"
|
||||
key_management_settings:
|
||||
store_virtual_keys: true
|
||||
prefix_for_stored_virtual_keys: "litellm/"
|
||||
access_mode: "read_and_write"
|
||||
|
||||
model_list:
|
||||
- model_name: aws/anthropic/bedrock-claude-3-5-sonnet-v1
|
||||
litellm_params:
|
||||
@@ -52,4 +45,8 @@ litellm_settings:
|
||||
cache: True
|
||||
cache_params:
|
||||
type: local
|
||||
drop_params: True
|
||||
drop_params: True
|
||||
|
||||
|
||||
general_settings:
|
||||
store_prompts_in_spend_logs: True
|
||||
@@ -676,6 +676,7 @@ class GuardrailUIAddGuardrailSettings(BaseModel):
|
||||
supported_actions: List[str]
|
||||
supported_modes: List[str]
|
||||
pii_entity_categories: List[PiiEntityCategoryMap]
|
||||
content_filter_settings: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PresidioPerRequestConfig(BaseModel):
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
|
||||
|
||||
|
||||
class LitellmContentFilterGuardrailConfigModel(GuardrailConfigModel):
|
||||
@staticmethod
|
||||
def ui_friendly_name() -> str:
|
||||
return "LiteLLM Content Filter"
|
||||
@@ -4,6 +4,7 @@ import { Button, TextInput } from "@tremor/react";
|
||||
import {
|
||||
guardrail_provider_map,
|
||||
shouldRenderPIIConfigSettings,
|
||||
shouldRenderContentFilterConfigSettings,
|
||||
guardrailLogoMap,
|
||||
populateGuardrailProviders,
|
||||
populateGuardrailProviderMap,
|
||||
@@ -14,6 +15,7 @@ import PiiConfiguration from "./pii_configuration";
|
||||
import GuardrailProviderFields from "./guardrail_provider_fields";
|
||||
import GuardrailOptionalParams from "./guardrail_optional_params";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration";
|
||||
|
||||
const { Title, Text, Link } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -44,6 +46,15 @@ interface GuardrailSettings {
|
||||
category: string;
|
||||
entities: string[];
|
||||
}>;
|
||||
content_filter_settings?: {
|
||||
prebuilt_patterns: Array<{
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}>;
|
||||
pattern_categories: string[];
|
||||
supported_actions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface LiteLLMParams {
|
||||
@@ -85,6 +96,10 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
const [globalSeverityThreshold, setGlobalSeverityThreshold] = useState<number>(2);
|
||||
const [categorySpecificThresholds, setCategorySpecificThresholds] = useState<{ [key: string]: number }>({});
|
||||
|
||||
// Content Filter state
|
||||
const [selectedPatterns, setSelectedPatterns] = useState<any[]>([]);
|
||||
const [blockedWords, setBlockedWords] = useState<any[]>([]);
|
||||
|
||||
// Fetch guardrail UI settings + provider params on mount / accessToken change
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
@@ -266,6 +281,26 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
guardrailData.litellm_params.presidio_anonymizer_api_base = values.presidio_anonymizer_api_base;
|
||||
}
|
||||
}
|
||||
|
||||
// For Content Filter, add patterns and blocked words
|
||||
if (shouldRenderContentFilterConfigSettings(values.provider)) {
|
||||
if (selectedPatterns.length > 0) {
|
||||
guardrailData.litellm_params.patterns = selectedPatterns.map((p) => ({
|
||||
pattern_type: p.type === "prebuilt" ? "prebuilt" : "regex",
|
||||
pattern_name: p.type === "prebuilt" ? p.name : undefined,
|
||||
pattern: p.type === "custom" ? p.pattern : undefined,
|
||||
name: p.name,
|
||||
action: p.action,
|
||||
}));
|
||||
}
|
||||
if (blockedWords.length > 0) {
|
||||
guardrailData.litellm_params.blocked_words = blockedWords.map((w) => ({
|
||||
keyword: w.keyword,
|
||||
action: w.action,
|
||||
description: w.description,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Add config values to the guardrail_info if provided
|
||||
else if (values.config) {
|
||||
try {
|
||||
@@ -524,6 +559,38 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
);
|
||||
};
|
||||
|
||||
const renderContentFilterConfiguration = (step: "patterns" | "keywords") => {
|
||||
if (!guardrailSettings || !shouldRenderContentFilterConfigSettings(selectedProvider)) return null;
|
||||
|
||||
const contentFilterSettings = guardrailSettings.content_filter_settings;
|
||||
if (!contentFilterSettings) return null;
|
||||
|
||||
return (
|
||||
<ContentFilterConfiguration
|
||||
prebuiltPatterns={contentFilterSettings.prebuilt_patterns || []}
|
||||
categories={contentFilterSettings.pattern_categories || []}
|
||||
selectedPatterns={selectedPatterns}
|
||||
blockedWords={blockedWords}
|
||||
onPatternAdd={(pattern) => setSelectedPatterns([...selectedPatterns, pattern])}
|
||||
onPatternRemove={(id) => setSelectedPatterns(selectedPatterns.filter((p) => p.id !== id))}
|
||||
onPatternActionChange={(id, action) => {
|
||||
setSelectedPatterns(
|
||||
selectedPatterns.map((p) => (p.id === id ? { ...p, action } : p))
|
||||
);
|
||||
}}
|
||||
onBlockedWordAdd={(word) => setBlockedWords([...blockedWords, word])}
|
||||
onBlockedWordRemove={(id) => setBlockedWords(blockedWords.filter((w) => w.id !== id))}
|
||||
onBlockedWordUpdate={(id, field, value) => {
|
||||
setBlockedWords(
|
||||
blockedWords.map((w) => (w.id === id ? { ...w, [field]: value } : w))
|
||||
);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
showStep={step}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOptionalParams = () => {
|
||||
if (!selectedProvider || !providerParams) return null;
|
||||
|
||||
@@ -545,13 +612,24 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
if (shouldRenderPIIConfigSettings(selectedProvider)) {
|
||||
return renderPiiConfiguration();
|
||||
}
|
||||
if (shouldRenderContentFilterConfigSettings(selectedProvider)) {
|
||||
return renderContentFilterConfiguration("patterns");
|
||||
}
|
||||
return renderOptionalParams();
|
||||
case 2:
|
||||
if (shouldRenderContentFilterConfigSettings(selectedProvider)) {
|
||||
return renderContentFilterConfiguration("keywords");
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepButtons = () => {
|
||||
const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 3 : 2;
|
||||
const isLastStep = currentStep === totalSteps - 1;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
{currentStep > 0 && (
|
||||
@@ -559,8 +637,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < 2 && <Button onClick={nextStep}>Next</Button>}
|
||||
{currentStep === 2 && (
|
||||
{!isLastStep && <Button onClick={nextStep}>Next</Button>}
|
||||
{isLastStep && (
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Create Guardrail
|
||||
</Button>
|
||||
@@ -585,8 +663,17 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
<Steps current={currentStep} className="mb-6">
|
||||
<Step title="Basic Info" />
|
||||
<Step
|
||||
title={shouldRenderPIIConfigSettings(selectedProvider) ? "PII Configuration" : "Provider Configuration"}
|
||||
title={
|
||||
shouldRenderPIIConfigSettings(selectedProvider)
|
||||
? "PII Configuration"
|
||||
: shouldRenderContentFilterConfigSettings(selectedProvider)
|
||||
? "Pattern Detection"
|
||||
: "Provider Configuration"
|
||||
}
|
||||
/>
|
||||
{shouldRenderContentFilterConfigSettings(selectedProvider) && (
|
||||
<Step title="Blocked Keywords" />
|
||||
)}
|
||||
</Steps>
|
||||
|
||||
{renderStepContent()}
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
import React, { useState } from "react";
|
||||
import { Typography, Space, Upload, Card } from "antd";
|
||||
import { PlusOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@tremor/react";
|
||||
import { validateBlockedWordsFile } from "../../networking";
|
||||
import NotificationsManager from "../../molecules/notifications_manager";
|
||||
import PatternModal from "./PatternModal";
|
||||
import CustomPatternModal from "./CustomPatternModal";
|
||||
import KeywordModal from "./KeywordModal";
|
||||
import PatternTable from "./PatternTable";
|
||||
import KeywordTable from "./KeywordTable";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface PrebuiltPattern {
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Pattern {
|
||||
id: string;
|
||||
type: "prebuilt" | "custom";
|
||||
name: string;
|
||||
pattern?: string;
|
||||
action: "BLOCK" | "MASK";
|
||||
}
|
||||
|
||||
interface BlockedWord {
|
||||
id: string;
|
||||
keyword: string;
|
||||
action: "BLOCK" | "MASK";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ContentFilterConfigurationProps {
|
||||
prebuiltPatterns: PrebuiltPattern[];
|
||||
categories: string[];
|
||||
selectedPatterns: Pattern[];
|
||||
blockedWords: BlockedWord[];
|
||||
onPatternAdd: (pattern: Pattern) => void;
|
||||
onPatternRemove: (id: string) => void;
|
||||
onPatternActionChange: (id: string, action: "BLOCK" | "MASK") => void;
|
||||
onBlockedWordAdd: (word: BlockedWord) => void;
|
||||
onBlockedWordRemove: (id: string) => void;
|
||||
onBlockedWordUpdate: (id: string, field: string, value: any) => void;
|
||||
onFileUpload?: (content: string) => void;
|
||||
accessToken: string | null;
|
||||
showStep?: "patterns" | "keywords";
|
||||
}
|
||||
|
||||
const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
|
||||
prebuiltPatterns,
|
||||
categories,
|
||||
selectedPatterns,
|
||||
blockedWords,
|
||||
onPatternAdd,
|
||||
onPatternRemove,
|
||||
onPatternActionChange,
|
||||
onBlockedWordAdd,
|
||||
onBlockedWordRemove,
|
||||
onBlockedWordUpdate,
|
||||
onFileUpload,
|
||||
accessToken,
|
||||
showStep,
|
||||
}) => {
|
||||
const [patternModalVisible, setPatternModalVisible] = useState(false);
|
||||
const [keywordModalVisible, setKeywordModalVisible] = useState(false);
|
||||
const [customPatternModalVisible, setCustomPatternModalVisible] = useState(false);
|
||||
|
||||
const [selectedPatternName, setSelectedPatternName] = useState<string>("");
|
||||
const [patternAction, setPatternAction] = useState<"BLOCK" | "MASK">("BLOCK");
|
||||
const [customPatternName, setCustomPatternName] = useState<string>("");
|
||||
const [customPatternRegex, setCustomPatternRegex] = useState<string>("");
|
||||
const [customPatternAction, setCustomPatternAction] = useState<"BLOCK" | "MASK">("BLOCK");
|
||||
const [newKeyword, setNewKeyword] = useState<string>("");
|
||||
const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK");
|
||||
const [newKeywordDescription, setNewKeywordDescription] = useState<string>("");
|
||||
const [uploadValidating, setUploadValidating] = useState(false);
|
||||
|
||||
const handleAddPrebuiltPattern = () => {
|
||||
if (!selectedPatternName) {
|
||||
NotificationsManager.error("Please select a pattern");
|
||||
return;
|
||||
}
|
||||
|
||||
onPatternAdd({
|
||||
id: `pattern-${Date.now()}`,
|
||||
type: "prebuilt",
|
||||
name: selectedPatternName,
|
||||
action: patternAction,
|
||||
});
|
||||
|
||||
setPatternModalVisible(false);
|
||||
setSelectedPatternName("");
|
||||
setPatternAction("BLOCK");
|
||||
};
|
||||
|
||||
const handleAddCustomPattern = () => {
|
||||
if (!customPatternName || !customPatternRegex) {
|
||||
NotificationsManager.error("Please provide pattern name and regex");
|
||||
return;
|
||||
}
|
||||
|
||||
onPatternAdd({
|
||||
id: `custom-${Date.now()}`,
|
||||
type: "custom",
|
||||
name: customPatternName,
|
||||
pattern: customPatternRegex,
|
||||
action: customPatternAction,
|
||||
});
|
||||
|
||||
setCustomPatternModalVisible(false);
|
||||
setCustomPatternName("");
|
||||
setCustomPatternRegex("");
|
||||
setCustomPatternAction("BLOCK");
|
||||
};
|
||||
|
||||
const handleAddKeyword = () => {
|
||||
if (!newKeyword) {
|
||||
NotificationsManager.error("Please enter a keyword");
|
||||
return;
|
||||
}
|
||||
|
||||
onBlockedWordAdd({
|
||||
id: `word-${Date.now()}`,
|
||||
keyword: newKeyword,
|
||||
action: newKeywordAction,
|
||||
description: newKeywordDescription || undefined,
|
||||
});
|
||||
|
||||
setKeywordModalVisible(false);
|
||||
setNewKeyword("");
|
||||
setNewKeywordDescription("");
|
||||
setNewKeywordAction("BLOCK");
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
setUploadValidating(true);
|
||||
try {
|
||||
const content = await file.text();
|
||||
|
||||
if (accessToken) {
|
||||
const result = await validateBlockedWordsFile(accessToken, content);
|
||||
if (result.valid) {
|
||||
if (onFileUpload) {
|
||||
onFileUpload(content);
|
||||
}
|
||||
NotificationsManager.success(result.message || "File uploaded successfully");
|
||||
} else {
|
||||
const errorMessage = result.error || (result.errors && result.errors.join(", ")) || "Invalid file";
|
||||
NotificationsManager.error(`Validation failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
NotificationsManager.error(`Failed to upload file: ${error}`);
|
||||
} finally {
|
||||
setUploadValidating(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const showPatterns = !showStep || showStep === "patterns";
|
||||
const showKeywords = !showStep || showStep === "keywords";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!showStep && (
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
Configure patterns and keywords to detect and filter sensitive information in requests and responses.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPatterns && (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Pattern Detection
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
|
||||
Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button onClick={() => setPatternModalVisible(true)} icon={PlusOutlined}>
|
||||
Add prebuilt pattern
|
||||
</Button>
|
||||
<Button onClick={() => setCustomPatternModalVisible(true)} variant="secondary" icon={PlusOutlined}>
|
||||
Add custom regex
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<PatternTable
|
||||
patterns={selectedPatterns}
|
||||
onActionChange={onPatternActionChange}
|
||||
onRemove={onPatternRemove}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showKeywords && (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Blocked Keywords
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
|
||||
Block or mask specific sensitive terms and phrases
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button onClick={() => setKeywordModalVisible(true)} icon={PlusOutlined}>
|
||||
Add keyword
|
||||
</Button>
|
||||
<Upload beforeUpload={handleFileUpload} accept=".yaml,.yml" showUploadList={false}>
|
||||
<Button variant="secondary" icon={UploadOutlined} loading={uploadValidating}>
|
||||
Upload YAML file
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
<KeywordTable
|
||||
keywords={blockedWords}
|
||||
onActionChange={onBlockedWordUpdate}
|
||||
onRemove={onBlockedWordRemove}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<PatternModal
|
||||
visible={patternModalVisible}
|
||||
prebuiltPatterns={prebuiltPatterns}
|
||||
categories={categories}
|
||||
selectedPatternName={selectedPatternName}
|
||||
patternAction={patternAction}
|
||||
onPatternNameChange={setSelectedPatternName}
|
||||
onActionChange={(value) => setPatternAction(value as "BLOCK" | "MASK")}
|
||||
onAdd={handleAddPrebuiltPattern}
|
||||
onCancel={() => {
|
||||
setPatternModalVisible(false);
|
||||
setSelectedPatternName("");
|
||||
setPatternAction("BLOCK");
|
||||
}}
|
||||
/>
|
||||
|
||||
<CustomPatternModal
|
||||
visible={customPatternModalVisible}
|
||||
patternName={customPatternName}
|
||||
patternRegex={customPatternRegex}
|
||||
patternAction={customPatternAction}
|
||||
onNameChange={setCustomPatternName}
|
||||
onRegexChange={setCustomPatternRegex}
|
||||
onActionChange={(value) => setCustomPatternAction(value as "BLOCK" | "MASK")}
|
||||
onAdd={handleAddCustomPattern}
|
||||
onCancel={() => {
|
||||
setCustomPatternModalVisible(false);
|
||||
setCustomPatternName("");
|
||||
setCustomPatternRegex("");
|
||||
setCustomPatternAction("BLOCK");
|
||||
}}
|
||||
/>
|
||||
|
||||
<KeywordModal
|
||||
visible={keywordModalVisible}
|
||||
keyword={newKeyword}
|
||||
action={newKeywordAction}
|
||||
description={newKeywordDescription}
|
||||
onKeywordChange={setNewKeyword}
|
||||
onActionChange={(value) => setNewKeywordAction(value as "BLOCK" | "MASK")}
|
||||
onDescriptionChange={setNewKeywordDescription}
|
||||
onAdd={handleAddKeyword}
|
||||
onCancel={() => {
|
||||
setKeywordModalVisible(false);
|
||||
setNewKeyword("");
|
||||
setNewKeywordDescription("");
|
||||
setNewKeywordAction("BLOCK");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentFilterConfiguration;
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import CustomPatternModal from "./CustomPatternModal";
|
||||
|
||||
describe("CustomPatternModal", () => {
|
||||
const mockOnAdd = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
const mockOnNameChange = vi.fn();
|
||||
const mockOnRegexChange = vi.fn();
|
||||
const mockOnActionChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should allow entering regex and pattern name and call onAdd when clicking add button", async () => {
|
||||
/**
|
||||
* Tests that user can enter a pattern name and regex, then clicking Add
|
||||
* calls the onAdd callback. This is the core functionality of adding custom patterns.
|
||||
*/
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<CustomPatternModal
|
||||
visible={true}
|
||||
patternName=""
|
||||
patternRegex=""
|
||||
patternAction="BLOCK"
|
||||
onNameChange={mockOnNameChange}
|
||||
onRegexChange={mockOnRegexChange}
|
||||
onActionChange={mockOnActionChange}
|
||||
onAdd={mockOnAdd}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for modal to be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add custom regex pattern")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and fill the pattern name input
|
||||
const nameInput = screen.getByPlaceholderText("e.g., internal_id, employee_code");
|
||||
await user.type(nameInput, "employee_id");
|
||||
|
||||
// Find and fill the regex pattern input - use paste instead of type to avoid special char issues
|
||||
const regexInput = screen.getByPlaceholderText("e.g., ID-[0-9]{6}");
|
||||
await user.click(regexInput);
|
||||
await user.paste("EMP-[0-9]{5}");
|
||||
|
||||
// Verify the change handlers were called
|
||||
expect(mockOnNameChange).toHaveBeenCalled();
|
||||
expect(mockOnRegexChange).toHaveBeenCalled();
|
||||
|
||||
// Find and click the Add button
|
||||
const addButton = screen.getByRole("button", { name: /add/i });
|
||||
await user.click(addButton);
|
||||
|
||||
// Verify onAdd was called
|
||||
expect(mockOnAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Modal, Space } from "antd";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface CustomPatternModalProps {
|
||||
visible: boolean;
|
||||
patternName: string;
|
||||
patternRegex: string;
|
||||
patternAction: "BLOCK" | "MASK";
|
||||
onNameChange: (name: string) => void;
|
||||
onRegexChange: (regex: string) => void;
|
||||
onActionChange: (action: "BLOCK" | "MASK") => void;
|
||||
onAdd: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
|
||||
visible,
|
||||
patternName,
|
||||
patternRegex,
|
||||
patternAction,
|
||||
onNameChange,
|
||||
onRegexChange,
|
||||
onActionChange,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="Add custom regex pattern"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
<div>
|
||||
<Text strong>Pattern name</Text>
|
||||
<TextInput
|
||||
placeholder="e.g., internal_id, employee_code"
|
||||
value={patternName}
|
||||
onValueChange={onNameChange}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Regex pattern</Text>
|
||||
<TextInput
|
||||
placeholder="e.g., ID-[0-9]{6}"
|
||||
value={patternRegex}
|
||||
onValueChange={onRegexChange}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Enter a valid regular expression to match sensitive data
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Action</Text>
|
||||
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
|
||||
Choose what action the guardrail should take when this pattern is detected
|
||||
</Text>
|
||||
<Select
|
||||
value={patternAction}
|
||||
onChange={onActionChange}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Option value="BLOCK">Block</Option>
|
||||
<Option value="MASK">Mask</Option>
|
||||
</Select>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPatternModal;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Modal, Space } from "antd";
|
||||
import { Button, TextInput, Textarea } from "@tremor/react";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface KeywordModalProps {
|
||||
visible: boolean;
|
||||
keyword: string;
|
||||
action: "BLOCK" | "MASK";
|
||||
description: string;
|
||||
onKeywordChange: (keyword: string) => void;
|
||||
onActionChange: (action: "BLOCK" | "MASK") => void;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
onAdd: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const KeywordModal: React.FC<KeywordModalProps> = ({
|
||||
visible,
|
||||
keyword,
|
||||
action,
|
||||
description,
|
||||
onKeywordChange,
|
||||
onActionChange,
|
||||
onDescriptionChange,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="Add blocked keyword"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
<div>
|
||||
<Text strong>Keyword</Text>
|
||||
<TextInput
|
||||
placeholder="Enter sensitive keyword or phrase"
|
||||
value={keyword}
|
||||
onValueChange={onKeywordChange}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Action</Text>
|
||||
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
|
||||
Choose what action the guardrail should take when this keyword is detected
|
||||
</Text>
|
||||
<Select
|
||||
value={action}
|
||||
onChange={onActionChange}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Option value="BLOCK">Block</Option>
|
||||
<Option value="MASK">Mask</Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Description (optional)</Text>
|
||||
<Textarea
|
||||
placeholder="Explain why this keyword is sensitive"
|
||||
value={description}
|
||||
onValueChange={onDescriptionChange}
|
||||
rows={3}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordModal;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Table } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@tremor/react";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface BlockedWord {
|
||||
id: string;
|
||||
keyword: string;
|
||||
action: "BLOCK" | "MASK";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface KeywordTableProps {
|
||||
keywords: BlockedWord[];
|
||||
onActionChange: (id: string, field: string, value: any) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const KeywordTable: React.FC<KeywordTableProps> = ({
|
||||
keywords,
|
||||
onActionChange,
|
||||
onRemove,
|
||||
}) => {
|
||||
const columns = [
|
||||
{
|
||||
title: "Keyword",
|
||||
dataIndex: "keyword",
|
||||
key: "keyword",
|
||||
},
|
||||
{
|
||||
title: "Action",
|
||||
dataIndex: "action",
|
||||
key: "action",
|
||||
width: 150,
|
||||
render: (action: string, record: BlockedWord) => (
|
||||
<Select
|
||||
value={action}
|
||||
onChange={(value) => onActionChange(record.id, "action", value)}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
>
|
||||
<Option value="BLOCK">Block</Option>
|
||||
<Option value="MASK">Mask</Option>
|
||||
</Select>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
render: (desc: string) => desc || "-",
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
render: (_: any, record: BlockedWord) => (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
icon={DeleteOutlined}
|
||||
onClick={() => onRemove(record.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (keywords.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
||||
No keywords added.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={keywords}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordTable;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import PatternModal from "./PatternModal";
|
||||
|
||||
describe("PatternModal", () => {
|
||||
const mockOnAdd = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
const mockOnPatternNameChange = vi.fn();
|
||||
const mockOnActionChange = vi.fn();
|
||||
|
||||
const mockPrebuiltPatterns = [
|
||||
{ name: "us_ssn", category: "PII Patterns", description: "US Social Security Number" },
|
||||
{ name: "email", category: "PII Patterns", description: "Email addresses" },
|
||||
{ name: "visa", category: "Financial Patterns", description: "Visa credit card numbers" },
|
||||
{ name: "aws_access_key", category: "Credential Patterns", description: "AWS Access Keys" },
|
||||
];
|
||||
|
||||
const mockCategories = ["PII Patterns", "Financial Patterns", "Credential Patterns"];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should show dropdown with prebuilt pattern options grouped by category", async () => {
|
||||
/**
|
||||
* Tests that the modal displays a dropdown with prebuilt patterns
|
||||
* organized by category. This verifies the pattern selection UI is working.
|
||||
*/
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PatternModal
|
||||
visible={true}
|
||||
prebuiltPatterns={mockPrebuiltPatterns}
|
||||
categories={mockCategories}
|
||||
selectedPatternName=""
|
||||
patternAction="BLOCK"
|
||||
onPatternNameChange={mockOnPatternNameChange}
|
||||
onActionChange={mockOnActionChange}
|
||||
onAdd={mockOnAdd}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for modal to be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add prebuilt pattern")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the pattern type dropdown by looking for the first combobox input
|
||||
const comboboxes = screen.getAllByRole("combobox");
|
||||
const dropdown = comboboxes[0]; // First combobox is the pattern selector
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
|
||||
// Click to open the dropdown
|
||||
await user.click(dropdown);
|
||||
|
||||
// Verify that pattern options are available in the dropdown
|
||||
// Ant Design renders Select options in a portal, so we need to query the whole document
|
||||
await waitFor(() => {
|
||||
const options = document.querySelectorAll('.ant-select-item-option');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify categories are shown as group labels
|
||||
await waitFor(() => {
|
||||
expect(document.body).toHaveTextContent("PII Patterns");
|
||||
expect(document.body).toHaveTextContent("Financial Patterns");
|
||||
expect(document.body).toHaveTextContent("Credential Patterns");
|
||||
});
|
||||
|
||||
// Verify pattern options are available
|
||||
expect(document.body).toHaveTextContent("us_ssn");
|
||||
expect(document.body).toHaveTextContent("email");
|
||||
expect(document.body).toHaveTextContent("visa");
|
||||
expect(document.body).toHaveTextContent("aws_access_key");
|
||||
|
||||
// Select a pattern by clicking on its option element
|
||||
const ssnOption = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
el => el.textContent === "us_ssn"
|
||||
) as HTMLElement;
|
||||
await user.click(ssnOption);
|
||||
|
||||
// Verify the change handler was called with the pattern name
|
||||
// Note: Ant Design Select calls onChange with (value, option), so we check if it was called
|
||||
expect(mockOnPatternNameChange).toHaveBeenCalled();
|
||||
const callArgs = mockOnPatternNameChange.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("us_ssn");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Modal, Space } from "antd";
|
||||
import { Button } from "@tremor/react";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface PrebuiltPattern {
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PatternModalProps {
|
||||
visible: boolean;
|
||||
prebuiltPatterns: PrebuiltPattern[];
|
||||
categories: string[];
|
||||
selectedPatternName: string;
|
||||
patternAction: "BLOCK" | "MASK";
|
||||
onPatternNameChange: (name: string) => void;
|
||||
onActionChange: (action: "BLOCK" | "MASK") => void;
|
||||
onAdd: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PatternModal: React.FC<PatternModalProps> = ({
|
||||
visible,
|
||||
prebuiltPatterns,
|
||||
categories,
|
||||
selectedPatternName,
|
||||
patternAction,
|
||||
onPatternNameChange,
|
||||
onActionChange,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="Add prebuilt pattern"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
<div>
|
||||
<Text strong>Pattern type</Text>
|
||||
<Select
|
||||
placeholder="Choose pattern type"
|
||||
value={selectedPatternName}
|
||||
onChange={onPatternNameChange}
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
if (typeof option?.value === 'string') {
|
||||
return option.value.toLowerCase().includes(input.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const categoryPatterns = prebuiltPatterns.filter((p) => p.category === category);
|
||||
if (categoryPatterns.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Select.OptGroup key={category} label={category}>
|
||||
{categoryPatterns.map((pattern) => (
|
||||
<Option key={pattern.name} value={pattern.name}>
|
||||
{pattern.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Action</Text>
|
||||
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
|
||||
Choose what action the guardrail should take when this pattern is detected
|
||||
</Text>
|
||||
<Select
|
||||
value={patternAction}
|
||||
onChange={onActionChange}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Option value="BLOCK">Block</Option>
|
||||
<Option value="MASK">Mask</Option>
|
||||
</Select>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatternModal;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Table, Tag } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@tremor/react";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface Pattern {
|
||||
id: string;
|
||||
type: "prebuilt" | "custom";
|
||||
name: string;
|
||||
pattern?: string;
|
||||
action: "BLOCK" | "MASK";
|
||||
}
|
||||
|
||||
interface PatternTableProps {
|
||||
patterns: Pattern[];
|
||||
onActionChange: (id: string, action: "BLOCK" | "MASK") => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const PatternTable: React.FC<PatternTableProps> = ({
|
||||
patterns,
|
||||
onActionChange,
|
||||
onRemove,
|
||||
}) => {
|
||||
const columns = [
|
||||
{
|
||||
title: "Type",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
width: 100,
|
||||
render: (type: string) => (
|
||||
<Tag color={type === "prebuilt" ? "blue" : "green"}>
|
||||
{type === "prebuilt" ? "Prebuilt" : "Custom"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Pattern name",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "Regex pattern",
|
||||
dataIndex: "pattern",
|
||||
key: "pattern",
|
||||
render: (pattern: string) => (pattern ? <Text code style={{ fontSize: 12 }}>{pattern.substring(0, 40)}...</Text> : "-"),
|
||||
},
|
||||
{
|
||||
title: "Action",
|
||||
dataIndex: "action",
|
||||
key: "action",
|
||||
width: 150,
|
||||
render: (action: string, record: Pattern) => (
|
||||
<Select
|
||||
value={action}
|
||||
onChange={(value) => onActionChange(record.id, value as "BLOCK" | "MASK")}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
>
|
||||
<Option value="BLOCK">Block</Option>
|
||||
<Option value="MASK">Mask</Option>
|
||||
</Select>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
render: (_: any, record: Pattern) => (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
icon={DeleteOutlined}
|
||||
onClick={() => onRemove(record.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (patterns.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
||||
No patterns added.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={patterns}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatternTable;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Type definitions for Content Filter Configuration
|
||||
*/
|
||||
|
||||
export interface PrebuiltPattern {
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
id: string;
|
||||
type: "prebuilt" | "custom";
|
||||
name: string;
|
||||
pattern?: string;
|
||||
action: "BLOCK" | "MASK";
|
||||
}
|
||||
|
||||
export interface BlockedWord {
|
||||
id: string;
|
||||
keyword: string;
|
||||
action: "BLOCK" | "MASK";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ContentFilterSettings {
|
||||
prebuilt_patterns: PrebuiltPattern[];
|
||||
pattern_categories: string[];
|
||||
supported_actions: string[];
|
||||
}
|
||||
|
||||
export interface ContentFilterConfigurationProps {
|
||||
prebuiltPatterns: PrebuiltPattern[];
|
||||
categories: string[];
|
||||
selectedPatterns: Pattern[];
|
||||
blockedWords: BlockedWord[];
|
||||
blockedWordsFile?: string;
|
||||
onPatternAdd: (pattern: Pattern) => void;
|
||||
onPatternRemove: (id: string) => void;
|
||||
onPatternActionChange: (id: string, action: "BLOCK" | "MASK") => void;
|
||||
onBlockedWordAdd: (word: BlockedWord) => void;
|
||||
onBlockedWordRemove: (id: string) => void;
|
||||
onBlockedWordUpdate: (id: string, field: string, value: any) => void;
|
||||
onFileUpload: (content: string) => void;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const guardrail_provider_map: Record<string, string> = {
|
||||
PresidioPII: "presidio",
|
||||
Bedrock: "bedrock",
|
||||
Lakera: "lakera_v2",
|
||||
LitellmContentFilter: "litellm_content_filter",
|
||||
};
|
||||
|
||||
// Function to populate provider map from API response - updates the original map
|
||||
@@ -88,6 +89,17 @@ export const shouldRenderAzureTextModerationConfigSettings = (provider: string |
|
||||
return providerEnum === "Azure Content Safety Text Moderation";
|
||||
};
|
||||
|
||||
// Decides if we should render the Content Filter config settings for a given provider
|
||||
export const shouldRenderContentFilterConfigSettings = (provider: string | null) => {
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
// Check both dynamic and legacy providers
|
||||
const currentProviders = getGuardrailProviders();
|
||||
const providerEnum = currentProviders[provider as keyof typeof currentProviders];
|
||||
return providerEnum === "LiteLLM Content Filter";
|
||||
};
|
||||
|
||||
const asset_logos_folder = "../ui/assets/logos/";
|
||||
|
||||
export const guardrailLogoMap: Record<string, string> = {
|
||||
@@ -108,6 +120,7 @@ export const guardrailLogoMap: Record<string, string> = {
|
||||
"AIM Guardrail": `${asset_logos_folder}aim_security.jpeg`,
|
||||
"OpenAI Moderation": `${asset_logos_folder}openai_small.svg`,
|
||||
EnkryptAI: `${asset_logos_folder}enkrypt_ai.avif`,
|
||||
"LiteLLM Content Filter": `${asset_logos_folder}litellm_logo.svg`,
|
||||
};
|
||||
|
||||
export const getGuardrailLogoAndName = (guardrailValue: string): { logo: string; displayName: string } => {
|
||||
|
||||
@@ -6618,6 +6618,36 @@ export const applyGuardrail = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const validateBlockedWordsFile = async (accessToken: string, fileContent: string) => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/guardrails/validate_blocked_words_file`
|
||||
: `/guardrails/validate_blocked_words_file`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ file_content: fileContent }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
throw new Error("Failed to validate blocked words file");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Validate blocked words file response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to validate blocked words file:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSSOSettings = async (accessToken: string) => {
|
||||
try {
|
||||
// Construct base URL
|
||||
|
||||
Reference in New Issue
Block a user