Security
Audit logging
The JSON Lines audit event, the SHA-256 hash chain that makes it tamper-evident, the file and stderr providers, rotation, durability modes, and how to verify a log including across rotated files.
Secronyx records every tool invocation and every HTTP authentication decision as one JSON object per line, chained by SHA-256 so that a modified, inserted or deleted line is detectable. Audit logging is on by default. If the configured file cannot be opened, events go to stderr instead of nowhere. This page documents the event, the chain, the providers, rotation and verification as implemented in internal/audit.
Defaults and flags
audit.DefaultConfig() is what the command line starts from:
| Flag | Default | Effect |
|---|---|---|
--audit |
true |
Enable audit logging. |
--no-audit |
false |
Disable it. Logged as WARNING: audit logging is DISABLED. Ignored when --audit-verify is present, because verification needs the provider. |
--audit-output |
/var/log/secronyx/audit.jsonl |
Log file path. The directory is created with mode 0750 and the file with 0640. |
--audit-buffer-size |
100 |
Events held in an in-memory channel before the async writer takes them. 0 removes the channel (see durability below). |
--audit-flush-interval |
5s |
How often the async writer flushes to the file. |
--audit-max-file-size |
104857600 (100 MiB, in bytes) |
Size at which the file is rotated. 0 disables rotation. |
--audit-max-files |
10 |
Rotated files to keep. 0 or below disables cleanup. |
--audit-sync-write |
false |
Flush and fsync after every event. |
--audit-verify |
false |
Verify the file named by --audit-output and exit. |
The hash chain is always on from the command line (IncludeHash is forced to true), and so is the stderr fallback. On start-up the server logs which provider is receiving events:
Audit logging enabled: provider=default output=/var/log/secronyx/audit.jsonl
If the file cannot be opened the log shows the fallback and continues:
WARNING: audit provider "default" could not open "/var/log/secronyx/audit.jsonl" (failed to create audit directory: mkdir /var/log/secronyx: permission denied); audit events are being written to stderr instead
Audit logging enabled: provider=stderr output=/var/log/secronyx/audit.jsonl
On Windows set --audit-output explicitly; the Unix default is not a sensible location there. See Windows service.
Event shape
Every line is one audit.Event:
type Event struct {
Timestamp time.Time `json:"timestamp"`
Sequence uint64 `json:"seq"`
EventID string `json:"event_id"`
CorrelationID string `json:"correlation_id,omitempty"`
Action string `json:"action"`
Resource string `json:"resource,omitempty"`
Identity string `json:"identity,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
Parameters map[string]interface{} `json:"params,omitempty"`
Result EventResult `json:"result"`
Error string `json:"error,omitempty"`
Duration time.Duration `json:"duration_ns,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
PreviousHash string `json:"prev_hash,omitempty"`
Hash string `json:"hash,omitempty"`
}| Field | Content |
|---|---|
timestamp |
RFC 3339 with nanoseconds, UTC, set when the provider accepts the event. |
seq |
Process-wide counter, incremented per event. On opening an existing file the provider reads the last line and resumes from its seq, so numbering continues across restarts. |
event_id |
Random UUID. |
action |
tools/call for tool invocations; auth/token_validation for HTTP authentication. |
resource |
The tool name for tools/call. |
identity |
The authenticated subject: the API key's name, the JWT sub, the SSH principal, the mapped mTLS identity's name (the certificate subject when no mapping matches and default scopes are configured), or bearer-token for the static token. Absent on stdio. |
client_ip |
The TCP peer address, or, when --trust-proxy-headers is set, the first X-Forwarded-For entry, falling back to X-Real-IP when that header is absent. Absent on stdio. |
params |
The tool call's arguments object, verbatim. Redaction is not applied to audit parameters, so a path or target a caller supplies appears as sent. |
result |
success, error or denied. |
error |
For tools/call: tool not found, scope denied, result_too_large, or the handler's error message. |
duration_ns |
Handler wall time in nanoseconds; omitted when zero (denials, lookups). |
metadata |
For authentication events: client_id, method and scopes on success; error and, for non-MCP endpoints such as /metrics, path on denial. |
prev_hash |
The previous event's hash. Omitted for the first event in a fresh file. |
hash |
SHA-256 over this event with hash excluded. |
Tool results are not recorded; the trail says what was asked and by whom, not what came back.
Real events
Two events from a stdio session (no identity, no client address), a successful call followed by a call to a tool that does not exist:
{"timestamp":"2026-09-17T08:06:43.222847544Z","seq":1,"event_id":"4d927b23-d31c-41b3-8399-83f57e10d152","action":"tools/call","resource":"get_uptime","result":"success","duration_ns":305755,"hash":"7ae20c710e927aaba562e239247f92d751690d7ca17065b73b191f74bc459e3f"}
{"timestamp":"2026-09-17T08:06:43.224534479Z","seq":2,"event_id":"3c420eae-f3bc-40da-b8f2-85ab06682307","action":"tools/call","resource":"get_nonexistent","result":"error","error":"tool not found","prev_hash":"7ae20c710e927aaba562e239247f92d751690d7ca17065b73b191f74bc459e3f","hash":"62f69b69ba02a7a1c026d0bcd2fdc11d9575e7ce26390407ff5225650db9fda7"}From an HTTP session authenticated with an API key scoped to core (timestamps, event IDs and the tails of the hashes elided): the authentication, a scope denial, a success, a bad key, and an unauthenticated request to /metrics.
{"seq":1,"action":"auth/token_validation","identity":"demo","client_ip":"127.0.0.1","result":"success","metadata":{"client_id":"apikey:78ef7122","method":"api-key","scopes":["core"]},"hash":"25f705f4d9d0..."}
{"seq":3,"action":"tools/call","resource":"get_journal_logs","identity":"demo","client_ip":"127.0.0.1","params":{"lines":5},"result":"denied","error":"scope denied","prev_hash":"0267fd266b99...","hash":"4c2ab1120223..."}
{"seq":5,"action":"tools/call","resource":"get_uptime","identity":"demo","client_ip":"127.0.0.1","result":"success","duration_ns":69829,"prev_hash":"20b63a019a1e...","hash":"c4f796465874..."}
{"seq":6,"action":"auth/token_validation","client_ip":"127.0.0.1","result":"denied","metadata":{"error":"api-key: invalid API key"},"prev_hash":"c4f796465874...","hash":"5c27c9a8f22a..."}
{"seq":7,"action":"auth/token_validation","client_ip":"127.0.0.1","result":"denied","metadata":{"error":"no acceptable credentials presented","path":"/metrics"},"prev_hash":"5c27c9a8f22a...","hash":"3ad01c032474..."}Each HTTP request is authenticated independently, so an auth/token_validation success precedes every tools/call from an authenticated client. The tools/list, initialize and ping methods record no event of their own, although over HTTP each of those requests is still authenticated and so still logs its own auth/token_validation event.
The hash chain
computeHash marshals every field except hash to JSON, in the struct order above, and hex-encodes the SHA-256 of those bytes. prev_hash is the hash of the event written immediately before. Together with seq this gives three detectable failure modes:
- Modification. Any change to a line changes its recomputed hash:
hash mismatch at event N (seq=S). - Deletion or insertion. The next line's
prev_hashno longer matches:hash chain broken at event N (seq=S). A gap inseqshows which events are missing. - Corruption. A line that is not valid JSON:
failed to parse event at line N.
When the provider opens an existing file it reads the final 4 KiB, takes the last complete line, and seeds both lastHash and the sequence counter from it. The chain therefore runs unbroken across restarts: after a restart the next event carries the previous run's final hash as prev_hash. The chain also runs across rotations, which has a consequence for verification described below.
The chain proves that the file has not been altered since it was written by a process holding the previous hash. It does not by itself prove who wrote it; anyone with write access to the file and the ability to compute SHA-256 can append consistent events. Protect the file with filesystem permissions and ship it off-host promptly.
Verifying
--audit-verify opens the file named by --audit-output, walks it line by line, checks each prev_hash against the previous line's hash, recomputes each hash, and exits 0 on success or 1 on the first failure:
secronyx --audit-verify --audit-output /var/log/secronyx/audit.jsonl
# Audit verification OK: 1523 events verifiedA modified line and a deleted line, from the two-event file above:
sed -i '1s/get_uptime/get_cpu_info/' tampered.jsonl
secronyx --audit-verify --audit-output ./tampered.jsonl
# Audit verification FAILED: hash mismatch at event 1 (seq=1)
sed -i '1d' gap.jsonl
secronyx --audit-verify --audit-output ./gap.jsonl
# Audit verification FAILED: hash chain broken at event 1 (seq=2)Verification uses the default provider, so it needs a file. The stderr provider's Verify returns stderr audit output cannot be verified; configure a file output. Note also that opening the file for verification uses the same O_APPEND|O_CREATE open as logging, so verifying a path that does not exist creates an empty file and reports zero events.
Verifying across rotated files
The chain is not reset at rotation: the first event in the new file carries the last hash of the rotated file as its prev_hash. --audit-verify starts from an empty previous hash, so when it is run against a file whose first event already carries a prev_hash — any live file that has been rotated at least once — it reports hash chain broken at event 1 (seq=N), where N is that file's first sequence number. This is the expected result, not tampering. To verify the whole history, concatenate the rotated files in order (oldest first, decompressed) followed by the live file, and verify the concatenation:
cd /var/log/secronyx
( for f in $(ls audit.jsonl.*.gz 2>/dev/null | sort); do zcat "$f"; done
cat audit.jsonl ) > /tmp/audit-full.jsonl
secronyx --audit-verify --audit-output /tmp/audit-full.jsonl
# Audit verification OK: 6 events verifiedRotated files that --audit-max-files has already deleted cannot be reconstructed; the first surviving file will fail at event 1 for the same reason unless the history was shipped elsewhere first. Tutorial: verify the audit chain walks through this end to end.
Providers
default (file)
Opens --audit-output with O_APPEND|O_CREATE|O_WRONLY, mode 0640, after creating the directory with 0750. O_APPEND makes each write an atomic append at the operating-system level, so a second writer cannot overwrite earlier bytes. Writes go through a 64 KiB bufio.Writer.
stderr
Writes the same JSON lines, with the same hash chain, synchronously and unbuffered to stderr. It exists so that an unwritable log directory never silently turns auditing off; from the command line it is reached only through the fallback described above, and Configure reports the switch with a FallbackError rather than hiding it. Container deployments that collect stderr can rely on it, but they cannot run --audit-verify against it.
Custom providers
The package exposes a registry for other backends:
type Provider interface {
Name() string
Write(ctx context.Context, event *Event) error
Flush(ctx context.Context) error
Close() error
Verify(ctx context.Context) (int, error)
}
audit.RegisterProvider("siem", func(cfg audit.Config) (audit.Provider, error) { ... })There is no command-line flag to select a provider by name; internal/audit is an internal package, so a custom provider means building from a tree that contains it and wiring it in main. See Contributing.
Durability modes
How quickly an event reaches disk depends on two settings, and the interaction is not obvious from their names.
--audit-buffer-size |
--audit-sync-write |
Behaviour |
|---|---|---|
100 (default) |
false |
Write places the event on a channel and returns. An async goroutine writes events into the 64 KiB bufio buffer and flushes that buffer every --audit-flush-interval (5 s). Events in the channel or the buffer are lost if the process dies. A failed write in this path is swallowed so that auditing cannot crash the server. |
0 |
false |
No channel: Write writes into the bufio buffer before returning, but nothing flushes that buffer except it filling (64 KiB), rotation, or shutdown, because the flush ticker belongs to the async goroutine that is not started. For a long-running server this holds events in memory longer than the default does. |
0 |
true |
Each event is written, the buffer flushed and the file fsynced before Write returns. Slowest, and the only mode in which a returned Write means the event is on disk. |
100 (default) |
true |
Every event is still flushed and fsynced, but by the async writer after Write has already returned, so an event still sitting on the channel is lost if the process dies. |
For compliance-critical hosts combine --audit-sync-write with --audit-buffer-size 0; the buffer size is what decides whether the fsync happens before the audited call returns. For everything else the default is adequate and the 5-second window is the exposure. Close, deferred in main and reached when SIGINT or SIGTERM cancels the server context, drains the channel, flushes and syncs.
# Default: async, flushed every 5 seconds
secronyx
# High integrity: every event fsynced before the call returns
secronyx --audit-buffer-size 0 --audit-sync-write
# Higher throughput, larger loss window on crash
secronyx --audit-buffer-size 1000 --audit-flush-interval 30sRotation
Before each write the provider compares the current file size with --audit-max-file-size. When the file is at or above the limit it:
- flushes the buffer and
fsyncs the file; - closes and renames it to
<path>.<UTC timestamp>, for exampleaudit.jsonl.20260917-081021; - starts a background goroutine that gzips the rotated file to
<name>.gzand removes the original on success (an incomplete archive is discarded and the original kept); - starts a background goroutine that lists
<path>.*in the directory, sorts by name (which is chronological), and deletes the oldest beyond--audit-max-files; - opens a fresh file with the same flags and mode.
The hash chain and sequence continue into the new file, as described under verification. A listing after rotation looks like:
audit.jsonl
audit.jsonl.20260917-081021.gz
The live file is opened with mode 0640. The archive is created with os.Create, so its mode is 0666 masked by the process umask rather than 0640; tighten it in the archival step if that matters.
Operating the trail
- Permissions. Run the server as a dedicated user, own the directory
root:admor equivalent with0750, and let only the log-forwarding group read the file. The file mode0640is chosen so a forwarder in the file's group can read it without write access. - Forwarding. The format is plain JSON Lines, so any file-tailing shipper works. Ship before
--audit-max-filescan delete history, and verify the chain on the receiving side. - Client addresses. Behind a reverse proxy every event shows the proxy's address unless
--trust-proxy-headersis set; set it only when the listener is reachable solely through that proxy, otherwise any caller can write an arbitraryclient_ip. See Remote access over HTTP. - Parameters.
paramsis verbatim. If callers pass sensitive values as arguments, treat the audit file with the same care as the data those arguments name. - Metrics. Denials and errors are also counted in the
/metricsendpoint under error typesscope_denied,not_found,execution_errorandresult_too_large; the endpoint requires the same credentials as the MCP endpoint.
For the retention periods that common frameworks expect and how the trail maps onto their controls, see Compliance mapping. For what the denied result means in terms of scopes, see Scopes and authorization.
Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.