100% free and open source · every tool, every host, no licence key · read the licence
Secronyx

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_hash no longer matches: hash chain broken at event N (seq=S). A gap in seq shows 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 verified

A 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 verified

Rotated 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 30s

Rotation

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:

  1. flushes the buffer and fsyncs the file;
  2. closes and renames it to <path>.<UTC timestamp>, for example audit.jsonl.20260917-081021;
  3. starts a background goroutine that gzips the rotated file to <name>.gz and removes the original on success (an incomplete archive is discarded and the original kept);
  4. starts a background goroutine that lists <path>.* in the directory, sorts by name (which is chronological), and deletes the oldest beyond --audit-max-files;
  5. 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:adm or equivalent with 0750, and let only the log-forwarding group read the file. The file mode 0640 is 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-files can 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-headers is set; set it only when the listener is reachable solely through that proxy, otherwise any caller can write an arbitrary client_ip. See Remote access over HTTP.
  • Parameters. params is 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 /metrics endpoint under error types scope_denied, not_found, execution_error and result_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.