Skip to content

Exempt placeholders from a credential rule

A credential recognizer is carried by its prefix. ghp_ followed by thirty-six characters is a GitHub token, and the recognizer cannot tell whether those thirty-six characters came from GitHub or from the .env.example in your repository. A coding assistant carries more of that text than of any real key.

So this happens:

guardrails:
rules:
- name: no credentials to the model
when: 'annotations.exists(a, a.category == "credential")'
action: deny
message: "that looks like an API key"

and a developer asking about their own example configuration gets a 403. The rule is right, the annotation is right, and the request should have gone through anyway.

There are two places to say so, and they are not equivalent.

Narrow the condition. The annotation stays, so the audit trail still records that something key-shaped was in the request, and the exemption applies to this one rule rather than to the whole engine.

guardrails:
rules:
- name: no credentials to the model
when: >-
annotations.exists(a, a.category == "credential" &&
!a.text.matches("(x{8,}|X{8,}|0{8,})"))
action: deny
message: "that looks like an API key"

a.text is the matched span. Everything CEL can do to a string is available, so the exemption can be as narrow as the thing you are actually exempting:

what you are exempting condition to add
filler runs `!a.text.matches(“(x{8,}
one known example key a.text != "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
a vendor’s documented value !a.text.endsWith("EXAMPLE")
anything in your fixtures directory a.ref != "messages[0].content", or better, use kinds:

Test it before it ships:

Terminal window
$ pistra -config pistra.yaml -test-rules pistra.tests.yaml

That is the cheap way to find out that the exemption you wrote also exempts something you meant to deny. See Test your rules before they ship.

allow_list is the detector-layer answer. It removes the finding before any rule sees it:

guardrails:
allow_list:
- '^ghp_x+$'
- '^AKIA[X0]+$'
allow_list_match: regex
detectors:
- type: pii

Reach for this when the value is genuinely not a secret anywhere in the deployment, your own demo card number, the support mailbox, the fixed example key in your onboarding docs. It is the blunter instrument. The finding is gone for every rule, and gone from the evidence.

allow_list_match: regex is Presidio’s reading, and Presidio’s reading is wider than it looks. Entries are joined into one alternation, matched anywhere inside the span, case-insensitively. So this:

allow_list: ['x{8,}'] # do not do this
allow_list_match: regex

does not allow “the placeholder”. It allows every span containing eight x’s in a row, including a real token that happens to contain one, and including entity types that have nothing to do with credentials:

allow_list: [x{8,}]
ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx suppressed (intended)
ghp_xxxxxxxxxxURq30rOdYdoJ47aDAZYtitYX suppressed (a real token)
xxxxxxxxxx@example.com suppressed (an address)

^ghp_x+$ says what you meant. The default is exact and case-sensitive for this reason. Widening this list is not a convenience, it is a hole. It is a quiet one, because a suppressed finding leaves nothing behind to notice.

A placeholder that was generated rather than typed is indistinguishable from a key. It is not hard to distinguish but indistinguishable, because it was produced the same way:

AIzaSyAnLA7NfeLquW1tJFpx_eQCxoX-oo6YyIs
AIzaSyCkEhVjf3pduRDt6d1yKOMitrUEke8agEM

Those are two of sixteen fake Google keys that circulate widely enough that secret scanners carry them by name. No statistic separates them from a real key, and pistra does not try. There is no entropy floor and no token-efficiency check, because both eventually drop a real credential. A dropped credential is silent where a wrongly denied request is not.

If values like these appear in your traffic, list them. That is what everyone else does with them too.

PISTRA_KEY declares a checksum, so pistra’s own virtual keys are the one format where a placeholder is refused rather than reported. pistra_ followed by forty-nine A’s is not annotated at all. Where a vendor publishes a checksum precisely enough to reimplement, a row can do the same. Most do not, and a guess is worse than nothing. GitHub documents “a 32 bit checksum in the last 6 digits” without saying what it is computed over, so a validator built on that would drop every real token of that format and say nothing. The credential coverage page says, per row, which ones carry a check and why the rest do not.