diff --git a/.github/e2e-stack/select_tests.py b/.github/e2e-stack/select_tests.py index a62358f81ff..633ee6d8a23 100644 --- a/.github/e2e-stack/select_tests.py +++ b/.github/e2e-stack/select_tests.py @@ -7,7 +7,7 @@ UNSUPPORTED: Final = re.compile( r"^tests/e2e/(ui|claude_code|load)/" r"|^tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e\.py$" r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$" - r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$" + r"|^tests/e2e/guardrails/test_presidio_.*_e2e\.py$" ) HARNESS: Final = re.compile( r"^tests/e2e/[A-Za-z0-9_.-]+\.(py|ini)$" diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 1183096b81e..2355523191d 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -52,6 +52,8 @@ The suites run against a live proxy, so bring one up first by running the litell Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and point `PHOENIX_COLLECTOR_HTTP_ENDPOINT` at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy. The presidio guardrail tests need a running Presidio analyzer and anonymizer the proxy can reach, addressed by `PRESIDIO_ANALYZER_API_BASE` / `PRESIDIO_ANONYMIZER_API_BASE` +Those two endpoints are treated as secrets, at the same handling as a provider API key: they name an internal deployment, so they are never checked in, never written into a config committed to this repo, and never printed. Supply them from the runner's environment only. `guardrails/presidio_env.py` is the single place that reads them, and every failure message in the presidio suites that interpolates a proxy response body passes it through that module's `scrub` first, so an endpoint cannot reach a CI log through a test failure. Two consequences worth knowing: run the proxy without `--detailed_debug` for these suites, because the guardrail logs the analyzer URL it is calling at debug level, and keep both presidio files on the `UNSUPPORTED` list in `.github/e2e-stack/select_tests.py`, which is what stops the GitHub Actions lane from needing the secret at all + The Presidio spend-log audit test also requires prompt storage on the proxy, because its assertions inspect the detected entities and their scores. Add the following to the proxy config before starting it: ```yaml diff --git a/tests/e2e/coverage_registry/guardrail.yaml b/tests/e2e/coverage_registry/guardrail.yaml index 81832bebf49..e693564cd2f 100644 --- a/tests/e2e/coverage_registry/guardrail.yaml +++ b/tests/e2e/coverage_registry/guardrail.yaml @@ -3,6 +3,11 @@ - {id: guardrail.presidio.pre_call.masks, module: guardrail, tier: P0, hook_point: pre_call, assertions: [masks], exercised_on: [chat_completions, messages], source: "guardrail_hooks/presidio.py", rationale: "PII masking pre-call; data-leak blast radius"} - {id: guardrail.presidio.post_call.masks, module: guardrail, tier: P0, hook_point: post_call, assertions: [masks], exercised_on: [chat_completions, messages], source: "guardrail_hooks/presidio.py", rationale: "Mask PII in model output"} - {id: guardrail.presidio.logging_only.masks, module: guardrail, tier: P0, hook_point: logging_only, assertions: [masks], exercised_on: [chat_completions, messages], source: "guardrail_hooks/presidio.py", rationale: "Redact in logs without blocking"} +- {id: guardrail.presidio.pre_call.masks_only_configured_entities, module: guardrail, tier: P0, hook_point: pre_call, assertions: [masks_only_configured_entities], exercised_on: [chat_completions], source: "guardrail_hooks/presidio.py", rationale: "pii_entities_config is put on the analyze request as the entities filter. If that stops reaching Presidio the guardrail masks every type Presidio recognizes, silently redacting text a customer configured it to leave alone"} +- {id: guardrail.presidio.pre_call.masks_each_entity_in_place, module: guardrail, tier: P0, hook_point: pre_call, assertions: [masks_each_entity_in_place], exercised_on: [chat_completions], source: "guardrail_hooks/presidio.py", rationale: "Every detection Presidio returns must be replaced where it sits by a placeholder naming its own type. Spans applied at the wrong offsets still remove the PII while corrupting the surrounding prompt"} +- {id: guardrail.presidio.pre_call.blocks, module: guardrail, tier: P0, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/presidio.py", rationale: "A PII entity configured BLOCK must refuse the request. The decision reads entity_type off the analyze response, so it is the one path that proves we parse the analysis and not just the anonymized text"} +- {id: guardrail.presidio.pre_call.restores_masked_values, module: guardrail, tier: P0, hook_point: pre_call, assertions: [restores_masked_values], exercised_on: [chat_completions], source: "guardrail_hooks/presidio.py", rationale: "output_parse_pii numbers each placeholder from the analyze spans and restores the original value in the answer. A broken mapping either leaks the wrong value back or leaves a raw token in the customer response"} +- {id: guardrail.presidio.pre_call.fails_closed_when_unreachable, module: guardrail, tier: P0, hook_point: pre_call, assertions: [fails_closed_when_unreachable], exercised_on: [chat_completions], source: "guardrail_hooks/presidio.py", rationale: "A Presidio the proxy cannot reach must refuse the request. Degrading to an unmasked call would send raw PII to the provider during an outage, which is the exact failure the guardrail exists to prevent"} - {id: guardrail.presidio.pre_call.logs_masked_entities, module: guardrail, tier: P0, hook_point: pre_call, assertions: [logs_masked_entities], exercised_on: [chat_completions], source: "guardrail_hooks/presidio.py", rationale: "A masking run must record itself on the spend log: the dashboard's guardrail panel renders the masked-entity counts and per-entity scores straight off metadata.guardrail_information, so a run that masks but records nothing leaves an operator unable to audit it"} - {id: guardrail.bedrock.pre_call.blocks, module: guardrail, tier: P0, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/bedrock_guardrails.py", rationale: "AWS content guardrail blocks harmful input"} - {id: guardrail.litellm_content_filter.pre_call.blocks, module: guardrail, tier: P0, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "test_team_disable_global_guardrail_e2e.py", rationale: "Local content-filter default-on blocks banned keyword pre-call"} diff --git a/tests/e2e/guardrails/presidio_env.py b/tests/e2e/guardrails/presidio_env.py new file mode 100644 index 00000000000..232853b730a --- /dev/null +++ b/tests/e2e/guardrails/presidio_env.py @@ -0,0 +1,56 @@ +"""Where the Presidio analyzer/anonymizer endpoints come from, and how they stay +out of test output. + +The endpoints are an internal deployment, so they are handled at the same +secrecy as a provider API key: never checked in, supplied only through +PRESIDIO_ANALYZER_API_BASE / PRESIDIO_ANONYMIZER_API_BASE in the runner's +environment, and hard-failed when absent rather than skipped. + +`scrub` exists because a failing e2e test prints proxy response bodies into CI +logs, which are read by more people than the endpoints are meant for. Every +failure message in the presidio suites that interpolates a body goes through it, +so a body that ever echoes an endpoint back cannot publish it. +""" + +from __future__ import annotations + +import os +from functools import cache, reduce +from urllib.parse import urlparse + +import pytest + +ANALYZER_ENV_VAR = "PRESIDIO_ANALYZER_API_BASE" +ANONYMIZER_ENV_VAR = "PRESIDIO_ANONYMIZER_API_BASE" + +REDACTED = "" + + +@cache +def presidio_bases() -> tuple[str, str]: + analyzer = os.environ.get(ANALYZER_ENV_VAR, "").strip() + anonymizer = os.environ.get(ANONYMIZER_ENV_VAR, "").strip() + if not analyzer or not anonymizer: + pytest.fail( + f"Presidio e2e requires {ANALYZER_ENV_VAR} and {ANONYMIZER_ENV_VAR} " + "(the running Presidio analyzer/anonymizer services); missing env is a hard failure, not a skip" + ) + return analyzer, anonymizer + + +def secret_fragments(urls: tuple[str, ...]) -> tuple[str, ...]: + """Every substring that would identify the deployment: each URL as given, + without its trailing slash, and its bare host, so a connection error naming + only the host is caught as well as a message quoting the whole URL. Longest + first, so a full URL is replaced whole instead of leaving its path behind.""" + hosts = tuple(netloc for netloc in (urlparse(url).netloc for url in urls) if netloc) + return tuple(sorted({*urls, *(url.rstrip("/") for url in urls), *hosts}, key=len, reverse=True)) + + +def redact(text: str, fragments: tuple[str, ...]) -> str: + return reduce(lambda carried, fragment: carried.replace(fragment, REDACTED), fragments, text) + + +def scrub(text: str) -> str: + """Replace any trace of the configured Presidio deployment in text bound for a log.""" + return redact(text, secret_fragments(presidio_bases())) diff --git a/tests/e2e/guardrails/test_presidio_contract_e2e.py b/tests/e2e/guardrails/test_presidio_contract_e2e.py new file mode 100644 index 00000000000..4ce1a7377ce --- /dev/null +++ b/tests/e2e/guardrails/test_presidio_contract_e2e.py @@ -0,0 +1,359 @@ +"""Live e2e: the contract between LiteLLM and a real Presidio deployment. + +Whether Presidio finds an email address is Presidio's business, and this suite +does not test it. What it tests is our half of the exchange: that the entity +filter we were configured with is actually put on the /analyze request, that the +spans and entity types Presidio answers with are turned into the right +placeholders, blocks, and restored values, and that a Presidio we cannot reach +stops the request instead of forwarding raw PII to the model. + +Every case is written to survive Presidio changing its mind about confidence +scores or adding a recognizer: the assertions turn on which entity types were +asked for and on what our code did with the answer, never on a score. + +Most cases drive POST /guardrails/apply_guardrail, which runs the guardrail over +literal text and hands back what the model would have received. That puts the +assertion on the masked text itself rather than on a model's willingness to echo +it, and costs no provider spend. The two cases that must prove an effect on a +real call, blocking and restoring the masked value on the way out, go through +/chat/completions. + +The analyzer and anonymizer endpoints come from the environment and are treated +as secrets; see presidio_env. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from typing import Final, Literal + +import pytest + +from e2e_config import MASTER_KEY, POLL_INTERVAL, POLL_TIMEOUT, unique_marker +from e2e_http import Result, Success, UnknownApiError +from guardrails_client import ( + ApplyGuardrailResponse, + GuardrailMode, + GuardrailsClient, + PiiAction, + PiiEntity, + PresidioParamsBody, +) +from lifecycle import ResourceManager +from models import ChatResponse +from presidio_env import presidio_bases, scrub + +pytestmark = pytest.mark.e2e + +MODEL = "gemini-2.5-flash" + +RAW_EMAIL = "dana.reyes@example.com" +RAW_PHONE = "+1 415-555-0134" +RAW_CARD = "4111 1111 1111 1111" +PII_SENTENCE = f"Reach Dana at {RAW_EMAIL} or on {RAW_PHONE} about card {RAW_CARD} today." + +UNREACHABLE_BASE = "http://127.0.0.1:9/" + +MAX_ECHO_TOKENS = 128 + + +def _register( + client: GuardrailsClient, + resources: ResourceManager, + *, + name: str, + entities: dict[PiiEntity, PiiAction], + mode: GuardrailMode = "pre_call", + filter_scope: Literal["input", "output", "both"] = "input", + output_parse_pii: bool = False, + analyzer_base: str | None = None, + anonymizer_base: str | None = None, +) -> None: + configured_analyzer, configured_anonymizer = presidio_bases() + guardrail_id: Final = client.register( + name, + PresidioParamsBody( + mode=mode, + default_on=False, + presidio_analyzer_api_base=analyzer_base or configured_analyzer, + presidio_anonymizer_api_base=anonymizer_base or configured_anonymizer, + presidio_filter_scope=filter_scope, + output_parse_pii=output_parse_pii, + pii_entities_config=entities, + ), + ) + resources.defer(lambda: client.delete_guardrail(guardrail_id)) + + +def _apply(client: GuardrailsClient, name: str, text: str) -> str: + """Run the guardrail over literal text and return what the model would see. + + /guardrails/apply_guardrail is a management route, so it is called with the + master key like the rest of the apply surface. A replica that has not + reloaded its config yet answers 404, which is in-flight propagation and + worth retrying; every other refusal is a verdict and fails immediately + rather than being waited out to the deadline. + """ + deadline: Final = time.monotonic() + POLL_TIMEOUT + last: Result[ApplyGuardrailResponse] = client.apply_guardrail(MASTER_KEY, name=name, text=text) + while not isinstance(last, Success): + if not isinstance(last, UnknownApiError) or last.status_code != 404: + pytest.fail(f"apply_guardrail refused to run {name!r}: {scrub(str(last))}") + if time.monotonic() >= deadline: + pytest.fail(f"apply_guardrail never ran {name!r} within {POLL_TIMEOUT}s: {scrub(str(last))}") + time.sleep(POLL_INTERVAL) + last = client.apply_guardrail(MASTER_KEY, name=name, text=text) + return last.data.response_text + + +def _poll_until_refused(call: Callable[[], Result[ChatResponse]]) -> Result[ChatResponse]: + """Retry a call a guardrail should refuse until it does, returning the last + result. A call served right after the create ran on a worker that has not + picked the guardrail up yet, which is propagation, not a guardrail that + failed to refuse; one that is still served at the deadline is the failure.""" + deadline: Final = time.monotonic() + POLL_TIMEOUT + last: Result[ChatResponse] = call() + while isinstance(last, Success) and time.monotonic() < deadline: + time.sleep(POLL_INTERVAL) + last = call() + return last + + +def _first_content(response: ChatResponse) -> str: + if not response.choices: + return "" + message: Final = response.choices[0].message + return (message.content if message else None) or "" + + +class TestPresidioEntityFilterContract: + @pytest.mark.covers( + "guardrail.presidio.pre_call.masks_only_configured_entities", + exercised_on=["chat_completions"], + ) + def test_only_the_configured_entity_types_are_masked( + self, client: GuardrailsClient, resources: ResourceManager + ) -> None: + """The configured entity filter must reach Presidio, not stay in our own + bookkeeping. + + A guardrail configured for EMAIL_ADDRESS alone puts `entities` on the + analyze request, so the phone number and card in the same sentence come + back untouched. Stop sending that field and Presidio answers with + everything it recognizes, which masks all three and fails here. + """ + name: Final = f"e2e-presidio-filter-{unique_marker()}" + _register(client, resources, name=name, entities={"EMAIL_ADDRESS": "MASK"}) + + masked: Final = _apply(client, name, PII_SENTENCE) + + assert "" in masked, f"the configured entity must be masked, got {scrub(masked)!r}" + assert RAW_EMAIL not in masked, f"the raw address must not survive masking, got {scrub(masked)!r}" + assert RAW_PHONE in masked, ( + "PHONE_NUMBER was not configured, so it must reach the model untouched; the guardrail " + f"masked more than it was asked to: {scrub(masked)!r}" + ) + assert RAW_CARD in masked, ( + "CREDIT_CARD was not configured, so it must reach the model untouched; the guardrail " + f"masked more than it was asked to: {scrub(masked)!r}" + ) + + @pytest.mark.covers( + "guardrail.presidio.pre_call.masks_each_entity_in_place", + exercised_on=["chat_completions"], + ) + def test_every_detection_is_replaced_in_place_by_its_own_type_placeholder( + self, client: GuardrailsClient, resources: ResourceManager + ) -> None: + """Presidio answers with one span per detection and we replace each one + where it sits. + + Pinning the whole sentence is what makes this more than "something was + redacted": spans applied at the wrong offsets would still remove the PII + while eating the words around it, and a placeholder built from the wrong + field would name the wrong type. + """ + name: Final = f"e2e-presidio-placeholders-{unique_marker()}" + _register( + client, + resources, + name=name, + entities={"EMAIL_ADDRESS": "MASK", "PHONE_NUMBER": "MASK", "CREDIT_CARD": "MASK"}, + ) + + masked: Final = _apply(client, name, PII_SENTENCE) + + assert masked == "Reach Dana at or on about card today.", ( + "each detection must be replaced by a placeholder naming its own entity type, in place, " + f"leaving the rest of the sentence byte for byte; got {scrub(masked)!r}" + ) + + +class TestPresidioBlockContract: + @pytest.mark.covers( + "guardrail.presidio.pre_call.blocks", + exercised_on=["chat_completions"], + ) + def test_a_block_entity_refuses_the_request_while_clean_text_still_passes( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + """BLOCK is decided by matching Presidio's entity_type against our config, + so it proves we read the analyze response and not only its masked text. + + The clean prompt is the liveness half. Without it a guardrail that + refused every request, or an analyzer erroring on everything, would look + exactly like one that blocks the right thing. + """ + name: Final = f"e2e-presidio-block-{unique_marker()}" + _register(client, resources, name=name, entities={"CREDIT_CARD": "BLOCK"}) + + marker: Final = unique_marker() + blocked: Final = _poll_until_refused( + lambda: client.chat(scoped_key, MODEL, f"{marker} Charge card {RAW_CARD}.", guardrails=[name]) + ) + assert not isinstance(blocked, Success), ( + f"a request carrying a CREDIT_CARD must be refused, but it was served: {scrub(str(blocked))}" + ) + assert "CREDIT_CARD" in scrub(str(blocked)), ( + "the refusal must name the entity that caused it, so an operator can tell it from an " + f"unrelated 4xx; got {scrub(str(blocked))}" + ) + + allowed: Final = client.chat( + scoped_key, + MODEL, + f"{marker} Say the word hello and nothing else.", + guardrails=[name], + max_tokens=32, + ) + assert isinstance(allowed, Success), ( + "text carrying no blocked entity must still be served by the same guardrail; it refused " + f"everything, so the block above proves nothing: {scrub(str(allowed))}" + ) + + +class TestPresidioOutputParseContract: + @pytest.mark.covers( + "guardrail.presidio.pre_call.restores_masked_values", + exercised_on=["chat_completions"], + ) + def test_numbered_tokens_mask_the_prompt_and_are_restored_in_the_answer( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + """With output_parse_pii each placeholder is numbered per detection and + the caller gets the original values back. + + Both halves are ours and both are built from Presidio's spans: the + numbering comes from sorting the analyze results left to right, and the + restore puts back the text those offsets pointed at. The apply_guardrail + call pins what the model receives, the chat call pins what the caller + receives, and the caller only sees the real address if the + token-to-value mapping survived the round trip. + """ + name: Final = f"e2e-presidio-parse-{unique_marker()}" + _register( + client, + resources, + name=name, + entities={"EMAIL_ADDRESS": "MASK", "PHONE_NUMBER": "MASK"}, + output_parse_pii=True, + ) + + to_the_model: Final = _apply(client, name, f"Dana is {RAW_EMAIL} on {RAW_PHONE}") + assert to_the_model == "Dana is on ", ( + "output_parse_pii numbers each placeholder in the order its entity appears, which is what " + f"makes the restore reversible; got {scrub(to_the_model)!r}" + ) + + restored: Final = _poll_until_restored(client, scoped_key, name, _echo_prompt(unique_marker())) + assert RAW_EMAIL in restored, ( + "the caller must get the real address back, not the placeholder the model saw; a token left " + f"in the answer means the mapping was lost between the two hooks: {scrub(restored)!r}" + ) + assert "EMAIL_ADDRESS_1" not in restored, ( + f"no numbered token may survive into the caller's response: {scrub(restored)!r}" + ) + + +def _echo_prompt(marker: str) -> str: + """A transcription framing rather than "repeat this back". + + The model is handed the numbered placeholders. Asked to repeat them, a model + may instead explain that it will not echo someone's contact details, which + leaves the restore nothing to act on and turns a product assertion into a + test of the model's mood. Framed as transcription, the line comes back + verbatim. + """ + return ( + f"{marker} You are a text transcription tool. Output the input text character for character, " + f"with no commentary and no explanation. Input: Dana is {RAW_EMAIL} on {RAW_PHONE}" + ) + + +def _poll_until_restored(client: GuardrailsClient, key: str, name: str, prompt: str) -> str: + """Retry until the guardrail has attached and the model has echoed the line. + + The applied-guardrails header is the liveness gate: without it a response + carrying the raw address would be indistinguishable from one the guardrail + never touched, and the restore assertion would pass vacuously. + """ + deadline: Final = time.monotonic() + POLL_TIMEOUT + last = "" # rebind-ok: last-observation accumulator for the failure message + while True: + outcome = client.chat_raw(key, MODEL, prompt, guardrails=[name], max_tokens=MAX_ECHO_TOKENS) + if outcome.ok and name in outcome.headers.get("x-litellm-applied-guardrails", ""): + last = _first_content(ChatResponse.model_validate_json(outcome.body)) + if RAW_EMAIL in last or "EMAIL_ADDRESS" in last: + return last + else: + last = f"" + if time.monotonic() >= deadline: + pytest.fail( + f"the guarded call never came back with the model's echo within {POLL_TIMEOUT}s; " + f"last observation: {scrub(last)[:300]!r}" + ) + time.sleep(POLL_INTERVAL) + + +class TestPresidioUnreachableContract: + @pytest.mark.covers( + "guardrail.presidio.pre_call.fails_closed_when_unreachable", + exercised_on=["chat_completions"], + ) + def test_an_unreachable_analyzer_refuses_the_request_instead_of_forwarding_raw_pii( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + """A Presidio outage must not degrade into "no masking today". + + The guardrail is pointed at a closed local port so the analyzer call + cannot connect. With PII entities configured the guardrail fails closed + and the prompt never reaches the model with the address still in it. The + error goes straight back to the caller, so it is also asserted to carry + neither the raw address nor the endpoint it failed to reach. + """ + name: Final = f"e2e-presidio-unreachable-{unique_marker()}" + _register( + client, + resources, + name=name, + entities={"EMAIL_ADDRESS": "MASK"}, + analyzer_base=UNREACHABLE_BASE, + anonymizer_base=UNREACHABLE_BASE, + ) + + marker: Final = unique_marker() + outcome: Final = _poll_until_refused( + lambda: client.chat(scoped_key, MODEL, f"{marker} Repeat: {RAW_EMAIL}", guardrails=[name]) + ) + + assert not isinstance(outcome, Success), ( + "with the analyzer unreachable the guardrail cannot know whether the prompt holds PII, so " + f"the request must be refused rather than forwarded unmasked: {scrub(str(outcome))}" + ) + body: Final = scrub(str(outcome)) + assert RAW_EMAIL not in body, f"the failure must not echo the prompt's PII back to the caller: {body}" + assert "Presidio" in body, ( + f"the refusal must say the guardrail could not run, or an operator cannot tell an outage " + f"from a model error: {body}" + ) diff --git a/tests/e2e/guardrails/test_presidio_env.py b/tests/e2e/guardrails/test_presidio_env.py new file mode 100644 index 00000000000..5211c592b05 --- /dev/null +++ b/tests/e2e/guardrails/test_presidio_env.py @@ -0,0 +1,60 @@ +"""Harness coverage for the Presidio endpoint scrubber. + +`scrub` is what keeps an internal endpoint out of a CI log when a presidio test +fails, and nothing downstream would notice if it quietly stopped replacing +anything, so its two pure halves are covered directly. No proxy, no e2e marker. +""" + +from __future__ import annotations + +from presidio_env import REDACTED, redact, secret_fragments + +ANALYZER = "https://analyzer.internal.example/" +ANONYMIZER = "https://anonymizer.internal.example/" +BASES = (ANALYZER, ANONYMIZER) + + +class TestSecretFragments: + def test_each_endpoint_contributes_its_url_slashless_form_and_bare_host(self) -> None: + fragments = secret_fragments(BASES) + + assert set(fragments) == { + ANALYZER, + ANALYZER.rstrip("/"), + "analyzer.internal.example", + ANONYMIZER, + ANONYMIZER.rstrip("/"), + "anonymizer.internal.example", + } + + def test_fragments_are_longest_first(self) -> None: + """A host is a substring of its own URL, so replacing the short form first + would leave the scheme and path of the long form behind in the log.""" + lengths = [len(fragment) for fragment in secret_fragments(BASES)] + + assert lengths == sorted(lengths, reverse=True) + + +class TestRedact: + def test_a_full_url_is_replaced_whole(self) -> None: + redacted = redact(f"Making request to: {ANALYZER}analyze", secret_fragments(BASES)) + + assert redacted == f"Making request to: {REDACTED}analyze" + assert "internal.example" not in redacted + + def test_a_bare_host_is_replaced_too(self) -> None: + """aiohttp names only the host when it cannot connect, so a connection + error is the shape most likely to carry the endpoint into a log.""" + redacted = redact("Cannot connect to host anonymizer.internal.example:443", secret_fragments(BASES)) + + assert redacted == f"Cannot connect to host {REDACTED}:443" + + def test_every_occurrence_goes_not_just_the_first(self) -> None: + redacted = redact(f"{ANALYZER} then {ANALYZER}", secret_fragments(BASES)) + + assert redacted == f"{REDACTED} then {REDACTED}" + + def test_text_carrying_no_endpoint_is_returned_unchanged(self) -> None: + assert redact("Presidio PII analysis failed: ClientConnectorError", secret_fragments(BASES)) == ( + "Presidio PII analysis failed: ClientConnectorError" + ) diff --git a/tests/e2e/guardrails/test_presidio_masking_e2e.py b/tests/e2e/guardrails/test_presidio_masking_e2e.py index 49d698938ce..fc6e1d49f0b 100644 --- a/tests/e2e/guardrails/test_presidio_masking_e2e.py +++ b/tests/e2e/guardrails/test_presidio_masking_e2e.py @@ -14,8 +14,8 @@ configuration: the post_call prompt asks for a value derived from the raw email address itself, so the answer proves the model saw the raw address while the address in the same response comes back as . -The analyzer/anonymizer endpoints come from PRESIDIO_ANALYZER_API_BASE / -PRESIDIO_ANONYMIZER_API_BASE; missing env is a hard failure, never a skip. +The analyzer/anonymizer endpoints come from the environment and are treated as +secrets; see presidio_env. Missing env is a hard failure, never a skip. Each guardrail registers with an explicit presidio_filter_scope so only the configured hook's callback exists (the default "both" registers input masking AND a post_call output masker), and is deleted on teardown. @@ -29,7 +29,6 @@ this suite deliberately requires the detected-entity details to remain visible. from __future__ import annotations -import os import time from collections.abc import Callable from typing import Final, Literal @@ -48,6 +47,7 @@ from models import ( GuardrailRunRecord, SpendLogRow, ) +from presidio_env import presidio_bases, scrub pytestmark = pytest.mark.e2e @@ -70,17 +70,6 @@ MASKED_PHONE_TOKEN = "PHONE_NUMBER" FAKE_PHONE = "+1 415-555-0134" -def _presidio_bases() -> tuple[str, str]: - analyzer = os.environ.get("PRESIDIO_ANALYZER_API_BASE", "").strip() - anonymizer = os.environ.get("PRESIDIO_ANONYMIZER_API_BASE", "").strip() - if not analyzer or not anonymizer: - pytest.fail( - "Presidio e2e requires PRESIDIO_ANALYZER_API_BASE and PRESIDIO_ANONYMIZER_API_BASE " - "(the running Presidio analyzer/anonymizer services); missing env is a hard failure, not a skip" - ) - return analyzer, anonymizer - - def _register_presidio( client: GuardrailsClient, resources: ResourceManager, @@ -90,7 +79,7 @@ def _register_presidio( filter_scope: Literal["input", "output", "both"] = "input", entities: dict[PiiEntity, PiiAction] | None = None, ) -> None: - analyzer, anonymizer = _presidio_bases() + analyzer, anonymizer = presidio_bases() guardrail_id = client.register( name, PresidioParamsBody( @@ -110,9 +99,17 @@ def _fake_email() -> str: def _pii_prompt(marker: str, email: str) -> str: + """A transcription framing rather than "repeat this back". + + The model is handed the masked placeholders. Asked to repeat those, a model + may instead explain that it will not echo someone's contact details, which + leaves the assertion testing the model's mood rather than the guardrail. + Framed as transcription, the line comes back verbatim. + """ return ( - f"{marker} Repeat this sentence back to me exactly, word for word: " - f"My email address is {email} and my phone number is {FAKE_PHONE}." + f"{marker} You are a text transcription tool. Output the input text character for character, " + f"with no commentary and no explanation. " + f"Input: My email address is {email} and my phone number is {FAKE_PHONE}." ) @@ -160,7 +157,7 @@ def _assert_eventually_masked[R: BaseModel]( ) return case _: - last = f"" + last = scrub(f"") if time.monotonic() >= deadline: pytest.fail( f"presidio pre_call guardrail never masked the PII within " @@ -281,7 +278,7 @@ class TestPresidioPostCallMasking: ) return case _: - last = f"" + last = scrub(f"") if time.monotonic() >= deadline: pytest.fail( f"presidio post_call guardrail never masked the model's output within " diff --git a/tests/e2e/ui/tests/guardrails/presidioUserStory.spec.ts b/tests/e2e/ui/tests/guardrails/presidioUserStory.spec.ts index d4ed5308342..ccba339989d 100644 --- a/tests/e2e/ui/tests/guardrails/presidioUserStory.spec.ts +++ b/tests/e2e/ui/tests/guardrails/presidioUserStory.spec.ts @@ -146,8 +146,9 @@ test.describe("Presidio PII guardrail, end to end from the dashboard", () => { await expect(onlyVisible(drawer.getByText("Detected Entities (2)"))).toBeVisible({ timeout: 10_000 }); await expect(onlyVisible(drawer.getByText("EMAIL_ADDRESS", { exact: true }))).toBeVisible({ timeout: 10_000 }); await expect(onlyVisible(drawer.getByText("PHONE_NUMBER", { exact: true }))).toBeVisible({ timeout: 10_000 }); - await expect(onlyVisible(drawer.getByText("Score: 1.00", { exact: true }))).toBeVisible({ timeout: 10_000 }); - await expect(onlyVisible(drawer.getByText("Score: 0.75", { exact: true }))).toBeVisible({ timeout: 10_000 }); + await expect(drawer.getByText(/^Score: \d\.\d{2}$/).filter({ visible: true })).toHaveCount(2, { + timeout: 10_000, + }); await expect(drawer.getByText(RAW_EMAIL)).toHaveCount(0); await expect(drawer.getByText(RAW_PHONE)).toHaveCount(0);