Compliance evidence report
vanzik v2.0updated view on github
vanzik report turns the audit log into a report you can hand
to an auditor: what Vanzik decided over a period, whether the log that
recorded it was changed, and which SOC 2 criteria that evidence bears on.
The report is supporting evidence. It is not a SOC 2 report, a certification or an audit opinion. It shows what the audit log recorded. An auditor decides what that proves and whether it is enough.
Generate it#
From the audit log files (the default). This also verifies the seal chain,
as vanzik audit verify does. Without --key-file, a log sealed with
a key still gets every check but its hashes (every entry after the seal is
sealed, seq has no gaps, each prev_hash links up), and the report says
the hashes were not checked:
vanzik report --since 2026-09-01 --until 2026-10-01 \
--audit-log /var/lib/vanzik/audit.jsonl \
--key-file /var/lib/vanzik/audit-seal.key \
--policy /etc/vanzik/default.yaml \
--output html --out-file september.html
From a running server, when you can't read its files:
vanzik report --url https://guard.internal --api-key "$KEY" \
--since 7d --output json > week.json
Without --since and --until the report covers the 24 hours before now.
--since is inclusive and --until exclusive, so back-to-back reports
(--since 2026-09-01 --until 2026-10-01, then --since 2026-10-01 …)
never count an entry twice or miss one. Times are RFC 3339, a date
(midnight UTC), or a duration back in time: 90m, 24h, 7d (d is 24
hours; Go durations have no day unit, so the report adds it). All flags:
CLI.md § vanzik report.
Formats: md (Markdown, the default), json (the stable shape below) and
html (one page with inline CSS and no scripts, so it can be mailed or
archived as is). Every value taken from the log is escaped in Markdown and
HTML, so an agent id can't add markup to the report, and a URL or an
address in it does not become a link. In both, control characters (but
tab and newline) and the bidi controls that reorder text are replaced
with U+FFFD, so a value can't move a terminal's cursor or read as
something else. JSON keeps every value exactly as logged; its encoder
escapes the C0 controls.
Exit status: 0 written; 1 written, but the audit log failed
verification; 2 command-line mistake; 3 the log, a key file, the
policy file or the server can't be read.
Files or server#
Files (--audit-log) |
Server (--url) |
|
|---|---|---|
| Entries read | The live file and every rotated archive linked to it (gzip or plain) | GET /v1/audit, oldest first, paged. A server with the file backend answers from its live file only, not its archives |
| Tenants | All of them | One: local, or --tenant-id |
| Seal chain | Verified (--key-file for a log sealed with a key) |
Not verified. The head comes from GET /v1/audit/head |
| Unreadable lines | Counted | Unknown (the server skips them) |
| API key check | Unknown | Checked: the report asks for one entry without the key, without following redirects. 401 or 403 means required; a JSON list of entries means open; anything else is unknown |
| Policy file | --policy, else the file vanzik server would load |
--policy only |
The server is asked for the period with since and until, which
GET /v1/audit takes from 1.4 (they came with the sessions work). A server
that predates them ignores them; the report drops entries outside the
period itself, so the numbers are the same, and source.outside_period
says how many were dropped. A server older than 1.4 also has no
GET /v1/audit/head, so the seal status is then "unknown".
The report pages by time, not by offset: each page starts a moment before the last entry of the one before, and an entry seen twice is counted once. An offset into the server's live file stops meaning anything when the file rotates. Rotation still loses the entries left in the old file, which the server no longer serves; on a sealed server the report notices (the seal head names a new file afterwards) and adds a warning to the report. The report stops with exit 3 if a server sends the same page over and over, or after 50,000 pages, 10 million entries or 30 minutes: report on that much from the files.
--url must not hold a user name or password (http://user:pass@host):
it would be written into the report. Give the key with --api-key.
What the report holds#
Summary. The period, when the report was made, the Vanzik version, where the entries came from, and the policy file with its SHA-256.
Audit log integrity.
intact,broken,not_sealed,not_verified(the log says it is sealed, but the hashes were not checked: server mode, or no--key-file) orunknown. With the seal type (HMAC key or plain SHA-256), the latest seal anchor (seq and hash), the files read, and audit verify's warnings.Activity. Entries by decision (ALLOW, DENY and REQUIRE_APPROVAL are always listed), scope, transport, tenant and agent. Entries written without a transport count as
sdk, and without a tenant aslocal, as everywhere else.Top rules. The rules behind DENY and REQUIRE_APPROVAL decisions, by count. A DENY that no rule matched shows as
(default deny).Approvals. Approval ids sent for approval in the period, approved by a person, denied by a person, and with no outcome on record (see Limits). "Timed out" is always unknown.
Agents and sessions. How many were seen, and their ids (the first 100 by name).
Posture checks. Each is
pass,warn,failorunknown:Check How it is decided Audit log sealed Passes only when the chain verified. Fails when it is broken. A seal the report could not verify is a claim: a _metaheader anyone who can write the file can forge (warn, files read without--key-file), or the server's word (unknown)Seal uses a secret key HMAC-SHA256 passes only when it verified with --key-file; read from an unverified header it is unknown. Plain SHA-256 warns (anyone who can write the file can recompute it)Seal chain verified audit verify's result; unknown in server mode Entries outside the seal chain Files: entries written before sealing began, or fail when the chain breaks (nothing after the break is protected). Server: entries in the period that came without seqorhashUnreadable lines Lines of the files that are not entries, or files that end early Secrets masked in the log Fails when a request or reason still matches the built-in secret patterns of --audit-redact. Otherwise unknown: masked values ([REDACTED]) with no unmasked secret are consistent with masking being on, but not proof, since an agent's own text can contain the marker; with neither, the log can't show itAudit API requires an API key Server mode: whether GET /v1/auditanswers without the keyControl mapping. See below.
Limits. What the report can't show, in the report itself.
Control mapping#
The report maps its evidence to five SOC 2 Trust Services Criteria. Each row says what the report shows and in which sections. It never says a criterion is met.
| Criterion | What the report shows | Sections |
|---|---|---|
| CC6.1 Logical access to protected assets is restricted | Agent actions were checked against a named policy (SHA-256); how many were allowed and denied, by scope and agent | Summary, Activity, Top rules, Posture checks |
| CC6.6 Access from outside the system boundary is controlled | Network, browser, MCP tool and LLM tool-call actions went through Vanzik; what was let through and blocked, by scope and transport | Activity, Top rules |
| CC7.2 System components are monitored for anomalies | Every decision is in the audit log; whether the log is sealed and intact; unreadable lines; unmasked secrets | Audit log integrity, Activity, Posture checks |
| CC7.3 Security events are evaluated | Actions sent to a person for approval and the decisions on record; the rules that stopped risky actions | Approvals, Top rules |
| CC8.1 Changes are authorized | The policy file's SHA-256, to match against an approved change; actions the policy sends for approval (deploys, writes) needed a person's yes | Summary, Approvals |
Limits#
- The report counts what the audit log recorded. An action that never reached Vanzik (an agent not wired to it, a tool outside the gateway) is not in it.
- The audit log has an entry when an action is sent for approval, one
when a person approves or denies it (
matched_rule: approval:resolved:<via>, with how the call was authenticated and the caller's address, not a named person), and another when the agent comes back with the approval id. It does not record a request that timed out. The report's Approved and Denied counts come from the entries written when the agent comes back, so "No outcome on record" covers requests that timed out, were decided but not retried, were decided after the period, or are still pending. The resolution entries count in the decision totals, and a deny's rule shows among the deny rules. - The policy SHA-256 is of the file when the report is made. The log does not record which version of the policy made each decision; keep the policy in version control and match the hash to a commit.
- The secret check uses the built-in patterns only, not a policy's
notifications.redaction.extra_patterns. - Reading files: archives that no file links to, and archives already
pruned (
--audit-max-backups,--audit-max-age-days), are not read. Entries a running server still holds in its buffer or overflow file are not in the log yet. Keep archives for as long as your audit period needs. - Server mode: the chain is not verified, rotated archives of a file backend are not included, and only one tenant is covered.
- A passing verify shows the log was not changed by anyone without the
key; it does not rule out the newest entries being cut off. Check
published anchors with
vanzik audit verify --anchorfor that (OPERATIONS § Sealing and anchoring).
JSON shape#
--output json prints one object. Its shape is stable for 2.x: fields are
only added, never renamed or removed. format_version (now 1) changes
only if that ever stops being true. Times are RFC 3339 UTC.
| Field | Meaning |
|---|---|
format_version |
1 |
generated_at |
When the report was made |
vanzik_version |
e.g. "2.0.0 (abc1234)" |
period.since, period.until |
The period; since inclusive, until exclusive |
source.mode |
"file" or "server" |
source.audit_log, source.files, source.bad_lines, source.read_errors |
Files mode: the live file, the files read (oldest first), lines that are not entries, files that end early |
source.server, source.tenant, source.fetched, source.outside_period, source.auth_required |
Server mode: the URL, the tenant, different entries fetched, entries dropped as outside the period, whether the audit API needs the key (absent when unknown) |
source.warnings |
Why the entries may be incomplete, e.g. the server's live file rotated while the report read it |
policy |
{path, sha256, found} or null. found is --policy, VANZIK_POLICY, the default or found automatically |
integrity.method |
"verify" or "seal_head" |
integrity.status |
intact, broken, not_sealed, not_verified or unknown |
integrity.summary |
One sentence |
integrity.seals, integrity.key_based |
How the entries are sealed, as the log's _meta headers say; key_based is null when unknown. Confirmed only when status is intact |
integrity.head |
{seq, hash, segment_id?, at?}: the latest seal anchor |
integrity.verify |
audit verify's full report, as audit verify --output json prints it (files mode) |
activity.entries, activity.first_entry, activity.last_entry |
Entries in the period and their time span |
activity.decisions |
[{name, count}], ALLOW, DENY, REQUIRE_APPROVAL first |
activity.scopes, .transports, .tenants, .agents, .deny_rules, .approval_rules |
{distinct, top: [{name, count}]}: how many values, and the most frequent (all scopes and transports, the top 10 of the others) |
activity.agents_seen, activity.sessions_seen |
{count, names, truncated}: the first 100 ids by name |
activity.redacted_entries, activity.unmasked_secret_entries |
Entries with masked secrets, and with text that looks like an unmasked one |
activity.unsealed_entries |
Entries in the period without seq or hash |
approvals |
{requested, approved, denied, no_outcome, timed_out}; timed_out is always null |
posture |
[{name, status, detail}] |
controls |
[{criterion, title, shows, where}] |
limits |
[string] |
The renderers and the numbers live in pkg/report, with golden files in
pkg/report/testdata/ for each format.