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 01/15] 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 From ad093f0585e6b1e91a9d787e602986562e1717e1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:17:58 -0700 Subject: [PATCH 02/15] fix(guardrails): mask a checksum-valid card that overlaps a rejected look-alike --- .../litellm_content_filter/content_filter.py | 26 +++++++++++++------ .../content_filter/test_content_filter.py | 24 +++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) 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 305c2061d09..3b08ac6dc88 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, Callable, Coroutine, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Coroutine, Iterator, Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime from re import Pattern @@ -852,15 +852,16 @@ class ContentFilterGuardrail(CustomGuardrail): return [] match_spans: Final[list[tuple[int, int]]] = [] + candidate_spans: Final = ( + self._checksum_valid_spans(regex, text, validator) + if validator is not None + else ((match.start(), match.end()) for match in regex.finditer(text)) + ) - for match in regex.finditer(text): - if validator is not None and not validator(match.group()): + for start, end in candidate_spans: + if keyword_matches is not None and not self._match_near_keyword(start, end, keyword_matches, text): continue - if keyword_matches is not None and not self._match_near_keyword( - match.start(), match.end(), keyword_matches, text - ): - continue - match_spans.append((match.start(), match.end())) + match_spans.append((start, end)) if allow_word_numbers: for word_match in WORD_NUMBER_SEQUENCE_PATTERN.finditer(text): @@ -877,6 +878,15 @@ class ContentFilterGuardrail(CustomGuardrail): return self._merge_spans(match_spans) + @staticmethod + def _checksum_valid_spans( + regex: Pattern[str], text: str, validator: Callable[[str], bool] + ) -> Iterator[tuple[int, int]]: + overlapping_regex: Final = re.compile(f"(?=({regex.pattern}))", regex.flags) + return ( + (match.start(1), match.end(1)) for match in overlapping_regex.finditer(text) if validator(match.group(1)) + ) + def _match_near_keyword( self, value_start: int, 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 55aecc4dc1f..3d69b37c855 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 @@ -404,6 +404,30 @@ class TestContentFilterGuardrail: f"real [{pattern_name.upper()}_REDACTED] and look-alike {checksum_invalid}" ] + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("PIN 4821 4532 0151 1283 0366 thanks", "PIN 4821 [CREDIT_CARD_REDACTED] thanks"), + ("order 4111 4532 0151 1283 0366", "order [CREDIT_CARD_REDACTED]"), + ], + ) + async def test_checksum_pattern_masks_a_valid_card_overlapping_another_candidate(self, text: str, expected: str): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-overlap", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK) + ], + ) + + guardrailed_inputs: Final = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + + assert guardrailed_inputs.get("texts") == [expected] + def test_api_key_patterns(self): """ Test API key pattern detection From 54c8c50a77d9ad6a2fb73c6e7b7990b2a302a632 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:15:23 -0700 Subject: [PATCH 03/15] fix(guardrails): mask a whole candidate run around a valid card, fold IBAN look-alike characters, speed up Luhn --- .../litellm_content_filter/content_filter.py | 22 ++++++++++----- .../litellm_content_filter/patterns.json | 14 +++++----- .../litellm_content_filter/validators.py | 21 +++++++++----- .../content_filter/test_content_filter.py | 28 +++++++++++++++++-- .../litellm_content_filter/test_validators.py | 17 +++++++++++ 5 files changed, 79 insertions(+), 23 deletions(-) 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 3b08ac6dc88..4132f4960c8 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,8 @@ import json import os import re import time -from collections.abc import AsyncGenerator, Callable, Coroutine, Iterator, Mapping, Sequence +from bisect import bisect_left +from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime from re import Pattern @@ -878,14 +879,21 @@ class ContentFilterGuardrail(CustomGuardrail): return self._merge_spans(match_spans) - @staticmethod def _checksum_valid_spans( - regex: Pattern[str], text: str, validator: Callable[[str], bool] - ) -> Iterator[tuple[int, int]]: + self, regex: Pattern[str], text: str, validator: Callable[[str], bool] + ) -> tuple[tuple[int, int], ...]: overlapping_regex: Final = re.compile(f"(?=({regex.pattern}))", regex.flags) - return ( - (match.start(1), match.end(1)) for match in overlapping_regex.finditer(text) if validator(match.group(1)) - ) + candidates: Final = tuple(overlapping_regex.finditer(text)) + valid_starts: Final = tuple(candidate.start(1) for candidate in candidates if validator(candidate.group(1))) + if not valid_starts: + return () + candidate_runs: Final = self._merge_spans([candidate.span(1) for candidate in candidates]) + return tuple(run for run in candidate_runs if self._run_contains_start(run, valid_starts)) + + @staticmethod + def _run_contains_start(run: tuple[int, int], sorted_starts: Sequence[int]) -> bool: + index: Final = bisect_left(sorted_starts, run[0]) + return index < len(sorted_starts) and sorted_starts[index] < run[1] def _match_near_keyword( self, 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 44fe4775120..50b6d86ae7e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json @@ -34,7 +34,7 @@ "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" + "description": "Detects Visa credit card numbers that pass the Luhn checksum" }, { "name": "mastercard", @@ -42,7 +42,7 @@ "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" + "description": "Detects Mastercard credit card numbers that pass the Luhn checksum" }, { "name": "amex", @@ -50,7 +50,7 @@ "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" + "description": "Detects American Express credit card numbers that pass the Luhn checksum" }, { "name": "discover", @@ -58,7 +58,7 @@ "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" + "description": "Detects Discover credit card numbers that pass the Luhn checksum" }, { "name": "credit_card", @@ -66,7 +66,7 @@ "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" + "description": "Detects any major credit card number that passes the Luhn checksum" }, { "name": "aws_access_key", @@ -402,7 +402,7 @@ "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)" + "description": "Detects IBANs (2 letter country code + 2 check digits + 4 char bank code + 7 digit base + optional 0-16 alphanumeric) that pass the mod-97 check" }, { "name": "fr_nir", @@ -417,7 +417,7 @@ "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)" + "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) that pass the mod-97 check" }, { "name": "fr_phone", diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py index 4021c873356..125e426815c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py @@ -5,24 +5,31 @@ from typing import Final _SEPARATORS: Final = re.compile(r"[\s\-]") _IBAN_CHARACTERS: Final = re.compile(r"[0-9A-Z]{5,34}") +_IGNORECASE_ASCII_LETTER_ALIASES: Final = str.maketrans({"\u0130": "I", "\u212a": "K"}) +_DIGIT_VALUES: Final = bytes.maketrans(b"0123456789", bytes(range(10))) +_DOUBLED_DIGIT_SUMS: Final = bytes.maketrans(b"0123456789", bytes((0, 2, 4, 6, 8, 1, 3, 5, 7, 9))) -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 _to_ascii(value: str) -> str: + if value.isascii(): + return value + folded: Final = value.translate(_IGNORECASE_ASCII_LETTER_ALIASES) + return "".join(str(int(character)) if character.isdecimal() else character for character in folded) 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 + reversed_digits: Final = _to_ascii(digits)[::-1].encode() + checksum: Final = sum(reversed_digits[::2].translate(_DIGIT_VALUES)) + sum( + reversed_digits[1::2].translate(_DOUBLED_DIGIT_SUMS) + ) + return checksum % 10 == 0 def passes_iban_mod97(value: str) -> bool: - iban: Final = _SEPARATORS.sub("", value).upper() + iban: Final = _to_ascii(_SEPARATORS.sub("", value)).upper() if _IBAN_CHARACTERS.fullmatch(iban) is None: return False rearranged: Final = iban[4:] + iban[:4] 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 3d69b37c855..29294070b80 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 @@ -408,11 +408,12 @@ class TestContentFilterGuardrail: @pytest.mark.parametrize( ("text", "expected"), [ - ("PIN 4821 4532 0151 1283 0366 thanks", "PIN 4821 [CREDIT_CARD_REDACTED] thanks"), + ("PIN 4821 4532 0151 1283 0366 thanks", "PIN [CREDIT_CARD_REDACTED] thanks"), ("order 4111 4532 0151 1283 0366", "order [CREDIT_CARD_REDACTED]"), + ("order 4532 1234 5678 9010 paid by 4532 0151 1283 0366", "order 4532 1234 5678 9010 paid by [CREDIT_CARD_REDACTED]"), ], ) - async def test_checksum_pattern_masks_a_valid_card_overlapping_another_candidate(self, text: str, expected: str): + async def test_checksum_pattern_masks_each_run_of_candidates_that_holds_a_valid_card(self, text: str, expected: str): guardrail: Final = ContentFilterGuardrail( guardrail_name="test-checksum-overlap", patterns=[ @@ -428,6 +429,29 @@ class TestContentFilterGuardrail: assert guardrailed_inputs.get("texts") == [expected] + @pytest.mark.asyncio + @pytest.mark.parametrize( + "iban", + [ + "GB66\u212aEST12345698765432", + "GB73W\u0130ST12345698765432", + "GB\u0668\u0662WEST12345698765432", + ], + ) + async def test_checksum_pattern_masks_a_valid_iban_with_characters_the_regex_case_folds(self, iban: str): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-folding", + patterns=[ContentFilterPattern(pattern_type="prebuilt", pattern_name="iban", action=ContentFilterAction.MASK)], + ) + + guardrailed_inputs: Final = await guardrail.apply_guardrail( + inputs={"texts": [f"send it to {iban}"]}, + request_data={}, + input_type="request", + ) + + assert guardrailed_inputs.get("texts") == ["send it to [IBAN_REDACTED]"] + 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 index 1cf963fae85..0214777cd22 100644 --- 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 @@ -1,3 +1,6 @@ +from itertools import product +from typing import Final + import pytest from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.validators import ( @@ -20,6 +23,20 @@ def test_passes_luhn(value: str, expected: bool): assert passes_luhn(value) is expected +@pytest.mark.parametrize( + "valid_number", + ["4111111111111111", "4012888888881881", "5555555555554444", "378282246310005", "6011000990139424"], +) +def test_passes_luhn_rejects_every_single_digit_change(valid_number: str): + single_digit_changes: Final = tuple( + valid_number[:position] + replacement + valid_number[position + 1 :] + for position, replacement in product(range(len(valid_number)), "0123456789") + ) + + assert passes_luhn(valid_number) + assert not any(passes_luhn(changed) for changed in single_digit_changes if changed != valid_number) + + @pytest.mark.parametrize( ("value", "expected"), [ From 3132e4bbc4de8b94c506d017121d4c79948131af Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:11:21 -0700 Subject: [PATCH 04/15] fix(guardrails): mask only the candidates overlapping a valid card so streamed replies keep their text Masking a whole run of overlapping candidates let a late valid card grow the masked span past the stream holdback, so text already sent shifted and the rest of the reply was lost. A valid window now masks only the candidates that overlap it, and text with no candidate skips the overlapping scan. --- .../litellm_content_filter/content_filter.py | 25 +++++++---- .../content_filter/test_content_filter.py | 44 ++++++++++++++++++- 2 files changed, 59 insertions(+), 10 deletions(-) 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 4132f4960c8..7178494075d 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 @@ -882,18 +882,25 @@ class ContentFilterGuardrail(CustomGuardrail): def _checksum_valid_spans( self, regex: Pattern[str], text: str, validator: Callable[[str], bool] ) -> tuple[tuple[int, int], ...]: - overlapping_regex: Final = re.compile(f"(?=({regex.pattern}))", regex.flags) - candidates: Final = tuple(overlapping_regex.finditer(text)) - valid_starts: Final = tuple(candidate.start(1) for candidate in candidates if validator(candidate.group(1))) - if not valid_starts: + if regex.search(text) is None: return () - candidate_runs: Final = self._merge_spans([candidate.span(1) for candidate in candidates]) - return tuple(run for run in candidate_runs if self._run_contains_start(run, valid_starts)) + overlapping_regex: Final = re.compile(f"(?=({regex.pattern}))", regex.flags) + candidate_spans: Final = tuple(candidate.span(1) for candidate in overlapping_regex.finditer(text)) + valid_spans: Final = tuple(span for span in candidate_spans if validator(text[span[0] : span[1]])) + if not valid_spans: + return () + valid_starts: Final = tuple(start for start, _ in valid_spans) + furthest_valid_ends: Final = tuple(itertools.accumulate((end for _, end in valid_spans), max)) + return tuple( + span for span in candidate_spans if self._overlaps_a_valid_span(span, valid_starts, furthest_valid_ends) + ) @staticmethod - def _run_contains_start(run: tuple[int, int], sorted_starts: Sequence[int]) -> bool: - index: Final = bisect_left(sorted_starts, run[0]) - return index < len(sorted_starts) and sorted_starts[index] < run[1] + def _overlaps_a_valid_span( + span: tuple[int, int], sorted_valid_starts: Sequence[int], furthest_valid_ends: Sequence[int] + ) -> bool: + index: Final = bisect_left(sorted_valid_starts, span[1]) + return index > 0 and furthest_valid_ends[index - 1] > span[0] def _match_near_keyword( self, 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 29294070b80..6f69bb25185 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 @@ -410,10 +410,11 @@ class TestContentFilterGuardrail: [ ("PIN 4821 4532 0151 1283 0366 thanks", "PIN [CREDIT_CARD_REDACTED] thanks"), ("order 4111 4532 0151 1283 0366", "order [CREDIT_CARD_REDACTED]"), + ("codes 4532 4532 4532 4532 4532 0151 1283 0366 thanks", "codes 4532 [CREDIT_CARD_REDACTED] thanks"), ("order 4532 1234 5678 9010 paid by 4532 0151 1283 0366", "order 4532 1234 5678 9010 paid by [CREDIT_CARD_REDACTED]"), ], ) - async def test_checksum_pattern_masks_each_run_of_candidates_that_holds_a_valid_card(self, text: str, expected: str): + async def test_checksum_pattern_masks_a_valid_card_with_the_candidates_overlapping_it(self, text: str, expected: str): guardrail: Final = ContentFilterGuardrail( guardrail_name="test-checksum-overlap", patterns=[ @@ -429,6 +430,47 @@ class TestContentFilterGuardrail: assert guardrailed_inputs.get("texts") == [expected] + @pytest.mark.asyncio + async def test_checksum_pattern_streams_the_same_text_as_a_whole_response(self): + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-streaming", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK) + ], + ) + chunks: Final = ("4532 " * 20, "4532 0151 1283 0366 thanks") + + async def mock_stream(): + for index, content in enumerate(chunks): + yield ModelResponseStream( + id=f"chunk{index}", + choices=[ + StreamingChoices( + delta=Delta(content=content), + index=0, + finish_reason="stop" if index == len(chunks) - 1 else None, + ) + ], + model="gpt-4", + ) + + streamed: Final = "".join( + [ + chunk.choices[0].delta.content or "" + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=MagicMock(), response=mock_stream(), request_data={} + ) + ] + ) + whole_response: Final = await guardrail.apply_guardrail( + inputs={"texts": ["".join(chunks)]}, request_data={}, input_type="response" + ) + + assert "0366" not in streamed + assert [streamed] == whole_response.get("texts") + @pytest.mark.asyncio @pytest.mark.parametrize( "iban", From 56c00af0c4733fd82c49d6567a79885c11a4a5d7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:09:39 -0700 Subject: [PATCH 05/15] fix(guardrails): check an IBAN's registry length before its suffix and memoize checksums per stream --- litellm/constants.py | 1 + .../litellm_content_filter/content_filter.py | 40 ++++-- .../litellm_content_filter/validators.py | 121 +++++++++++++++++- .../content_filter/test_content_filter.py | 28 +++- .../litellm_content_filter/test_validators.py | 2 + 5 files changed, 180 insertions(+), 12 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 0d6dfb5c0d4..420ce599737 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -495,6 +495,7 @@ GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS: Final = get_env_int( BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS: Final = 25_000 CONTENT_FILTER_STREAMING_HOLDBACK_CHARS: Final = 50 CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS: Final = 512 +CONTENT_FILTER_STREAMING_CHECKSUM_CACHE_SIZE: Final = 1024 DEFAULT_PRESIDIO_ANALYZE_CHUNK_SIZE_BYTES: Final = 500_000 PRESIDIO_ANALYZE_CHUNK_OVERLAP_CHARS: Final = 4096 PRESIDIO_ANALYZE_CHUNK_CONCURRENCY: Final = 8 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 7178494075d..bd3b78c959e 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 @@ -7,6 +7,7 @@ to detect and block/mask sensitive content. import asyncio import contextvars +import functools import itertools import json import os @@ -27,6 +28,7 @@ from typing_extensions import ReadOnly from litellm import Router from litellm._logging import verbose_proxy_logger from litellm.constants import ( + CONTENT_FILTER_STREAMING_CHECKSUM_CACHE_SIZE, CONTENT_FILTER_STREAMING_HOLDBACK_CHARS, CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS, DEFAULT_MAX_RECURSE_DEPTH, @@ -77,7 +79,7 @@ from .competitor_intent import ( BaseCompetitorIntentChecker, ) from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern -from .validators import PATTERN_VALIDATORS +from .validators import PATTERN_VALIDATORS, CandidateCheck, run_validator MAX_KEYWORD_VALUE_GAP_WORDS: Final = 1 _DECODED_ARGUMENTS: Final = TypeAdapter(object) @@ -840,7 +842,12 @@ class ContentFilterGuardrail(CustomGuardrail): except Exception as e: raise Exception(f"Error loading blocked words file {file_path}: {e}") - def _find_pattern_spans(self, text: str, pattern_entry: CompiledPatternEntry) -> list[tuple[int, int]]: + def _find_pattern_spans( + self, + text: str, + pattern_entry: CompiledPatternEntry, + check_candidate: CandidateCheck = run_validator, + ) -> list[tuple[int, int]]: """Return all match spans for a pattern, applying contextual rules if required.""" regex: Final[Pattern[str]] = pattern_entry["regex"] @@ -854,7 +861,7 @@ class ContentFilterGuardrail(CustomGuardrail): match_spans: Final[list[tuple[int, int]]] = [] candidate_spans: Final = ( - self._checksum_valid_spans(regex, text, validator) + self._checksum_valid_spans(regex, text, validator, check_candidate) if validator is not None else ((match.start(), match.end()) for match in regex.finditer(text)) ) @@ -880,13 +887,19 @@ class ContentFilterGuardrail(CustomGuardrail): return self._merge_spans(match_spans) def _checksum_valid_spans( - self, regex: Pattern[str], text: str, validator: Callable[[str], bool] + self, + regex: Pattern[str], + text: str, + validator: Callable[[str], bool], + check_candidate: CandidateCheck, ) -> tuple[tuple[int, int], ...]: if regex.search(text) is None: return () overlapping_regex: Final = re.compile(f"(?=({regex.pattern}))", regex.flags) candidate_spans: Final = tuple(candidate.span(1) for candidate in overlapping_regex.finditer(text)) - valid_spans: Final = tuple(span for span in candidate_spans if validator(text[span[0] : span[1]])) + valid_spans: Final = tuple( + span for span in candidate_spans if check_candidate(validator, text[span[0] : span[1]]) + ) if not valid_spans: return () valid_starts: Final = tuple(start for start, _ in valid_spans) @@ -1431,7 +1444,12 @@ class ContentFilterGuardrail(CustomGuardrail): return text - def _filter_single_text(self, text: str, detections: list[ContentFilterDetection] | None = None) -> str: + def _filter_single_text( + self, + text: str, + detections: list[ContentFilterDetection] | None = None, + check_candidate: CandidateCheck = run_validator, + ) -> str: """ Apply all content filtering checks to a single text. @@ -1443,6 +1461,7 @@ class ContentFilterGuardrail(CustomGuardrail): Args: text: Text to filter detections: Optional list to append detection information + check_candidate: Runs a pattern's checksum validator on a candidate match Returns: Filtered text (with masking applied if action is MASK) @@ -1475,7 +1494,7 @@ class ContentFilterGuardrail(CustomGuardrail): # Check regex patterns - process ALL patterns, not just first match for pattern_entry in self.compiled_patterns: - spans = self._find_pattern_spans(text, pattern_entry) + spans = self._find_pattern_spans(text, pattern_entry, check_candidate) if spans: pattern_name = pattern_entry["pattern_name"] action = pattern_entry["action"] @@ -2130,6 +2149,9 @@ class ContentFilterGuardrail(CustomGuardrail): """ state_by_choice: Final[dict[int, _StreamedChoiceState]] = {} plan: Final = self._streamed_scan_plan() + check_candidate: Final = functools.lru_cache(maxsize=CONTENT_FILTER_STREAMING_CHECKSUM_CACHE_SIZE)( + run_validator + ) start_time: Final = datetime.now() scan_seconds: float = 0.0 # rebind-ok: accumulates per-chunk scan time across the stream @@ -2170,7 +2192,9 @@ class ContentFilterGuardrail(CustomGuardrail): scan_started = time.perf_counter() try: - masked_text = self._filter_single_text(text_to_scan, detections=choice_detections) + masked_text = self._filter_single_text( + text_to_scan, detections=choice_detections, check_candidate=check_candidate + ) if is_final and masked_text.endswith(" "): masked_text = masked_text[:-1] latest_detections = tuple(choice_detections) diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py index 125e426815c..7ab3e194371 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py @@ -1,11 +1,120 @@ import re from collections.abc import Callable, Mapping from types import MappingProxyType -from typing import Final +from typing import Final, TypeAlias _SEPARATORS: Final = re.compile(r"[\s\-]") _IBAN_CHARACTERS: Final = re.compile(r"[0-9A-Z]{5,34}") _IGNORECASE_ASCII_LETTER_ALIASES: Final = str.maketrans({"\u0130": "I", "\u212a": "K"}) +_ISO_13616_IBAN_LENGTHS: Final[Mapping[str, int]] = MappingProxyType( + { + "AD": 24, + "AE": 23, + "AL": 28, + "AT": 20, + "AX": 18, + "AZ": 28, + "BA": 20, + "BE": 16, + "BG": 22, + "BH": 22, + "BI": 27, + "BL": 27, + "BR": 29, + "BY": 28, + "CH": 21, + "CR": 22, + "CY": 28, + "CZ": 24, + "DE": 22, + "DJ": 27, + "DK": 18, + "DO": 28, + "EE": 20, + "EG": 29, + "ES": 24, + "FI": 18, + "FK": 18, + "FO": 18, + "FR": 27, + "GB": 22, + "GE": 22, + "GF": 27, + "GG": 22, + "GI": 23, + "GL": 18, + "GP": 27, + "GR": 27, + "GT": 28, + "HN": 28, + "HR": 21, + "HU": 28, + "IE": 22, + "IL": 23, + "IM": 22, + "IQ": 23, + "IS": 26, + "IT": 27, + "JE": 22, + "JO": 30, + "KW": 30, + "KZ": 20, + "LB": 28, + "LC": 32, + "LI": 21, + "LT": 20, + "LU": 20, + "LV": 21, + "LY": 25, + "MC": 27, + "MD": 24, + "ME": 22, + "MF": 27, + "MK": 19, + "MN": 20, + "MQ": 27, + "MR": 27, + "MT": 31, + "MU": 30, + "NC": 27, + "NI": 28, + "NL": 18, + "NO": 15, + "OM": 23, + "PF": 27, + "PK": 24, + "PL": 28, + "PM": 27, + "PS": 29, + "PT": 25, + "QA": 29, + "RE": 27, + "RO": 24, + "RS": 22, + "RU": 33, + "SA": 24, + "SC": 31, + "SD": 18, + "SE": 24, + "SI": 19, + "SK": 24, + "SM": 27, + "SO": 23, + "ST": 25, + "SV": 28, + "TF": 27, + "TL": 23, + "TN": 24, + "TR": 26, + "UA": 29, + "VA": 22, + "VG": 24, + "WF": 27, + "XK": 20, + "YE": 30, + "YT": 27, + } +) _DIGIT_VALUES: Final = bytes.maketrans(b"0123456789", bytes(range(10))) _DOUBLED_DIGIT_SUMS: Final = bytes.maketrans(b"0123456789", bytes((0, 2, 4, 6, 8, 1, 3, 5, 7, 9))) @@ -29,13 +138,21 @@ def passes_luhn(value: str) -> bool: def passes_iban_mod97(value: str) -> bool: - iban: Final = _to_ascii(_SEPARATORS.sub("", value)).upper() + compact: Final = _to_ascii(_SEPARATORS.sub("", value)).upper() + iban: Final = compact[: _ISO_13616_IBAN_LENGTHS.get(compact[:2], len(compact))] 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 +CandidateCheck: TypeAlias = Callable[[Callable[[str], bool], str], bool] + + +def run_validator(validator: Callable[[str], bool], candidate: str) -> bool: + return validator(candidate) + + 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 6f69bb25185..00ea00eb363 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 @@ -20,6 +20,10 @@ from litellm.constants import ( from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( ContentFilterGuardrail, ) +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.validators import ( + CandidateCheck, + run_validator, +) from litellm.types.guardrails import ( BlockedWord, ContentFilterAction, @@ -494,6 +498,24 @@ class TestContentFilterGuardrail: assert guardrailed_inputs.get("texts") == ["send it to [IBAN_REDACTED]"] + @pytest.mark.asyncio + @pytest.mark.parametrize("pattern_name", ["iban", "eu_iban_enhanced"]) + async def test_checksum_pattern_masks_a_valid_iban_with_characters_attached_to_its_end(self, pattern_name: str): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-attached", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name=pattern_name, action=ContentFilterAction.MASK) + ], + ) + + guardrailed_inputs: Final = await guardrail.apply_guardrail( + inputs={"texts": ["pay DE89370400440532013000EUR or DE88370400440532013000EUR"]}, + request_data={}, + input_type="request", + ) + + assert guardrailed_inputs.get("texts") == [f"pay [{pattern_name.upper()}_REDACTED] or DE88370400440532013000EUR"] + def test_api_key_patterns(self): """ Test API key pattern detection @@ -1080,9 +1102,10 @@ class TestContentFilterGuardrail: self, text: str, detections: list[ContentFilterDetection] | None = None, + check_candidate: CandidateCheck = run_validator, ) -> str: scanned_lengths.append(len(text)) - return super()._filter_single_text(text, detections=detections) + return super()._filter_single_text(text, detections=detections, check_candidate=check_candidate) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-bounded-scan", @@ -1122,9 +1145,10 @@ class TestContentFilterGuardrail: self, text: str, detections: list[ContentFilterDetection] | None = None, + check_candidate: CandidateCheck = run_validator, ) -> str: scanned_lengths.append(len(text)) - return super()._filter_single_text(text, detections=detections) + return super()._filter_single_text(text, detections=detections, check_candidate=check_candidate) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-refused-cut-backoff", 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 index 0214777cd22..d2d6da2d722 100644 --- 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 @@ -45,6 +45,8 @@ def test_passes_luhn_rejects_every_single_digit_change(valid_number: str): ("GB82WEST12345698765433", False), ("GB82", False), ("GB82WEST1234569876543é", False), + ("DE89370400440532013000EUR", True), + ("DE88370400440532013000EUR", False), ], ) def test_passes_iban_mod97(value: str, expected: bool): From 16743848cf7e0fd6856f5735ecd77bda3ec7a972 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:49:50 -0700 Subject: [PATCH 06/15] fix(guardrails): mask only the base matches that overlap a valid card and cache the lookahead regex --- .../litellm_content_filter/content_filter.py | 16 +++++++++++----- .../content_filter/test_content_filter.py | 6 ++++-- 2 files changed, 15 insertions(+), 7 deletions(-) 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 bd3b78c959e..eeea224077d 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 @@ -109,6 +109,12 @@ WORD_NUMBER_SEQUENCE_PATTERN: Final = re.compile( ) WORD_NUMBER_TOKEN_FINDER: Final = re.compile(rf"(?:{WORD_NUMBER_TOKEN_REGEX})", re.IGNORECASE) + +@functools.cache +def _overlapping_matches_regex(regex: Pattern[str]) -> Pattern[str]: + return re.compile(f"(?=({regex.pattern}))", regex.flags) + + _logging_only_scan_guardrail: Final[contextvars.ContextVar["ContentFilterGuardrail | None"]] = contextvars.ContextVar( "litellm_content_filter_logging_only_scan", default=None ) @@ -893,10 +899,10 @@ class ContentFilterGuardrail(CustomGuardrail): validator: Callable[[str], bool], check_candidate: CandidateCheck, ) -> tuple[tuple[int, int], ...]: - if regex.search(text) is None: + leftmost_spans: Final = tuple(match.span() for match in regex.finditer(text)) + if not leftmost_spans: return () - overlapping_regex: Final = re.compile(f"(?=({regex.pattern}))", regex.flags) - candidate_spans: Final = tuple(candidate.span(1) for candidate in overlapping_regex.finditer(text)) + candidate_spans: Final = (candidate.span(1) for candidate in _overlapping_matches_regex(regex).finditer(text)) valid_spans: Final = tuple( span for span in candidate_spans if check_candidate(validator, text[span[0] : span[1]]) ) @@ -904,8 +910,8 @@ class ContentFilterGuardrail(CustomGuardrail): return () valid_starts: Final = tuple(start for start, _ in valid_spans) furthest_valid_ends: Final = tuple(itertools.accumulate((end for _, end in valid_spans), max)) - return tuple( - span for span in candidate_spans if self._overlaps_a_valid_span(span, valid_starts, furthest_valid_ends) + return valid_spans + tuple( + span for span in leftmost_spans if self._overlaps_a_valid_span(span, valid_starts, furthest_valid_ends) ) @staticmethod 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 00ea00eb363..8ee0e3790ce 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 @@ -414,11 +414,13 @@ class TestContentFilterGuardrail: [ ("PIN 4821 4532 0151 1283 0366 thanks", "PIN [CREDIT_CARD_REDACTED] thanks"), ("order 4111 4532 0151 1283 0366", "order [CREDIT_CARD_REDACTED]"), - ("codes 4532 4532 4532 4532 4532 0151 1283 0366 thanks", "codes 4532 [CREDIT_CARD_REDACTED] thanks"), + ("codes 4532 4532 4532 4532 4532 0151 1283 0366 thanks", "codes 4532 4532 4532 4532 [CREDIT_CARD_REDACTED] thanks"), ("order 4532 1234 5678 9010 paid by 4532 0151 1283 0366", "order 4532 1234 5678 9010 paid by [CREDIT_CARD_REDACTED]"), + ("card 4532 4151 4283 4000 4777 4888 4999 ok", "card [CREDIT_CARD_REDACTED] 4777 4888 4999 ok"), + ("card 4532 4111 1111 1116 2025 ok", "card [CREDIT_CARD_REDACTED] 2025 ok"), ], ) - async def test_checksum_pattern_masks_a_valid_card_with_the_candidates_overlapping_it(self, text: str, expected: str): + async def test_checksum_pattern_masks_a_valid_card_with_the_base_matches_overlapping_it(self, text: str, expected: str): guardrail: Final = ContentFilterGuardrail( guardrail_name="test-checksum-overlap", patterns=[ From d28c8a629a009b1cbab8f638a707a4c86a983241 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:09:43 -0700 Subject: [PATCH 07/15] fix(guardrails): mask other card brands' valid numbers that a card mask touches, and speed up mod-97 --- .../litellm_content_filter/content_filter.py | 56 +++++++++++++++---- .../litellm_content_filter/validators.py | 6 +- .../content_filter/test_content_filter.py | 27 +++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) 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 eeea224077d..f6c8f2a65e4 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 @@ -853,7 +853,7 @@ class ContentFilterGuardrail(CustomGuardrail): text: str, pattern_entry: CompiledPatternEntry, check_candidate: CandidateCheck = run_validator, - ) -> list[tuple[int, int]]: + ) -> Sequence[tuple[int, int]]: """Return all match spans for a pattern, applying contextual rules if required.""" regex: Final[Pattern[str]] = pattern_entry["regex"] @@ -890,7 +890,42 @@ class ContentFilterGuardrail(CustomGuardrail): continue match_spans.append((word_match.start(), word_match.end())) - return self._merge_spans(match_spans) + merged_spans: Final = self._merge_spans(match_spans) + if validator is None or not merged_spans: + return merged_spans + return self._with_overlapping_checksum_matches(text, pattern_entry, merged_spans, check_candidate) + + def _with_overlapping_checksum_matches( + self, + text: str, + pattern_entry: CompiledPatternEntry, + spans: Sequence[tuple[int, int]], + check_candidate: CandidateCheck, + ) -> Sequence[tuple[int, int]]: + other_checksum_spans: Final = tuple( + itertools.chain.from_iterable( + self._entry_checksum_valid_spans(entry, text, check_candidate) + for entry in self.compiled_patterns + if entry is not pattern_entry + ) + ) + if not other_checksum_spans: + return spans + span_starts: Final = tuple(start for start, _ in spans) + span_ends: Final = tuple(end for _, end in spans) + return tuple( + component + for component in self._merge_spans([*spans, *other_checksum_spans]) + if self._overlaps_any_span(component, span_starts, span_ends) + ) + + def _entry_checksum_valid_spans( + self, entry: CompiledPatternEntry, text: str, check_candidate: CandidateCheck + ) -> tuple[tuple[int, int], ...]: + validator: Final = entry.get("validator") + if validator is None: + return () + return self._checksum_valid_spans(entry["regex"], text, validator, check_candidate) def _checksum_valid_spans( self, @@ -911,15 +946,13 @@ class ContentFilterGuardrail(CustomGuardrail): valid_starts: Final = tuple(start for start, _ in valid_spans) furthest_valid_ends: Final = tuple(itertools.accumulate((end for _, end in valid_spans), max)) return valid_spans + tuple( - span for span in leftmost_spans if self._overlaps_a_valid_span(span, valid_starts, furthest_valid_ends) + span for span in leftmost_spans if self._overlaps_any_span(span, valid_starts, furthest_valid_ends) ) @staticmethod - def _overlaps_a_valid_span( - span: tuple[int, int], sorted_valid_starts: Sequence[int], furthest_valid_ends: Sequence[int] - ) -> bool: - index: Final = bisect_left(sorted_valid_starts, span[1]) - return index > 0 and furthest_valid_ends[index - 1] > span[0] + def _overlaps_any_span(span: tuple[int, int], sorted_starts: Sequence[int], furthest_ends: Sequence[int]) -> bool: + index: Final = bisect_left(sorted_starts, span[1]) + return index > 0 and furthest_ends[index - 1] > span[0] def _match_near_keyword( self, @@ -973,7 +1006,7 @@ class ContentFilterGuardrail(CustomGuardrail): merged.append((start, end)) return merged - def _mask_spans(self, text: str, spans: list[tuple[int, int]], redaction: str) -> str: + def _mask_spans(self, text: str, spans: Sequence[tuple[int, int]], redaction: str) -> str: """Apply masking for the provided spans using the given redaction tag.""" if not spans: @@ -1379,7 +1412,7 @@ class ContentFilterGuardrail(CustomGuardrail): pattern_name: str, action: ContentFilterAction, text: str, - spans: list[tuple[int, int]], + spans: Sequence[tuple[int, int]], detections: list[ContentFilterDetection] | None, ) -> str: """Handle regex pattern match detection and action.""" @@ -1499,8 +1532,9 @@ class ContentFilterGuardrail(CustomGuardrail): text = self._handle_category_keyword_match(keyword, category_name, severity, action, text, detections) # Check regex patterns - process ALL patterns, not just first match + memoized_check: Final = functools.cache(check_candidate) for pattern_entry in self.compiled_patterns: - spans = self._find_pattern_spans(text, pattern_entry, check_candidate) + spans = self._find_pattern_spans(text, pattern_entry, memoized_check) if spans: pattern_name = pattern_entry["pattern_name"] action = pattern_entry["action"] diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py index 7ab3e194371..2af21d377f8 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py @@ -1,4 +1,5 @@ import re +import string from collections.abc import Callable, Mapping from types import MappingProxyType from typing import Final, TypeAlias @@ -115,6 +116,9 @@ _ISO_13616_IBAN_LENGTHS: Final[Mapping[str, int]] = MappingProxyType( "YT": 27, } ) +_IBAN_LETTER_VALUES: Final = str.maketrans( + {letter: str(value) for value, letter in enumerate(string.ascii_uppercase, 10)} +) _DIGIT_VALUES: Final = bytes.maketrans(b"0123456789", bytes(range(10))) _DOUBLED_DIGIT_SUMS: Final = bytes.maketrans(b"0123456789", bytes((0, 2, 4, 6, 8, 1, 3, 5, 7, 9))) @@ -143,7 +147,7 @@ def passes_iban_mod97(value: str) -> bool: 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 + return int(rearranged.translate(_IBAN_LETTER_VALUES)) % 97 == 1 CandidateCheck: TypeAlias = Callable[[Callable[[str], bool], str], bool] 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 8ee0e3790ce..05f6b6d637f 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 @@ -436,6 +436,33 @@ class TestContentFilterGuardrail: assert guardrailed_inputs.get("texts") == [expected] + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("ids 4853 4091 4909 4475 5508 5016 6232 6258 end", "ids [VISA_REDACTED] end"), + ("ids 4336 4262 4755 4637 3753 786027 13221 end", "ids [VISA_REDACTED] end"), + ("4000 4000 0000 0000\n5555 5555 5555 4444", "[VISA_REDACTED]"), + ("cards 4532 0151 1283 0366 5555 5555 5555 4444 ok", "cards [VISA_REDACTED] [MASTERCARD_REDACTED] ok"), + ], + ) + async def test_checksum_pattern_masks_the_valid_cards_of_later_patterns_it_overlaps(self, text: str, expected: str): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-cross-pattern", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name=pattern_name, action=ContentFilterAction.MASK) + for pattern_name in ("visa", "mastercard", "amex", "discover", "credit_card", "iban") + ], + ) + + guardrailed_inputs: Final = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + + assert guardrailed_inputs.get("texts") == [expected] + @pytest.mark.asyncio async def test_checksum_pattern_streams_the_same_text_as_a_whole_response(self): from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices From bdec96442b2adc4c98b8037d6d5279be560e279e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:40:07 -0700 Subject: [PATCH 08/15] fix(guardrails): keep base masking where a checksum mask meets a later pattern's match A MASK checksum pattern now merges only with later patterns' matches, skips that step for BLOCK patterns, and falls back to its own matches where a merged region would cut a later masking match or cover a later blocking match. A later BLOCK pattern keeps seeing its card, and two overlapping brands keep their own tags. --- .../litellm_content_filter/content_filter.py | 97 +++++++++++++++---- .../content_filter/test_content_filter.py | 54 ++++++++++- 2 files changed, 129 insertions(+), 22 deletions(-) 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 f6c8f2a65e4..d2c3ad4c322 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 @@ -13,8 +13,8 @@ import json import os import re import time -from bisect import bisect_left -from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping, Sequence +from bisect import bisect_left, bisect_right +from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime from re import Pattern @@ -161,6 +161,12 @@ class _StreamedChoiceState: next_trim_len: int = 0 +@dataclass(frozen=True, slots=True) +class _SpanIndex: + spans: tuple[tuple[int, int], ...] + furthest_ends: tuple[int, ...] + + @dataclass(frozen=True, slots=True) class _StreamedScanPlan: context_chars: int @@ -902,31 +908,86 @@ class ContentFilterGuardrail(CustomGuardrail): spans: Sequence[tuple[int, int]], check_candidate: CandidateCheck, ) -> Sequence[tuple[int, int]]: - other_checksum_spans: Final = tuple( + if pattern_entry["action"] == ContentFilterAction.BLOCK: + return spans + position: Final = next(index for index, entry in enumerate(self.compiled_patterns) if entry is pattern_entry) + later_entries: Final = self.compiled_patterns[position + 1 :] + masking: Final = self._span_index( + text, (entry for entry in later_entries if entry["action"] != ContentFilterAction.BLOCK), check_candidate + ) + blocking: Final = self._span_index( + text, (entry for entry in later_entries if entry["action"] == ContentFilterAction.BLOCK), check_candidate + ) + if not masking.spans and not blocking.spans: + return spans + leftmost_spans: Final = tuple(match.span() for match in pattern_entry["regex"].finditer(text)) + union_regions: Final = tuple(self._merge_spans([*spans, *masking.spans])) + resolved: Final = self._merge_spans( + [ + *itertools.chain.from_iterable( + self._resolve_component(component, leftmost_spans, masking, union_regions) for component in spans + ) + ] + ) + return tuple( itertools.chain.from_iterable( - self._entry_checksum_valid_spans(entry, text, check_candidate) - for entry in self.compiled_patterns - if entry is not pattern_entry + self._spans_overlapping(leftmost_spans, region) + if any(self._spans_reaching(region, blocking)) + else (region,) + for region in resolved ) ) - if not other_checksum_spans: - return spans - span_starts: Final = tuple(start for start, _ in spans) - span_ends: Final = tuple(end for _, end in spans) - return tuple( - component - for component in self._merge_spans([*spans, *other_checksum_spans]) - if self._overlaps_any_span(component, span_starts, span_ends) - ) - def _entry_checksum_valid_spans( + def _resolve_component( + self, + component: tuple[int, int], + leftmost_spans: Sequence[tuple[int, int]], + masking: _SpanIndex, + union_regions: Sequence[tuple[int, int]], + ) -> Sequence[tuple[int, int]]: + sticking_out: Final = tuple( + span + for span in self._spans_reaching(component, masking) + if span[0] < component[0] or span[1] > component[1] + ) + if not sticking_out: + return (component,) + leftmost_in_component: Final = self._spans_overlapping(leftmost_spans, component) + if not any(self._spans_overlapping(leftmost_in_component, span) for span in sticking_out): + return leftmost_in_component + return (union_regions[bisect_right(union_regions, component[0], key=lambda region: region[0]) - 1],) + + def _span_index( + self, text: str, entries: Iterable[CompiledPatternEntry], check_candidate: CandidateCheck + ) -> _SpanIndex: + spans: Final = tuple( + sorted(itertools.chain.from_iterable(self._entry_spans(entry, text, check_candidate) for entry in entries)) + ) + return _SpanIndex(spans=spans, furthest_ends=tuple(itertools.accumulate((end for _, end in spans), max))) + + def _entry_spans( self, entry: CompiledPatternEntry, text: str, check_candidate: CandidateCheck - ) -> tuple[tuple[int, int], ...]: + ) -> Sequence[tuple[int, int]]: validator: Final = entry.get("validator") if validator is None: - return () + return self._find_pattern_spans(text, entry, check_candidate) return self._checksum_valid_spans(entry["regex"], text, validator, check_candidate) + @staticmethod + def _spans_reaching(component: tuple[int, int], index: _SpanIndex) -> Iterator[tuple[int, int]]: + later_position: Final = bisect_left(index.spans, (component[1],)) + positions_reaching_start: Final = itertools.takewhile( + lambda position: index.furthest_ends[position] > component[0], range(later_position - 1, -1, -1) + ) + return ( + index.spans[position] for position in positions_reaching_start if index.spans[position][1] > component[0] + ) + + @staticmethod + def _spans_overlapping(spans: Sequence[tuple[int, int]], component: tuple[int, int]) -> Sequence[tuple[int, int]]: + first: Final = bisect_right(spans, component[0], key=lambda span: span[1]) + return spans[first : bisect_left(spans, component[1], lo=first, key=lambda span: span[0])] + def _checksum_valid_spans( self, regex: Pattern[str], 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 05f6b6d637f..531dd5d3715 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 @@ -440,13 +440,15 @@ class TestContentFilterGuardrail: @pytest.mark.parametrize( ("text", "expected"), [ - ("ids 4853 4091 4909 4475 5508 5016 6232 6258 end", "ids [VISA_REDACTED] end"), - ("ids 4336 4262 4755 4637 3753 786027 13221 end", "ids [VISA_REDACTED] end"), - ("4000 4000 0000 0000\n5555 5555 5555 4444", "[VISA_REDACTED]"), + ("ids 4853 4091 4909 4475 5508 5016 6232 6258 end", "ids [VISA_REDACTED] [MASTERCARD_REDACTED] end"), + ("ids 4336 4262 4755 4637 3753 786027 13221 end", "ids [VISA_REDACTED] [AMEX_REDACTED] end"), + ("4000 4000 0000 0000\n5555 5555 5555 4444", "[VISA_REDACTED]\n[MASTERCARD_REDACTED]"), + ("cards 4378 4937 4618 4485 5555 5555 5555 4444 ok", "cards [VISA_REDACTED] [MASTERCARD_REDACTED] ok"), + ("pay 5472 3038 5280 1841 1485 4534 3853 6336 1330 ok", "pay [VISA_REDACTED] ok"), ("cards 4532 0151 1283 0366 5555 5555 5555 4444 ok", "cards [VISA_REDACTED] [MASTERCARD_REDACTED] ok"), ], ) - async def test_checksum_pattern_masks_the_valid_cards_of_later_patterns_it_overlaps(self, text: str, expected: str): + async def test_checksum_patterns_mask_each_valid_card_where_two_brands_overlap(self, text: str, expected: str): guardrail: Final = ContentFilterGuardrail( guardrail_name="test-checksum-cross-pattern", patterns=[ @@ -463,6 +465,50 @@ class TestContentFilterGuardrail: assert guardrailed_inputs.get("texts") == [expected] + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("masking_pattern", "blocking_pattern", "text"), + [ + ("visa", "mastercard", "4004 5555 5555 5557 5555 5555 5555 4444"), + ("visa", "mastercard", "4000 4000 0000 0000 5555 5555 5555 4444"), + ("visa", "mastercard", "ids 4853 4091 4909 4475 5508 5016 6232 6258 end"), + ("discover", "credit_card", "4629 4611-6260-8050-5592-2700 65405563 93269332"), + ], + ) + async def test_checksum_mask_leaves_a_later_blocking_pattern_its_valid_card( + self, masking_pattern: str, blocking_pattern: str, text: str + ): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-mask-then-block", + patterns=[ + ContentFilterPattern( + pattern_type="prebuilt", pattern_name=masking_pattern, action=ContentFilterAction.MASK + ), + ContentFilterPattern( + pattern_type="prebuilt", pattern_name=blocking_pattern, action=ContentFilterAction.BLOCK + ), + ], + ) + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail(inputs={"texts": [text]}, request_data={}, input_type="request") + assert exc_info.value.detail["pattern"] == blocking_pattern + + @pytest.mark.asyncio + async def test_checksum_mask_before_a_blocking_pattern_still_masks_the_card_both_match(self): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-mask-before-block", + patterns=[ + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern(pattern_type="prebuilt", pattern_name="visa", action=ContentFilterAction.BLOCK), + ], + ) + guardrailed_inputs: Final = await guardrail.apply_guardrail( + inputs={"texts": ["card 4111 1111 1111 1111 ok"]}, request_data={}, input_type="request" + ) + assert guardrailed_inputs.get("texts") == ["card [CREDIT_CARD_REDACTED] ok"] + @pytest.mark.asyncio async def test_checksum_pattern_streams_the_same_text_as_a_whole_response(self): from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices From c4ea04e4b8b7b37d8edebc1be6c78632a2a1083a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:36:23 -0700 Subject: [PATCH 09/15] fix(guardrails): keep a later rule's keyword when a checksum mask meets its match --- .../litellm_content_filter/content_filter.py | 47 ++++++++++----- .../content_filter/test_content_filter.py | 57 +++++++++++++++++++ 2 files changed, 89 insertions(+), 15 deletions(-) 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 d2c3ad4c322..120650c69e5 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 @@ -912,20 +912,27 @@ class ContentFilterGuardrail(CustomGuardrail): return spans position: Final = next(index for index, entry in enumerate(self.compiled_patterns) if entry is pattern_entry) later_entries: Final = self.compiled_patterns[position + 1 :] - masking: Final = self._span_index( - text, (entry for entry in later_entries if entry["action"] != ContentFilterAction.BLOCK), check_candidate + later_masking_entries: Final = tuple( + entry for entry in later_entries if entry["action"] != ContentFilterAction.BLOCK + ) + checksum_masking: Final = self._span_index( + text, (entry for entry in later_masking_entries if entry.get("validator") is not None), check_candidate + ) + other_masking: Final = self._span_index( + text, (entry for entry in later_masking_entries if entry.get("validator") is None), check_candidate ) blocking: Final = self._span_index( text, (entry for entry in later_entries if entry["action"] == ContentFilterAction.BLOCK), check_candidate ) - if not masking.spans and not blocking.spans: + if not checksum_masking.spans and not other_masking.spans and not blocking.spans: return spans leftmost_spans: Final = tuple(match.span() for match in pattern_entry["regex"].finditer(text)) - union_regions: Final = tuple(self._merge_spans([*spans, *masking.spans])) + union_regions: Final = tuple(self._merge_spans([*spans, *checksum_masking.spans])) resolved: Final = self._merge_spans( [ *itertools.chain.from_iterable( - self._resolve_component(component, leftmost_spans, masking, union_regions) for component in spans + self._resolve_component(component, leftmost_spans, checksum_masking, other_masking, union_regions) + for component in spans ) ] ) @@ -942,20 +949,30 @@ class ContentFilterGuardrail(CustomGuardrail): self, component: tuple[int, int], leftmost_spans: Sequence[tuple[int, int]], - masking: _SpanIndex, + checksum_masking: _SpanIndex, + other_masking: _SpanIndex, union_regions: Sequence[tuple[int, int]], ) -> Sequence[tuple[int, int]]: - sticking_out: Final = tuple( - span - for span in self._spans_reaching(component, masking) - if span[0] < component[0] or span[1] > component[1] - ) - if not sticking_out: - return (component,) leftmost_in_component: Final = self._spans_overlapping(leftmost_spans, component) - if not any(self._spans_overlapping(leftmost_in_component, span) for span in sticking_out): + sticking_out: Final = tuple(self._spans_sticking_out(component, checksum_masking)) + if sticking_out and not any(self._spans_overlapping(leftmost_in_component, span) for span in sticking_out): return leftmost_in_component - return (union_regions[bisect_right(union_regions, component[0], key=lambda region: region[0]) - 1],) + region: Final = ( + union_regions[bisect_right(union_regions, component[0], key=lambda region: region[0]) - 1] + if sticking_out + else component + ) + leftmost_in_region: Final = self._spans_overlapping(leftmost_spans, region) + if any( + not self._spans_overlapping(leftmost_in_region, span) + for span in self._spans_sticking_out(region, other_masking) + ): + return leftmost_in_component + return (region,) + + @classmethod + def _spans_sticking_out(cls, region: tuple[int, int], index: _SpanIndex) -> Iterator[tuple[int, int]]: + return (span for span in cls._spans_reaching(region, index) if span[0] < region[0] or span[1] > region[1]) def _span_index( self, text: str, entries: Iterable[CompiledPatternEntry], check_candidate: CandidateCheck 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 531dd5d3715..7d09087bc4f 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 @@ -509,6 +509,63 @@ class TestContentFilterGuardrail: ) assert guardrailed_inputs.get("texts") == ["card [CREDIT_CARD_REDACTED] ok"] + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("checksum_patterns", "regex", "later_patterns", "text", "expected"), + [ + ( + ("credit_card",), + "1111 BSN", + ("nl_bsn_contextual",), + "4111 1111 1111 1111 BSN 123456789", + "[CREDIT_CARD_REDACTED] BSN [NL_BSN_CONTEXTUAL_REDACTED]", + ), + ( + ("visa", "mastercard"), + "4444 BSN", + ("nl_bsn_contextual",), + "4532 0151 1283 0366 5555 5555 5555 4444 BSN 123456789", + "[VISA_REDACTED] [MASTERCARD_REDACTED] BSN [NL_BSN_CONTEXTUAL_REDACTED]", + ), + ( + ("visa", "mastercard", "amex", "discover", "credit_card", "iban"), + "1330 BSN", + ("nl_bsn_contextual",), + "pay 5472 3038 5280 1841 1485 4534 3853 6336 1330 BSN 123456789", + "pay [VISA_REDACTED] BSN [NL_BSN_CONTEXTUAL_REDACTED]", + ), + (("credit_card",), "PIN 4821", (), "PIN 4821 4532 0151 1283 0366 ok", "PIN [CREDIT_CARD_REDACTED] ok"), + ], + ) + async def test_checksum_mask_keeps_the_words_a_later_regex_match_shares_with_the_card( + self, + checksum_patterns: tuple[str, ...], + regex: str, + later_patterns: tuple[str, ...], + text: str, + expected: str, + ): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-mask-then-regex", + patterns=[ + *( + ContentFilterPattern(pattern_type="prebuilt", pattern_name=name, action=ContentFilterAction.MASK) + for name in checksum_patterns + ), + ContentFilterPattern( + pattern_type="regex", pattern=regex, name="account_note", action=ContentFilterAction.MASK + ), + *( + ContentFilterPattern(pattern_type="prebuilt", pattern_name=name, action=ContentFilterAction.MASK) + for name in later_patterns + ), + ], + ) + guardrailed_inputs: Final = await guardrail.apply_guardrail( + inputs={"texts": [text]}, request_data={}, input_type="request" + ) + assert guardrailed_inputs.get("texts") == [expected] + @pytest.mark.asyncio async def test_checksum_pattern_streams_the_same_text_as_a_whole_response(self): from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices From 618b1225c22c88d251796c004256f1337015f4f1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:59:14 -0700 Subject: [PATCH 10/15] fix(content_filter): keep base masking and put back only matches no valid card or IBAN overlaps Every pattern runs on the same masked text as before, and after the pattern loop a redaction is put back to its original characters only when its span overlaps no Luhn or mod-97 valid window of any configured checksum pattern. A pattern's detection and BLOCK fire only when one of its matches is kept. --- .../litellm_content_filter/content_filter.py | 334 +++++++++--------- .../content_filter/test_content_filter.py | 136 ++++--- 2 files changed, 245 insertions(+), 225 deletions(-) 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 120650c69e5..1c15872df3e 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 @@ -14,7 +14,7 @@ import os import re import time from bisect import bisect_left, bisect_right -from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Iterator, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime from re import Pattern @@ -111,8 +111,8 @@ WORD_NUMBER_TOKEN_FINDER: Final = re.compile(rf"(?:{WORD_NUMBER_TOKEN_REGEX})", @functools.cache -def _overlapping_matches_regex(regex: Pattern[str]) -> Pattern[str]: - return re.compile(f"(?=({regex.pattern}))", regex.flags) +def _checksum_window_regex(regex: Pattern[str]) -> Pattern[str]: + return re.compile("(?=(" + regex.pattern.replace(r"\b", "") + "))", regex.flags) _logging_only_scan_guardrail: Final[contextvars.ContextVar["ContentFilterGuardrail | None"]] = contextvars.ContextVar( @@ -162,10 +162,89 @@ class _StreamedChoiceState: @dataclass(frozen=True, slots=True) -class _SpanIndex: - spans: tuple[tuple[int, int], ...] +class _ChecksumWindows: + starts: tuple[int, ...] furthest_ends: tuple[int, ...] + def overlap(self, span: tuple[int, int]) -> bool: + index: Final = bisect_left(self.starts, span[1]) + return index > 0 and self.furthest_ends[index - 1] > span[0] + + +@dataclass(frozen=True, slots=True) +class _Redaction: + start: int + end: int + original_start: int + original_end: int + failed_checksum: bool + + +@dataclass(frozen=True, slots=True) +class _MaskedText: + text: str + redactions: tuple[_Redaction, ...] = () + + def original_span(self, span: tuple[int, int]) -> tuple[int, int]: + return self._original_position(span[0], is_end=False), self._original_position(span[1], is_end=True) + + def _original_position(self, position: int, *, is_end: bool) -> int: + find: Final = bisect_left if is_end else bisect_right + index: Final = find(self.redactions, position, key=lambda redaction: redaction.start) - 1 + if index < 0: + return position + redaction: Final = self.redactions[index] + if position >= redaction.end: + return redaction.original_end + position - redaction.end + return redaction.original_end if is_end else redaction.original_start + + def _redactions_overlapping(self, span: tuple[int, int]) -> Sequence[_Redaction]: + first: Final = bisect_right(self.redactions, span[0], key=lambda redaction: redaction.end) + return self.redactions[ + first : bisect_left(self.redactions, span[1], lo=first, key=lambda redaction: redaction.start) + ] + + def masked(self, spans: Sequence[tuple[int, int]], tag: str, failed_checksum: Sequence[bool]) -> "_MaskedText": + growth: Final = tuple(itertools.accumulate((len(tag) - end + start for start, end in spans), initial=0)) + span_ends: Final = tuple(end for _, end in spans) + absorbed: Final = tuple(self._redactions_overlapping(span) for span in spans) + dropped: Final = frozenset(itertools.chain.from_iterable(absorbed)) + shifted: Final = ( + replace(redaction, start=redaction.start + shift, end=redaction.end + shift) + for redaction, shift in zip( + self.redactions, (growth[bisect_right(span_ends, redaction.start)] for redaction in self.redactions) + ) + if redaction not in dropped + ) + added: Final = ( + _Redaction( + start=start + growth[index], + end=start + growth[index] + len(tag), + original_start=self._original_position(start, is_end=False), + original_end=self._original_position(end, is_end=True), + failed_checksum=failed_checksum[index] + and all(redaction.failed_checksum for redaction in absorbed[index]), + ) + for index, (start, end) in enumerate(spans) + ) + text_pieces: Final = itertools.chain.from_iterable( + (self.text[previous_end:start], tag) for previous_end, (start, _) in zip((0, *span_ends), spans) + ) + return _MaskedText( + text="".join((*text_pieces, self.text[span_ends[-1] :])), + redactions=tuple(sorted((*shifted, *added), key=lambda redaction: redaction.start)), + ) + + def restored(self, original: str) -> str: + failed: Final = tuple(redaction for redaction in self.redactions if redaction.failed_checksum) + if not failed: + return self.text + pieces: Final = itertools.chain.from_iterable( + (self.text[previous_end : redaction.start], original[redaction.original_start : redaction.original_end]) + for previous_end, redaction in zip((0, *(redaction.end for redaction in failed)), failed) + ) + return "".join((*pieces, self.text[failed[-1].end :])) + @dataclass(frozen=True, slots=True) class _StreamedScanPlan: @@ -854,34 +933,25 @@ class ContentFilterGuardrail(CustomGuardrail): except Exception as e: raise Exception(f"Error loading blocked words file {file_path}: {e}") - def _find_pattern_spans( - self, - text: str, - pattern_entry: CompiledPatternEntry, - check_candidate: CandidateCheck = run_validator, - ) -> Sequence[tuple[int, int]]: + def _find_pattern_spans(self, text: str, pattern_entry: CompiledPatternEntry) -> list[tuple[int, int]]: """Return all match spans for a pattern, applying contextual rules if required.""" 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: return [] match_spans: Final[list[tuple[int, int]]] = [] - candidate_spans: Final = ( - self._checksum_valid_spans(regex, text, validator, check_candidate) - if validator is not None - else ((match.start(), match.end()) for match in regex.finditer(text)) - ) - for start, end in candidate_spans: - if keyword_matches is not None and not self._match_near_keyword(start, end, keyword_matches, text): + for match in regex.finditer(text): + if keyword_matches is not None and not self._match_near_keyword( + match.start(), match.end(), keyword_matches, text + ): continue - match_spans.append((start, end)) + match_spans.append((match.start(), match.end())) if allow_word_numbers: for word_match in WORD_NUMBER_SEQUENCE_PATTERN.finditer(text): @@ -896,141 +966,40 @@ class ContentFilterGuardrail(CustomGuardrail): continue match_spans.append((word_match.start(), word_match.end())) - merged_spans: Final = self._merge_spans(match_spans) - if validator is None or not merged_spans: - return merged_spans - return self._with_overlapping_checksum_matches(text, pattern_entry, merged_spans, check_candidate) + return self._merge_spans(match_spans) - def _with_overlapping_checksum_matches( - self, - text: str, - pattern_entry: CompiledPatternEntry, - spans: Sequence[tuple[int, int]], - check_candidate: CandidateCheck, - ) -> Sequence[tuple[int, int]]: - if pattern_entry["action"] == ContentFilterAction.BLOCK: - return spans - position: Final = next(index for index, entry in enumerate(self.compiled_patterns) if entry is pattern_entry) - later_entries: Final = self.compiled_patterns[position + 1 :] - later_masking_entries: Final = tuple( - entry for entry in later_entries if entry["action"] != ContentFilterAction.BLOCK - ) - checksum_masking: Final = self._span_index( - text, (entry for entry in later_masking_entries if entry.get("validator") is not None), check_candidate - ) - other_masking: Final = self._span_index( - text, (entry for entry in later_masking_entries if entry.get("validator") is None), check_candidate - ) - blocking: Final = self._span_index( - text, (entry for entry in later_entries if entry["action"] == ContentFilterAction.BLOCK), check_candidate - ) - if not checksum_masking.spans and not other_masking.spans and not blocking.spans: - return spans - leftmost_spans: Final = tuple(match.span() for match in pattern_entry["regex"].finditer(text)) - union_regions: Final = tuple(self._merge_spans([*spans, *checksum_masking.spans])) - resolved: Final = self._merge_spans( - [ - *itertools.chain.from_iterable( - self._resolve_component(component, leftmost_spans, checksum_masking, other_masking, union_regions) - for component in spans - ) - ] - ) - return tuple( + def _checksum_windows(self, text: str, check_candidate: CandidateCheck) -> _ChecksumWindows: + windows: Final = sorted( itertools.chain.from_iterable( - self._spans_overlapping(leftmost_spans, region) - if any(self._spans_reaching(region, blocking)) - else (region,) - for region in resolved + self._valid_checksum_windows(text, entry, check_candidate) for entry in self.compiled_patterns ) ) - - def _resolve_component( - self, - component: tuple[int, int], - leftmost_spans: Sequence[tuple[int, int]], - checksum_masking: _SpanIndex, - other_masking: _SpanIndex, - union_regions: Sequence[tuple[int, int]], - ) -> Sequence[tuple[int, int]]: - leftmost_in_component: Final = self._spans_overlapping(leftmost_spans, component) - sticking_out: Final = tuple(self._spans_sticking_out(component, checksum_masking)) - if sticking_out and not any(self._spans_overlapping(leftmost_in_component, span) for span in sticking_out): - return leftmost_in_component - region: Final = ( - union_regions[bisect_right(union_regions, component[0], key=lambda region: region[0]) - 1] - if sticking_out - else component - ) - leftmost_in_region: Final = self._spans_overlapping(leftmost_spans, region) - if any( - not self._spans_overlapping(leftmost_in_region, span) - for span in self._spans_sticking_out(region, other_masking) - ): - return leftmost_in_component - return (region,) - - @classmethod - def _spans_sticking_out(cls, region: tuple[int, int], index: _SpanIndex) -> Iterator[tuple[int, int]]: - return (span for span in cls._spans_reaching(region, index) if span[0] < region[0] or span[1] > region[1]) - - def _span_index( - self, text: str, entries: Iterable[CompiledPatternEntry], check_candidate: CandidateCheck - ) -> _SpanIndex: - spans: Final = tuple( - sorted(itertools.chain.from_iterable(self._entry_spans(entry, text, check_candidate) for entry in entries)) - ) - return _SpanIndex(spans=spans, furthest_ends=tuple(itertools.accumulate((end for _, end in spans), max))) - - def _entry_spans( - self, entry: CompiledPatternEntry, text: str, check_candidate: CandidateCheck - ) -> Sequence[tuple[int, int]]: - validator: Final = entry.get("validator") - if validator is None: - return self._find_pattern_spans(text, entry, check_candidate) - return self._checksum_valid_spans(entry["regex"], text, validator, check_candidate) - - @staticmethod - def _spans_reaching(component: tuple[int, int], index: _SpanIndex) -> Iterator[tuple[int, int]]: - later_position: Final = bisect_left(index.spans, (component[1],)) - positions_reaching_start: Final = itertools.takewhile( - lambda position: index.furthest_ends[position] > component[0], range(later_position - 1, -1, -1) - ) - return ( - index.spans[position] for position in positions_reaching_start if index.spans[position][1] > component[0] + return _ChecksumWindows( + starts=tuple(start for start, _ in windows), + furthest_ends=tuple(itertools.accumulate((end for _, end in windows), max)), ) @staticmethod - def _spans_overlapping(spans: Sequence[tuple[int, int]], component: tuple[int, int]) -> Sequence[tuple[int, int]]: - first: Final = bisect_right(spans, component[0], key=lambda span: span[1]) - return spans[first : bisect_left(spans, component[1], lo=first, key=lambda span: span[0])] - - def _checksum_valid_spans( - self, - regex: Pattern[str], - text: str, - validator: Callable[[str], bool], - check_candidate: CandidateCheck, + def _valid_checksum_windows( + text: str, pattern_entry: CompiledPatternEntry, check_candidate: CandidateCheck ) -> tuple[tuple[int, int], ...]: - leftmost_spans: Final = tuple(match.span() for match in regex.finditer(text)) - if not leftmost_spans: + validator: Final = pattern_entry["validator"] + if validator is None: return () - candidate_spans: Final = (candidate.span(1) for candidate in _overlapping_matches_regex(regex).finditer(text)) - valid_spans: Final = tuple( - span for span in candidate_spans if check_candidate(validator, text[span[0] : span[1]]) - ) - if not valid_spans: - return () - valid_starts: Final = tuple(start for start, _ in valid_spans) - furthest_valid_ends: Final = tuple(itertools.accumulate((end for _, end in valid_spans), max)) - return valid_spans + tuple( - span for span in leftmost_spans if self._overlaps_any_span(span, valid_starts, furthest_valid_ends) + return tuple( + candidate.span(1) + for candidate in _checksum_window_regex(pattern_entry["regex"]).finditer(text) + if check_candidate(validator, candidate.group(1)) ) @staticmethod - def _overlaps_any_span(span: tuple[int, int], sorted_starts: Sequence[int], furthest_ends: Sequence[int]) -> bool: - index: Final = bisect_left(sorted_starts, span[1]) - return index > 0 and furthest_ends[index - 1] > span[0] + def _fails_checksum( + masked: _MaskedText, + span: tuple[int, int], + pattern_entry: CompiledPatternEntry, + checksum_windows: Callable[[], _ChecksumWindows], + ) -> bool: + return pattern_entry["validator"] is not None and not checksum_windows().overlap(masked.original_span(span)) def _match_near_keyword( self, @@ -1084,21 +1053,6 @@ class ContentFilterGuardrail(CustomGuardrail): merged.append((start, end)) return merged - def _mask_spans(self, text: str, spans: Sequence[tuple[int, int]], redaction: str) -> str: - """Apply masking for the provided spans using the given redaction tag.""" - - if not spans: - return text - - result_parts: Final[list[str]] = [] - previous_end = 0 - for start, end in spans: - result_parts.append(text[previous_end:start]) - result_parts.append(redaction) - previous_end = end - result_parts.append(text[previous_end:]) - return "".join(result_parts) - def _convert_word_number_sequence(self, sequence: str) -> str | None: """Convert a spelled-out digit sequence (e.g., 'One-Two') into digits.""" @@ -1125,8 +1079,14 @@ class ContentFilterGuardrail(CustomGuardrail): Returns: Tuple of (matched_text, pattern_name, action) if match found, None otherwise """ + checksum_windows: Final = functools.cache(functools.partial(self._checksum_windows, text, run_validator)) + unmasked: Final = _MaskedText(text=text) for pattern_entry in self.compiled_patterns: - spans = self._find_pattern_spans(text, pattern_entry) + spans = [ + span + for span in self._find_pattern_spans(text, pattern_entry) + if not self._fails_checksum(unmasked, span, pattern_entry, checksum_windows) + ] if spans: start, end = spans[0] matched_text = text[start:end] @@ -1489,11 +1449,9 @@ class ContentFilterGuardrail(CustomGuardrail): self, pattern_name: str, action: ContentFilterAction, - text: str, - spans: Sequence[tuple[int, int]], detections: list[ContentFilterDetection] | None, - ) -> str: - """Handle regex pattern match detection and action.""" + ) -> None: + """Record a regex pattern detection and raise when its action blocks.""" if detections is not None: pattern_detection: Final[PatternDetection] = { "type": "pattern", @@ -1509,12 +1467,44 @@ class ContentFilterGuardrail(CustomGuardrail): status_code=400, detail={"error": error_msg, "pattern": pattern_name}, ) - elif action == ContentFilterAction.MASK: - redaction_tag: Final = self.pattern_redaction_format.format(pattern_name=pattern_name.upper()) - text = self._mask_spans(text, spans, redaction_tag) - verbose_proxy_logger.info("Masked all %s matches in content", pattern_name) - return text + def _apply_patterns( + self, text: str, detections: list[ContentFilterDetection] | None, check_candidate: CandidateCheck + ) -> str: + checksum_windows: Final = functools.cache( + functools.partial(self._checksum_windows, text, functools.cache(check_candidate)) + ) + masked: Final = functools.reduce( + lambda masked_so_far, pattern_entry: self._apply_pattern( + masked_so_far, pattern_entry, checksum_windows, detections + ), + self.compiled_patterns, + _MaskedText(text=text), + ) + return masked.restored(text) + + def _apply_pattern( + self, + masked: _MaskedText, + pattern_entry: CompiledPatternEntry, + checksum_windows: Callable[[], _ChecksumWindows], + detections: list[ContentFilterDetection] | None, + ) -> _MaskedText: + spans: Final = self._find_pattern_spans(masked.text, pattern_entry) + if not spans: + return masked + pattern_name: Final = pattern_entry["pattern_name"] + failed_checksum: Final = tuple( + self._fails_checksum(masked, span, pattern_entry, checksum_windows) for span in spans + ) + if not all(failed_checksum): + self._handle_pattern_match(pattern_name, pattern_entry["action"], detections) + if pattern_entry["action"] != ContentFilterAction.MASK: + return masked + verbose_proxy_logger.info("Masked all %s matches in content", pattern_name) + return masked.masked( + spans, self.pattern_redaction_format.format(pattern_name=pattern_name.upper()), failed_checksum + ) def _handle_blocked_word_match( self, @@ -1610,13 +1600,7 @@ class ContentFilterGuardrail(CustomGuardrail): text = self._handle_category_keyword_match(keyword, category_name, severity, action, text, detections) # Check regex patterns - process ALL patterns, not just first match - memoized_check: Final = functools.cache(check_candidate) - for pattern_entry in self.compiled_patterns: - spans = self._find_pattern_spans(text, pattern_entry, memoized_check) - if spans: - pattern_name = pattern_entry["pattern_name"] - action = pattern_entry["action"] - text = self._handle_pattern_match(pattern_name, action, text, spans, detections) + text = self._apply_patterns(text, detections, check_candidate) # Check blocked words - iterate through ALL blocked words text_lower = text.lower() 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 7d09087bc4f..d4da5675ccf 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 @@ -412,15 +412,16 @@ class TestContentFilterGuardrail: @pytest.mark.parametrize( ("text", "expected"), [ - ("PIN 4821 4532 0151 1283 0366 thanks", "PIN [CREDIT_CARD_REDACTED] thanks"), - ("order 4111 4532 0151 1283 0366", "order [CREDIT_CARD_REDACTED]"), + ("PIN 4821 4532 0151 1283 0366 thanks", "PIN [CREDIT_CARD_REDACTED] 0366 thanks"), + ("order 4111 4532 0151 1283 0366", "order [CREDIT_CARD_REDACTED] 0366"), ("codes 4532 4532 4532 4532 4532 0151 1283 0366 thanks", "codes 4532 4532 4532 4532 [CREDIT_CARD_REDACTED] thanks"), ("order 4532 1234 5678 9010 paid by 4532 0151 1283 0366", "order 4532 1234 5678 9010 paid by [CREDIT_CARD_REDACTED]"), ("card 4532 4151 4283 4000 4777 4888 4999 ok", "card [CREDIT_CARD_REDACTED] 4777 4888 4999 ok"), ("card 4532 4111 1111 1116 2025 ok", "card [CREDIT_CARD_REDACTED] 2025 ok"), + ("VISA4000 4000 0000 0004 1111 ok", "VISA4000 [CREDIT_CARD_REDACTED] ok"), ], ) - async def test_checksum_pattern_masks_a_valid_card_with_the_base_matches_overlapping_it(self, text: str, expected: str): + async def test_checksum_pattern_keeps_the_mask_on_a_match_that_overlaps_a_valid_card(self, text: str, expected: str): guardrail: Final = ContentFilterGuardrail( guardrail_name="test-checksum-overlap", patterns=[ @@ -444,8 +445,13 @@ class TestContentFilterGuardrail: ("ids 4336 4262 4755 4637 3753 786027 13221 end", "ids [VISA_REDACTED] [AMEX_REDACTED] end"), ("4000 4000 0000 0000\n5555 5555 5555 4444", "[VISA_REDACTED]\n[MASTERCARD_REDACTED]"), ("cards 4378 4937 4618 4485 5555 5555 5555 4444 ok", "cards [VISA_REDACTED] [MASTERCARD_REDACTED] ok"), - ("pay 5472 3038 5280 1841 1485 4534 3853 6336 1330 ok", "pay [VISA_REDACTED] ok"), + ("pay 5472 3038 5280 1841 1485 4534 3853 6336 1330 ok", "pay [MASTERCARD_REDACTED] 1485 [VISA_REDACTED] ok"), ("cards 4532 0151 1283 0366 5555 5555 5555 4444 ok", "cards [VISA_REDACTED] [MASTERCARD_REDACTED] ok"), + ( + "5521-5568-8763-1997 4966 7452 4225 5188 4264 ref 580355", + "[MASTERCARD_REDACTED] [VISA_REDACTED] 4264 ref 580355", + ), + ("ref XX00DE89370400440532013000 ok", "ref [IBAN_REDACTED] ok"), ], ) async def test_checksum_patterns_mask_each_valid_card_where_two_brands_overlap(self, text: str, expected: str): @@ -473,9 +479,11 @@ class TestContentFilterGuardrail: ("visa", "mastercard", "4000 4000 0000 0000 5555 5555 5555 4444"), ("visa", "mastercard", "ids 4853 4091 4909 4475 5508 5016 6232 6258 end"), ("discover", "credit_card", "4629 4611-6260-8050-5592-2700 65405563 93269332"), + ("visa", "mastercard", "pay 5472 3038 5280 1841 1485 4534 3853 6336 1330 ok"), + ("credit_card", "nl_bsn_contextual", "BSN 4111 1111 1111 1112 123456789"), ], ) - async def test_checksum_mask_leaves_a_later_blocking_pattern_its_valid_card( + async def test_checksum_mask_leaves_a_later_blocking_pattern_its_match( self, masking_pattern: str, blocking_pattern: str, text: str ): guardrail: Final = ContentFilterGuardrail( @@ -511,54 +519,32 @@ class TestContentFilterGuardrail: @pytest.mark.asyncio @pytest.mark.parametrize( - ("checksum_patterns", "regex", "later_patterns", "text", "expected"), + ("later_pattern", "text", "expected"), [ ( - ("credit_card",), - "1111 BSN", - ("nl_bsn_contextual",), - "4111 1111 1111 1111 BSN 123456789", - "[CREDIT_CARD_REDACTED] BSN [NL_BSN_CONTEXTUAL_REDACTED]", + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="nl_bsn_contextual", action=ContentFilterAction.MASK + ), + "BSN 4111 1111 1111 1112 123456789", + "BSN 4111 1111 1111 1112 [NL_BSN_CONTEXTUAL_REDACTED]", ), ( - ("visa", "mastercard"), - "4444 BSN", - ("nl_bsn_contextual",), - "4532 0151 1283 0366 5555 5555 5555 4444 BSN 123456789", - "[VISA_REDACTED] [MASTERCARD_REDACTED] BSN [NL_BSN_CONTEXTUAL_REDACTED]", + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}[\s-]?\d{4}\b", name="acct8", action=ContentFilterAction.MASK + ), + "4381 4099 4560 4729 4064 4577 4061", + "[CREDIT_CARD_REDACTED] [ACCT8_REDACTED] 4061", ), - ( - ("visa", "mastercard", "amex", "discover", "credit_card", "iban"), - "1330 BSN", - ("nl_bsn_contextual",), - "pay 5472 3038 5280 1841 1485 4534 3853 6336 1330 BSN 123456789", - "pay [VISA_REDACTED] BSN [NL_BSN_CONTEXTUAL_REDACTED]", - ), - (("credit_card",), "PIN 4821", (), "PIN 4821 4532 0151 1283 0366 ok", "PIN [CREDIT_CARD_REDACTED] ok"), ], ) - async def test_checksum_mask_keeps_the_words_a_later_regex_match_shares_with_the_card( - self, - checksum_patterns: tuple[str, ...], - regex: str, - later_patterns: tuple[str, ...], - text: str, - expected: str, + async def test_later_patterns_run_on_the_text_with_every_checksum_match_masked( + self, later_pattern: ContentFilterPattern, text: str, expected: str ): guardrail: Final = ContentFilterGuardrail( - guardrail_name="test-checksum-mask-then-regex", + guardrail_name="test-checksum-mask-then-later-pattern", patterns=[ - *( - ContentFilterPattern(pattern_type="prebuilt", pattern_name=name, action=ContentFilterAction.MASK) - for name in checksum_patterns - ), - ContentFilterPattern( - pattern_type="regex", pattern=regex, name="account_note", action=ContentFilterAction.MASK - ), - *( - ContentFilterPattern(pattern_type="prebuilt", pattern_name=name, action=ContentFilterAction.MASK) - for name in later_patterns - ), + ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK), + later_pattern, ], ) guardrailed_inputs: Final = await guardrail.apply_guardrail( @@ -567,16 +553,66 @@ class TestContentFilterGuardrail: assert guardrailed_inputs.get("texts") == [expected] @pytest.mark.asyncio - async def test_checksum_pattern_streams_the_same_text_as_a_whole_response(self): - from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices - + @pytest.mark.parametrize( + ("pattern_name", "checksum_valid", "checksum_invalid"), + [ + ("visa", "4111 1111 1111 1111", "4111 1111 1111 1112"), + ("mastercard", "5555555555554444", "5555555555554445"), + ("iban", "GB82WEST12345698765432", "GB83WEST12345698765432"), + ], + ) + async def test_checksum_pattern_blocks_only_values_that_pass_the_checksum( + self, pattern_name: str, checksum_valid: str, checksum_invalid: str + ): guardrail: Final = ContentFilterGuardrail( - guardrail_name="test-checksum-streaming", + guardrail_name="test-checksum-block", patterns=[ - ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK) + ContentFilterPattern(pattern_type="prebuilt", pattern_name=pattern_name, action=ContentFilterAction.BLOCK) ], ) - chunks: Final = ("4532 " * 20, "4532 0151 1283 0366 thanks") + + passed: Final = await guardrail.apply_guardrail( + inputs={"texts": [f"look-alike {checksum_invalid}"]}, request_data={}, input_type="request" + ) + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs={"texts": [f"real {checksum_valid}"]}, request_data={}, input_type="request" + ) + + assert passed.get("texts") == [f"look-alike {checksum_invalid}"] + assert exc_info.value.detail["pattern"] == pattern_name + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("patterns", "chunks", "hidden"), + [ + ( + ( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ), + ("4532 " * 20, "4532 0151 1283 0366 thanks"), + "0366", + ), + ( + ( + ContentFilterPattern(pattern_type="prebuilt", pattern_name="iban", action=ContentFilterAction.MASK), + ContentFilterPattern( + pattern_type="regex", pattern="904580", name="ref", action=ContentFilterAction.BLOCK + ), + ), + ("send it to DE07841266521904580", "435 thanks"), + "904580", + ), + ], + ) + async def test_checksum_pattern_streams_the_same_text_as_a_whole_response( + self, patterns: tuple[ContentFilterPattern, ...], chunks: tuple[str, ...], hidden: str + ): + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + guardrail: Final = ContentFilterGuardrail(guardrail_name="test-checksum-streaming", patterns=list(patterns)) async def mock_stream(): for index, content in enumerate(chunks): @@ -604,7 +640,7 @@ class TestContentFilterGuardrail: inputs={"texts": ["".join(chunks)]}, request_data={}, input_type="response" ) - assert "0366" not in streamed + assert hidden not in streamed assert [streamed] == whole_response.get("texts") @pytest.mark.asyncio From a53fe4a2b6212b78692be2875a9ccd0a87cbf915 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:23:46 -0700 Subject: [PATCH 11/15] fix(content_filter): let later rules mask or block numbers put back after failing the checksum After the pattern loop puts back the numbers that fail Luhn or mod-97, every pattern without a validator runs again over the put-back text and acts only on matches that overlap a put-back number, so a later rule masks or blocks it as it would with no card or IBAN pattern configured. A still-streaming buffer passes the 50 character holdback as unsettled_tail, and a second-pass match acts only when it starts before that tail, since later text may still turn the number into a valid one. --- .../litellm_content_filter/content_filter.py | 108 +++++++++++++----- .../content_filter/test_content_filter.py | 74 +++++++++++- 2 files changed, 154 insertions(+), 28 deletions(-) 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 1c15872df3e..85be5f5decb 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 @@ -14,7 +14,7 @@ import os import re import time from bisect import bisect_left, bisect_right -from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime from re import Pattern @@ -162,10 +162,18 @@ class _StreamedChoiceState: @dataclass(frozen=True, slots=True) -class _ChecksumWindows: +class _SortedSpans: starts: tuple[int, ...] furthest_ends: tuple[int, ...] + @classmethod + def of(cls, spans: Iterable[tuple[int, int]]) -> "_SortedSpans": + ordered: Final = sorted(spans) + return cls( + starts=tuple(start for start, _ in ordered), + furthest_ends=tuple(itertools.accumulate((end for _, end in ordered), max)), + ) + def overlap(self, span: tuple[int, int]) -> bool: index: Final = bisect_left(self.starts, span[1]) return index > 0 and self.furthest_ends[index - 1] > span[0] @@ -235,15 +243,20 @@ class _MaskedText: redactions=tuple(sorted((*shifted, *added), key=lambda redaction: redaction.start)), ) - def restored(self, original: str) -> str: + def restored(self, original: str) -> tuple[str, tuple[tuple[int, int], ...]]: failed: Final = tuple(redaction for redaction in self.redactions if redaction.failed_checksum) if not failed: - return self.text - pieces: Final = itertools.chain.from_iterable( - (self.text[previous_end : redaction.start], original[redaction.original_start : redaction.original_end]) + return self.text, () + gaps: Final = tuple( + self.text[previous_end : redaction.start] for previous_end, redaction in zip((0, *(redaction.end for redaction in failed)), failed) ) - return "".join((*pieces, self.text[failed[-1].end :])) + numbers: Final = tuple(original[redaction.original_start : redaction.original_end] for redaction in failed) + ends: Final = tuple(itertools.accumulate(len(gap) + len(number) for gap, number in zip(gaps, numbers))) + return ( + "".join((*itertools.chain.from_iterable(zip(gaps, numbers)), self.text[failed[-1].end :])), + tuple((end - len(number), end) for end, number in zip(ends, numbers)), + ) @dataclass(frozen=True, slots=True) @@ -968,16 +981,12 @@ class ContentFilterGuardrail(CustomGuardrail): return self._merge_spans(match_spans) - def _checksum_windows(self, text: str, check_candidate: CandidateCheck) -> _ChecksumWindows: - windows: Final = sorted( + def _checksum_windows(self, text: str, check_candidate: CandidateCheck) -> _SortedSpans: + return _SortedSpans.of( itertools.chain.from_iterable( self._valid_checksum_windows(text, entry, check_candidate) for entry in self.compiled_patterns ) ) - return _ChecksumWindows( - starts=tuple(start for start, _ in windows), - furthest_ends=tuple(itertools.accumulate((end for _, end in windows), max)), - ) @staticmethod def _valid_checksum_windows( @@ -997,7 +1006,7 @@ class ContentFilterGuardrail(CustomGuardrail): masked: _MaskedText, span: tuple[int, int], pattern_entry: CompiledPatternEntry, - checksum_windows: Callable[[], _ChecksumWindows], + checksum_windows: Callable[[], _SortedSpans], ) -> bool: return pattern_entry["validator"] is not None and not checksum_windows().overlap(masked.original_span(span)) @@ -1469,34 +1478,75 @@ class ContentFilterGuardrail(CustomGuardrail): ) def _apply_patterns( - self, text: str, detections: list[ContentFilterDetection] | None, check_candidate: CandidateCheck + self, + text: str, + detections: list[ContentFilterDetection] | None, + check_candidate: CandidateCheck, + unsettled_tail: int, ) -> str: checksum_windows: Final = functools.cache( functools.partial(self._checksum_windows, text, functools.cache(check_candidate)) ) masked: Final = functools.reduce( - lambda masked_so_far, pattern_entry: self._apply_pattern( - masked_so_far, pattern_entry, checksum_windows, detections + lambda masked_so_far, pattern_entry: self._act_on_spans( + masked_so_far, + pattern_entry, + *self._checked_matches(masked_so_far, pattern_entry, checksum_windows), + detections, ), self.compiled_patterns, _MaskedText(text=text), ) - return masked.restored(text) + put_back_text, put_back = masked.restored(text) + if not put_back: + return put_back_text + put_back_spans: Final = _SortedSpans.of(put_back) + settled_end: Final = len(put_back_text) - unsettled_tail + return functools.reduce( + lambda masked_so_far, pattern_entry: self._act_on_spans( + masked_so_far, + pattern_entry, + *self._matches_on_put_back_numbers(masked_so_far, pattern_entry, put_back_spans, settled_end), + detections, + ), + (pattern_entry for pattern_entry in self.compiled_patterns if pattern_entry["validator"] is None), + _MaskedText(text=put_back_text), + ).text - def _apply_pattern( + def _checked_matches( self, masked: _MaskedText, pattern_entry: CompiledPatternEntry, - checksum_windows: Callable[[], _ChecksumWindows], + checksum_windows: Callable[[], _SortedSpans], + ) -> tuple[Sequence[tuple[int, int]], tuple[bool, ...]]: + spans: Final = self._find_pattern_spans(masked.text, pattern_entry) + return spans, tuple(self._fails_checksum(masked, span, pattern_entry, checksum_windows) for span in spans) + + def _matches_on_put_back_numbers( + self, + masked: _MaskedText, + pattern_entry: CompiledPatternEntry, + put_back: _SortedSpans, + settled_end: int, + ) -> tuple[Sequence[tuple[int, int]], tuple[bool, ...]]: + spans: Final = tuple( + span + for span in self._find_pattern_spans(masked.text, pattern_entry) + if masked.original_span(span)[0] < settled_end and put_back.overlap(masked.original_span(span)) + ) + return spans, (False,) * len(spans) + + def _act_on_spans( + self, + masked: _MaskedText, + pattern_entry: CompiledPatternEntry, + spans: Sequence[tuple[int, int]], + failed_checksum: Sequence[bool], detections: list[ContentFilterDetection] | None, ) -> _MaskedText: - spans: Final = self._find_pattern_spans(masked.text, pattern_entry) if not spans: return masked pattern_name: Final = pattern_entry["pattern_name"] - failed_checksum: Final = tuple( - self._fails_checksum(masked, span, pattern_entry, checksum_windows) for span in spans - ) if not all(failed_checksum): self._handle_pattern_match(pattern_name, pattern_entry["action"], detections) if pattern_entry["action"] != ContentFilterAction.MASK: @@ -1556,6 +1606,7 @@ class ContentFilterGuardrail(CustomGuardrail): text: str, detections: list[ContentFilterDetection] | None = None, check_candidate: CandidateCheck = run_validator, + unsettled_tail: int = 0, ) -> str: """ Apply all content filtering checks to a single text. @@ -1569,6 +1620,8 @@ class ContentFilterGuardrail(CustomGuardrail): text: Text to filter detections: Optional list to append detection information check_candidate: Runs a pattern's checksum validator on a candidate match + unsettled_tail: Trailing characters of a still-streaming text that are not yielded yet. A later pattern + acts on a number that failed its checksum only through a match starting before this tail Returns: Filtered text (with masking applied if action is MASK) @@ -1600,7 +1653,7 @@ class ContentFilterGuardrail(CustomGuardrail): text = self._handle_category_keyword_match(keyword, category_name, severity, action, text, detections) # Check regex patterns - process ALL patterns, not just first match - text = self._apply_patterns(text, detections, check_candidate) + text = self._apply_patterns(text, detections, check_candidate, unsettled_tail) # Check blocked words - iterate through ALL blocked words text_lower = text.lower() @@ -2295,7 +2348,10 @@ class ContentFilterGuardrail(CustomGuardrail): try: masked_text = self._filter_single_text( - text_to_scan, detections=choice_detections, check_candidate=check_candidate + text_to_scan, + detections=choice_detections, + check_candidate=check_candidate, + unsettled_tail=0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS, ) if is_final and masked_text.endswith(" "): masked_text = masked_text[:-1] 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 d4da5675ccf..ccd2263e744 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 @@ -552,6 +552,58 @@ class TestContentFilterGuardrail: ) assert guardrailed_inputs.get("texts") == [expected] + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("later_pattern", "text", "expected"), + [ + ( + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}\b", name="group4", action=ContentFilterAction.MASK + ), + "card 4111 1111 1111 1111 and 4111 1111 1111 1112 end", + "card [CREDIT_CARD_REDACTED] and [GROUP4_REDACTED] [GROUP4_REDACTED] [GROUP4_REDACTED] [GROUP4_REDACTED] end", + ), + ( + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}[\s-]?\d{4}\b", name="acct8", action=ContentFilterAction.MASK + ), + "order 4398 2296 8107 4767 4479 3291", + "order [ACCT8_REDACTED] [ACCT8_REDACTED] [ACCT8_REDACTED]", + ), + ], + ) + async def test_later_patterns_mask_a_number_put_back_after_failing_the_checksum( + self, later_pattern: ContentFilterPattern, text: str, expected: str + ): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-mask-then-later-pattern", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK), + later_pattern, + ], + ) + guardrailed_inputs: Final = await guardrail.apply_guardrail( + inputs={"texts": [text]}, request_data={}, input_type="request" + ) + assert guardrailed_inputs.get("texts") == [expected] + + @pytest.mark.asyncio + async def test_later_blocking_pattern_blocks_a_number_put_back_after_failing_the_checksum(self): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-mask-then-later-block", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK), + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}\b", name="group4", action=ContentFilterAction.BLOCK + ), + ], + ) + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs={"texts": ["order 4111 1111 1111 1112"]}, request_data={}, input_type="request" + ) + assert exc_info.value.detail["pattern"] == "group4" + @pytest.mark.asyncio @pytest.mark.parametrize( ("pattern_name", "checksum_valid", "checksum_invalid"), @@ -605,6 +657,18 @@ class TestContentFilterGuardrail: ("send it to DE07841266521904580", "435 thanks"), "904580", ), + ( + ( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}\b", name="group4", action=ContentFilterAction.MASK + ), + ), + ("card 4111 1111 11", "11 1112", " end"), + "1112", + ), ], ) async def test_checksum_pattern_streams_the_same_text_as_a_whole_response( @@ -1271,9 +1335,12 @@ class TestContentFilterGuardrail: text: str, detections: list[ContentFilterDetection] | None = None, check_candidate: CandidateCheck = run_validator, + unsettled_tail: int = 0, ) -> str: scanned_lengths.append(len(text)) - return super()._filter_single_text(text, detections=detections, check_candidate=check_candidate) + return super()._filter_single_text( + text, detections=detections, check_candidate=check_candidate, unsettled_tail=unsettled_tail + ) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-bounded-scan", @@ -1314,9 +1381,12 @@ class TestContentFilterGuardrail: text: str, detections: list[ContentFilterDetection] | None = None, check_candidate: CandidateCheck = run_validator, + unsettled_tail: int = 0, ) -> str: scanned_lengths.append(len(text)) - return super()._filter_single_text(text, detections=detections, check_candidate=check_candidate) + return super()._filter_single_text( + text, detections=detections, check_candidate=check_candidate, unsettled_tail=unsettled_tail + ) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-refused-cut-backoff", From 76090c5176d09869275987636bcd06af863cb381 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:29:47 -0700 Subject: [PATCH 12/15] fix(content_filter): stream only text that holds whether an unfinished number passes its checksum A number that fails its checksum near the end of a still-streaming text now stays masked, and the stream sends only the text that reads the same whether that number turns out valid or not. Later rules run again on every number put back, and a rule that already matched in the first pass is counted once --- .../litellm_content_filter/content_filter.py | 131 ++++++++++----- .../content_filter/test_content_filter.py | 153 +++++++++++++++--- 2 files changed, 228 insertions(+), 56 deletions(-) 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 85be5f5decb..3b9eace167c 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 @@ -243,8 +243,15 @@ class _MaskedText: redactions=tuple(sorted((*shifted, *added), key=lambda redaction: redaction.start)), ) - def restored(self, original: str) -> tuple[str, tuple[tuple[int, int], ...]]: - failed: Final = tuple(redaction for redaction in self.redactions if redaction.failed_checksum) + def holds_failed_after(self, settled_end: int) -> bool: + return any(redaction.failed_checksum and redaction.original_end > settled_end for redaction in self.redactions) + + def restored(self, original: str, settled_end: int) -> tuple[str, tuple[tuple[int, int], ...]]: + failed: Final = tuple( + redaction + for redaction in self.redactions + if redaction.failed_checksum and redaction.original_end <= settled_end + ) if not failed: return self.text, () gaps: Final = tuple( @@ -259,6 +266,12 @@ class _MaskedText: ) +@dataclass(frozen=True, slots=True) +class _FilteredText: + text: str + holds_unsettled_numbers: bool = False + + @dataclass(frozen=True, slots=True) class _StreamedScanPlan: context_chars: int @@ -1106,7 +1119,7 @@ class ContentFilterGuardrail(CustomGuardrail): return None def _check_conditional_categories( - self, text: str, exceptions: list[str] + self, text: str, exceptions: Sequence[str] ) -> tuple[str, str, str, ContentFilterAction] | None: """ Check text for conditional category matches (identifier + block word in same sentence). @@ -1194,7 +1207,7 @@ class ContentFilterGuardrail(CustomGuardrail): return None def _check_phrase_patterns( - self, text: str, exceptions: list[str] + self, text: str, exceptions: Sequence[str] ) -> tuple[str, str, str, ContentFilterAction] | None: """ Check text against phrase patterns from loaded categories. @@ -1243,7 +1256,7 @@ class ContentFilterGuardrail(CustomGuardrail): return None def _check_category_keywords( - self, text: str, exceptions: list[str] + self, text: str, exceptions: Sequence[str] ) -> tuple[str, str, str, ContentFilterAction] | None: """ Check text for category keywords. @@ -1483,10 +1496,11 @@ class ContentFilterGuardrail(CustomGuardrail): detections: list[ContentFilterDetection] | None, check_candidate: CandidateCheck, unsettled_tail: int, - ) -> str: + ) -> _FilteredText: checksum_windows: Final = functools.cache( functools.partial(self._checksum_windows, text, functools.cache(check_candidate)) ) + first_detection: Final = len(detections) if detections is not None else 0 masked: Final = functools.reduce( lambda masked_so_far, pattern_entry: self._act_on_spans( masked_so_far, @@ -1497,21 +1511,30 @@ class ContentFilterGuardrail(CustomGuardrail): self.compiled_patterns, _MaskedText(text=text), ) - put_back_text, put_back = masked.restored(text) + settled_end: Final = len(text) - unsettled_tail + holds_unsettled_numbers: Final = masked.holds_failed_after(settled_end) + put_back_text, put_back = masked.restored(text, settled_end) if not put_back: - return put_back_text + return _FilteredText(text=put_back_text, holds_unsettled_numbers=holds_unsettled_numbers) put_back_spans: Final = _SortedSpans.of(put_back) - settled_end: Final = len(put_back_text) - unsettled_tail - return functools.reduce( - lambda masked_so_far, pattern_entry: self._act_on_spans( - masked_so_far, - pattern_entry, - *self._matches_on_put_back_numbers(masked_so_far, pattern_entry, put_back_spans, settled_end), - detections, - ), - (pattern_entry for pattern_entry in self.compiled_patterns if pattern_entry["validator"] is None), - _MaskedText(text=put_back_text), - ).text + detected_patterns: Final = frozenset( + detection["pattern_name"] + for detection in (detections or ())[first_detection:] + if detection["type"] == "pattern" + ) + return _FilteredText( + text=functools.reduce( + lambda masked_so_far, pattern_entry: self._act_on_spans( + masked_so_far, + pattern_entry, + *self._matches_on_put_back_numbers(masked_so_far, pattern_entry, put_back_spans), + None if pattern_entry["pattern_name"] in detected_patterns else detections, + ), + (pattern_entry for pattern_entry in self.compiled_patterns if pattern_entry["validator"] is None), + _MaskedText(text=put_back_text), + ).text, + holds_unsettled_numbers=holds_unsettled_numbers, + ) def _checked_matches( self, @@ -1527,12 +1550,11 @@ class ContentFilterGuardrail(CustomGuardrail): masked: _MaskedText, pattern_entry: CompiledPatternEntry, put_back: _SortedSpans, - settled_end: int, ) -> tuple[Sequence[tuple[int, int]], tuple[bool, ...]]: spans: Final = tuple( span for span in self._find_pattern_spans(masked.text, pattern_entry) - if masked.original_span(span)[0] < settled_end and put_back.overlap(masked.original_span(span)) + if put_back.overlap(masked.original_span(span)) ) return spans, (False,) * len(spans) @@ -1606,8 +1628,16 @@ class ContentFilterGuardrail(CustomGuardrail): text: str, detections: list[ContentFilterDetection] | None = None, check_candidate: CandidateCheck = run_validator, - unsettled_tail: int = 0, ) -> str: + return self._filter_text(text, detections, check_candidate, unsettled_tail=0).text + + def _filter_text( + self, + text: str, + detections: list[ContentFilterDetection] | None, + check_candidate: CandidateCheck, + unsettled_tail: int, + ) -> _FilteredText: """ Apply all content filtering checks to a single text. @@ -1620,8 +1650,8 @@ class ContentFilterGuardrail(CustomGuardrail): text: Text to filter detections: Optional list to append detection information check_candidate: Runs a pattern's checksum validator on a candidate match - unsettled_tail: Trailing characters of a still-streaming text that are not yielded yet. A later pattern - acts on a number that failed its checksum only through a match starting before this tail + unsettled_tail: Trailing characters of a still-streaming text that more text can still change. A number + that fails its checksum and reaches into this tail stays masked, and the result says so Returns: Filtered text (with masking applied if action is MASK) @@ -1630,9 +1660,9 @@ class ContentFilterGuardrail(CustomGuardrail): HTTPException: If sensitive content is detected and action is BLOCK """ # Collect all exceptions from loaded categories - all_exceptions: Final[list[str]] = [] - for category in self.loaded_categories.values(): - all_exceptions.extend(category.exceptions) + all_exceptions: Final = tuple( + itertools.chain.from_iterable(category.exceptions for category in self.loaded_categories.values()) + ) # Check conditional categories first conditional_match: Final = self._check_conditional_categories(text, all_exceptions) @@ -1653,7 +1683,8 @@ class ContentFilterGuardrail(CustomGuardrail): text = self._handle_category_keyword_match(keyword, category_name, severity, action, text, detections) # Check regex patterns - process ALL patterns, not just first match - text = self._apply_patterns(text, detections, check_candidate, unsettled_tail) + filtered: Final = self._apply_patterns(text, detections, check_candidate, unsettled_tail) + text = filtered.text # Check blocked words - iterate through ALL blocked words text_lower = text.lower() @@ -1663,7 +1694,7 @@ class ContentFilterGuardrail(CustomGuardrail): text = self._handle_blocked_word_match(keyword, action, description, text, detections) text_lower = text.lower() # Update after masking - return text + return replace(filtered, text=text) @staticmethod def _keyword_to_regex_pattern(keyword: str) -> str: @@ -2280,6 +2311,20 @@ class ContentFilterGuardrail(CustomGuardrail): next_trim_len=0, ) + def _masked_if_stream_ended(self, text: str, check_candidate: CandidateCheck) -> str | None: + try: + return self._filter_text(text, None, check_candidate, unsettled_tail=0).text + except HTTPException: + return None + + @staticmethod + def _shared_prefix_len(masked_text: str, masked_if_ended: str | None) -> int: + if masked_if_ended is None: + return 0 + if masked_if_ended.startswith(masked_text): + return len(masked_text) + return len(os.path.commonprefix((masked_text, masked_if_ended))) + @staticmethod def _merge_detections(detections: Sequence[ContentFilterDetection]) -> tuple[ContentFilterDetection, ...]: return tuple(detection for index, detection in enumerate(detections) if detection not in detections[:index]) @@ -2347,14 +2392,17 @@ class ContentFilterGuardrail(CustomGuardrail): scan_started = time.perf_counter() try: - masked_text = self._filter_single_text( + filtered = self._filter_text( text_to_scan, - detections=choice_detections, - check_candidate=check_candidate, + choice_detections, + check_candidate, unsettled_tail=0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS, ) - if is_final and masked_text.endswith(" "): - masked_text = masked_text[:-1] + masked_if_ended = ( + self._masked_if_stream_ended(text_to_scan, check_candidate) + if filtered.holds_unsettled_numbers + else filtered.text + ) latest_detections = tuple(choice_detections) except HTTPException: state_by_choice[choice_index] = replace( @@ -2363,14 +2411,19 @@ class ContentFilterGuardrail(CustomGuardrail): raise except Exception as e: verbose_proxy_logger.error("ContentFilterGuardrail: Error in masking: %s", e) - masked_text = text_to_scan # Fallback to current text + filtered = _FilteredText(text=text_to_scan) # Fallback to current text + masked_if_ended = text_to_scan latest_detections = previous_state.latest_detections finally: scan_seconds += time.perf_counter() - scan_started + masked_text = filtered.text[:-1] if is_final and filtered.text.endswith(" ") else filtered.text safe_to_yield_len = max( previous_state.yielded_masked_text_len, - len(masked_text) - (0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS), + min( + len(masked_text) - (0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS), + self._shared_prefix_len(masked_text, masked_if_ended), + ), ) choice.delta.content = masked_text[previous_state.yielded_masked_text_len : safe_to_yield_len] next_state = replace( @@ -2384,7 +2437,11 @@ class ContentFilterGuardrail(CustomGuardrail): continue trim_started = time.perf_counter() - state_by_choice[choice_index] = self._trim_streamed_choice_buffer(next_state, masked_text, plan) + state_by_choice[choice_index] = ( + next_state + if masked_if_ended is None + else self._trim_streamed_choice_buffer(next_state, masked_if_ended, plan) + ) scan_seconds += time.perf_counter() - trim_started yield item 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 ccd2263e744..8157b4e9bcc 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 @@ -19,6 +19,7 @@ from litellm.constants import ( ) from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( ContentFilterGuardrail, + _FilteredText, ) from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.validators import ( CandidateCheck, @@ -37,6 +38,10 @@ from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter impor from litellm.types.utils import StandardLoggingGuardrailInformation +def _in_chunks(text: str, size: int) -> tuple[str, ...]: + return tuple(text[start : start + size] for start in range(0, len(text), size)) + + class TestContentFilterGuardrail: """Test the ContentFilterGuardrail class""" @@ -604,6 +609,28 @@ class TestContentFilterGuardrail: ) assert exc_info.value.detail["pattern"] == "group4" + @pytest.mark.asyncio + async def test_later_pattern_counts_once_when_it_also_masks_a_number_put_back(self): + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-mask-then-later-pattern-count", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK), + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}\b", name="group4", action=ContentFilterAction.MASK + ), + ], + ) + request_data: Final[dict[str, dict[str, list[StandardLoggingGuardrailInformation]]]] = {"metadata": {}} + + await guardrail.apply_guardrail( + inputs={"texts": ["pin 1234 and card 4111 1111 1111 1112 end"]}, + request_data=request_data, + input_type="request", + ) + + entry: Final = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert entry["masked_entity_count"] == {"group4": 1} + @pytest.mark.asyncio @pytest.mark.parametrize( ("pattern_name", "checksum_valid", "checksum_invalid"), @@ -636,7 +663,7 @@ class TestContentFilterGuardrail: @pytest.mark.asyncio @pytest.mark.parametrize( - ("patterns", "chunks", "hidden"), + ("patterns", "blocked_words", "chunks", "hidden"), [ ( ( @@ -644,6 +671,7 @@ class TestContentFilterGuardrail: pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK ), ), + (), ("4532 " * 20, "4532 0151 1283 0366 thanks"), "0366", ), @@ -654,6 +682,7 @@ class TestContentFilterGuardrail: pattern_type="regex", pattern="904580", name="ref", action=ContentFilterAction.BLOCK ), ), + (), ("send it to DE07841266521904580", "435 thanks"), "904580", ), @@ -666,17 +695,62 @@ class TestContentFilterGuardrail: pattern_type="regex", pattern=r"\b\d{4}\b", name="group4", action=ContentFilterAction.MASK ), ), + (), ("card 4111 1111 11", "11 1112", " end"), "1112", ), + ( + ( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}\b", name="group4", action=ContentFilterAction.MASK + ), + ), + (BlockedWord(keyword="ok", action=ContentFilterAction.MASK),), + _in_chunks("card 4111 1111 1111 1112 ok ok ok ok ok ok ok ok ok ok ok ok and more text after it end", 8), + "1112", + ), + ( + ( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern( + pattern_type="regex", pattern=r"\b\d{4}[\s-]?\d{4}\b", name="acct8", action=ContentFilterAction.MASK + ), + ), + (BlockedWord(keyword="ok", action=ContentFilterAction.MASK),), + _in_chunks("order 4398 2296 8107 4767 4479 3291 ok ok ok ok ok ok ok ok ok ok ok ok and more text after it end", 8), + "4767", + ), + ( + ( + ContentFilterPattern(pattern_type="prebuilt", pattern_name="amex", action=ContentFilterAction.MASK), + ContentFilterPattern(pattern_type="regex", pattern=r"\d{6}", name="d6", action=ContentFilterAction.MASK), + ContentFilterPattern( + pattern_type="regex", pattern=r"\d{4} \d{4}", name="pair", action=ContentFilterAction.MASK + ), + ), + (), + _in_chunks("FR27 5892 9262 5322 3897 2114 796 it 3797 621746 14951 3722 314364 73911 textok 4336 2547 3487 7245 safe and more words", 8), + "621746", + ), ], ) async def test_checksum_pattern_streams_the_same_text_as_a_whole_response( - self, patterns: tuple[ContentFilterPattern, ...], chunks: tuple[str, ...], hidden: str + self, + patterns: tuple[ContentFilterPattern, ...], + blocked_words: tuple[BlockedWord, ...], + chunks: tuple[str, ...], + hidden: str, ): from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices - guardrail: Final = ContentFilterGuardrail(guardrail_name="test-checksum-streaming", patterns=list(patterns)) + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-streaming", patterns=list(patterns), blocked_words=list(blocked_words) or None + ) async def mock_stream(): for index, content in enumerate(chunks): @@ -707,6 +781,51 @@ class TestContentFilterGuardrail: assert hidden not in streamed assert [streamed] == whole_response.get("texts") + @pytest.mark.asyncio + async def test_checksum_pattern_stream_sends_no_digit_of_a_number_a_later_rule_blocks(self): + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-streaming-later-block", + patterns=[ + ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK), + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="au_medicare", action=ContentFilterAction.BLOCK + ), + ], + ) + chunks: Final = _in_chunks( + "ref 8834 6011 1484 9325 7256 904580\nmore text here to push it along, please keep going for a while now", 3 + ) + + async def mock_stream(): + for index, content in enumerate(chunks): + yield ModelResponseStream( + id=f"chunk{index}", + choices=[ + StreamingChoices( + delta=Delta(content=content), + index=0, + finish_reason="stop" if index == len(chunks) - 1 else None, + ) + ], + model="gpt-4", + ) + + sent: Final[list[str]] = [] + + async def send_stream(): + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=MagicMock(), response=mock_stream(), request_data={} + ): + sent.append(chunk.choices[0].delta.content or "") + + with pytest.raises(HTTPException) as exc_info: + await send_stream() + + assert exc_info.value.detail["pattern"] == "au_medicare" + assert "6011" not in "".join(sent) + @pytest.mark.asyncio @pytest.mark.parametrize( "iban", @@ -1330,17 +1449,15 @@ class TestContentFilterGuardrail: scanned_lengths: Final[list[int]] = [] class RecordingGuardrail(ContentFilterGuardrail): - def _filter_single_text( + def _filter_text( self, text: str, - detections: list[ContentFilterDetection] | None = None, - check_candidate: CandidateCheck = run_validator, - unsettled_tail: int = 0, - ) -> str: + detections: list[ContentFilterDetection] | None, + check_candidate: CandidateCheck, + unsettled_tail: int, + ) -> _FilteredText: scanned_lengths.append(len(text)) - return super()._filter_single_text( - text, detections=detections, check_candidate=check_candidate, unsettled_tail=unsettled_tail - ) + return super()._filter_text(text, detections, check_candidate, unsettled_tail) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-bounded-scan", @@ -1376,17 +1493,15 @@ class TestContentFilterGuardrail: scanned_lengths: Final[list[int]] = [] class RecordingGuardrail(ContentFilterGuardrail): - def _filter_single_text( + def _filter_text( self, text: str, - detections: list[ContentFilterDetection] | None = None, - check_candidate: CandidateCheck = run_validator, - unsettled_tail: int = 0, - ) -> str: + detections: list[ContentFilterDetection] | None, + check_candidate: CandidateCheck, + unsettled_tail: int, + ) -> _FilteredText: scanned_lengths.append(len(text)) - return super()._filter_single_text( - text, detections=detections, check_candidate=check_candidate, unsettled_tail=unsettled_tail - ) + return super()._filter_text(text, detections, check_candidate, unsettled_tail) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-refused-cut-backoff", From 94c928faaecd228338b98059e8e30ec633352e78 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:07:16 -0700 Subject: [PATCH 13/15] fix(content_filter): count a later rule's detection unless the same name and action already recorded one Unnamed regex rules all share the name custom_regex, so keying the second pass on the name alone dropped a later unnamed BLOCK rule's detection when an unnamed MASK rule had already matched. In logging_only mode that logged success instead of guardrail_flagged. --- .../litellm_content_filter/content_filter.py | 10 +++++++--- .../test_content_filter.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) 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 3b9eace167c..5a7a90eb68a 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 @@ -1517,8 +1517,8 @@ class ContentFilterGuardrail(CustomGuardrail): if not put_back: return _FilteredText(text=put_back_text, holds_unsettled_numbers=holds_unsettled_numbers) put_back_spans: Final = _SortedSpans.of(put_back) - detected_patterns: Final = frozenset( - detection["pattern_name"] + detected_rules: Final = frozenset( + (detection["pattern_name"], detection["action"]) for detection in (detections or ())[first_detection:] if detection["type"] == "pattern" ) @@ -1528,7 +1528,11 @@ class ContentFilterGuardrail(CustomGuardrail): masked_so_far, pattern_entry, *self._matches_on_put_back_numbers(masked_so_far, pattern_entry, put_back_spans), - None if pattern_entry["pattern_name"] in detected_patterns else detections, + ( + None + if (pattern_entry["pattern_name"], pattern_entry["action"].value) in detected_rules + else detections + ), ), (pattern_entry for pattern_entry in self.compiled_patterns if pattern_entry["validator"] is None), _MaskedText(text=put_back_text), diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_content_filter.py b/tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_content_filter.py index abcbb714186..ead48bba2ae 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_content_filter.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/litellm_content_filter/test_content_filter.py @@ -1938,6 +1938,25 @@ async def test_logging_only_block_records_every_match_as_flagged( assert response_entry["guardrail_status"] == "success" +@pytest.mark.asyncio +async def test_logging_only_flags_an_unnamed_block_rule_on_a_number_put_back_after_failing_the_checksum() -> None: + entries: Final = await _log_call( + _observing_filter( + patterns=( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern(pattern_type="regex", pattern="secret", action=ContentFilterAction.MASK), + ContentFilterPattern(pattern_type="regex", pattern=r"\b\d{4}\b", action=ContentFilterAction.BLOCK), + ) + ), + "secret 4111 1111 1111 1112", + Message(role="assistant", content="here you go"), + ) + + assert entries[0]["guardrail_status"] == "guardrail_flagged" + + @pytest.mark.asyncio async def test_logging_only_block_scans_the_response_after_a_request_match() -> None: entries: Final = await _log_call( From 1deb827b562ce522c163a63a702fb30c21a700b9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:25:10 -0700 Subject: [PATCH 14/15] fix(content_filter): streams mask every match as before, and count each rule once across both passes --- litellm/constants.py | 1 - .../litellm_content_filter/content_filter.py | 194 +++++++----------- .../litellm_content_filter/validators.py | 9 +- .../content_filter/test_content_filter.py | 149 ++++++++------ 4 files changed, 156 insertions(+), 197 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 420ce599737..0d6dfb5c0d4 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -495,7 +495,6 @@ GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS: Final = get_env_int( BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS: Final = 25_000 CONTENT_FILTER_STREAMING_HOLDBACK_CHARS: Final = 50 CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS: Final = 512 -CONTENT_FILTER_STREAMING_CHECKSUM_CACHE_SIZE: Final = 1024 DEFAULT_PRESIDIO_ANALYZE_CHUNK_SIZE_BYTES: Final = 500_000 PRESIDIO_ANALYZE_CHUNK_OVERLAP_CHARS: Final = 4096 PRESIDIO_ANALYZE_CHUNK_CONCURRENCY: Final = 8 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 5a7a90eb68a..c2cee5ee80f 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 @@ -28,7 +28,6 @@ from typing_extensions import ReadOnly from litellm import Router from litellm._logging import verbose_proxy_logger from litellm.constants import ( - CONTENT_FILTER_STREAMING_CHECKSUM_CACHE_SIZE, CONTENT_FILTER_STREAMING_HOLDBACK_CHARS, CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS, DEFAULT_MAX_RECURSE_DEPTH, @@ -79,7 +78,7 @@ from .competitor_intent import ( BaseCompetitorIntentChecker, ) from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern -from .validators import PATTERN_VALIDATORS, CandidateCheck, run_validator +from .validators import PATTERN_VALIDATORS MAX_KEYWORD_VALUE_GAP_WORDS: Final = 1 _DECODED_ARGUMENTS: Final = TypeAdapter(object) @@ -243,15 +242,8 @@ class _MaskedText: redactions=tuple(sorted((*shifted, *added), key=lambda redaction: redaction.start)), ) - def holds_failed_after(self, settled_end: int) -> bool: - return any(redaction.failed_checksum and redaction.original_end > settled_end for redaction in self.redactions) - - def restored(self, original: str, settled_end: int) -> tuple[str, tuple[tuple[int, int], ...]]: - failed: Final = tuple( - redaction - for redaction in self.redactions - if redaction.failed_checksum and redaction.original_end <= settled_end - ) + def restored(self, original: str) -> tuple[str, tuple[tuple[int, int], ...]]: + failed: Final = tuple(redaction for redaction in self.redactions if redaction.failed_checksum) if not failed: return self.text, () gaps: Final = tuple( @@ -267,9 +259,9 @@ class _MaskedText: @dataclass(frozen=True, slots=True) -class _FilteredText: - text: str - holds_unsettled_numbers: bool = False +class _FirstPass: + masked: _MaskedText + detected_rules: frozenset[int] = frozenset() @dataclass(frozen=True, slots=True) @@ -994,24 +986,20 @@ class ContentFilterGuardrail(CustomGuardrail): return self._merge_spans(match_spans) - def _checksum_windows(self, text: str, check_candidate: CandidateCheck) -> _SortedSpans: + def _checksum_windows(self, text: str) -> _SortedSpans: return _SortedSpans.of( - itertools.chain.from_iterable( - self._valid_checksum_windows(text, entry, check_candidate) for entry in self.compiled_patterns - ) + itertools.chain.from_iterable(self._valid_checksum_windows(text, entry) for entry in self.compiled_patterns) ) @staticmethod - def _valid_checksum_windows( - text: str, pattern_entry: CompiledPatternEntry, check_candidate: CandidateCheck - ) -> tuple[tuple[int, int], ...]: + def _valid_checksum_windows(text: str, pattern_entry: CompiledPatternEntry) -> tuple[tuple[int, int], ...]: validator: Final = pattern_entry["validator"] if validator is None: return () return tuple( candidate.span(1) for candidate in _checksum_window_regex(pattern_entry["regex"]).finditer(text) - if check_candidate(validator, candidate.group(1)) + if validator(candidate.group(1)) ) @staticmethod @@ -1101,7 +1089,7 @@ class ContentFilterGuardrail(CustomGuardrail): Returns: Tuple of (matched_text, pattern_name, action) if match found, None otherwise """ - checksum_windows: Final = functools.cache(functools.partial(self._checksum_windows, text, run_validator)) + checksum_windows: Final = functools.cache(functools.partial(self._checksum_windows, text)) unmasked: Final = _MaskedText(text=text) for pattern_entry in self.compiled_patterns: spans = [ @@ -1494,59 +1482,62 @@ class ContentFilterGuardrail(CustomGuardrail): self, text: str, detections: list[ContentFilterDetection] | None, - check_candidate: CandidateCheck, - unsettled_tail: int, - ) -> _FilteredText: - checksum_windows: Final = functools.cache( - functools.partial(self._checksum_windows, text, functools.cache(check_candidate)) + validates_checksums: bool, + ) -> str: + checksum_windows: Final = ( + functools.cache(functools.partial(self._checksum_windows, text)) if validates_checksums else None ) - first_detection: Final = len(detections) if detections is not None else 0 - masked: Final = functools.reduce( - lambda masked_so_far, pattern_entry: self._act_on_spans( - masked_so_far, - pattern_entry, - *self._checked_matches(masked_so_far, pattern_entry, checksum_windows), - detections, + first_pass: Final = functools.reduce( + lambda so_far, indexed_entry: self._act_on_first_pass_matches( + so_far, *indexed_entry, checksum_windows, detections ), - self.compiled_patterns, - _MaskedText(text=text), + enumerate(self.compiled_patterns), + _FirstPass(masked=_MaskedText(text=text)), ) - settled_end: Final = len(text) - unsettled_tail - holds_unsettled_numbers: Final = masked.holds_failed_after(settled_end) - put_back_text, put_back = masked.restored(text, settled_end) + put_back_text, put_back = first_pass.masked.restored(text) if not put_back: - return _FilteredText(text=put_back_text, holds_unsettled_numbers=holds_unsettled_numbers) + return put_back_text put_back_spans: Final = _SortedSpans.of(put_back) - detected_rules: Final = frozenset( - (detection["pattern_name"], detection["action"]) - for detection in (detections or ())[first_detection:] - if detection["type"] == "pattern" - ) - return _FilteredText( - text=functools.reduce( - lambda masked_so_far, pattern_entry: self._act_on_spans( - masked_so_far, - pattern_entry, - *self._matches_on_put_back_numbers(masked_so_far, pattern_entry, put_back_spans), - ( - None - if (pattern_entry["pattern_name"], pattern_entry["action"].value) in detected_rules - else detections - ), - ), - (pattern_entry for pattern_entry in self.compiled_patterns if pattern_entry["validator"] is None), - _MaskedText(text=put_back_text), - ).text, - holds_unsettled_numbers=holds_unsettled_numbers, + return functools.reduce( + lambda masked_so_far, indexed_entry: self._act_on_spans( + masked_so_far, + indexed_entry[1], + *self._matches_on_put_back_numbers(masked_so_far, indexed_entry[1], put_back_spans), + None if indexed_entry[0] in first_pass.detected_rules else detections, + ), + ( + (index, pattern_entry) + for index, pattern_entry in enumerate(self.compiled_patterns) + if pattern_entry["validator"] is None + ), + _MaskedText(text=put_back_text), + ).text + + def _act_on_first_pass_matches( + self, + so_far: _FirstPass, + index: int, + pattern_entry: CompiledPatternEntry, + checksum_windows: Callable[[], _SortedSpans] | None, + detections: list[ContentFilterDetection] | None, + ) -> _FirstPass: + spans, failed_checksum = self._checked_matches(so_far.masked, pattern_entry, checksum_windows) + return _FirstPass( + masked=self._act_on_spans(so_far.masked, pattern_entry, spans, failed_checksum, detections), + detected_rules=( + so_far.detected_rules if all(failed_checksum) else so_far.detected_rules | frozenset((index,)) + ), ) def _checked_matches( self, masked: _MaskedText, pattern_entry: CompiledPatternEntry, - checksum_windows: Callable[[], _SortedSpans], + checksum_windows: Callable[[], _SortedSpans] | None, ) -> tuple[Sequence[tuple[int, int]], tuple[bool, ...]]: spans: Final = self._find_pattern_spans(masked.text, pattern_entry) + if checksum_windows is None: + return spans, (False,) * len(spans) return spans, tuple(self._fails_checksum(masked, span, pattern_entry, checksum_windows) for span in spans) def _matches_on_put_back_numbers( @@ -1627,21 +1618,18 @@ class ContentFilterGuardrail(CustomGuardrail): return text - def _filter_single_text( - self, - text: str, - detections: list[ContentFilterDetection] | None = None, - check_candidate: CandidateCheck = run_validator, - ) -> str: - return self._filter_text(text, detections, check_candidate, unsettled_tail=0).text + def _filter_single_text(self, text: str, detections: list[ContentFilterDetection] | None = None) -> str: + return self._filter_text(text, detections, validates_checksums=True) + + def _filter_streamed_text(self, text: str, detections: list[ContentFilterDetection] | None = None) -> str: + return self._filter_text(text, detections, validates_checksums=False) def _filter_text( self, text: str, detections: list[ContentFilterDetection] | None, - check_candidate: CandidateCheck, - unsettled_tail: int, - ) -> _FilteredText: + validates_checksums: bool, + ) -> str: """ Apply all content filtering checks to a single text. @@ -1653,9 +1641,8 @@ class ContentFilterGuardrail(CustomGuardrail): Args: text: Text to filter detections: Optional list to append detection information - check_candidate: Runs a pattern's checksum validator on a candidate match - unsettled_tail: Trailing characters of a still-streaming text that more text can still change. A number - that fails its checksum and reaches into this tail stays masked, and the result says so + validates_checksums: Whether a match that fails its pattern's checksum is put back. Streams pass False, + since later text can still change what a rule matches, so they mask every match as before Returns: Filtered text (with masking applied if action is MASK) @@ -1687,8 +1674,7 @@ class ContentFilterGuardrail(CustomGuardrail): text = self._handle_category_keyword_match(keyword, category_name, severity, action, text, detections) # Check regex patterns - process ALL patterns, not just first match - filtered: Final = self._apply_patterns(text, detections, check_candidate, unsettled_tail) - text = filtered.text + text = self._apply_patterns(text, detections, validates_checksums) # Check blocked words - iterate through ALL blocked words text_lower = text.lower() @@ -1698,7 +1684,7 @@ class ContentFilterGuardrail(CustomGuardrail): text = self._handle_blocked_word_match(keyword, action, description, text, detections) text_lower = text.lower() # Update after masking - return replace(filtered, text=text) + return text @staticmethod def _keyword_to_regex_pattern(keyword: str) -> str: @@ -2301,8 +2287,8 @@ class ContentFilterGuardrail(CustomGuardrail): return deferred head_detections: Final[list[ContentFilterDetection]] = [] # mutable-ok: filled by _filter_single_text try: - masked_head: Final = self._filter_single_text(head, detections=head_detections) - masked_tail: Final = self._filter_single_text(tail) + masked_head: Final = self._filter_streamed_text(head, detections=head_detections) + masked_tail: Final = self._filter_streamed_text(tail) except Exception: return deferred if masked_head + masked_tail != masked_text or len(masked_head) > state.yielded_masked_text_len: @@ -2315,20 +2301,6 @@ class ContentFilterGuardrail(CustomGuardrail): next_trim_len=0, ) - def _masked_if_stream_ended(self, text: str, check_candidate: CandidateCheck) -> str | None: - try: - return self._filter_text(text, None, check_candidate, unsettled_tail=0).text - except HTTPException: - return None - - @staticmethod - def _shared_prefix_len(masked_text: str, masked_if_ended: str | None) -> int: - if masked_if_ended is None: - return 0 - if masked_if_ended.startswith(masked_text): - return len(masked_text) - return len(os.path.commonprefix((masked_text, masked_if_ended))) - @staticmethod def _merge_detections(detections: Sequence[ContentFilterDetection]) -> tuple[ContentFilterDetection, ...]: return tuple(detection for index, detection in enumerate(detections) if detection not in detections[:index]) @@ -2353,9 +2325,6 @@ class ContentFilterGuardrail(CustomGuardrail): """ state_by_choice: Final[dict[int, _StreamedChoiceState]] = {} plan: Final = self._streamed_scan_plan() - check_candidate: Final = functools.lru_cache(maxsize=CONTENT_FILTER_STREAMING_CHECKSUM_CACHE_SIZE)( - run_validator - ) start_time: Final = datetime.now() scan_seconds: float = 0.0 # rebind-ok: accumulates per-chunk scan time across the stream @@ -2396,17 +2365,9 @@ class ContentFilterGuardrail(CustomGuardrail): scan_started = time.perf_counter() try: - filtered = self._filter_text( - text_to_scan, - choice_detections, - check_candidate, - unsettled_tail=0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS, - ) - masked_if_ended = ( - self._masked_if_stream_ended(text_to_scan, check_candidate) - if filtered.holds_unsettled_numbers - else filtered.text - ) + masked_text = self._filter_streamed_text(text_to_scan, detections=choice_detections) + if is_final and masked_text.endswith(" "): + masked_text = masked_text[:-1] latest_detections = tuple(choice_detections) except HTTPException: state_by_choice[choice_index] = replace( @@ -2415,19 +2376,14 @@ class ContentFilterGuardrail(CustomGuardrail): raise except Exception as e: verbose_proxy_logger.error("ContentFilterGuardrail: Error in masking: %s", e) - filtered = _FilteredText(text=text_to_scan) # Fallback to current text - masked_if_ended = text_to_scan + masked_text = text_to_scan # Fallback to current text latest_detections = previous_state.latest_detections finally: scan_seconds += time.perf_counter() - scan_started - masked_text = filtered.text[:-1] if is_final and filtered.text.endswith(" ") else filtered.text safe_to_yield_len = max( previous_state.yielded_masked_text_len, - min( - len(masked_text) - (0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS), - self._shared_prefix_len(masked_text, masked_if_ended), - ), + len(masked_text) - (0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS), ) choice.delta.content = masked_text[previous_state.yielded_masked_text_len : safe_to_yield_len] next_state = replace( @@ -2441,11 +2397,7 @@ class ContentFilterGuardrail(CustomGuardrail): continue trim_started = time.perf_counter() - state_by_choice[choice_index] = ( - next_state - if masked_if_ended is None - else self._trim_streamed_choice_buffer(next_state, masked_if_ended, plan) - ) + state_by_choice[choice_index] = self._trim_streamed_choice_buffer(next_state, masked_text, plan) scan_seconds += time.perf_counter() - trim_started yield item diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py index 2af21d377f8..928426d5f48 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/validators.py @@ -2,7 +2,7 @@ import re import string from collections.abc import Callable, Mapping from types import MappingProxyType -from typing import Final, TypeAlias +from typing import Final _SEPARATORS: Final = re.compile(r"[\s\-]") _IBAN_CHARACTERS: Final = re.compile(r"[0-9A-Z]{5,34}") @@ -150,13 +150,6 @@ def passes_iban_mod97(value: str) -> bool: return int(rearranged.translate(_IBAN_LETTER_VALUES)) % 97 == 1 -CandidateCheck: TypeAlias = Callable[[Callable[[str], bool], str], bool] - - -def run_validator(validator: Callable[[str], bool], candidate: str) -> bool: - return validator(candidate) - - 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 8157b4e9bcc..cb8cfebe4d5 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 @@ -19,11 +19,6 @@ from litellm.constants import ( ) from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( ContentFilterGuardrail, - _FilteredText, -) -from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.validators import ( - CandidateCheck, - run_validator, ) from litellm.types.guardrails import ( BlockedWord, @@ -631,6 +626,30 @@ class TestContentFilterGuardrail: entry: Final = request_data["metadata"]["standard_logging_guardrail_information"][0] assert entry["masked_entity_count"] == {"group4": 1} + @pytest.mark.asyncio + async def test_later_pattern_sharing_a_name_counts_its_matches_on_a_number_put_back(self): + later_patterns: Final = ( + ContentFilterPattern(pattern_type="regex", pattern=r"\bpin\b", action=ContentFilterAction.MASK), + ContentFilterPattern(pattern_type="regex", pattern=r"\b\d{4}\b", action=ContentFilterAction.MASK), + ) + card_rule: Final = ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ) + + async def masked_entity_count(patterns: tuple[ContentFilterPattern, ...]) -> dict[str, int]: + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-checksum-shared-name-count", patterns=list(patterns) + ) + request_data: Final[dict[str, dict[str, list[StandardLoggingGuardrailInformation]]]] = {"metadata": {}} + await guardrail.apply_guardrail( + inputs={"texts": ["pin and card 4111 1111 1111 1112 end"]}, + request_data=request_data, + input_type="request", + ) + return request_data["metadata"]["standard_logging_guardrail_information"][0]["masked_entity_count"] + + assert await masked_entity_count((card_rule, *later_patterns)) == await masked_entity_count(later_patterns) + @pytest.mark.asyncio @pytest.mark.parametrize( ("pattern_name", "checksum_valid", "checksum_invalid"), @@ -737,9 +756,57 @@ class TestContentFilterGuardrail: _in_chunks("FR27 5892 9262 5322 3897 2114 796 it 3797 621746 14951 3722 314364 73911 textok 4336 2547 3487 7245 safe and more words", 8), "621746", ), + ( + ( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="uoft_tcard", action=ContentFilterAction.MASK + ), + ), + (), + _in_chunks("id 4111111111111112" + "\n" * 60 + "tcard holder ok", 8), + "4111111111111112", + ), + ( + ( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern( + pattern_type="regex", + pattern=r"\b\d{4}(?: \d{4}){3}\b(?=.{0,90}\bcvv\b)", + name="card_with_cvv", + action=ContentFilterAction.BLOCK, + ), + ), + (), + _in_chunks( + "the number on file is 4111 1111 1111 1112, and the three digits printed on the back are the cvv 123", + 4, + ), + "1112", + ), + ( + ( + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK + ), + ContentFilterPattern( + pattern_type="prebuilt", pattern_name="au_medicare", action=ContentFilterAction.BLOCK + ), + ), + (), + _in_chunks( + "ref 8834 6011 1484 9325 7256 904580\nmore text here to push it along, please keep going for a while now", + 3, + ), + "6011", + ), ], ) - async def test_checksum_pattern_streams_the_same_text_as_a_whole_response( + async def test_checksum_pattern_stream_sends_no_digit_of_a_number_it_matched( self, patterns: tuple[ContentFilterPattern, ...], blocked_words: tuple[BlockedWord, ...], @@ -774,57 +841,9 @@ class TestContentFilterGuardrail: ) ] ) - whole_response: Final = await guardrail.apply_guardrail( - inputs={"texts": ["".join(chunks)]}, request_data={}, input_type="response" - ) assert hidden not in streamed - assert [streamed] == whole_response.get("texts") - - @pytest.mark.asyncio - async def test_checksum_pattern_stream_sends_no_digit_of_a_number_a_later_rule_blocks(self): - from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices - - guardrail: Final = ContentFilterGuardrail( - guardrail_name="test-checksum-streaming-later-block", - patterns=[ - ContentFilterPattern(pattern_type="prebuilt", pattern_name="credit_card", action=ContentFilterAction.MASK), - ContentFilterPattern( - pattern_type="prebuilt", pattern_name="au_medicare", action=ContentFilterAction.BLOCK - ), - ], - ) - chunks: Final = _in_chunks( - "ref 8834 6011 1484 9325 7256 904580\nmore text here to push it along, please keep going for a while now", 3 - ) - - async def mock_stream(): - for index, content in enumerate(chunks): - yield ModelResponseStream( - id=f"chunk{index}", - choices=[ - StreamingChoices( - delta=Delta(content=content), - index=0, - finish_reason="stop" if index == len(chunks) - 1 else None, - ) - ], - model="gpt-4", - ) - - sent: Final[list[str]] = [] - - async def send_stream(): - async for chunk in guardrail.async_post_call_streaming_iterator_hook( - user_api_key_dict=MagicMock(), response=mock_stream(), request_data={} - ): - sent.append(chunk.choices[0].delta.content or "") - - with pytest.raises(HTTPException) as exc_info: - await send_stream() - - assert exc_info.value.detail["pattern"] == "au_medicare" - assert "6011" not in "".join(sent) + assert streamed.endswith(chunks[-1][-2:]) @pytest.mark.asyncio @pytest.mark.parametrize( @@ -1449,15 +1468,13 @@ class TestContentFilterGuardrail: scanned_lengths: Final[list[int]] = [] class RecordingGuardrail(ContentFilterGuardrail): - def _filter_text( + def _filter_streamed_text( self, text: str, - detections: list[ContentFilterDetection] | None, - check_candidate: CandidateCheck, - unsettled_tail: int, - ) -> _FilteredText: + detections: list[ContentFilterDetection] | None = None, + ) -> str: scanned_lengths.append(len(text)) - return super()._filter_text(text, detections, check_candidate, unsettled_tail) + return super()._filter_streamed_text(text, detections=detections) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-bounded-scan", @@ -1493,15 +1510,13 @@ class TestContentFilterGuardrail: scanned_lengths: Final[list[int]] = [] class RecordingGuardrail(ContentFilterGuardrail): - def _filter_text( + def _filter_streamed_text( self, text: str, - detections: list[ContentFilterDetection] | None, - check_candidate: CandidateCheck, - unsettled_tail: int, - ) -> _FilteredText: + detections: list[ContentFilterDetection] | None = None, + ) -> str: scanned_lengths.append(len(text)) - return super()._filter_text(text, detections, check_candidate, unsettled_tail) + return super()._filter_streamed_text(text, detections=detections) guardrail: Final = RecordingGuardrail( guardrail_name="test-streaming-refused-cut-backoff", From b1f43cfe62bbf6577ea2a1f86d8f7b4a63f8c81b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:56:59 -0700 Subject: [PATCH 15/15] perf(content_filter): skip redaction tracking when no checksum can put a number back --- .../litellm_content_filter/content_filter.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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 c2cee5ee80f..41324e76582 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 @@ -191,6 +191,7 @@ class _Redaction: class _MaskedText: text: str redactions: tuple[_Redaction, ...] = () + tracks_redactions: bool = True def original_span(self, span: tuple[int, int]) -> tuple[int, int]: return self._original_position(span[0], is_end=False), self._original_position(span[1], is_end=True) @@ -212,8 +213,14 @@ class _MaskedText: ] def masked(self, spans: Sequence[tuple[int, int]], tag: str, failed_checksum: Sequence[bool]) -> "_MaskedText": - growth: Final = tuple(itertools.accumulate((len(tag) - end + start for start, end in spans), initial=0)) span_ends: Final = tuple(end for _, end in spans) + text_pieces: Final = itertools.chain.from_iterable( + (self.text[previous_end:start], tag) for previous_end, (start, _) in zip((0, *span_ends), spans) + ) + text: Final = "".join((*text_pieces, self.text[span_ends[-1] :])) + if not self.tracks_redactions: + return _MaskedText(text=text, tracks_redactions=False) + growth: Final = tuple(itertools.accumulate((len(tag) - end + start for start, end in spans), initial=0)) absorbed: Final = tuple(self._redactions_overlapping(span) for span in spans) dropped: Final = frozenset(itertools.chain.from_iterable(absorbed)) shifted: Final = ( @@ -234,11 +241,8 @@ class _MaskedText: ) for index, (start, end) in enumerate(spans) ) - text_pieces: Final = itertools.chain.from_iterable( - (self.text[previous_end:start], tag) for previous_end, (start, _) in zip((0, *span_ends), spans) - ) return _MaskedText( - text="".join((*text_pieces, self.text[span_ends[-1] :])), + text=text, redactions=tuple(sorted((*shifted, *added), key=lambda redaction: redaction.start)), ) @@ -1484,15 +1488,18 @@ class ContentFilterGuardrail(CustomGuardrail): detections: list[ContentFilterDetection] | None, validates_checksums: bool, ) -> str: + checks_checksums: Final = validates_checksums and any( + pattern_entry["validator"] is not None for pattern_entry in self.compiled_patterns + ) checksum_windows: Final = ( - functools.cache(functools.partial(self._checksum_windows, text)) if validates_checksums else None + functools.cache(functools.partial(self._checksum_windows, text)) if checks_checksums else None ) first_pass: Final = functools.reduce( lambda so_far, indexed_entry: self._act_on_first_pass_matches( so_far, *indexed_entry, checksum_windows, detections ), enumerate(self.compiled_patterns), - _FirstPass(masked=_MaskedText(text=text)), + _FirstPass(masked=_MaskedText(text=text, tracks_redactions=checks_checksums)), ) put_back_text, put_back = first_pass.masked.restored(text) if not put_back: