fix(guardrails): add opt-in mask_pii_fail_closed for Presidio mask-only configs

A Presidio guardrail that only masks PII (no pii_entities_config,
output_parse_pii, or apply_to_output) fell through _fail_on_invalid_response
to `return []` on an analyzer error, so anonymize_text returned the original
text and unmasked PII reached the provider. Add an opt-in `mask_pii_fail_closed`
flag (default False, preserving current behaviour) that includes mask-only
configs in the fail-closed path, so an analyzer outage blocks instead of
forwarding unmasked text.

Refs #30728
This commit is contained in:
Yupeng Lin 2026-06-23 14:41:41 +08:00
parent 8e30cfbeb1
commit 00d71caa6c
4 changed files with 67 additions and 2 deletions

View file

@ -76,6 +76,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
presidio_anonymizer_api_base: Optional[str] = None,
output_parse_pii: Optional[bool] = False,
apply_to_output: bool = False,
mask_pii_fail_closed: bool = False,
presidio_ad_hoc_recognizers: Optional[str] = None,
logging_only: Optional[bool] = None,
pii_entities_config: Optional[Dict[Union[PiiEntityType, str], PiiAction]] = None,
@ -93,6 +94,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
self.mock_redacted_text = mock_redacted_text
self.output_parse_pii = output_parse_pii or False
self.apply_to_output = apply_to_output
self.mask_pii_fail_closed = mask_pii_fail_closed
# When output_parse_pii or apply_to_output is enabled, the guardrail must
# also run on post_call to unmask/mask the response. Expand the event_hook
@ -298,7 +300,12 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
def _fail_on_invalid_response(
reason: str,
) -> List[PresidioAnalyzeResponseItem]:
should_fail_closed = bool(self.pii_entities_config) or self.output_parse_pii or self.apply_to_output
should_fail_closed = (
bool(self.pii_entities_config)
or self.output_parse_pii
or self.apply_to_output
or self.mask_pii_fail_closed
)
if should_fail_closed:
raise GuardrailRaisedException(
guardrail_name=self.guardrail_name,

View file

@ -97,7 +97,10 @@ def initialize_presidio(litellm_params: LitellmParams, guardrail: Guardrail):
apply_to_output=False,
)
params.update(overrides)
callback = _OPTIONAL_PresidioPIIMasking(**params)
callback = _OPTIONAL_PresidioPIIMasking(
**params,
mask_pii_fail_closed=bool(litellm_params.mask_pii_fail_closed),
)
litellm.logging_callback_manager.add_litellm_callback(callback)
return callback

View file

@ -345,6 +345,12 @@ class PresidioPresidioConfigModelUserInterface(BaseModel):
# extra param to let the ui know this is a boolean
json_schema_extra={"ui_type": GuardrailParamUITypes.BOOL},
)
mask_pii_fail_closed: Optional[bool] = Field(
default=None,
description="When True, a mask-only Presidio guardrail blocks the request (fail closed) if the analyzer errors, instead of forwarding unmasked text",
# extra param to let the ui know this is a boolean
json_schema_extra={"ui_type": GuardrailParamUITypes.BOOL},
)
presidio_language: Optional[str] = Field(
default="en",
description="Language code for Presidio PII analysis (e.g., 'en', 'de', 'es', 'fr')",

View file

@ -1079,6 +1079,55 @@ async def test_analyze_text_invalid_response_raises_when_mask_configured():
assert "PII protection is configured" in str(exc_info.value)
@pytest.mark.asyncio
async def test_analyze_text_mask_only_fails_open_by_default():
"""
Default mask-only config (no pii_entities_config / output / apply_to_output)
still fails open on analyzer error, preserving prior behaviour (issue #30728).
"""
presidio = _OPTIONAL_PresidioPIIMasking(
presidio_analyzer_api_base="http://mock-presidio:5002/",
presidio_anonymizer_api_base="http://mock-presidio:5001/",
output_parse_pii=False,
)
assert presidio.mask_pii_fail_closed is False
with patch.object(
presidio,
"_get_session_iterator",
_make_mock_session_iterator("Internal Server Error", status=500),
):
result = await presidio.analyze_text(
text="some text", presidio_config=None, request_data={}
)
assert result == []
@pytest.mark.asyncio
async def test_analyze_text_raises_when_mask_pii_fail_closed_set():
"""
With mask_pii_fail_closed=True, a mask-only guardrail blocks (fail closed)
on analyzer error instead of forwarding unmasked text (issue #30728).
"""
presidio = _OPTIONAL_PresidioPIIMasking(
presidio_analyzer_api_base="http://mock-presidio:5002/",
presidio_anonymizer_api_base="http://mock-presidio:5001/",
output_parse_pii=False,
mask_pii_fail_closed=True,
)
with patch.object(
presidio,
"_get_session_iterator",
_make_mock_session_iterator("Internal Server Error", status=500),
):
with pytest.raises(GuardrailRaisedException) as exc_info:
await presidio.analyze_text(
text="some text", presidio_config=None, request_data={}
)
assert "PII protection is configured" in str(exc_info.value)
@pytest.mark.asyncio
async def test_analyze_text_list_with_non_dict_items():
"""