Threat model disclosure
The document a security review asks for. It restates
the threat model in the shape a vendor
questionnaire expects: trust boundaries, a STRIDE map, the risks left
with you, and the artifacts a scanner can read. The engineering page is
the source. Four regions of this one are lifted or checked against it by
make gen-docs, so the two cannot disagree.
pistra is a self-hosted inline enforcement point. It sits in the request path between your applications and the model providers they call, which puts it in the blast radius of both. Review it as a Tier 1 network proxy that also holds credentials.
Trust boundaries
Section titled “Trust boundaries”Responsibility changes at each one.
A, the internal network. Your applications and agents to the pistra listener. They authenticate with a virtual key pistra minted or a token your identity provider signed. pistra terminates the TLS on this hop when it is the front door, and does not when it runs behind your own gateway.
B, the enforcement engine. Process memory and the local raft store. Policy is evaluated here, detectors run here, and provider credentials are resident here. The raft log is on the node’s disk. It is not encrypted wholesale: named values are sealed individually, which the asset table below states per asset.
C, the egress. pistra to the model providers. pistra makes this connection and owns its TLS. The credential presented on it is the provider key held at boundary B, never the client’s.
D, the admin plane. Operators, your identity provider, and, on Kubernetes, the operator and controller. It is reached on a different listener, authenticated by a different credential, and decided by a different engine from anything at boundary A.
What the binary sends us
Section titled “What the binary sends us”Nothing. There is no licence check, no activation, no usage report and no diagnostic upload. The licence carries no metering code, so there is no build in which one is compiled out.
The binary does make outbound connections you configure, which is why
the paragraph above says no vendor endpoint rather than air-gapped. This
is the whole list: the model providers you route to; your OTLP
collector, when telemetry is exported; huggingface.co, when a models
entry resolves an hf: reference, which pistra models pin turns into
a one-time resolution; a presidio-analyzer you run, when the remote
detector is enabled; a provider’s admin usage API, when metering
reconciliation is on; your identity provider’s JWKS endpoint; and an
ACME directory, when the front door issues its own certificates. Every
one of them is off unless the configuration turns it on.
One destination is a library’s rather than ours. ONNX Runtime 1.29 runs
a telemetry uploader on Linux. guardrails/onnxrt sets the environment
variable that disables it before creating an environment, and binds
DisableTelemetryEvents, so the default in a pistra deployment is off.
It sets rather than forces, because the loader takes whatever
libonnxruntime is on the system and an operator who wants the
telemetry keeps it. The nativecheck image target runs with nothing
set, which is what makes the default checkable.
Assets
Section titled “Assets”What is worth stealing, where it sits, and what holds it.
| asset | where it lives | protection |
|---|---|---|
| provider API keys | config file or env, in memory | never sent downstream; swapped in after all mutations; the client’s own credential is stripped |
| virtual keys | the raft log | stored as SHA-256 only; the token itself is never written anywhere |
| cluster state | raft log, boltdb | ACME private keys sealed with AES-GCM (HKDF-SHA256 from the cluster secret) before they are proposed |
| prompt content | in flight only | see below |
STRIDE
Section titled “STRIDE”Each category names where the answer is written rather than restating it, because the restatement is what goes stale.
| Category | How it is answered | Where it is written |
|---|---|---|
| Spoofing | Keys are 32 bytes of entropy stored as SHA-256 and found by hash. Tokens are verified against the issuer’s JWKS and bound to the audience of the plane they present at. Roles come from an issuer-assigned claim. | impersonation |
| Tampering | Configuration is file-owned and comes from your repository. A catalog overlay can describe an endpoint and cannot invent a capability, a dialect or an action. A configuration that fails to build is rejected whole and the previous snapshot keeps serving. | trust boundaries in configuration |
| Repudiation | Every audit record is a link in a per-node hash chain signed with ed25519. Raft witnesses the head, so a node cannot rewrite its own history alone, and pistra audit verify checks an export offline. |
audit-trail |
| Information disclosure | No log statement, trace attribute or stored column carries a prompt, a completion or a matched span. Detection and rewriting happen in memory on the stream. Two exceptions are named rather than left out. | prompt content is not retained |
| Denial of service | Spend is reserved before a request is forwarded and settled against real usage after. Every CEL condition runs under a per-evaluation cost budget and stops when the client goes away. | what it defends against |
| Elevation of privilege | The admin and data planes use different engines over different inputs and neither reaches the other. Admin decisions default to deny over a vocabulary closed at load. A profile can only narrow what the deployment allows. | privilege escalation |
Residual risk
Section titled “Residual risk”This list is the threat model’s own, copied rather than summarised.
- A determined exfiltrator. Pattern detection finds structured PII in the shapes it knows. It does not find paraphrase, base64, an unusual encoding, or a description of a secret. Treat guardrails as compliance and accident prevention, not as an adversary-resistant filter.
- Prompt injection and jailbreaks. Tier 0 does not cover them at
all. Two detectors do, and both reduce rather than prevent. The
classifydetector, see stopping prompt injection with a classifier, runs a checkpoint trained on the question and has no instructions to exploit. It catches the phrasings it was trained on and false-positives on prose about security. Thellmjudge, see judging with a model, takes a policy in prose, and reads exactly the text an attacker wrote. A model that follows instructions can be told what to answer. Treat either’s verdicts as a filter on the common case, run it in shadow until the false-positive rate has been read, and do not build a control on the assumption that it cannot be talked round. - Anything the gateway never sees. Traffic on the same provider key that does not traverse pistra is ungoverned. Reconciliation exists to detect this, not to prevent it.
- A compromised gateway host. Provider credentials are in memory. A host with an attacker on it is over.
- Whoever can write the configuration. Policy, roles and rules are
file-owned, so the caller who can change that file, or restart the
process against a different one, holds the highest privilege in the
deployment. A built-in super-admin was rejected for that reason
rather than omitted: it would have been a second escape beside one
that cannot be closed. Govern the file the way you govern the cluster,
and read
admin.authzrecords for who changed what. - Whoever holds the cluster secret. The raft transport and the forward listener the nodes use for writes, membership and restores run under mutual TLS, with a certificate authority every node derives from the cluster secret and a per-node certificate under it that is replaced daily. Nothing on the path between the nodes reads or injects there, and a peer without the secret is refused at the handshake. The other side of that: the secret is the whole identity. A holder of it mints a certificate for any node name, joins the cluster, and reads every replicated document that is not sealed.
- Whoever holds the unsealing material. The cluster secret, and any
kek_filewrapping a key, decrypt everything the barrier sealed. They are the deployment’s root secret and no policy in the gateway constrains them. - A stolen Kubernetes credential in the operator’s pod. The operator
and the InferencePool controller are the only components holding one.
The grant is
geton a Credential’s Secret in the namespaces being watched and neverlist, nopatch, nowatchand nodelete, and each writes a source of its own through the admin API. That bounds what the credential reaches rather than removing the exposure. - Model output correctness. Not a security control and not claimed as one.
- Bodies it could not read. Over
max_body_bytes, unparseable, or, on inspected traffic, a response whose content-type is neither JSON nor SSE. All counted inpistra_guardrail_uninspected_total{route,reason}rather than assumed clean. Alert on it. A body that was not examined is not a clean one. - Entities split across frames of an unknown streaming API. On a route the segmenter has no dialect for, the inspector reads each SSE frame on its own rather than assembling them into one logical message. A match spanning two frames there is missed. Assembling them would invent entities spanning two unrelated messages, which is the worse error.
What the deployment owns
Section titled “What the deployment owns”Controls that are real only if you operate them.
- The configuration file. Policy, rules, profiles and roles are file-owned. Whoever can change that file, or restart the process against a different one, holds the highest privilege in the deployment. Review it the way you review cluster manifests.
- The unsealing material. The cluster secret, and any
kek_filewrapping it, decrypt everything the barrier sealed. Rotation is add, roll, drop, and rotating the cluster secret is the procedure. - The identity provider. It is the authority for admin identity and for agent identity, and pistra issues no tokens of its own. Its claims-to-roles mapping is a privilege decision made in your IdP.
- The failure direction, per control. A detector that errors is skipped or refuses the request, and a rule whose condition cannot be evaluated does the same. Both default closed where the failure is reachable from the client side. Neither default is right for every deployment.
- The uninspected counter.
pistra_guardrail_uninspected_totalcounts bodies that were not examined, by reason. A body that was not examined is not a clean one, and nothing alerts on this unless you do. - The audit heads. Verification is offline and needs the witnessed heads. Verifying the audit trail is the procedure, and it is worth running before you need it.
- Re-cutting a release after a scan. The weekly scan files an issue against the newest published image. Clearing it means rebuilding the same source on a current base, which is a decision only you can make.
Vulnerability management
Section titled “Vulnerability management”Five artifacts ship with each release, each answering part of the question. The scope columns say where each one stops.
| Artifact | Tool | What it covers | What it does not |
|---|---|---|---|
| SBOM (SPDX and CycloneDX) | syft |
the released image, catalogued from the image, then the two C++ libraries added from the pins the build verified them against | whether any package listed is exploitable here, which is what the rows below answer |
| VEX (OpenVEX) | govulncheck |
every Go module in the repository, by symbol reachability, so a statement says whether the vulnerable function is called and not merely whether the package is present | everything outside Go: the base image, and the three native libraries |
| Rust advisories | cargo-audit |
the tokenizer shim’s 78 crates against RUSTSEC, weekly; the same graph is embedded in the shipped library, so a scanner reading the image sees it too | nothing in that graph, though an unmaintained crate is reported as a warning and does not fail the run |
| SAST (SARIF) | gosec |
the Go source that ships, uploaded to GitHub code scanning on every push to main | dependencies, tests and the in-repo generators, the Rust shim, and anything reached through dlopen |
| Vulnerability scan | grype |
the newest published image, weekly, against a database that moves after a release is cut; Vectorscan is in scope through its Debian package identity | ONNX Runtime, for the reason below, and reachability: it reads packages and versions |
The image is signed with cosign keyless over its digest, and the SBOM and VEX name that digest, so a consumer can tie all three to the same bytes.
The native tier
Section titled “The native tier”The tokenizer shim is ours, built from guardrails/tokenizer/shim with
cargo-auditable, which writes the crate graph into the shipped library.
syft reads it out of the image and cargo audit reads the same graph
against RUSTSEC weekly.
Vectorscan is Debian’s libvectorscan5, copied out of a build stage.
The version dpkg reported travels into the image with it, and the SBOM
carries a Debian purl, so grype resolves it against the Debian security
tracker.
ONNX Runtime is a release tarball, checked against a SHA-256 the Dockerfile pins. Nothing scans it. NVD holds no CPE for ONNX Runtime and OSV no advisory, so a scanner reading the SBOM matches nothing whatever identifier the component carries, and the component says that in a property instead of implying coverage. The control is a version watch: the weekly workflow compares the pinned version against upstream’s newest release and opens an issue when it falls behind.
Reporting a finding, and what counts as one, is at the end of the threat model. The repository is pre-release and interfaces may change without notice, which includes this document’s own structure.