mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
feat(guardrails): checksum validators for content filter patterns
Prebuilt content filter patterns can now declare a "validator" that a regex match must pass before it counts. The card patterns use Luhn and the IBAN patterns use ISO 13616 mod-97, so look-alike digit runs such as order ids or random 16 digit numbers stop being redacted as card numbers
This commit is contained in:
parent
d56ff281de
commit
9e6ae458e1
5 changed files with 125 additions and 3 deletions
|
|
@ -12,7 +12,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, Coroutine, Mapping, Sequence
|
||||
from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
|
|
@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypedDict, cast
|
|||
import yaml
|
||||
from fastapi import HTTPException
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
from litellm import Router
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -75,6 +76,7 @@ from .competitor_intent import (
|
|||
BaseCompetitorIntentChecker,
|
||||
)
|
||||
from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern
|
||||
from .validators import PATTERN_VALIDATORS
|
||||
|
||||
MAX_KEYWORD_VALUE_GAP_WORDS: Final = 1
|
||||
_DECODED_ARGUMENTS: Final = TypeAdapter(object)
|
||||
|
|
@ -124,11 +126,13 @@ class CompiledPatternEntry(TypedDict):
|
|||
action: ContentFilterAction
|
||||
keyword_regex: Pattern[str] | None
|
||||
allow_word_numbers: bool
|
||||
validator: ReadOnly[Callable[[str], bool] | None]
|
||||
|
||||
|
||||
class _PatternExtraLookup(TypedDict):
|
||||
keyword_pattern: str | None
|
||||
allow_word_numbers: bool
|
||||
validator: ReadOnly[Callable[[str], bool] | None]
|
||||
|
||||
|
||||
class _CategoryConfigView(TypedDict):
|
||||
|
|
@ -749,7 +753,11 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
pattern_config: ContentFilterPattern configuration
|
||||
"""
|
||||
try:
|
||||
extra_config: _PatternExtraLookup = {"keyword_pattern": None, "allow_word_numbers": False}
|
||||
extra_config: _PatternExtraLookup = {
|
||||
"keyword_pattern": None,
|
||||
"allow_word_numbers": False,
|
||||
"validator": None,
|
||||
}
|
||||
if pattern_config.pattern_type == "prebuilt":
|
||||
if not pattern_config.pattern_name:
|
||||
raise ValueError("pattern_name is required for prebuilt patterns")
|
||||
|
|
@ -774,6 +782,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
"action": pattern_config.action,
|
||||
"keyword_regex": keyword_regex,
|
||||
"allow_word_numbers": extra_config["allow_word_numbers"],
|
||||
"validator": extra_config["validator"],
|
||||
}
|
||||
)
|
||||
verbose_proxy_logger.debug("Added pattern: %s with action %s", pattern_name, pattern_config.action)
|
||||
|
|
@ -784,9 +793,11 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
@staticmethod
|
||||
def _lookup_pattern_extra(pattern_name: str) -> _PatternExtraLookup:
|
||||
extra: Final = PATTERN_EXTRA_CONFIG.get(pattern_name)
|
||||
validator_name: Final = extra.get("validator") if extra is not None else None
|
||||
return {
|
||||
"keyword_pattern": extra.get("keyword_pattern") if extra is not None else None,
|
||||
"allow_word_numbers": bool(extra.get("allow_word_numbers")) if extra is not None else False,
|
||||
"validator": PATTERN_VALIDATORS[validator_name] if validator_name is not None else None,
|
||||
}
|
||||
|
||||
def _load_blocked_words_file(self, file_path: str) -> None:
|
||||
|
|
@ -834,6 +845,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
regex: Final[Pattern[str]] = pattern_entry["regex"]
|
||||
keyword_regex: Final[Pattern[str] | None] = pattern_entry.get("keyword_regex")
|
||||
allow_word_numbers: Final[bool] = pattern_entry.get("allow_word_numbers", False)
|
||||
validator: Final = pattern_entry.get("validator")
|
||||
|
||||
keyword_matches: Final = list(keyword_regex.finditer(text)) if keyword_regex is not None else None
|
||||
if keyword_matches is not None and not keyword_matches:
|
||||
|
|
@ -842,6 +854,8 @@ class ContentFilterGuardrail(CustomGuardrail):
|
|||
match_spans: Final[list[tuple[int, int]]] = []
|
||||
|
||||
for match in regex.finditer(text):
|
||||
if validator is not None and not validator(match.group()):
|
||||
continue
|
||||
if keyword_matches is not None and not self._match_near_keyword(
|
||||
match.start(), match.end(), keyword_matches, text
|
||||
):
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"name": "visa",
|
||||
"display_name": "Credit Card (Visa)",
|
||||
"pattern": "\\b4\\d{3}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}\\b",
|
||||
"validator": "luhn",
|
||||
"category": "Payment Card Patterns",
|
||||
"description": "Detects Visa credit card numbers"
|
||||
},
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"name": "mastercard",
|
||||
"display_name": "Credit Card (Mastercard)",
|
||||
"pattern": "\\b5[1-5]\\d{2}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}\\b",
|
||||
"validator": "luhn",
|
||||
"category": "Payment Card Patterns",
|
||||
"description": "Detects Mastercard credit card numbers"
|
||||
},
|
||||
|
|
@ -46,6 +48,7 @@
|
|||
"name": "amex",
|
||||
"display_name": "Credit Card (American Express)",
|
||||
"pattern": "\\b3[47]\\d{2}[\\s\\-]?\\d{6}[\\s\\-]?\\d{5}\\b",
|
||||
"validator": "luhn",
|
||||
"category": "Payment Card Patterns",
|
||||
"description": "Detects American Express credit card numbers"
|
||||
},
|
||||
|
|
@ -53,6 +56,7 @@
|
|||
"name": "discover",
|
||||
"display_name": "Credit Card (Discover)",
|
||||
"pattern": "\\b6(?:011|5\\d{2})[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}\\b",
|
||||
"validator": "luhn",
|
||||
"category": "Payment Card Patterns",
|
||||
"description": "Detects Discover credit card numbers"
|
||||
},
|
||||
|
|
@ -60,6 +64,7 @@
|
|||
"name": "credit_card",
|
||||
"display_name": "Credit Card (Any)",
|
||||
"pattern": "(?:\\b4\\d{3}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}\\b|\\b5[1-5]\\d{2}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}\\b|\\b3[47]\\d{2}[\\s\\-]?\\d{6}[\\s\\-]?\\d{5}\\b|\\b6(?:011|5\\d{2})[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}\\b)",
|
||||
"validator": "luhn",
|
||||
"category": "Payment Card Patterns",
|
||||
"description": "Detects any major credit card number"
|
||||
},
|
||||
|
|
@ -395,6 +400,7 @@
|
|||
"name": "iban",
|
||||
"display_name": "IBAN (International Bank Account Number)",
|
||||
"pattern": "\\b[A-Z]{2}\\d{2}[A-Z0-9]{11,30}\\b",
|
||||
"validator": "iban_mod97",
|
||||
"category": "Payment Card Patterns",
|
||||
"description": "Detects IBANs (2 letter country code + 2 check digits + 4 char bank code + 7 digit base + optional 0-16 alphanumeric)"
|
||||
},
|
||||
|
|
@ -409,6 +415,7 @@
|
|||
"name": "eu_iban_enhanced",
|
||||
"display_name": "IBAN (Enhanced EU Format)",
|
||||
"pattern": "\\b[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}[A-Z0-9]{0,16}\\b",
|
||||
"validator": "iban_mod97",
|
||||
"category": "EU PII Patterns",
|
||||
"description": "Enhanced IBAN detection with more specific format validation (2 letter country + 2 check digits + 4 char bank code + 7 digit account base + optional 0-16 alphanumeric)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
_SEPARATORS: Final = re.compile(r"[\s\-]")
|
||||
_IBAN_CHARACTERS: Final = re.compile(r"[0-9A-Z]{5,34}")
|
||||
|
||||
|
||||
def _luhn_term(position: int, digit: int) -> int:
|
||||
if position % 2 == 0:
|
||||
return digit
|
||||
doubled: Final = digit * 2
|
||||
return doubled - 9 if doubled > 9 else doubled
|
||||
|
||||
|
||||
def passes_luhn(value: str) -> bool:
|
||||
digits: Final = _SEPARATORS.sub("", value)
|
||||
if not digits.isdecimal():
|
||||
return False
|
||||
return sum(_luhn_term(position, int(digit)) for position, digit in enumerate(reversed(digits))) % 10 == 0
|
||||
|
||||
|
||||
def passes_iban_mod97(value: str) -> bool:
|
||||
iban: Final = _SEPARATORS.sub("", value).upper()
|
||||
if _IBAN_CHARACTERS.fullmatch(iban) is None:
|
||||
return False
|
||||
rearranged: Final = iban[4:] + iban[:4]
|
||||
return int("".join(str(int(character, 36)) for character in rearranged)) % 97 == 1
|
||||
|
||||
|
||||
PATTERN_VALIDATORS: Final[Mapping[str, Callable[[str], bool]]] = MappingProxyType(
|
||||
{"luhn": passes_luhn, "iban_mod97": passes_iban_mod97}
|
||||
)
|
||||
|
|
@ -367,10 +367,43 @@ class TestContentFilterGuardrail:
|
|||
)
|
||||
|
||||
# Test Visa card
|
||||
result = guardrail._check_patterns("My card is 4532-1234-5678-9010")
|
||||
result = guardrail._check_patterns("My card is 4532-0151-1283-0366")
|
||||
assert result is not None
|
||||
assert result[1] == "visa"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("pattern_name", "checksum_valid", "checksum_invalid"),
|
||||
[
|
||||
("credit_card", "4532 0151 1283 0366", "4532 0151 1283 0367"),
|
||||
("visa", "4532-0151-1283-0366", "4532-1234-5678-9010"),
|
||||
("mastercard", "5555555555554444", "5555555555554445"),
|
||||
("amex", "3782 822463 10005", "3782 822463 10006"),
|
||||
("discover", "6011111111111117", "6011111111111118"),
|
||||
("iban", "GB82WEST12345698765432", "GB83WEST12345698765432"),
|
||||
("eu_iban_enhanced", "DE89370400440532013000", "DE88370400440532013000"),
|
||||
],
|
||||
)
|
||||
async def test_checksum_pattern_masks_only_values_that_pass_the_checksum(
|
||||
self, pattern_name: str, checksum_valid: str, checksum_invalid: str
|
||||
):
|
||||
guardrail: Final = ContentFilterGuardrail(
|
||||
guardrail_name="test-checksum",
|
||||
patterns=[
|
||||
ContentFilterPattern(pattern_type="prebuilt", pattern_name=pattern_name, action=ContentFilterAction.MASK)
|
||||
],
|
||||
)
|
||||
|
||||
guardrailed_inputs: Final = await guardrail.apply_guardrail(
|
||||
inputs={"texts": [f"real {checksum_valid} and look-alike {checksum_invalid}"]},
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert guardrailed_inputs.get("texts") == [
|
||||
f"real [{pattern_name.upper()}_REDACTED] and look-alike {checksum_invalid}"
|
||||
]
|
||||
|
||||
def test_api_key_patterns(self):
|
||||
"""
|
||||
Test API key pattern detection
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import pytest
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.validators import (
|
||||
passes_iban_mod97,
|
||||
passes_luhn,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("4111 1111 1111 1111", True),
|
||||
("4111-1111-1111-1112", False),
|
||||
("٤" + "١" * 15, True),
|
||||
("4111 1111 1111 111x", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_passes_luhn(value: str, expected: bool):
|
||||
assert passes_luhn(value) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("GB82 WEST 1234 5698 7654 32", True),
|
||||
("gb82west12345698765432", True),
|
||||
("GB82WEST12345698765433", False),
|
||||
("GB82", False),
|
||||
("GB82WEST1234569876543é", False),
|
||||
],
|
||||
)
|
||||
def test_passes_iban_mod97(value: str, expected: bool):
|
||||
assert passes_iban_mod97(value) is expected
|
||||
Loading…
Add table
Reference in a new issue