Verify the audit trail
An audit trail is worth something only if its gaps are visible.
pistra audit verify reads an exported trail and says, per chain,
whether every record hashes to its entry, links to the one before it,
is signed by the key the chain started with, and whether any sequence
number is missing.
It is an offline command. It needs the trail and nothing else, no gateway, no cluster, no credential, so it runs in CI, on a laptop, or inside whatever holds your logs.
Verify what a node wrote
Section titled “Verify what a node wrote”Every audit record goes to the node’s own output as one JSON line whether or not a collector is configured, so the ordinary log is a trail:
$ pistra audit verify gateway.logNODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUSgw-a da3b27d1a322d9c0 7 1..7 genesis true true ok25 other lines ignoredverifiedExit status is 0 when every chain verified and 1 otherwise, so this is
a check you can gate on. Operational log lines interleaved on the same
stream are ignored and counted, so - works:
$ kubectl logs pistra-0 | pistra audit verify -Each column is a question the trail has to be able to answer:
| Column | |
|---|---|
NODE / KID |
which chain. A node signs with one key; KID is its fingerprint, and the two together name a chain, so a node that restarted without a key to keep starts a second one rather than colliding with its own past |
ENTRIES / SEQ |
how many entries were read, and the range they covered. Fewer entries than the range spans is a gap, and it is reported as one |
ORIGIN |
how the chain began: genesis for a chain this node will keep, resumed for one continued from disk, ephemeral for a node with nowhere to keep a key, and (not in input) when the export starts after the node did |
CLOSED |
whether the last entry is a close record. false means the node was still running, or stopped without saying so |
SIGNED |
whether a public key was available and every signature held |
What a finding looks like
Section titled “What a finding looks like”A record removed from the middle. The sequence numbers are in the signed hash, so a deletion cannot close over itself:
$ grep -v '"seq":3,' gateway.log > cut.log$ pistra audit verify cut.logNODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUSgw-a da3b27d1a322d9c0 6 1..7 genesis true true 1 findings25 other lines ignoredgw-a/da3b27d1a322d9c0 seq 3: gap: entries 3..3 are missingA record altered in place. Editing one record breaks its own hash and its own signature, and says so twice:
$ pistra audit verify tampered.logNODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUSgw-a da3b27d1a322d9c0 7 1..7 genesis true false 4 findings25 other lines ignoredgw-a/da3b27d1a322d9c0 seq 2: hash: record does not hash to the entry's hash: the record or the hash was alteredgw-a/da3b27d1a322d9c0 seq 2: signature: signature does not verify under the chain's public keyThe finding kinds are a closed set: malformed, hash, link, gap,
fork, signature, unverifiable, unwitnessed. fork is the one
worth knowing by name, two different entries at one sequence number,
which means the node was rewound to an older head, or an entry was
forged.
An export that begins after the node did. The public key travels in the chain’s start record, so a trail that does not include it cannot be checked against anything:
$ pistra audit verify late.logNODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUSgw-a da3b27d1a322d9c0 3 3..5 (not in input) true false 1 findings25 other lines ignoredgw-a/da3b27d1a322d9c0 seq 3: unverifiable: no public key: the start record is not in the input and none was suppliedThat is a refusal to guess rather than a failure. Supply the key and the same trail verifies:
$ pistra audit verify -key da3b27d1a322d9c0=k4sr6BsjBkAWH9PmY+AWhGTV9HKf0u/1xu946HdhvHk= late.logNODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUSgw-a da3b27d1a322d9c0 3 3..5 (not in input) true true ok25 other lines ignoredverified-key is checked before it is used. A key whose fingerprint is not the
KID you named is refused, so you cannot verify a chain against the
wrong key by typo.
The one thing a chain cannot show
Section titled “The one thing a chain cannot show”A chain proves nothing about its own end. Delete the last hundred entries and the remainder is a shorter, valid chain, every hash links, every signature holds. Truncation is the deletion that looks like nothing happened, and it is the one an attacker with write access to a log file would reach for first.
Witnessed heads cover that. Every node offers its signed head to the cluster about once a minute, and once more after its close record. The cluster checks the signature and keeps the last day of them per node in its replicated state, where log compaction cannot reach them.
$ pistra admin -socket /run/pistra/admin.sock /admin/v1/audit/heads | jq '.heads' > heads.json$ cat heads.json[ { "node": "gw-a", "kid": "da3b27d1a322d9c0", "seq": 1, "hash": "47dbae993c99d246b596b2ddf45cc998818d1e23ce3034403707e1ef0a0af509", "sig": "+dF1EhFOddPKdYEMhIJFXcnyrub8Vz76CFi0XBt2kuq5w+y+u4C4Cn7c08XCPS9EV+7GLk1/eb2fdswVLUrwBw==", "public_key": "k4sr6BsjBkAWH9PmY+AWhGTV9HKf0u/1xu946HdhvHk=", "at": "2026-09-01T08:16:59.633519Z" }, { "node": "gw-a", "kid": "da3b27d1a322d9c0", "seq": 5, "hash": "fdb3d3d81b45d565c6ab3dc384c07b221c7eaab3ac27573c024bc38e74344f2a", "sig": "jtFSQgoIkx0NGKbtj0aK+qD5p0uBmlO4GSaItWGxVukl5rs1thEe39kjWFIEkKWwmprL44bK1ljDnN8YSZDjCg==", "public_key": "k4sr6BsjBkAWH9PmY+AWhGTV9HKf0u/1xu946HdhvHk=", "at": "2026-09-01T08:17:00.888537Z" }]Note the jq '.heads', the API answers with an object and -heads
wants the bare array. Several heads per node is normal: one from when
the node came up, one from after its close record, and the minutely
ones in between, oldest first. A trail that stops short of any of them
is a finding rather than a clean bill:
$ pistra audit verify -heads heads.json short.logNODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUSgw-a da3b27d1a322d9c0 4 1..4 genesis false true 2 findings25 other lines ignoredgw-a/da3b27d1a322d9c0 seq 5: unwitnessed: witnessed head at 5 is not in the trail; the trail ends at 4gw-a/da3b27d1a322d9c0 seq 6: unwitnessed: witnessed head at 6 is not in the trail; the trail ends at 4They survive the node that made them. The heads above were read back after a restart, from the cluster’s replicated state rather than from anything the node still held.
-heads also accepts a head an operator wrote down. Only node,
kid, seq and hash are read. The cluster checked sig and
public_key before vouching for a head, and they are not re-checked
here.
A cluster of one witnesses itself, which proves nothing on its own. The witness has to be something else, the collector that received the records, or a copy taken off the host.
Do it on a schedule
Section titled “Do it on a schedule”The check is cheap and its exit status is the verdict, so the useful form is a job rather than an investigation:
$ for pod in $(kubectl get pods -l app.kubernetes.io/name=pistra -o name); do kubectl logs "$pod" --since=25h done | pistra audit verify -heads heads.json -Verify every node in one invocation, not one per pod. The heads file covers the whole cluster, and a head names a chain that has to be somewhere in the input. Piping one pod’s log against a cluster-wide heads file reports every other node as missing:
gw-b/aaaaaaaaaaaaaaaa seq 9: unwitnessed: witnessed head at 9, but no entry of this chain is in the trailThat is the correct answer to the question that was asked, and not the question you meant. Feed it every node’s trail at once and each chain is matched to its own heads.
Two things make this worth running when nothing is wrong. A finding is evidence of an incident that has already happened, so the gap between it occurring and being seen is the gap you are choosing. And a trail nobody has ever verified is one whose first verification will be during an incident. That is the worst time to discover that your export was missing the start records.
Alert on pistra_audit_export_failures_total alongside it, the one
Prometheus series to alert on at any value above zero, because each
count is a record the collector never received, whose only remaining
copy is the node’s own output. See
watch the gateway and the
metrics reference.
See also
Section titled “See also”- The audit trail, why delivery is synchronous, why a gap here is evidence where a gap in a log is noise, and what is deliberately not recorded
- Audit trail reference, every event, every field of a record, and the attributes a collector filters on
- Ship the audit trail to your SIEM, getting the trail off the node, and verifying what the SIEM holds
- Back up and restore the cluster, the witnessed heads are in the snapshot too