Skip to content

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.

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.

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:

Terminal window
$ python3 echo.py

It echoes back whatever it is sent, so later you will be able to see what the backend read.

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.yaml

The 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: 4

There 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:

Terminal window
$ go build -o pistra ./cmd/pistra
$ ./pistra -config pistra.yaml

You should see the inspector come up:

INFO extproc inspector listening addr=0.0.0.0:19002

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:

Terminal window
$ 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 warn

Run this command:

Terminal window
$ 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.

Run this command:

Terminal window
$ 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:

Terminal window
$ 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.

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 header

Two 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:

Terminal window
$ 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.

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.

The e2e test TestInspectorGuardsTrafficItDoesNotRoute runs everything above on every make e2e, so this tutorial cannot rot silently.