From 9e6ae458e1117fbaebe45158f9d6b366f0318843 Mon Sep 17 00:00:00 2001 From: Vivek Kalyanarangan <12863647+vivekkalyanarangan30@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:00:45 +0530 Subject: [PATCH] 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 --- .../litellm_content_filter/content_filter.py | 18 ++++++++-- .../litellm_content_filter/patterns.json | 7 ++++ .../litellm_content_filter/validators.py | 34 ++++++++++++++++++ .../content_filter/test_content_filter.py | 35 ++++++++++++++++++- .../litellm_content_filter/test_validators.py | 34 ++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py create mode 100644 tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_validators.py diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 40e44151e65..eb742902a28 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -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 ): diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json index db6dd36726c..44fe4775120 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json @@ -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)" }, diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py new file mode 100644 index 00000000000..4021c873356 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py @@ -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} +) diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py b/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py index f7fb7e1fed0..55aecc4dc1f 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py @@ -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 diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_validators.py b/tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_validators.py new file mode 100644 index 00000000000..1cf963fae85 --- /dev/null +++ b/tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_validators.py @@ -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