Skip to content

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.

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:

Terminal window
$ pistra audit verify gateway.log
NODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUS
gw-a da3b27d1a322d9c0 7 1..7 genesis true true ok
25 other lines ignored
verified

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

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

A record removed from the middle. The sequence numbers are in the signed hash, so a deletion cannot close over itself:

Terminal window
$ grep -v '"seq":3,' gateway.log > cut.log
$ pistra audit verify cut.log
NODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUS
gw-a da3b27d1a322d9c0 6 1..7 genesis true true 1 findings
25 other lines ignored
gw-a/da3b27d1a322d9c0 seq 3: gap: entries 3..3 are missing

A record altered in place. Editing one record breaks its own hash and its own signature, and says so twice:

Terminal window
$ pistra audit verify tampered.log
NODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUS
gw-a da3b27d1a322d9c0 7 1..7 genesis true false 4 findings
25 other lines ignored
gw-a/da3b27d1a322d9c0 seq 2: hash: record does not hash to the entry's hash: the record or the hash was altered
gw-a/da3b27d1a322d9c0 seq 2: signature: signature does not verify under the chain's public key

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

Terminal window
$ pistra audit verify late.log
NODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUS
gw-a da3b27d1a322d9c0 3 3..5 (not in input) true false 1 findings
25 other lines ignored
gw-a/da3b27d1a322d9c0 seq 3: unverifiable: no public key: the start record is not in the input and none was supplied

That is a refusal to guess rather than a failure. Supply the key and the same trail verifies:

Terminal window
$ pistra audit verify -key da3b27d1a322d9c0=k4sr6BsjBkAWH9PmY+AWhGTV9HKf0u/1xu946HdhvHk= late.log
NODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUS
gw-a da3b27d1a322d9c0 3 3..5 (not in input) true true ok
25 other lines ignored
verified

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

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.

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

Terminal window
$ pistra audit verify -heads heads.json short.log
NODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUS
gw-a da3b27d1a322d9c0 4 1..4 genesis false true 2 findings
25 other lines ignored
gw-a/da3b27d1a322d9c0 seq 5: unwitnessed: witnessed head at 5 is not in the trail; the trail ends at 4
gw-a/da3b27d1a322d9c0 seq 6: unwitnessed: witnessed head at 6 is not in the trail; the trail ends at 4

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

The check is cheap and its exit status is the verdict, so the useful form is a job rather than an investigation:

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

Terminal window
gw-b/aaaaaaaaaaaaaaaa seq 9: unwitnessed: witnessed head at 9, but no entry of this chain is in the trail

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