Ez az oldal csak angolul jelenik meg, és így is marad: a forrásfából generáljuk, és a CI minden változásnál a kód ellen ellenőrzi. Egy magyar másolat mögött nem állna ez az ellenőrzés, ezért az angol eredetit olvasod.
The audit log schema — the SIEM parser's contract
This document is the contract a SIEM (or any other external log consumer)
parses against: the file's location and permissions, the JSON shape of
one entry, the versioning rule that governs when that shape is allowed to
change, and the rotation behavior an ingesting agent must tolerate.
internal/audit/audit.go is the implementation; this file is what an
integrator should read instead of that source.
1. The file
Canonical path: $ISG_HOME/audit.log. In the isolated-CA production
layout (see docs/provisioning-isolated-ca.md) ISG_HOME is
/var/lib/isg, so the file is /var/lib/isg/audit.log.
- Mode:
0640. - Owner:group:
isg:isg-operators. A SIEM forwarding agent must run as a member of theisg-operatorsgroup (or asisgitself) to read it — a plain operator login account is not enough by design. - Format: JSONL — one JSON object per line, UTF-8, newline-terminated. Each line is independently parseable; a consumer must not assume anything about relationships between lines beyond file order.
The file is opened, appended to, and closed on every single write (see
§4) — there is no long-lived file handle inside isg holding it open.
2. The fields
Every line is an object with exactly these top-level keys:
| Key | Type | Required | Meaning |
|---|---|---|---|
schema_version |
string | see §3 | Contract version this line was written under. Absent on lines written before this field existed — see §3. |
timestamp |
string | yes | Event time, RFC3339 (e.g. 2026-06-18T10:30:00Z). |
broker |
string | yes | Which broker instance wrote this line, e.g. R1 — the id from that gateway's broker.yaml, the same mark that appears in the middle field of its session IDs (SESS-2026-R1-000042). Empty string means an unmarked broker, which is the supported single-broker default, not a missing value. Never omitted; absent only on lines written before this field existed — see §2.4. |
session_id |
string | yes | The ISG session this event belongs to, e.g. SESS-2026-000001. Empty string for events with no session context (some CA/admin/probe events). |
event |
string | yes | Event type, e.g. SESSION_STARTED, COMMAND_DENIED, CERT_ISSUED. See internal/audit/audit.go's AllEvents for the full, current list — the set of event names is not itself part of this schema's version and grows over time. |
details |
object | yes | Event-specific key/value data. Always present, {} when an event has none. |
prev |
string | yes | Hash chain link: the hex SHA-256 of the previous physical line of the file this line was appended to, newline excluded. Empty string means this line was the first of a chain (a genesis). Never omitted; absent only on lines written before the chaining existed — see §2.5. |
details is intentionally an open, event-dependent map — a command-denied
entry carries command and rule, a cert-issued entry carries different
keys entirely. A parser must not assume a fixed key set inside
details, and must not fail on keys it doesn't recognize there either.
The one documented cross-event convention: events that record who acted
use either details.actor or details.user depending on the event (see
Query's actor filter in internal/audit/audit.go for the exact split)
— a parser matching on "who did this" should check both.
2.1 Acting on someone else's behalf: as_user, and why it is not principal
An operator can open a session for a different directory identity
(isg connect <host> --as <user>). The events that know about that
distinction carry it in as_user, and they carry it always —
including when nobody was impersonated, in which case as_user holds the
acting user's own name. Absence is never the signal: a rule looking for
impersonation compares as_user against the event's own subject
(user, or caller_user where the event has one) rather than testing
whether a key is there.
That is a change of contract, made deliberately before any external
consumer existed. Until v0.231.0 this axis was written as principal,
omitted when empty — so a missing key meant both "no --as was passed"
and "no impersonation happened", and nothing in the line told the two
apart. Worse, principal was carrying four unrelated meanings across
the log at once:
| Meaning | Events |
|---|---|
the --as subject |
MFA_RECOVERY_USED, MFA_REPLAY_REJECTED, LOGIN_MFA_OK, REQUESTER_ACCESS_DENIED, REQUESTER_ACCESS_WOULD_DENY |
| the certificate's own principal | CERT_ISSUED |
| the requester's resolved directory name | ISSUE_REQUEST_DENIED, ISSUE_REQUEST_WOULD_DENY |
| the target-side SSH login name | BREAK_GLASS_KEYSET_READ |
A rule filtering on principal to answer "who acted for whom" therefore
pulled in certificate issuance and — inverting its own question — the
identity of the requester in the issuance-authorization events.
Since v0.231.0 the first group writes as_user, and the third writes
requester_directory_user, which pairs with the requester_user already
taken from the peer credential: which OS user asked, and who the directory
says that is. Rows two and four keep principal, because there the word is
the correct SSH term for what it names — a certificate's subject, and the
login name a probe authenticated as. principal in this log never means
"on whose behalf".
requester_directory_user keeps the omit-when-empty rule, and there the
absence is real information rather than ambiguity: it means the directory
lookup never got that far. caller_user is a third, separate axis — which
OS user opened the session — and is untouched: it is written only when it
differs from the subject, and that remains a deliberate choice recorded at
its own events.
2.2 One event records a QUERY, not an execution
Every other event in this log names something that happened: a command ran, a
certificate was issued, a session closed. COMMAND_COMPLETION is the
exception, and a consumer that reads it as an executed command will draw
conclusions the line does not support.
It records a Tab-completion lookup from the brokered prompt. The operator typed a path fragment and pressed Tab; the daemon ran a fixed listing command with that fragment as a single quoted argument and sent the names back to the client. Nothing the operator typed was executed, and the command gate never evaluated it — the gate decides about running a command, and here nothing is run on the operator's behalf.
{"event":"COMMAND_COMPLETION","session_id":"SESS-2026-000251",
"details":{"prefix":"/etc/ssh/ssh_h","matches":6,"truncated":false,
"cwd":"/mnt/cdrom","as_user":"peter"}}
prefix is the point of the event: it is the only field that says what was
probed. matches is a count and never the list — the names would add
nothing a reader needs, while writing a directory's whole contents into the
log on every Tab. truncated says the listing hit its cap, so the count is a
floor rather than a total.
2.3 Why it is a separate event, and what to alert on
The lookup could have been folded into COMMAND_ALLOWED. It was not,
deliberately: three Tab presses while typing one command would write three
times as many rows as the command itself, and a log that used to say "this
operator ran these seven commands" would dilute into noise.
The split also gives a consumer something better than noise. This is the one place in ISG where a directory tree can be examined without running a command, so volume is the signal: a burst of completions — say a hundred in a minute, or a walk down successive path components — looks nothing like a person finishing a filename, and looks exactly like someone mapping a filesystem. Alert on the rate, not on any single row; a single completion is an operator pressing Tab.
Two limits worth knowing before writing that rule. The lookup is gated at
the session level — it can only happen inside an open, authorized session on
a host that operator was already allowed to reach — so it widens no host
boundary. And a completion leaves no trace on the target beyond that
host's own sshd/auditd records of the listing command; this log is where
the intent is recorded.
2.4 broker: an absent key and an empty value are different facts
A parser sees three cases on this key, and only two of them are about the same thing:
| What the line looks like | What it means |
|---|---|
no broker key at all |
The line was written before the field existed. It says nothing about which broker wrote it. |
"broker":"" |
An unmarked broker wrote it — no broker.yaml, or an empty id in it. That is the supported default for a single-gateway deployment. |
"broker":"R1" |
Broker R1 wrote it. |
The first case exists forever, because the log is append-only (§5) and old lines are never rewritten to add the key. The second is why the field carries no "omit when empty" rule: on a line this code writes, the empty string is a statement — this broker has no mark — and dropping the key would merge that statement with the silence of the pre-field lines.
The distinction is only visible in the raw JSON, on the key's presence. A
consumer that unmarshals into a struct with a plain string field gets ""
for both and cannot tell them apart afterwards; if a rule needs to, it must
check for the key itself.
One thing the field does not claim: it names the broker that WROTE the line, which for a daemon-forwarded event (the client measures, the daemon records) is the daemon — not whatever process first observed the event. That is deliberate; a client-supplied mark would be a claim rather than a fact.
2.5 prev: the hash chain, and what it does and does not prove
Every line written since this landed carries the hex SHA-256 of the line
physically above it in the same file — the line's bytes without the
trailing newline, which is exactly what a line-oriented reader hands
back. Nothing is canonicalized anywhere: isg serializes the record once,
and that one byte sequence is what goes into audit.log, what is hashed,
and what is re-rendered into the syslog export's MSG (§7). There is no
second representation for a verifier to disagree with.
The same three-case reading broker has (§2.4) applies here, for the same
reason and with the same "the key's presence decides" caveat:
| What the line looks like | What it means |
|---|---|
no prev key at all |
Written before the chaining existed. Not verifiable, and not a fault. |
"prev":"" |
A genesis: this line was the first of a chain, with nothing above it. |
"prev":"<64 hex>" |
The hash of the line above it — across a rotation boundary too. |
The chain flows across rotation (§4): the first line written into a
fresh audit.log links to the last line of the piece that was just renamed
away, so the pieces and the live file are one chain, walked oldest-first.
It also flows across a daemon restart, which re-reads the tail on startup.
What it proves. An edit or a deletion anywhere in the trail breaks the
chain from that point on. isg system audit-chain verify walks the pieces
and the live file in one pass and names the first line that no longer
follows from the one above it.
What it does not. The file is on the broker's own disk, so whoever can
rewrite it can also recompute every hash after their edit and hand back a
chain that verifies perfectly. The chain is worth something because its
head leaves the machine: isg serve writes an AUDIT_CHAIN_HEAD event at
startup and every hour, carrying the current head in details.head, and
that event goes out on the ordinary fan-out — into the SIEM, over the
syslog export. A rewritten log has to reproduce every head exported before
the edit, over bytes that are no longer there.
So the head event is the one an ingesting agent should keep and be able to
look up. Two properties make it self-checking: details.head equals that
same line's own prev (the head is read immediately before the entry is
appended), and a missing hourly entry is not by itself evidence of
tampering — a daemon that was down writes none.
Per-record authentication — a MAC proving the line came from this ISG rather than from anyone with write access — is a separate, not-yet-built step. This one detects modification of a sequence; it does not authenticate an individual record.
2.6 The SSO login events, and the one field to alert on
Three events describe a login that went through an external OIDC identity
provider. They sit beside the LOGIN_MFA_* events rather than replacing
them, because they record a different party's decision.
surface says which front door, and it is on all three events, from
both fronts, always — never omitted, so a rule never has to infer it from
which other keys happen to be present. Two values: webui (the browser's
authorization-code callback) and gateway (the CLI's RFC 8628 device flow,
ssh isg-login@gateway).
The two fronts carry different companion keys, and the differences are deliberate rather than an inconsistency:
| Key | surface: webui |
surface: gateway |
|---|---|---|
ip |
present — the HTTP peer address, observed | absent |
client_ip_claimed |
absent | present — the unverified string the front-end read out of its own SSH_CONNECTION (see §2.4's sibling reasoning: a claimed value must never be written under the name of a measured one) |
login_attempt_id |
absent | present — joins these events to the GATEWAY_AUTH_OK/GATEWAY_AUTH_DENIED and any session events of the same connection |
user on LOGIN_SSO_START |
absent | present |
protocol says which SSO protocol (B272), and it is on all three
events, from both fronts, always: oidc or saml. The gateway front is
always oidc — under protocol: saml the CLI login stays password plus the
local second factor. On saml, issuer is the identity provider's
entityID, sub is the assertion's NameID, and LOGIN_SSO_OK carries
authn_context_class_ref and session_index instead of sid/amr.
| Event | details |
Written when |
|---|---|---|
LOGIN_SSO_START |
surface, protocol, issuer, + the per-surface keys above |
webui: the browser is redirected to the identity provider — no user, nobody has claimed an identity yet and inventing one from a cookie would record a guess. gateway: the IdP has issued a device code — with a user, because the operator typed one before the flow began. |
LOGIN_SSO_OK |
surface, protocol, user, issuer, sub, sid (when the IdP issues one), amr (list), mfa_source; on saml authn_context_class_ref and session_index instead of sid/amr |
The id_token verified and the username it carries resolved in the directory. On the gateway front the username also had to match what was typed. |
LOGIN_SSO_FAILED |
surface, protocol, reason, issuer, user (once known), claim_user (on user_mismatch only) |
Any refused callback, ACS post or device flow. |
mfa_source on LOGIN_SSO_OK is the field worth a rule. It has three
values:
| Value | What happened |
|---|---|
local |
sso.yaml says mfa: local. The gateway's own second factor runs next, and its LOGIN_MFA_OK follows on the same login. |
idp |
mfa: idp, and the id_token's amr/acr satisfied what sso.yaml requires. No local factor ran; this event is the whole record of the login. |
local_fallback |
mfa: idp was configured and the id_token did not carry the required assertion, so the local factor ran after all. |
local_fallback is the one to alert on. It is not a failure — the login is
still fully authenticated — but it means the identity provider is not
asserting what the deployment believes it asserts, and the operator should
find out why before anyone widens the trust.
reason on LOGIN_SSO_FAILED is a closed set. Most values belong to
one surface, because they name a step only that surface has:
reason |
Surface | What happened |
|---|---|---|
state |
webui | The callback matches no flow this gateway started — login CSRF, a stale bookmark, or a flow already consumed. |
nonce |
webui | The id_token was minted for a different authorization request — replay. |
idp_error |
webui | The IdP itself refused and sent ?error=; its code is in the daemon log, not in the response. |
exchange |
webui | The token endpoint rejected the code or was unreachable. |
verify |
webui | Signature, issuer, audience or expiry failed. |
claim_missing |
webui | The configured username_claim is absent or empty in an otherwise valid id_token — a claim-mapping misconfiguration. |
no_resolver |
webui | No directory is wired at all — infrastructure, not a rejection of this user. |
locked |
webui | The source IP or, once the claim was read, the user is already locked out by the password/MFA fronts' shared counters. |
user_locked |
webui | An administrator's incident lock (B477, §2.10) is on the user. |
locks_unavailable |
webui | The incident-lock list could not be read; the login is refused rather than let through. |
device_auth |
gateway | The device authorization request failed — the IdP was unreachable or answered unusably. Nothing was refused; no LOGIN_SSO_START precedes it, because no code was ever issued. |
poll |
gateway | A token-endpoint poll failed for a reason that is neither a verdict nor "still pending". Infrastructure, like device_auth. |
idp_denied |
gateway | The IdP answered access_denied — the human said no, or a policy at the IdP refused. |
expired |
gateway | The device code expired, or the connection's pre-auth deadline ran out first. |
rounds_exhausted |
gateway | Every one of sso.yaml's device_max_rounds rounds came back pending. The code may well still be alive; it is the operator who never finished. |
user_mismatch |
gateway | The id_token named a different user than the one typed at the username prompt. Both names are logged: user is what was typed, claim_user is what the IdP vouched for. |
not_in_directory |
both | The identity provider vouched for somebody this gateway does not know. |
Under protocol: saml the webui front has its own steps (B272). state,
not_in_directory, no_resolver and locked mean what they mean above; a
state here is an ACS post whose RelayState does not match its flow cookie, or
whose flow is unknown, expired or already used. The rest name the check the
SAML Response failed:
reason |
What happened |
|---|---|
malformed |
The post is not a parseable SAML Response. |
encrypted_assertion |
The assertion is encrypted, which this phase does not accept yet. |
signature |
The signature did not verify against the IdP metadata's signing certificates, was missing, covered only the Response and not the assertion itself, or the certificate is not valid now. |
status |
The IdP answered with a non-success status — typically the user cancelled at the IdP. |
destination |
The Response's Destination is missing or is not saml_acs_url. |
recipient |
The bearer confirmation's Recipient is not saml_acs_url. |
issuer |
The Response or the assertion was issued by another entity than the IdP metadata's. |
audience |
The assertion has no AudienceRestriction, or one that does not name saml_sp_entity_id. |
time |
NotBefore/NotOnOrAfter is outside the clock-skew window, or the bearer confirmation has expired. |
in_response_to |
The Response does not answer the AuthnRequest of this flow — or answers none: an IdP-initiated login. |
assertion_count |
Not exactly one assertion. |
username_missing |
The saml_username_attribute attribute (or, with saml_use_name_id, the NameID) is absent, empty or multi-valued. |
replay |
This assertion was already used — on any broker — within its validity. |
signature, in_response_to and replay deserve a rule of their own: nothing
legitimate produces them.
Three of them deserve their own rules. not_in_directory means a valid
token for a non-user, which is what a federation misconfiguration and an
IdP compromise both look like from here. verify means a token that did
not survive signature/issuer/audience/expiry checking, which nothing
legitimate produces. user_mismatch means somebody approved a browser step
as an account other than the one they claimed at the terminal — a run of
them is either an operator with two accounts or an IdP whose username claim
does not map to directory names.
None of these events counts toward a lockout, on either surface. There
is no password in either flow, so nothing was guessed; counting a refusal
here would let anyone lock a real user out by replaying a malformed callback
or by refusing somebody else's device authorization. Both fronts read the
lockout counters the password and MFA fronts write — a user locked out there
is refused here too — and neither ever adds to them. A rule correlating
brute force must therefore not expect LOGIN_LOCKED to follow a run of
LOGIN_SSO_FAILED.
All three carry an empty session_id: an ISG session (SESS-…) is a
brokered SSH session, and a login is not one — not a browser login, and not
the gateway login that goes on to open one (that session's own events carry
its id, and login_attempt_id is what joins them to these).
The logout events (B273, B463)
| Event | details |
Written when |
|---|---|---|
LOGOUT |
surface (always webui), user, ip, auth_source (password | sso; unknown for a session record that carries none), idp_logout; on sso also protocol, issuer, sid (when known) |
A webUI logout removed a live session. No line when the browser had no live session (nobody was logged out) or when the delete failed (the answer is a 503). |
LOGOUT_SSO_BACKCHANNEL |
ip, issuer, sid, sub (each when the logout token carried it), sessions_closed, pending_closed |
A verified OIDC back-channel logout token from the identity provider closed this gateway's webUI sessions of that IdP session (or, for a sub-only token, of that subject). pending_closed counts unfinished logins (pre-auth and enrollment). Zero closed is valid. Running SSH sessions are never touched. |
LOGOUT_SSO_BACKCHANNEL_FAILED |
ip, issuer, reason |
A back-channel logout request was refused. |
idp_logout on LOGOUT is a closed set: redirect — the browser was
sent to the identity provider's end_session_endpoint, so the IdP session
ends too (rp_logout: on, OIDC session, a kept id_token, and an IdP that
publishes the endpoint) — or none — only the gateway's own session ended
(a password or SAML session, rp_logout: off, or no endpoint/id_token).
reason on LOGOUT_SSO_BACKCHANNEL_FAILED is a closed set:
reason |
What happened |
|---|---|
verify |
Signature, issuer or audience failed. |
logout_events |
The token has no events member naming the back-channel logout event. |
logout_nonce |
The token carries a nonce, which a logout token must not. |
logout_no_sid_sub |
The token names neither sid nor sub. |
logout_iat |
iat is missing, older than five minutes or more than one minute in the future, or exp has passed. |
logout_jti |
The token has no jti. |
replay |
This jti was already used, on any broker. |
storage |
The login-state store failed; the request was answered 503 and the IdP may retry. |
verify and replay deserve a rule: nothing legitimate produces them. A
request without any logout_token is answered 400 and writes no line.
2.7 Target names: host is canonical, host_input is what was typed (B457)
Every target name that enters the gateway — a session request, a
gateway-login target, an issue request on the issuance socket, an mTLS
renewal — is brought to one canonical form before any decision: surrounding
space and one trailing dot removed, lower case, checked against the RFC 1123
label rule, and, when connect.yaml sets target_domain, a dot-less name
qualified with that domain. From then on host always holds the canonical
name — on the decision events, on SESSION_STARTED, on every in-session
event. Before this, an alias of a host (its FQDN, upper case, its IP) was a
different string to every gate, and could resolve to a lower lockdown level
than the host itself.
host_input is written next to host when, and only when, the typed
name differs from the canonical one. Absent means the operator typed the
canonical name. A rule correlating with what a user actually entered reads
host_input when present and host otherwise; a rule grouping by target
reads host only.
A name that cannot be canonicalized is refused on the spot with
reason: invalid_target_name: empty, a character outside the RFC 1123
label rule, or an IP literal while target_domain is set. That line carries
host_input and no host — there is no canonical name to write — plus a
detail with the parser's message. The event is the one the surface already
uses for a refusal:
| Surface | Event | decision / denied_by |
|---|---|---|
| session socket (connect, gateway front) | HUMAN_ACCESS_EVAL |
decision: deny |
issuance socket issue, mTLS renewal |
CERT_DENIED |
denied_by: policy |
invalid_target_name is added to the closed set of reason values those
two events carry. The issuance-path CERT_ISSUED, CERT_DENIED and
HOST_GROUP_* lines written by the shared signing core carry the canonical
host only; on that path host_input appears on the socket's own
ISSUE_REQUEST_* lines.
The lockdown-level source cmdb-miss (the servers level command, the
webUI and JIRA ticket bodies) names a host the CMDB does not know; its level
is cmdb.yaml's miss_level.
2.8 HTTP brokering: the application-session events (B277)
When apps.yaml is present, ISG brokers web applications behind the webUI
login. A browser reaches an application's own host name, is handed to the
webUI for a single-use code, and gets an application session of its own
(cookie isg_app) — one per user and application, a child of the webUI
session it came from. Four events describe it; an allowed proxied request
writes nothing to the audit log.
| Event | Details | reason (closed set) |
|---|---|---|
APP_SESSION_STARTED |
user, app, app_id, host, parent_auth_source, ip; account (identity: credential); approval_id, approval_until (require_approval) |
— |
APP_SESSION_CLOSED |
user, app, app_id, reason |
logout, idle, max, parent_ended, access_revoked, approval_window_ended, admin_close, locked, app_deleted, viewer_gone (B524), target_closed (B538), superseded (B539) |
APP_ACCESS_DENIED |
user (when known), app, app_id, reason, ip; account, holder_user, holder_session_id (rdp_account_busy) |
reserved_username, not_in_group, handoff_invalid, handoff_unbound, directory_unavailable, no_credential, approval_missing, locked, license (B467, §2.11), step_up_mismatch (B495), rdp_account_busy (B539) |
APP_REQUEST_DENIED |
user, app, app_id, method, path, rule, ip, mode |
— (rule is the http_rules entry's name, or http_mode:allow) |
APP_REQUEST_WOULD_DENY |
user, app, app_id, method, path, ip, mode |
— |
APP_STEP_UP_REQUIRED |
user, app, app_id, method, path, rule, ip |
— |
APP_STEP_UP_OK |
user, app, app_id, factor, valid_until, ip |
— (factor: totp, recovery, email) |
APP_STEP_UP_FAILED |
user, app, app_id, reason, ip; attempts (invalid_code, too_many_attempts) |
no_factor, invalid_code, expired, too_many_attempts, locked_out |
APP_UPSTREAM_LOGIN |
user, app, app_id, account, steps, relogin, duration_ms |
— |
APP_UPSTREAM_LOGIN_FAILED |
user, app, app_id, account, steps, relogin, duration_ms, step, status |
— (why, closed set: status, success_cookie, success_redirect, extract, timeout, transport, secret) |
APP_RDP_UPSTREAM_CONNECTED |
user, app, app_id, target, rbi_id, session_id, channels |
— (target is the rdp:// upstream's host:port; B278. channels: the opened rdp_channels, [] when all are off; B278 F2) |
APP_RDP_UPSTREAM_FAILED |
user, app, app_id, target, rbi_id, session_id, channels, error |
— (error is the dial's text, free form) |
APP_RDP_UPSTREAM_CLOSED |
user, app, app_id, target, rbi_id, session_id, channels, ended_by, first_eof, bytes_to_target, bytes_from_target, duration_seconds |
— (ended_by: target, isg; first_eof: target, guacd; B538) |
APP_RDP_CHANNEL_TRANSFER |
user, app, app_id, rbi_id, session_id, channel, mimetype, bytes, blocked; filename (printer only) |
— (channel: clipboard_to_remote, clipboard_from_remote, printer; B278 F2) |
-
app_id(B491) is the application's id (APP-<year>-<sequence>): theapps.yamlid, or for a dynamic application the one the record store minted. It is what every Web Access reference names — approvals, session records, locks, tickets — so two applications of the same name (a deleted and re-added one) never mix; filter on it.appstays the technical name, for reading. Lines written before B491 have noapp_id. -
APP_UPSTREAM_LOGIN/APP_UPSTREAM_LOGIN_FAILED(B468) are akind: formaccount's logins: the proxy itself posts the login form for one application session and keeps the upstream's cookies.reloginis true when the upstream had reported the session logged out. On a failurestepis the failed step's index (step==steps: the request retried after a re-login was logged out again) andstatusits HTTP status (0: no answer). No password, token, cookie value or response body is ever written. -
APP_RDP_UPSTREAM_CONNECTED/APP_RDP_UPSTREAM_FAILED(B278) are an RDP target's connections: guacd's RDP reaches the broker through the RBI host's agent, and the broker itself dials the configuredupstream— the only address it ever dials for that application.rbi_idjoins them to theAPP_RBI_SESSION_*lines. The injected account's name and secret are never written here. -
APP_RDP_UPSTREAM_CLOSED(B538) is that connection's end.ended_by: targetmeans the record was still active and the isolated session still running — the target (a logoff, the network, a Windows takeover) ended it, and the session closes withtarget_closed; any close the ISG made itself isended_by: isg.first_eofis diagnostic only: guacd often closes first. -
APP_RDP_CHANNEL_TRANSFER(B278 F2) is one clipboard transfer or one print of an RDP session, written when its stream ends (or when the view's tunnel ends with it still open).bytesis the decoded size;blockedtrue means the channel was off for the user and the broker's tunnel dropped the stream — the attempt is logged all the same. The content is never written. Sound (audio_out) is continuous and has no per-stream line; the connection'schannelsshows whether it was on. -
parent_auth_sourceis the webUI session'sauth_source(passwordorsso): how the person behind this application session logged in. -
parent_endedmeans the webUI session is gone (logout, expiry); the application session is closed on its next request, at most a few seconds after the webUI logout. -
access_revokedis written together with anAPP_ACCESS_DENIEDcarrying the actual reason: the group gate is re-evaluated on every request against a directory answer at mostDefaultIdentityCacheTTL(5 s) old, so a group taken away closes the session at most 5 s later. -
reserved_usernamerefuses a name the application would map onto a built-in account (measured:adminbecame the Grafana super-admin). -
handoff_invalidis an unknown, expired, already used or other-application code;handoff_unboundis a valid code presented by a browser that does not hold the state cookie it was minted for — a code someone else sent. -
no_credential(B277 a3): anidentity: credentialapplication, the user passesallow_groupsbut nocredentialsentry'sgroupsmeets theirs. There is no default account.accountonAPP_SESSION_STARTEDis the name of the entry the handoff chose (the first match); every request chooses again and its transcript line carrieshttp_account. The secret itself is never written anywhere; an unreadable one answers 503 without a line here. -
Approval (B277 b). An app with
require_approval: truehands off only under an approved request of that user for that app whose window is open. The request is an ordinary approval record andCOMMAND_APPROVAL_REQUESTEDline withprotocol: http,user,app,app_id(B491; the request'stargetis the app id),grant_secondsandjustification_len— the justification text itself is never written here, it is free text. The decision is the usual approve/deny line. Both carryapproval_mode: four_eyes(B496; an SSH approval line has noapproval_mode). An app withapproval_mode: self(B496) has no request and no approver: the grant is oneCOMMAND_APPROVAL_GRANTEDline withapproval_mode: self,protocol,user,app,app_id,grant_secondsandjustification_len, and noby— the stored request is approved with an emptydecision_byandmode: self, itsdecided_atthe moment of the grant. The window is[decided_at, decided_at + grant_seconds);approval_idandapproval_untilonAPP_SESSION_STARTEDname it, and the session record carriesapproval_idtoo (also on itsSESSION_STARTED).approval_window_endedis a session the window's end closed (it ended beforemax_durationwould have).approval_missingis arequire_approvalapp's code minted without an approval — a webUI broker with anotherapps.yaml, or an older one. -
admin_close(B277 b): an administrator closed the Web Access session record (webUI orisg sessions close). The application session is deleted on every broker and the approval window, if any, is closed with it; the broker serving the user acts on its next request (at most 5 s later), any other broker on its next sweep. -
directory_unavailableis written by the webUI handoff; a directory outage during a proxied request answers 503 without closing the session or writing a line. -
APP_REQUEST_DENIEDis one request refused by apolicy.yamlhttp_rulesentry; the application session stays open.pathis the decoded, normalized path without the query string (a query can carry secrets). A request whose path has a./..segment, an inner empty segment or a control character is answered 400 and writes no line — it is a malformed request, not a policy decision. The verdict sees a WebSocket upgrade request, not the frames after it. -
Allow-list mode (B497).
modeis the application'shttp_app_modesmode (allow,allow_audit, or empty). Underallowa request noaction: allowrule matches is refused withrule: http_mode:allow.APP_REQUEST_WOULD_DENYis theallow_audittrial: the same request is let through and this line records whatallowmode would have refused — itspathset is the application's missing allow rules. Lines written before B497 have nomode. -
Step-up (B495).
APP_STEP_UP_REQUIREDis one request anaction: step_uprule held because the application session has no live confirmation; a page load is sent to the webUI's step-up page, anything else is answered 403. The webUI writesAPP_STEP_UP_FAILED(no_factor: the user has no factormfa_modecan use;invalid_codewith the challenge'sattempts;too_many_attemptsspends the challenge;expired;locked_out: the login lockout holds the user or the address).APP_STEP_UP_OKis written by the application host once the confirmation marked the session:valid_untilis the end of the window — the rule'svalid_for, at most the session's own end.step_up_mismatchis a step-up code redeemed by a browser whoseisg_appsession, user or application is not the one it was minted for. A wrong code also writes the usualLOGIN_LOCKEDwhen it trips the lockout. -
An allowed Web Access request is not an audit line but a transcript line of a
protocol: httpsession (below); so are the refused ones, next to theirAPP_REQUEST_DENIED.
The Web Access session record (B277 a2-2). Each application session gets
an ordinary session record on every broker that serves it, opened by its first
request there, so SESSION_STARTED and SESSION_CLOSED describe it with the
usual fields plus protocol: http (absent on an SSH session). target is
the app id (B491; before it, the app name) and target_label the app's
friendly name at the start, auth is web_access, and commands_allowed /
commands_denied count requests. The reason of its SESSION_CLOSED is
client_exit (logout, or the application session is gone), idle_timeout,
max_duration, external_close (the webUI session ended, or an
administrator closed the record — then APP_SESSION_CLOSED says admin_close),
access_revoked (the group gate failed mid-session), locked (an incident
lock, §2.10 — then APP_SESSION_CLOSED says locked too), app_deleted (an
administrator deleted the dynamic app, below — APP_SESSION_CLOSED says
app_deleted too) or daemon_shutdown.
A failed transcript write is one RECORDING_UNAVAILABLE (session_id, app,
app_id, user, reason: transcript_write_failed) per failure spell; with
require_recording: true the session's requests are refused (503) until a
write succeeds again.
Dynamic applications (B475). With dynamic_apps_domain in apps.yaml, an
administrator past the session_admin_group gate (§2.9) adds, changes and
deletes applications on the webUI's /apps/manage page; the apps.yaml ones
cannot be changed there. Three events, via is webui:
| Event | Details |
|---|---|
APP_CREATED |
app_id (minted by the record store, B491), name, host, upstream, identity (none or jwt), allow_groups, require_approval, level, by, via |
APP_UPDATED |
APP_CREATED's, with the new values; old and new (each {allow_groups, require_approval, level}); changed (the name of every field that changed, also the ones outside old/new) |
APP_DELETED |
APP_CREATED's, with the deleted values; closed_sessions (session records the deleting broker closed with app_deleted) |
- A change takes effect without closing anything: the group gate runs per
request, so a group taken away refuses the next request (
access_revokedabove). Other brokers see a change within 5 s. - Deleting closes the application's active HTTP session records on the
deleting broker (
SESSION_CLOSEDreason: app_deleted,closed_by,via) and its application sessions on every broker; afterwards its host answers 421.
2.9 webUI admin writes refused by a group gate (B480)
Two ldap.yaml groups gate the webUI's administrative writes, checked against
the logged-in user. A refusal changes nothing and writes one line:
| Event | Group | op |
Details |
|---|---|---|---|
CA_ADMIN_DENIED |
ca_admin_group |
webui-revoke, webui-host-cert-revoke, webui-join-token-create |
op, user, via (webui), reason |
SESSION_ADMIN_DENIED |
session_admin_group |
session-close, system-refresh, lock-add, lock-remove, app-create, app-update, app-delete |
op, user, via (webui or cli), reason; session_id on session-close (also the line's own session_id) |
SESSION_ADMIN_DENIEDwithvia: cliisisg sessions closerefused on the issuance socket (B475);useris the SO_PEERCRED-attested caller.- Closing one's own session (the caller is its
userorcaller_user) asks no gate and never writesSESSION_ADMIN_DENIED, on either surface. - An administrator's close writes
SESSION_CLOSEDwithclosed_by(who) andvia(webuiorcli) in its details (B483). CA_ADMIN_DENIEDis the issuance socket's event too; there it carriesgroupandidentity_sourceand novia. Filter onviato tell the two surfaces apart.reasonis the gate's own error text, free text for a human, not a closed set.- A gate set to required (
ca_admin_group_required,session_admin_group_required) without a group or an identity source refuses the write but writes no line: that is a deployment error, not a decision about the user.
2.10 Incident locks on a user or an application (B477)
An administrator past the session_admin_group gate can lock a user or a Web
Access application (its app id, B491; isg lock add --app also takes the name
and resolves it) — on the webUI's /locks page or with isg lock add. A lock
made on an app name before B491 still holds. The lock is shared by every broker on the state root; creating
it closes what it locks out, and while it lives every entry point refuses.
| Event | Details |
|---|---|
LOCK_CREATED |
kind (user or app), target (a username lower-cased, or an app id), target_label (an app lock: the app's friendly name), reason (free text, required), by, via (webui or cli), until (RFC 3339, only on a time-limited lock), closed_sessions (session records closed on the creating broker), logged_out_logins (login-state records the lock deleted: webUI sessions, Web Access app sessions and app handoff codes together; B508) |
LOCK_REMOVED |
kind, target, target_label (an app lock that has one), by, via |
- A
userlock closes every active session record the user owns or called (SSH and HTTP) withSESSION_CLOSEDreason: locked,closed_byandvia, and logs the user out of the webUI and every application. Anapplock closes the application's HTTP records and application sessions. A live SSH connection on another broker is cut by that broker's watchdog within seconds: itsSESSION_TERMINATEDsayslocked. - The refusals while a lock lives:
GATEWAY_AUTH_DENIEDreason: user_locked(after the directory lookup, before any code is sent); a session-socket request for a locked principal or caller gets Result reasonuser_locked; a webUI password, MFA or first-enrollment step writesLOGIN_LOCKEDwithreason: user_lockedandstep(password,mfa,enroll) — the brute-forceLOGIN_LOCKEDcarries noreason; an SSO callback writesLOGIN_SSO_FAILEDreason: user_locked; Web Access writesAPP_ACCESS_DENIEDreason: locked. - An expired lock (
untilin the past) refuses nothing and writes nothing; it stays listed until removed. - The lock mail (B503,
notify.yamllock_mail.enabled) writes oneNOTIFY_SENT,NOTIFY_FAILEDorNOTIFY_SKIPPEDper lock created or removed on the broker that made the change, withkindlock_createdorlock_removed,sinks: ["mail"],lock_kind,target,recipients(the number of addresses mailed),recipients_without_mail(recipients the directory has no address for) and, on failure,error. No address is logged.NOTIFY_SKIPPEDmeans nobody could be mailed — e.g. an emptysession_admin_group. Withlock_mailoff nothing is written.
2.11 The license (B467)
The broker reads an offline, signed license.yaml (re-checked every 30 seconds,
no restart needed). Without one it runs in a 30-day evaluation mode whose
clock is one record in the shared record store. Only a NEW session is gated:
running sessions and break-glass are never refused or cut.
| Event | Details |
|---|---|
LICENSE_LOADED |
license_id, customer, not_after (RFC 3339), modules (list), named_users (0 = no ceiling), key_id |
LICENSE_REJECTED |
reason (why the file does not verify: unknown key, bad signature, bad JSON, a missing field, issued_at after not_after), file |
LICENSE_EVAL_STARTED |
started_at, ends_at (RFC 3339) |
LICENSE_DENIED |
module (ssh_broker or web_access), user, state, via (ssh or web_access); caller on ssh |
LICENSE_LOADEDis written when a broker loads a license it has not loaded before — so once per broker start, and once per replacement.LICENSE_REJECTEDis written once per file state: an unchanged bad file writes nothing more. A rejected replacement leaves the license loaded before in force until restart.LICENSE_EVAL_STARTEDis written once per fleet: the record is never rewritten, so a restart does not start a new evaluation.stateis one ofeval,eval_expired,valid,warning,grace,expired,invalid. A refused SSH session answers Result reasonlicense; a refused Web Access session writesAPP_ACCESS_DENIEDreason: licensenext toLICENSE_DENIED.
3. The versioning rule
schema_version states which version of this contract — the meaning
and type of the fields in §2 — the line was written under. Current value:
"1".
- It changes when an existing field's meaning or type changes — for
example, if
session_idever stopped being a plain string, or iftimestampchanged format. - It does not change when a field is added. New fields are additive
and backward compatible; a compliant parser tolerates unknown keys
without failing. Do not gate new-field handling on a
schema_versionbump — there won't be one.broker(§2.4) is this rule's first application: it was added afterschema_versionexisted, it changed no existing field's meaning or type, and the version therefore stayed"1". A parser must not read"1"as "this line has nobrokerkey" — the key's own presence is what answers that.prev(§2.5) is the second application, under the same rule and with the same caveat. - Its absence means the line predates versioning, not that it's
malformed.
schema_versionwas introduced after the audit log had already been in production use; existing lines were not, and will never be, rewritten to add it retroactively (the log is append-only — see §5). A parser must treat a missingschema_versionas "written before this contract existed" and process the line normally, not as a parse error.
4. The rotation contract
Rotation is configured in packaging/isg-audit.logrotate and is load-bearing
for how an ingesting agent must track the file:
/var/lib/isg/audit.log {
monthly
maxsize 50M
rotate 9999
compress
delaycompress
missingok
notifempty
create 0640 isg isg-operators
su isg isg-operators
}
- Rename-based rotation (
create), notcopytruncate.isg's auditLoggeropensaudit.logby path, appends, and closes the handle on every write, so a rotation that renames the old file and creates a fresh one loses nothing — the next write simply lands in the new file. A forwarding agent must therefore be inode-aware (follow the file by watching for rotation, e.g. via inotify or astat-based inode check), not just tail a file descriptor it opened once — a descriptor held across a rename keeps reading the old, now-frozen file. delaycompress: the most recently rotated piece is left uncompressed for one more rotation cycle, so an agent that reads compressed rotated pieces has one full cycle of lead time before a given piece is gzipped.rotate 9999: rotated pieces are, in effect, never deleted. Nothing is ever silently dropped from the trail by the rotation policy itself — an agent that falls behind does not race a deletion window.monthly+maxsize 50M: whichever threshold is hit first triggers rotation.su isg isg-operators: rotation runs as theisguser/group, not root. On the documented layout (/var/lib/isgat0750 isg:isg-operators, as the PIC §3 matrix and theisg_gatewayrole set it) the directive is not what makes rotation run — logrotate 3.18.0 was measured to run the drop-in on that root without it. It stays for two reasons: root never writes into theisgtree, so the piece thatcreate 0640 isg isg-operatorsmakes has ownership consistent with the rest of the tree; and the same drop-in keeps working on a setgid/group-writable root (2770, as a hand-built or older install may have it), where logrotate refuses a log under a group-writable directory owned by a group other than root and, withoutsu, would skip it silently (skipping ... because parent directory has insecure permissions).
5. The fail-closed limit — file is, and must remain, the audit-of-record
Writing to audit.log is fail-closed as the default posture:
internal/audit's Logger.Log returns an error on a failed write, and
the main session/command path (internal/session's session-start,
command-allowed/denied/error, and session-close audit calls) propagates
that error up rather than continuing as if the write had succeeded. A
narrow, explicit exception exists — LogOrWarn, used only by call sites
where the state change already happened before the audit write (lazy
expiry of stale approvals) or where failing the operation would be worse
than continuing without the record (some sshbroker probes) — and even
there the failure is never silent: it's logged to the process's own
standard logger so the gap is at least visible operationally, per that
function's own doc comment in internal/audit/audit.go.
Any future forwarding path this project might add — syslog, HTTP, a
message queue — would necessarily be fire-and-forget or otherwise
fail-open at the point where it hands off to a remote receiver that
might be unreachable, slow, or down. Because of that, no future
forwarding path may ever become the sole audit sink. The on-disk file
written by internal/audit must remain authoritative; disabling the file
write in favor of a forward-only path would silently open an audit gap
the moment that path's target is unreachable, with no local record left
to reconstruct what was missed. Anyone integrating a SIEM forwarder
should tail/ship this file (§4) rather than have isg push to them
directly, precisely so the local file stays the fail-closed source of
truth regardless of what happens downstream.
Such a forwarding path now exists — the optional syslog export described in §7 — and it was built to this rule rather than around it: it is a copy, it can never rescue a failed file write, and it cannot be made the sole sink. Read §7.4 before treating its stream as complete.
6. A second, different file: the collected break-glass log
Everything above is about audit.log, which isg writes. There is a
second file a SIEM will encounter in an ISG deployment, and it obeys none
of the rules above:
/var/log/isg-breakglass-remote.log
It is not written by isg. It is a verbatim rsyslog copy of the
target hosts' own sshd lines, forwarded to the gateway so that the
emergency (break-glass) door leaves a trace on a machine the person using
that door does not control. Plain RFC3164 syslog text, not JSONL; no
schema_version, no details, none of §2's guarantees. This section
exists because the lines in it are easy to misread in a way that inverts
their meaning.
6.1 Which line means somebody got in
sshd writes two different lines during a certificate login, and only
one of them is an entry:
| Line | What it means |
|---|---|
Accepted certificate ID "isg-bg:…" (serial N) signed by CA … |
The CA check passed. The presented certificate is valid and signed by a trusted CA. Nobody has logged in at this point. |
Accepted publickey for isg-breakglass … ED25519-CERT … ID isg-bg:… |
Somebody logged in. This is the entry. |
Measured (2026-08-15, on a target host): a login attempt whose private key
could not sign — the operator key was passphrase-protected and ssh ran
with BatchMode=yes — produced
Accepted certificate ID "isg-bg:peter@INC-9001" (serial 9001) signed by CA ED25519 SHA256:…
Postponed publickey for isg-breakglass … [preauth]
Failed publickey for isg-breakglass … ED25519 SHA256:…
No login happened. A rule that alerts on Accepted certificate as
"someone used the emergency door" would have reported successful root
access for an attempt that never got in.
6.2 Why Accepted certificate is not evidence of anything an attacker cannot fake
The offering phase of SSH public-key authentication does not require the
private key: the client may present a certificate and only later be asked
to prove possession. A certificate is public data by design — ISG's own
break-glass workflow copies certs around with scp, legitimately.
Therefore anyone who obtains a copy of an issued certificate can
produce an unlimited number of Accepted certificate lines on any target
that trusts the signing CA, without being able to log in at all. Treat the
line as "a certificate was presented", never as "a certificate was
used".
One further limit, measured: the message itself carries no username, no
source IP and no port — only the ID, the serial and the CA fingerprint —
so the break-glass account can be recognised in it solely through the
isg-bg: identity prefix.
The syslog frame, however, does carry a connection identifier that the
message body lacks: the tag field holds the sshd session's pid
(sshd-session[7516]), and one sshd session serves exactly one connection
and writes every line of it — the presentations, the Postponed publickey,
and the final Accepted/Failed — under that same pid. A presentation and
the login (or failure) that followed it can therefore be correlated on
(host, pid).
An earlier revision of this section stated the opposite, that no shared identifier existed. That was wrong: it described the message body and overlooked the frame around it. The correction matters for anyone writing rules here, so it is recorded rather than quietly edited away.
Two costs come with that key, and a consumer must pick which error it prefers, exactly as ISG did (§6.3): pids are reused, so two connections far enough apart can merge — hiding a "presented, never got in" behind a later success — while adding a date to the key splits a connection that crosses midnight and invents a "never got in" that did not happen.
6.3 What ISG itself does with these lines
internal/breakglasslog has two parsers, and the split is the same one
this section draws:
ParseAcceptedmatches onlyAccepted publickey for isg-breakglass. Every ISG-derived freshness claim — thesystem checkdrill-trace row,isg breakglass commands— is built from this parser alone, so no ISG output has ever conflated a presentation with an entry.ParsePresentedmatchesAccepted certificatelines and exists purely to count them.
Both parsers also expose the tag's pid, and the drill-trace row groups the
lines by (host, pid) before counting anything, so what it reports is
connections, not lines:
cert presentations: 14 connections, 10 entered, 4 without a login
Counting the lines would roughly double the truth. A successful
certificate login writes the Accepted certificate line twice — once when
the certificate is offered, once when the signature is checked — while an
attempt that presents a certificate and then cannot sign writes it once.
Measured on this project's own collected log: every one of its connections
had one of exactly two shapes, 2 presentations + 1 login (10 of them) or
1 presentation + 1 failure (4 of them). A line-based reading of the same
file reports "24 presentations, 15 entries" and invites the conclusion that
nine attempts failed; four did.
The row keeps the key without a date and therefore undercounts rather than raising a false alarm (§6.2). On a log whose lines carry no pid at all it falls back to one connection per line, which reproduces the old line-based reading on exactly the input where nothing better exists.
The row deliberately never changes its status because of these numbers. The collected log is append-only: one old failed drill would otherwise hold the row at WARN forever, and a warning that is always on is one operators learn to skip. Windowed alerting on this signal belongs in the consuming SIEM, which has the retention and the time windows to do it properly — this section is what such a rule should be written against.
A presentation without a matching entry is not suppressed anywhere, and it should not be: "a valid certificate was presented and nobody got in" is a real signal. It is a failed drill, or a copied certificate being probed against the fleet. Both are worth seeing.
6.4 Counting caveat
All three numbers describe the collected log as it stands. The file rotates, and a target that stops forwarding is indistinguishable from a target nobody touched. Pid reuse merges connections rather than splitting them (§6.2). Every one of those effects pushes the counts down, so treat them as lower bounds — never as a complete history.
7. The syslog export — what a receiving parser gets
Since B208's phase 1b, internal/audit fans every entry out to zero or
more sinks past the file, and phase 1b-2 ships the first one:
internal/auditsyslog, an opt-in copy of every entry forwarded to a SIEM.
It is off unless audit.yaml says otherwise (see audit.example.yaml);
with it off, nothing in this section exists — no goroutine, no queue, no
connection.
An earlier revision of this document argued that no Sink interface
should exist yet, precisely because an interface with no real
implementation invites someone to wire up a stub and switch the file
writer off (§5). The interface exists now, and that concern is answered in
code rather than in prose: a sink has to declare, through a method the
compiler demands, whether a successful write means the record reached
durable storage. The syslog sink answers no, permanently — see §7.4.
7.1 Transport and framing
- TCP with TLS. No UDP, and no switch to ask for it: RFC 5424 over UDP has no framing and no delivery signal, so a message longer than the path MTU is truncated in silence. Audit lines are regularly long, and a truncated audit line is worse than a missing one because it still parses.
- Certificate verification is not optional. An empty
tls_ca_filemeans the system trust store, never "skip verification"; no such switch exists anywhere in this project. - Framing is RFC 6587 octet-counting —
MSG-LEN SP SYSLOG-MSG, whereMSG-LENis the decimal byte count of everything after the space. Not the newline-terminated (non-transparent) framing: octet-counting is deterministic regardless of the payload. Today's audit lines contain no newline, but that is a by-product of JSON encoding rather than an enforced invariant, and the framing must not depend on it.
7.2 The RFC 5424 envelope
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
| Field | What a parser finds |
|---|---|
PRI |
facility*8 + 5. The severity is notice (5) on every entry, with no per-event map and no way to configure one. An audit entry is not a fault report: its severity is carried by the KIND of event, which is what MSGID and the JSON event field say. Which kinds are critical is a decision for the receiving rule set, which knows the deployment; we do not. |
| version | Always 1. |
TIMESTAMP |
RFC 3339 with microsecond precision, parsed from the entry's own timestamp field rather than read off a second clock — the envelope and the JSON can never name two different instants. The fractional digits are therefore always zero: the record's own precision is one second (§2), written in the envelope's grammar. - (NILVALUE) if the entry carries no readable timestamp. |
HOSTNAME |
The OS hostname of the machine. It is not the broker identifier — see §7.3. |
APP-NAME |
audit.yaml's app_name, default isg. |
PROCID |
The isg serve process's pid. Useful for tying a burst of entries to one daemon lifetime; it changes on every restart and means nothing across them. |
MSGID |
The event name (CERT_ISSUED, …), truncated to RFC 5424's 32 characters where a name is longer. Truncation never loses information: the full name is in the JSON event field of the same message a few bytes later. Route on MSGID if you must route before parsing; filter on event when you want the exact name. |
STRUCTURED-DATA |
Always -. The record's structure is the JSON in MSG; re-encoding the same fields as SD-PARAMs would create a second, subtly different representation for a consumer to disagree with. |
MSG |
The JSON line, byte for byte — the same json.Marshal of the same entry that produced the line in audit.log, minus the trailing newline (framing handles boundaries). Everything §2 and §3 say about the file's lines applies here unchanged. |
There is no BOM. RFC 5424 §6.4 says a UTF-8 MSG SHOULD begin with
one; this is a deliberate departure from that SHOULD. Our MSG is JSON
that collectors will parse, and a leading U+FEFF makes naive JSON parsers
fail on an otherwise valid document. The encoding is unambiguous without
it.
7.3 Correlate on broker, not on HOSTNAME
They answer different questions, and the difference bites exactly when it
matters — a trail merged from several gateways. HOSTNAME names the
machine; broker (§2.4) names the ISG instance. Two brokers can
share a host, one broker can be moved between hosts, and a containerized
gateway's hostname may be an ephemeral id nobody chose. A rule that groups
a session's entries, or that asks "which gateway served this", must read
the broker field inside MSG.
7.4 What a delivered message does and does not prove
It does not prove the record was stored. There is no
application-level acknowledgement in RFC 5424 over RFC 6587 framing: the
receiver sends nothing back, and a completed TCP write only means the
local kernel accepted the bytes. This is why the sink reports itself
not durable, and why a successful syslog send can never make a failed
audit.log write look successful — §5's rule, enforced at the fan-out
rather than merely documented. A deployment with strict: cert_issued: true still requires the file write to succeed.
Entries can be dropped, and dropping is reported, not silent. The
sink never blocks the gateway: an audit copy that could stall a session is
worse than an audit copy with a hole in it. Writes go into a bounded queue
(queue_size), and when it is full the new entry is refused — never a
queued one, so what does arrive stays in chronological order. Every drop
and every send failure increments
isg_audit_target_failure_total{audit_target="syslog"} and produces a
process-log line (rate-limited to one per minute per target, carrying the
count of the ones folded into it; the counter itself is never folded).
The file remains the complete record. Everything §5 says still holds:
audit.log is the audit-of-record, this export is a copy, and a consumer
that needs completeness should reconcile against the file (§4) rather than
assume the stream had no gaps.