SIEM export
What this is. A SIEM (Security Information and Event Management system) is the searchable central database where a SOC (security operations center — the team watching for attacks) collects security events from every tool it runs. A SOC runs its own SIEM (Splunk, Sentinel, Elastic, Chronicle) and wants probectl's security-relevant events flowing into it. This feature forwards two streams — probectl's audit log and its threat-plane signals — into that SIEM, rendered in a standard wire format and pushed over hardened TLS.
The important framing: it is a forwarder, not a SIEM. probectl does not store,
search, or correlate these events for you, and it never blocks traffic or acts as
an IPS. A threat finding is a confidence-scored signal that the SIEM correlates
(detection is a signal, never an enforcement action — one of probectl's
non-negotiables). The code is internal/siem
(pure — formatters, senders, a buffered forwarder), driven by internal/control
which maps audit.Event + incident.Signal onto the canonical siem.Event.
Off by default. Enabling it opens an outbound connection to the operator's
SIEM, so it is explicit and config-gated — off unless PROBECTL_SIEM_ENABLED=true
(sovereignty / no-phone-home).
Inbound syslog is a separate collection path. probectl can also accept device/security syslog as an input for correlation, but that does not make it the operator's log-search SIEM. The inbound receiver is tenant-bound by source configuration, parses RFC 5424 and RFC 3164 lines as untrusted input, rate-limits each source, stores provenance with the normalized event, and requires either an HMAC signature or a TLS client-certificate subject. The listener is TLS-only: missing TLS config fails closed.
What is forwarded
| Source | Category | Severity | Notes |
|---|---|---|---|
| Audit log (config changes, logins, data-access) | audit |
info; warning on a failed / denied outcome |
drained from the tamper-evident audit chain, tenant-scoped, PII / secret redacted |
| Threat signals — TLS / cert posture, IOC matches, NDR-lite detections, compliance segmentation violations | threat |
mapped from the signal's severity | the same confidence-scored signals that build incidents |
Both map onto one canonical siem.Event, so every output format carries the same
fields: time, tenant, category, action, severity, actor, target, outcome,
message, and attributes. One canonical record, four renderings — the formatters
only change the costume, never the facts.
Wire formats
A wire format is the exact byte layout the receiving SIEM expects.
Selectable via PROBECTL_SIEM_FORMAT (or the preset's default):
syslog— RFC 5424, the classic line-based log protocol, with structured data ([probectl@32473 tenant="…" …]).cef— ArcSight CEF (the pipe-delimited Common Event Format:CEF:0|probectl|probectl|…), tenant incs1.ecs— Elastic Common Schema JSON (Elastic's standard field naming:event.*,organization.id= tenant).otlp— OTLP/HTTP logs JSON (the OpenTelemetry log protocol; resource attrprobectl.tenant_id).
Presets
PROBECTL_SIEM_PRESET adapts the auth header + the default format to a target
SIEM. The endpoint is operator-supplied (the HEC / ingest / Elasticsearch
URL — HEC is Splunk's HTTP Event Collector, its token-authenticated ingest
endpoint):
| Preset | Auth header | Default format |
|---|---|---|
splunk |
Authorization: Splunk <token> |
cef |
sentinel |
Authorization: Bearer <token> |
cef |
elastic |
Authorization: ApiKey <token> |
ecs |
chronicle |
Authorization: Bearer <token> |
otlp |
generic |
Authorization: Bearer <token> (if set) |
cef |
The scheme in that table is the preset's default, not a fixed property of the
preset: PROBECTL_SIEM_AUTH_SCHEME pins apikey, bearer, basic, splunk
or none when your target disagrees. The case that matters in practice is
OpenSearch, the Elastic-compatible engine most self-hosters run: it speaks
ECS and the same ingest API, but it authenticates with HTTP Basic, not with an
Elasticsearch API key. Point it at
PROBECTL_SIEM_PRESET=elastic
PROBECTL_SIEM_AUTH_SCHEME=basic
PROBECTL_SIEM_TOKEN=<user>:<password> # encoded for you; inject it as a secret
PROBECTL_SIEM_ENDPOINT=https://opensearch.example:9200/probectl-events/_doc
The token is never logged and never appears in a response; basic base64-encodes
it so an operator does not have to pre-encode a credential into configuration.
Delivery guarantees (no drops)
A SIEM is a security audit destination, so the design rule is: never silently drop an event — a gap in the security record is indistinguishable from an attacker erasing tracks. The two streams reach that guarantee by different means.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
subgraph control[Control plane]
A[Audit chain<br/>per tenant] -->|Drain from cursor| P[SIEM audit poller]
T[Threat consumers<br/>TLS / IOC] -->|Enqueue| B[(bounded buffer)]
P -->|Deliver| F[Forwarder<br/>format + retry]
B --> F
C[(siem_delivery<br/>per-tenant cursor)] <-->|advance only past delivered| P
end
F -->|HTTPS + auth| S[Operator SIEM]
- Audit path —
SIEMAuditPollerdrains each tenant's audit events from a durable per-tenant cursor (siem_delivery, RLS-scoped). A cursor is a persisted bookmark: "everything before this point was delivered." It drains one bounded page transaction and advances the committed cursor only past events the SIEM acknowledged — like a registered-mail clerk who crosses an item off the ledger only when the signed receipt is in hand. So a restart resumes exactly where it paused — no drops, and (outside a narrow crash window) no re-sends. - Replica concurrency — each page transaction creates the cursor row when
needed and locks it with
SELECT ... FOR UPDATEbefore reading audit events. A second replica waits, then observes the committed cursor, so overlapping pollers do not forward the same page twice. A process crash after remote acknowledgment but before commit can still replay that final event; SIEM receivers should therefore keep their documented(tenant_id, audit.seq)idempotency key. This is at-least-once delivery with no gaps, not a false exactly-once claim. - Threat path — consumers enqueue signals into a bounded buffer; when it is full, producers block (backpressure — the pipeline slows down rather than throwing events away) instead of dropping. A worker delivers with exponential-backoff retry (each failed attempt waits twice as long, up to a ceiling, so a struggling SIEM is never hammered).
- Outage handling — a SIEM outage pauses the cursor (the poller commits whatever was delivered and resumes next tick); the buffer applies backpressure. Nothing is silently discarded.
Governance & redaction
Exported audit events are scrubbed of secrets / PII before they leave the network
— the SIEM gets the security record, never a copy of the credentials inside it.
A built-in case-insensitive denylist (password, passwd, secret, token,
api_key, apikey, authorization, cookie, private_key, client_secret,
ssn) plus any keys in PROBECTL_SIEM_REDACT_KEYS are matched on the audit
record's data keys. A redacted value becomes [redacted] — the key is kept, so
the SIEM still sees the shape of the event without the sensitive value.
After key redaction, the audit-to-SIEM mapper also runs the core governance
PII scanner over actor, action, target, outcome, and every remaining data value
before any syslog/CEF/ECS/OTLP formatter sees the event. That scanner masks
emails and IPs, strips URL query strings/fragments and path IDs, and blanks
embedded bearer tokens or token=... / password=...-style secrets.
Security
- TLS out — delivery uses the hardened, certificate-validating HTTP client
(
crypto.HardenedHTTPClient); validation is never disabled. The ingest token is sent only as an auth header, never in a URL (URLs land in proxy and access logs; headers do not). - Tenant isolation — the audit poller drains inside each tenant's RLS scope; the tenant stamped on every exported record is the drained scope's tenant, never a value from the event body. One tenant's data can never be forwarded under another's id.
- Secrets — the ingest token is runtime config; inject it from a secret manager, never commit it, and it is never logged.
- Syslog in — inbound syslog is stamped with the tenant from the registered
source, not from the payload. The source must authenticate with
sha256=<hmac>over the exact line or with a configured TLS client subject; malformed, oversized, unsigned, forged, or rate-limited messages are rejected before storage.
Checking delivery posture
Three equivalent surfaces answer "is this actually being forwarded, and to what":
- UI — Admin → SIEM export posture. Says whether the exporter is running, which streams are
forwarded, the resolved format and preset, the destination
host:port, the back-pressure policy, the poll cadence and buffer size, and how many redaction keys are in effect. When it is not delivering it shows the server's own reason (disabled,missing_endpoint,insecure_endpoint,invalid_format) rather than an empty panel. - CLI —
probectl siem status. - API —
GET /v1/siem/status(requiresthreat.read).
All three are posture, never values. The endpoint path, its query string and the ingest token
are never returned by the API, so no surface can display them; the endpoint appears as scheme-free
host:port and the credential as configured / not configured. Read that as designed, not as
missing detail — see Security above.
Configuration
See configuration.md for the full key table.
Minimal Splunk HEC example:
PROBECTL_SIEM_ENABLED=true
PROBECTL_SIEM_PRESET=splunk
PROBECTL_SIEM_ENDPOINT=https://splunk.example:8088/services/collector/raw
PROBECTL_SIEM_TOKEN=<hec-token>