diff --git a/engine/compliance/pii.py b/engine/compliance/pii.py index c336340c..4d60343b 100644 --- a/engine/compliance/pii.py +++ b/engine/compliance/pii.py @@ -59,8 +59,14 @@ class PIISensitivity(StrEnum): PIICategory.EMAIL: re.compile( r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", ), + # The guards keep a 10-digit run from matching inside a longer alphanumeric + # token — opaque identifiers such as `q_5c9382647927` (handle_match's + # query_id) otherwise read as phone numbers and get redacted out of the + # response. `\b` cannot be used here because a leading `+` or `(` is not a + # word character, so it would break `+1 (555) 123-4567`. SSN and IP_ADDRESS + # below rely on `\b` for the same reason, their patterns start with a digit. PIICategory.PHONE: re.compile( - r"(?:\+?1[\-\s.]?)?\(?\d{3}\)?[\-\s.]?\d{3}[\-\s.]?\d{4}", + r"(? None: assert detections[0].category == PIICategory.SSN assert detections[0].detected_by == "pattern_match" + def test_phone_pattern_does_not_match_inside_opaque_identifier(self) -> None: + """A digit run inside a longer token is not a phone number. + + Regression: handle_match builds query_id as `q_<12 hex chars>`. Roughly + 1.6% of those contain a 10-digit run, which the unguarded phone pattern + matched — ComplianceEngine.redact_response then deleted query_id from + the match response for those requests. + """ + handler = PIIHandler() + + for opaque in ("q_5c9382647927", "q_a90333567456", "q_6281353751e8"): + assert handler.detect_pii({"query_id": opaque}) == [], f"{opaque} must not be treated as PII" + + def test_detect_phone_by_pattern_still_matches_real_numbers(self) -> None: + """Guarding the phone pattern must not cost real phone detection.""" + handler = PIIHandler() + + for number in ("555-123-4567", "+1 (555) 123-4567", "5551234567", "call 555.123.4567 now"): + detections = handler.detect_pii({"notes": number}) + assert [d.category for d in detections] == [PIICategory.PHONE], f"{number} must be detected as a phone" + def test_detect_nested_pii(self) -> None: """PII detected in nested dicts.""" handler = PIIHandler() diff --git a/tests/unit/test_gate_egress.py b/tests/unit/test_gate_egress.py index 922080c7..55d68ae6 100644 --- a/tests/unit/test_gate_egress.py +++ b/tests/unit/test_gate_egress.py @@ -82,7 +82,12 @@ async def test_request_enrichment_fails_closed_without_gate_url(monkeypatch: pyt fake = _FakeClient(response=_response_packet()) monkeypatch.setattr(gate_egress, "get_gate_client", lambda: fake) result = await request_enrichment(tenant="acme", entity_id="ent-1", domain="plasticos", target_fields=["polymer"]) - assert result == {"status": "failed", "error": "gate_not_configured", "action": "enrich"} + assert result == { + "status": "failed", + "error": "gate_not_configured", + "action": "enrich", + "idempotency_key": enrichment_idempotency_key("acme", "ent-1", ["polymer"]), + } assert fake.calls == [], "no direct fallback: nothing may be sent when Gate is not configured"