Audit chain and SIEM export
How QueryProxy chains and verifies audit records, and how to send every audit event to stdout, syslog or a signed webhook for your SIEM.
Updated:
Every audit record is protected twice: the application database links records into a hash chain, and optional sinks copy each event to a system the database administrator does not control. The second part matters, because a chain kept only inside the database can be rewritten by someone who owns the database.
The hash chain
Each record stores prev_hash and hash, where
hash = SHA-256(prev_hash || canonical_json) over the record’s fields (team,
user, action, request, connection, SQL, metadata with sorted keys, IP and time).
Records are linked inside one transaction that also takes a lock row, so the
chain cannot fork. Unique indexes on both hashes refuse a second record with the
same predecessor.
Database triggers refuse UPDATE and DELETE on the audit table, and
TRUNCATE on PostgreSQL (MySQL and MariaDB triggers cannot block it).
Verify it
docker exec queryproxy php artisan audit:verify
The command walks the chain from the first record. When it is intact it prints
the number of records and the chain head and exits with 0. When it is not, it
prints the id of the first broken record and the reason, and exits with 1. It
detects modified records, removed or inserted records, a reordered chain, a
chain that does not start at the first record, and a chain that is shorter than
the number of records written (a removed tail or an emptied table).
queryproxy:doctor runs a quicker check of the newest records and the head;
queryproxy:doctor --full-audit walks the whole chain.
Checkpoints
The scheduler runs audit:checkpoint daily. It writes an audit.checkpoint
event with the last record id, its hash and the number of records, unless
nothing was written since the previous one. A checkpoint is an ordinary audit
event, so it also goes to your sinks.
What the chain cannot do
- It cannot catch a database owner who removes the triggers and recomputes the chain. Someone with that power can write a consistent history. Only a copy held outside the database, in your SIEM, shows the difference. That is what the checkpoints and the sinks below are for.
- MySQL and MariaDB: the connection time zone is pinned to UTC because
created_atis hashed as UTC wall time. Do not change the server or session time zone behind QueryProxy’s back. - Long outer transactions that call into the audit writer hold the lock row until they finish, so other writers wait.
Export to a SIEM
Every sink is off by default. QUERYPROXY_SIEM_SINKS turns them on: a
comma-separated list of stdout, syslog and webhook, any combination.
Delivery runs on the queue after the audit record is committed, so a sink that
is down never blocks or rolls back an audit write. Delivery is at least
once: a receiver must deduplicate on the event’s id. A webhook or syslog
delivery that keeps failing lands in failed_jobs, which acts as the dead-letter
queue (php artisan queue:failed). If the queue backend itself is down, the
event is not exported and the problem is logged; the database stays the system of
record and its chain stays complete. The gap shows in the SIEM copy, as a missing
id in the sequence.
| Variable | Default | Meaning |
|---|---|---|
QUERYPROXY_SIEM_SINKS |
none | Sinks to turn on: stdout, syslog, webhook |
QUERYPROXY_SIEM_QUEUE |
default |
Queue the deliveries run on. The stock worker serves default; a different name needs a worker for it |
QUERYPROXY_SIEM_STDOUT_STREAM |
php://stdout |
Stream the stdout sink writes to |
QUERYPROXY_SIEM_SYSLOG_HOST |
none | Syslog receiver host |
QUERYPROXY_SIEM_SYSLOG_PORT |
514 (6514 for tls) |
Receiver port |
QUERYPROXY_SIEM_SYSLOG_TRANSPORT |
udp |
udp, tcp or tls |
QUERYPROXY_SIEM_SYSLOG_FRAMING |
octet-counting |
For tcp and tls: octet-counting or newline |
QUERYPROXY_SIEM_SYSLOG_FACILITY |
13 |
Syslog facility (13 is “log audit”) |
QUERYPROXY_SIEM_SYSLOG_APP_NAME |
queryproxy |
APP-NAME field |
QUERYPROXY_SIEM_SYSLOG_HOSTNAME |
none | HOSTNAME field |
QUERYPROXY_SIEM_SYSLOG_TLS_CA_FILE |
none | CA file for verifying a tls receiver; empty uses the system trust store |
QUERYPROXY_SIEM_SYSLOG_TIMEOUT |
5 |
Seconds |
QUERYPROXY_SIEM_WEBHOOK_URL |
none | http:// or https:// URL that receives each event |
QUERYPROXY_SIEM_WEBHOOK_SECRET |
none | Signing secret, at least 16 characters. QUERYPROXY_SIEM_WEBHOOK_SECRET_FILE reads it from a file |
QUERYPROXY_SIEM_WEBHOOK_TIMEOUT |
5 |
Seconds |
QUERYPROXY_SIEM_WEBHOOK_TRIES |
6 |
Delivery attempts before the job fails |
A sink with a bad configuration is not used and the problem is logged once per
process. queryproxy:doctor reports it as siem_sinks.
The event
All sinks send the same JSON document, described by
docs/audit-event.schema.json
(JSON Schema Draft 2020-12, additionalProperties: false, schema_version: 1).
| Field | Meaning |
|---|---|
id |
Audit record id; unique per installation. Deduplicate on it |
timestamp |
UTC, second precision |
instance |
Host of the installation’s APP_URL, or null when it is not set |
action |
Event name, for example request.approved |
category |
The part of action before the dot |
actor |
{ "id", "email" }, or null for system events |
team_id, connection_id, query_request_id |
Related objects, or null |
ip |
Client address, or null for console and queue events |
sql_redacted |
The SQL with literals replaced by placeholders, or null |
sql_hidden |
true when SQL exists but could not be redacted safely |
metadata |
Event-specific details, or null |
Raw SQL is never exported. The schema’s action list is generated from the
event catalog and has 50 events, grouped in the categories user, request,
connection, masking_rule, team, auth, chat_integration,
chat_identity, execution, audit, masking_key, error_messages and
result. The actor.email is read when the event is delivered, so a user who
changes their e-mail address between the audit write and the delivery appears
with the new one.
stdout
One JSON object per line on the container’s standard output. The same stream
also carries the output of nginx, php-fpm and the queue worker (RUNNING and
DONE lines), so the collector must skip lines that are not JSON. Container
logging drivers and agents that read the Docker log pick it up.
syslog
An RFC 5424 message with the JSON event as its body, over UDP, TCP or TLS. TCP and
TLS use octet-counting framing (RFC 6587) unless you choose newline. With
tls, QueryProxy verifies the receiver’s certificate and name; set
QUERYPROXY_SIEM_SYSLOG_TLS_CA_FILE when the receiver’s CA is not in the
system trust store. The standard TLS syslog port is 6514.
Signed webhook
QueryProxy POSTs the JSON event with these headers and does not follow redirects:
| Header | Content |
|---|---|
X-QueryProxy-Timestamp |
Unix seconds when the delivery was made |
X-QueryProxy-Signature |
sha256= followed by the hex HMAC-SHA256 of "{timestamp}.{body}" with the secret |
X-QueryProxy-Event |
The action |
X-QueryProxy-Event-Id |
The audit record id |
User-Agent |
QueryProxy-Audit/1 |
A webhook without a secret of at least 16 characters is never sent unsigned. Any HTTP status outside 2xx counts as a failure and is retried with a growing delay (10 s, 1 min, 5 min, 15 min, then 1 h). Verify a delivery before you trust it:
import hashlib
import hmac
import time
def is_valid(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
# Reject stale deliveries; a retry carries a fresh timestamp.
if abs(time.time() - int(timestamp)) > 300:
return False
message = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Sign over the raw body exactly as received, not over a re-serialised copy.
Product-specific connectors (Splunk, Elastic, Wazuh, Microsoft Sentinel) are not shipped. Any collector that accepts syslog, reads container logs or receives a signed HTTP POST can ingest these events.