feat(audit): off-box chain-head export, verify-heads, maintenance login (ScadaBridge#107)

Item 1 (owner decision: no signing). AuditLog:HashChain:HeadExport (default
off): both centrals periodically read every month's chain head (LastSeq,
LastHash, purge anchor, chained rows, tombstones; one snapshot under the
chain lock SHARED) and append the changed heads to a sink the DB login cannot
write - File (append-only JSONL per node per day, local or UNC) or Syslog
(RFC 5424 over UDP / TCP octet counting). `audit verify-heads --path` reads
those records back (file or syslog-collector lines) and the new
VerifyAuditChainHeads command checks each against the database: Matches /
Extended / Purged, or Mismatch / Regressed / MissingHead / MissingLink /
CountMismatch (exit 3). AuditChainHeadStoreTests forges a chain with the
production hasher: verify-chain says Intact, verify-heads says Mismatch.

Item 3. AuditLog:MaintenanceConnectionString (${secret:}-capable, unset =
today's single login) runs the chain job, chain verify / head export, the
partition switch-out + archive and the per-channel purge on
AuditMaintenanceContext (keyed audit-maintenance repository/archive, purge
actor prefers them). AuditMaintenanceLoginTests runs the hardened setup on
SQL Server: writer (datareader/datawriter/audit_writer) ingests and is
refused chaining and deleting; the chainer+purger login does the full cycle.
Two-login grants documented in Component-AuditLog.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 23:16:05 -05:00
parent 5e7d5389d4
commit c4d7ec9b96
40 changed files with 2769 additions and 43 deletions
+35
View File
@@ -2071,6 +2071,41 @@ end, `DuplicateSequence`, `IncompleteLink`). `--format table` prints a summary;
The chain only exists once `AuditLog:HashChain:Enabled` is on for the central nodes
(default off); see `Component-AuditLog.md`, Security & Tamper-Evidence.
#### `audit verify-heads`
Check AuditLog chain heads that were exported off-box (ScadaBridge#107) against the
database chain.
```sh
scadabridge --url <url> audit verify-heads --path <file-or-directory>
```
| Option | Required | Default | Description |
|--------|----------|---------|-------------|
| `--path` | yes | — | An export file, or a directory read recursively for `*.jsonl` / `*.log` / `*.txt` |
The central nodes write one JSON record per month head (`month`, `lastSeq`, `lastHash`,
purge anchor, chained-row and tombstone counts, `exportedAtUtc`, `node`) when
`AuditLog:HashChain:HeadExport:Enabled` is on — to append-only files
(`{Directory}/{yyyy}/audit-chain-heads-{yyyy-MM-dd}-{node}.jsonl`) or to syslog (RFC 5424, the
JSON record as the message). The CLI reads both forms (the JSON object is taken from each
line's first `{` to its last `}`, so a syslog collector's prefixes do not matter), keeps one
record per distinct (month, sequence, hash), and sends `VerifyAuditChainHeads`. Per record the
server reports `Matches`, `Extended`, `Purged` (the link was removed by a partition purge —
check the month's archive), or a failure: `Mismatch` (the chain was rewritten and re-hashed),
`Regressed` (head rolled back), `MissingHead`, `MissingLink` (a chained row deleted outside the
purge paths), `CountMismatch`.
| Exit code | Meaning |
|-----------|---------|
| `0` | Every exported head is consistent with the database |
| `3` | At least one failure — tamper evidence |
| `1` | No records found, bad input, `CHAIN_BUSY`, server error |
| `2` | Authorization failure (needs `Administrator` or `Viewer`, not site-scoped) |
Run it with `audit verify-chain`: `verify-chain` recomputes each month's links;
`verify-heads` proves the database still holds the heads that left the box.
#### `audit archive list` / `audit archive verify`
List and verify the AuditLog Parquet cold archives (ScadaBridge#42) that the retention