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:
Vivek Kalyanarangan 2026-10-20 09:00:45 +05:30
parent d56ff281de
commit 9e6ae458e1
5 changed files with 125 additions and 3 deletions

View file

@ -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
):

View file

@ -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)"
},

View file

@ -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}
)

View file

@ -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

View file

@ -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