From f61fd2fb6d5dfd07850cb0ae1a486a6b9adcb18b Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 11 Jul 2026 19:46:02 -0700 Subject: [PATCH] fix(xecguard): sanitize scan result before recording it for logging (#32935) --- .../guardrail_hooks/xecguard/xecguard.py | 11 +++++++- .../guardrail_hooks/test_xecguard.py | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py b/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py index f4a6f0aeb3b..7fe942bcb38 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py +++ b/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py @@ -44,6 +44,8 @@ from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, ) +from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys +from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -64,6 +66,13 @@ if TYPE_CHECKING: ) +def _sanitize_scan_result_for_logging(scan_result: dict) -> dict: + without_secrets = {key: value for key, value in scan_result.items() if key != "secret_fields"} + redacted = redact_nested_match_and_regex_keys(without_secrets) + masked = mask_credentials_in_payload(redacted if isinstance(redacted, dict) else without_secrets) + return masked if isinstance(masked, dict) else without_secrets + + _DEFAULT_API_BASE = "https://api-xecguard.cycraft.ai" _SCAN_ENDPOINT = "/xecguard/v1/scan" _GROUNDING_ENDPOINT = "/xecguard/v1/grounding" @@ -253,7 +262,7 @@ class XecGuardGuardrail(CustomGuardrail): slg = StandardLoggingGuardrailInformation( guardrail_name=self.guardrail_name or "xecguard", guardrail_mode=GuardrailEventHooks.logging_only, - guardrail_response=scan_result, + guardrail_response=_sanitize_scan_result_for_logging(scan_result), guardrail_status=guardrail_status, start_time=start_time.timestamp(), end_time=end_time.timestamp(), diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py index f64967abbb0..6e601df897b 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py @@ -1675,6 +1675,33 @@ class TestXecGuardLoggingHook: assert info_list[1]["guardrail_name"] == "test-xecguard" assert info_list[1]["guardrail_response"]["trace_id"] == "lg-4" + @pytest.mark.asyncio + async def test_async_logging_hook_sanitizes_scan_result( + self, xecguard_guardrail, mock_request_data + ): + resp = _make_response( + { + "decision": "SAFE", + "trace_id": "lg-5", + "secret_fields": {"authorization": "Bearer xgs_raw"}, + "detections": [{"match": "raw matched span", "policy": "pii"}], + "api_key": "xgs_super_secret_value", + } + ) + with patch.object(xecguard_guardrail.async_handler, "post", return_value=resp): + kwargs = {**mock_request_data, "standard_logging_object": {}} + await xecguard_guardrail.async_logging_hook( + kwargs=kwargs, + result=_build_model_response("some answer"), + call_type="acompletion", + ) + info = kwargs["standard_logging_object"]["guardrail_information"][0] + guardrail_response = info["guardrail_response"] + assert "secret_fields" not in guardrail_response + assert guardrail_response["detections"][0]["match"] == "[REDACTED]" + assert guardrail_response["api_key"] != "xgs_super_secret_value" + assert guardrail_response["trace_id"] == "lg-5" + @pytest.mark.asyncio async def test_async_logging_hook_without_response_records_info( self, xecguard_guardrail, mock_request_data