Guard traffic you don't route
In this tutorial you will put pistra behind an Envoy that already exists and watch it stop a credit card number from reaching a backend. pistra routes nothing, holds no credentials, and does not know what the backend is.
It takes about ten minutes. You need Go, Docker, and curl.
What you are building
Section titled “What you are building”Use the following example:
curl ──▶ Envoy ──▶ backend │ ext_proc ▼ pistra (says yes, no, or "yes but rewritten")Envoy has already decided where the request goes. pistra only sees the request body and answers one question about it.
1. Start a backend to protect
Section titled “1. Start a backend to protect”Anything that shows you what it received will do. Save this as echo.py:
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler): def do_POST(self): n = int(self.headers.get('content-length', 0)) body = self.rfile.read(n) self.send_response(200) self.send_header('content-type', 'application/json') self.send_header('content-length', str(len(body))) self.end_headers() self.wfile.write(body)
HTTPServer(('0.0.0.0', 9099), H).serve_forever()Run it:
$ python3 echo.pyIt echoes back whatever it is sent, so later you will be able to see what the backend read.
2. Configure the inspector
Section titled “2. Configure the inspector”The node file names the listener and the policy document. Save this
as pistra.yaml:
extproc: inspect_listen: "0.0.0.0:19002"deployment: guardrails.yamlThe policy is a deployment document of its own. Save this as
guardrails.yaml beside it:
guardrails: detectors: - type: pii entities: [CREDIT_CARD, US_SSN] rules: - name: block-cards when: '"CREDIT_CARD" in entities' action: deny message: card numbers must not leave the network - name: mask-the-rest action: redact operator: mask mask_chars: 4There are no providers, no models and no keys in either file. That is the point of the role, and it is a valid configuration.
Build and run it:
$ go build -o pistra ./cmd/pistra$ ./pistra -config pistra.yamlYou should see the inspector come up:
INFO extproc inspector listening addr=0.0.0.0:190023. Point Envoy at it
Section titled “3. Point Envoy at it”Save this as envoy.yaml:
static_resources: listeners: - name: main address: {socket_address: {address: 0.0.0.0, port_value: 10000}} filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress route_config: name: local virtual_hosts: - name: all domains: ["*"] routes: - match: {prefix: "/"} route: {cluster: backend} http_filters: - name: envoy.filters.http.ext_proc typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor grpc_service: envoy_grpc: {cluster_name: pistra_inspect} processing_mode: request_header_mode: SEND request_body_mode: STREAMED response_header_mode: SKIP allow_mode_override: true # Applies to every held chunk in STREAMED. The # mode without it is FULL_DUPLEX_STREAMED; see # extproc.request_body_mode in the reference. message_timeout: 5s - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: pistra_inspect type: LOGICAL_DNS connect_timeout: 5s typed_extension_protocol_options: envoy.extensions.upstreams.http.v3.HttpProtocolOptions: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: {http2_protocol_options: {}} load_assignment: cluster_name: pistra_inspect endpoints: - lb_endpoints: - endpoint: address: socket_address: {address: host.docker.internal, port_value: 19002} - name: backend type: LOGICAL_DNS connect_timeout: 5s load_assignment: cluster_name: backend endpoints: - lb_endpoints: - endpoint: address: socket_address: {address: host.docker.internal, port_value: 9099}admin: address: {socket_address: {address: 0.0.0.0, port_value: 9901}}Two settings in there matter and are easy to get wrong. http2_protocol_options
on the pistra_inspect cluster is required because ext_proc is a gRPC
service. allow_mode_override: true is required because the inspector
asks for the request body itself, once it knows there is one. Without
it, Envoy ignores the request and no body ever arrives.
Run Envoy:
$ docker run --rm -p 10000:10000 -p 9901:9901 \ -v "$PWD/envoy.yaml:/etc/envoy/envoy.yaml:ro" \ envoyproxy/envoy:v1.33-latest -c /etc/envoy/envoy.yaml --log-level warn4. Send a clean request
Section titled “4. Send a clean request”Run this command:
$ curl -s localhost:10000/api/tickets \ -H 'content-type: application/json' \ -d '{"note":"please refund my order","tags":["billing"]}'The backend echoes it back exactly as sent:
{"note":"please refund my order","tags":["billing"]}Nothing was rewritten. Traffic with nothing to find in it passes through byte for byte.
5. Send a card number
Section titled “5. Send a card number”Run this command:
$ curl -s localhost:10000/api/tickets \ -H 'content-type: application/json' \ -d '{"note":"charge 4012888888881881 today","tags":["billing"]}'This time the backend never sees the request:
{"error":{"code":"guardrail_denied","message":"card numbers must not leave the network","type":"pistra_error"}}The refusal carries the message you wrote, and pistra logs which rule acted:
INFO request denied by guardrail rule=block-cards action=deny route=generic entities=map[pii/CREDIT_CARD:1]6. Send something that gets rewritten instead
Section titled “6. Send something that gets rewritten instead”The block-cards rule only matches cards. An SSN falls through to
mask-the-rest:
$ curl -s localhost:10000/api/tickets \ -H 'content-type: application/json' \ -d '{"note":"my ssn is 456-78-9012 ok","tags":["billing"]}'The response looks like this:
{"note":"my ssn is ****78-9012 ok","tags":["billing"]}Remember that the backend echoes what it received, so this shows what
the backend read. The SSN was masked before it arrived, tags came
through untouched, and only the matched span moved.
If you try 123-45-6789 instead, nothing is masked. That is not a bug.
It is a known-invalid SSN, and the recognizer rejects it exactly as
Presidio’s does.
7. Look at a header and a query string
Section titled “7. Look at a header and a query string”The body is not the only place a credential travels. Add a detector that reads the request envelope, and a rule that acts on what it finds:
guardrails: detectors: - type: pii entities: [CREDIT_CARD, US_SSN] - type: pii name: envelope entities: [CREDIT_CARD, US_SSN] apply_to: [header, query] rules: # ... the rules above, plus: - name: no-pii-in-the-url-or-headers when: 'annotations.exists(a, a.kind == "header" || a.kind == "query")' action: deny message: personal data must not travel in a URL or a headerTwo things here are deliberate and worth knowing before you deploy this.
apply_to: [header, query] is required. Leaving apply_to blank scans
the body kinds and not the envelope. Scanning the envelope means
running every detector, including any remote: one, over roughly
thirty values per request, and one of them is usually
authorization: Bearer …. A configuration that starts sending
credentials to an analyzer because a list was left blank would be the
worst possible default, so the list has to say so.
The rule is a deny, not a redact, and no spelling of a redact rule
will reach these findings. Header and query segments are read-only. A
header is not part of the body, so there is nothing for a rewrite to
land in. Rewriting a query string means rewriting the request’s path,
which is a routing decision, and the inspector role exists in order not
to make one. A rule that says so out loud, kinds: [header] on a
redact rule, is refused when the config loads, with that reason. Every
other way of asking finds nothing.
Now a request with no body at all can still be refused:
$ curl -s 'localhost:10000/api/lookup?ssn=456-78-9012'The response looks like this:
{"error":{"code":"guardrail_denied","message":"personal data must not travel in a URL or a header","type":"pistra_error"}}There was no body to inspect. The query string was the whole request.
What you did
Section titled “What you did”You applied policy to somebody else’s traffic without becoming its gateway. pistra resolved no provider, reserved no budget, set no routing header and held no credentials. The one connection Envoy makes to it cannot produce a routing decision, because the inspector is a separate listener with no code path to one.
- Govern your first provider, the other half, where pistra does route.
- Redact PII from model output,
the same detectors on responses, including on inspected traffic once
extproc.inspect_responsesis set. - Threat model, what this does and does not defend against.
The e2e test TestInspectorGuardsTrafficItDoesNotRoute runs everything
above on every make e2e, so this tutorial cannot rot silently.