Reeflex SIEM Integration¶
Every /v1/decide call can emit a structured syslog event to a SIEM collector.
The emitter is disabled by default and is fire-and-forget: it never blocks
the decision gate.
1. Quickstart¶
Set three environment variables and restart the container:
To verify reception before wiring a full SIEM, open a UDP listener on the collector host and fire one decision:
# On the collector host
nc -luk 514
# In another terminal — trigger any decision to generate an event
curl -s -X POST http://localhost:8080/v1/decide \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $REEFLEX_API_TOKEN" \
-d '{"session_id":"test-001","agent_id":"agent-a","ability":"wordpress/post.create",
"verb":"create","axes":{"reversibility":"reversible",
"blast_radius":"single","externality":"internal"},
"magnitude":{"count":1},"environment":"staging","on_behalf_of":"alice"}'
Expected line received on nc (JSON format, one RFC 5424 syslog line):
<134>1 2026-07-03T12:00:00Z my-host reeflex 42 decision - {"ts":"2026-07-03T12:00:00Z","event":"decision","verdict":"allow","rule_id":"reeflex.core/R1_allow","verb":"create","ability":"wordpress/post.create","axes":{"reversibility":"reversible","blast_radius":"single","externality":"internal"},"magnitude_count":1,"session_id":"test-001","agent_id":"agent-a","on_behalf_of":"alice","environment":"staging","mode":"enforce","decision_latency_ms":3,"reason":"","reeflex_version":"0.1.8","epoch_ms":1751543200000}
All values above are synthetic examples. Real values depend on your envelope and policy configuration.
2. Event reference¶
2.1 Decision event — JSON schema¶
Emitted once after every /v1/decide response, just before returning to the
caller. Fields sourced from DECISION_EVENT_FIELDS in telemetry.py.
| Field | Type | Meaning |
|---|---|---|
ts |
string (RFC3339 UTC) | Timestamp of the decision |
event |
string | Always "decision" |
verdict |
string | allow | deny | require_approval |
rule_id |
string | Fired OPA rule identifier |
verb |
string | Action verb (read, delete, execute, …) |
ability |
string | Fully-qualified ability name (namespace/verb) |
axes.reversibility |
string | Reversibility axis value |
axes.blast_radius |
string | Blast-radius axis value |
axes.externality |
string | Externality axis value |
magnitude_count |
integer | magnitude.count integer from the envelope |
session_id |
string | Agent session identifier |
agent_id |
string | Agent identifier |
on_behalf_of |
string | User the agent acts on behalf of |
environment |
string | Target environment (production, staging, …) |
mode |
string | Enforcement mode: enforce | observe |
decision_latency_ms |
integer | Wall-clock ms between start and end of decide.process() |
reeflex_version |
string | reeflex-core engine version string |
Additional fields present in the wire message (not in DECISION_EVENT_FIELDS
but emitted by the engine):
| Field | Type | Meaning |
|---|---|---|
epoch_ms |
integer | Decision timestamp as milliseconds since Unix epoch (used by CEF rt) |
reason |
string | Human-readable reason from OPA (may be empty string) |
Traceability fields (additive):
| Field | Type | Meaning |
|---|---|---|
decision_id |
string | uuid4 hex primary key of this /v1/decide transit. Always present. |
envelope_hash |
string | sha256 hex of the {action, axes, magnitude, target} projection — the same value as the hold record's envelope_hash. Always present. |
hold_id |
string | Present only when a hold is involved: the hold this decision just created (require_approval), or the hold this decision just consumed (a resolved resubmission). Key absent otherwise. |
parent_decision_id |
string | Present only on a resolved resubmission: the decision_id of the original require_approval decision that created the consumed hold. Key absent otherwise. |
traceparent |
string | Opaque W3C trace-context string, echoed verbatim from envelope.context.traceparent. No OpenTelemetry SDK, no spans. Key absent when the envelope did not carry one. |
Sample JSON event¶
{
"ts": "2026-07-03T12:00:00Z",
"event": "decision",
"verdict": "deny",
"rule_id": "reeflex.core/R3_deny",
"verb": "delete",
"ability": "wordpress/post.delete",
"axes": {
"reversibility": "irreversible",
"blast_radius": "systemic",
"externality": "external"
},
"magnitude_count": 50,
"session_id": "sess-abc123",
"agent_id": "agent-writer-01",
"on_behalf_of": "bob",
"environment": "production",
"mode": "enforce",
"decision_latency_ms": 7,
"reason": "irreversible + systemic + production",
"reeflex_version": "0.1.8",
"epoch_ms": 1751543200000,
"decision_id": "9ca1ebe9b72d42ec840a8eafad5f0702",
"envelope_hash": "d4302672fd72b87145ae5ad5d64679134b7699a2948c9738b570a36081cb512"
}
All values are synthetic examples. hold_id, parent_decision_id, and
traceparent are omitted here since this sample is a plain deny with no
hold and no trace-context on the envelope.
2.2 CEF format¶
When REEFLEX_SYSLOG_FORMAT=cef, the MSG portion of the syslog frame is a
CEF:0 string:
The extension mapping below is sourced from CEF_MAPPING_TABLE in
telemetry.py. Label fields (cs1Label, cs2Label, etc.) are emitted as
literal strings alongside their value field.
| CEF key | CEF label / note | Reeflex field |
|---|---|---|
rt |
timestamp ms | epoch_ms of the decision |
act |
action verb | verb |
suser |
subject user | on_behalf_of |
cs1 |
session_id | session_id |
cs1Label |
label for cs1 | literal 'session_id' |
cs2 |
agent_id | agent_id |
cs2Label |
label for cs2 | literal 'agent_id' |
cs3 |
reversibility | axes.reversibility |
cs3Label |
label for cs3 | literal 'reversibility' |
cs4 |
blast_radius | axes.blast_radius |
cs4Label |
label for cs4 | literal 'blast_radius' |
cs5 |
externality | axes.externality |
cs5Label |
label for cs5 | literal 'externality' |
cs6 |
environment | target environment |
cs6Label |
label for cs6 | literal 'environment' |
cn1 |
magnitude_count | magnitude.count integer |
cn1Label |
label for cn1 | literal 'magnitude_count' |
cn2 |
decision_latency_ms | decision latency in ms |
cn2Label |
label for cn2 | literal 'decision_latency_ms' |
msg |
reason | human-readable reason from OPA |
flexString1 |
mode | enforce | observe |
flexString1Label |
label | literal 'mode' |
externalId |
decision_id | uuid4 hex primary key of this /v1/decide transit. Always present. |
envelopeHash |
envelope_hash | sha256 hex of the {action,axes,magnitude,target} projection. Always present. |
holdId |
hold_id | present only when a hold is involved (created or consumed) |
parentDecisionId |
parent_decision_id | present only on a resolved resubmission |
traceparent |
traceparent | opaque W3C trace-context string, echoed verbatim; present only if the envelope carried one |
The rule_id appears as the CEF EventID (4th pipe-delimited header field).
The verdict is the CEF Name (5th field). externalId and envelopeHash
are standard/self-describing CEF keys used directly (no cs/cn slot was
free); holdId, parentDecisionId, and traceparent are non-standard,
self-describing custom keys for the same reason.
Sample CEF line¶
CEF:0|Reeflex|reeflex-core|0.1.8|reeflex.core/R3_deny|deny|3|rt=1751543200000 act=delete suser=bob cs1=sess-abc123 cs1Label=session_id cs2=agent-writer-01 cs2Label=agent_id cs3=irreversible cs3Label=reversibility cs4=systemic cs4Label=blast_radius cs5=external cs5Label=externality cs6=production cs6Label=environment cn1=50 cn1Label=magnitude_count cn2=7 cn2Label=decision_latency_ms msg=irreversible + systemic + production flexString1=enforce flexString1Label=mode externalId=9ca1ebe9b72d42ec840a8eafad5f0702 envelopeHash=d4302672fd72b87145ae5ad5d64679134b7699a2948c9738b570a36081cb512
All values are synthetic examples. holdId, parentDecisionId, and
traceparent extensions are omitted here since this deny has no hold and
no trace-context on the envelope.
2.3 Severity map (RFC 5424)¶
| Verdict / event type | RFC 5424 severity code | Label |
|---|---|---|
allow |
6 | informational |
require_approval |
4 | warning |
deny |
3 | error |
lifecycle |
5 | notice |
kill_switch |
2 | critical |
The PRI byte in the syslog frame is facility_code * 8 + severity_code.
Default facility is local0 (code 16), so a deny event has PRI 16*8+3 = 131,
i.e. <131>.
2.4 Additional event types¶
lifecycle — emitted on engine start and stop. Shape:
MSGID in the syslog header is lifecycle. Severity: 5 (notice).
kill_switch — emitted when the freeze kill-switch (REEFLEX_FREEZE) flips.
On a state change reeflex-core calls emit_kill_switch() (freeze engaged →
flipped, freeze cleared → cleared), alongside the freeze.flipped audit
record + webhook. It emits the following shape with severity 2 (critical) and
MSGID kill_switch:
| Field | Type | Meaning |
|---|---|---|
ts |
string (RFC3339 UTC) | Timestamp of the kill-switch flip |
event |
string | Always "kill_switch" |
action |
string | flipped | cleared | queried |
reason |
string | Human-readable reason for the flip |
reeflex_version |
string | reeflex-core engine version string |
A kill-switch event is severity critical (2) and should trigger an on-call alert in any properly configured SIEM.
3. Consuming from your platform¶
Reeflex emits standard RFC 5424 syslog over UDP, TCP, or TLS. The guides below show input/receiver configuration and one example search per platform. No Reeflex-specific dashboards or detection packs are shipped — you own the alerting logic.
Splunk¶
Add a UDP or TCP syslog input in Splunk Web (Settings > Data inputs > UDP/TCP):
- Source type:
syslog - Port:
514(or your chosen port) - Index: e.g.
reeflex
For JSON format, also add a TRANSFORMS entry to extract the JSON blob from the
MSG field:
# props.conf
[syslog]
TRANSFORMS-reeflex = reeflex_json_extract
# transforms.conf
[reeflex_json_extract]
REGEX = \} (\{.+\})$
FORMAT = _raw::$1
SOURCE_KEY = _raw
Example SPL search (find all deny decisions):
IBM QRadar¶
- Log Source Type: Syslog
- Protocol: UDP or TCP (use TCP for QRadar > 7.5 to avoid datagram loss)
- Log Source Identifier:
reeflex-core - Parse the MSG JSON body using a Custom Log Source Extension (LSX)
pointing to the
eventandverdictfields.
Example AQL query (require_approval events in the last hour):
SELECT "sourceip", UTF8(payload) AS raw, "starttime"
FROM events
WHERE LOGSOURCETYPENAME(devicetype) = 'Syslog'
AND UTF8(payload) LIKE '%"verdict":"require_approval"%'
LAST 1 HOURS
Wazuh¶
Add a remote syslog input in /var/ossec/etc/ossec.conf:
<ossec_config>
<remote>
<connection>syslog</connection>
<port>514</port>
<protocol>udp</protocol>
<allowed-ips>REEFLEX_CORE_IP/32</allowed-ips>
</remote>
</ossec_config>
Then add a custom decoder in /var/ossec/etc/decoders/reeflex.xml:
<decoder name="reeflex-decision">
<prematch>reeflex.*"event":"decision"</prematch>
<regex>"verdict":"(\w+)".*"agent_id":"(\S+)"</regex>
<order>verdict, agent_id</order>
</decoder>
Example alert rule (deny events create a level-10 alert):
<rule id="100500" level="10">
<decoded_as>reeflex-decision</decoded_as>
<match>verdict=deny</match>
<description>Reeflex: action denied by policy</description>
</rule>
FortiSIEM¶
- Admin > Device Support > Event Pulling: add a Syslog listener on the collector port.
- Create a Parser under Analytics > Rules: extract
verdict,agent_id,session_idfrom the JSON MSG field using JSON Path attribute mapping. - Map
reeflex-coreto a new Device TypeReeflex Core Engine.
Example incident rule query:
eventType = "reeflex-decision" AND verdict = "deny"
GROUP BY agent_id
HAVING COUNT(*) > 10 IN 5 MINUTES
Graylog¶
- Create a Syslog UDP or Syslog TCP input (System > Inputs).
- Enable Store full message to retain the JSON body.
- Add a JSON extractor on the
messagefield to auto-parse all JSON keys as Graylog fields.
Example search (all decisions for a specific agent):
Grafana Loki (promtail syslog)¶
Add a syslog listener block to promtail.yaml:
scrape_configs:
- job_name: reeflex_syslog
syslog:
listen_address: 0.0.0.0:514
label_structured_data: true
labels:
job: reeflex-core
pipeline_stages:
- json:
expressions:
verdict: verdict
agent_id: agent_id
environment: environment
- labels:
verdict:
environment:
Example LogQL query (deny rate per minute):
Datadog¶
Enable the Syslog integration or use the Datadog Agent syslog listener.
In datadog.yaml:
logs:
- type: udp
port: 514
service: reeflex-core
source: syslog
log_processing_rules:
- type: multi_line
name: reeflex_json
pattern: '^\{'
Example Logs query (approval-required events in production):
Logstash (native syslog input)¶
Use the built-in syslog input plugin — no additional config on the Reeflex
side:
input {
syslog {
port => 514
type => "reeflex"
}
}
filter {
if [type] == "reeflex" {
json {
source => "message"
target => "reeflex"
}
}
}
output {
elasticsearch {
hosts => ["https://es-host:9200"]
index => "reeflex-%{+YYYY.MM.dd}"
}
}
Example Kibana KQL search:
Filebeat (tail the audit JSONL)¶
For deployments where syslog is not available, Filebeat can tail the audit log directly. The audit JSONL is the authoritative record — syslog and JSONL carry the same decision data.
Audit log path (default, override with REEFLEX_AUDIT_LOG):
- Bare Python:
./audit/decisions.jsonl - Docker / reeflex-wordpress plugin: typically
wp-content/uploads/reeflex/audit/decisions.jsonl(exact path set by the adapter)
filebeat.inputs:
- type: filestream
id: reeflex-audit
paths:
- /var/lib/reeflex/audit/decisions.jsonl
parsers:
- ndjson:
target: reeflex
overwrite_keys: true
output.logstash:
hosts: ["logstash-host:5044"]
No configuration required on the Reeflex side — this is a read-only tail of an existing file.
Fluentd (tail the audit JSONL)¶
Same approach as Filebeat — tail the JSONL file, no Reeflex config needed:
<source>
@type tail
path /var/lib/reeflex/audit/decisions.jsonl
pos_file /var/log/td-agent/reeflex-audit.pos
tag reeflex.audit
<parse>
@type json
</parse>
</source>
<match reeflex.audit>
@type elasticsearch
host es-host
port 9200
index_name reeflex_audit
</match>
Forwarding to Kafka¶
A Fluentd, Logstash, or Vector sidecar can bridge Reeflex syslog or JSONL
events to a Kafka topic with no changes to reeflex-core. Example using
Fluentd's kafka2 output plugin:
<source>
@type syslog
port 514
bind 0.0.0.0
tag reeflex.syslog
<parse>
@type json
</parse>
</source>
<match reeflex.syslog>
@type kafka2
brokers kafka-broker:9092
default_topic reeflex-decisions
<format>
@type json
</format>
</match>
A native Kafka emitter built into reeflex-core is not planned — it would add an external dependency to a stdlib-only engine. If real enterprise demand emerges, a separate sidecar process (not a core module) is the correct architecture.
4. The invariant¶
Fail-closed for decisions, fail-open for telemetry.
Telemetry is fire-and-forget:
- A bounded in-memory queue (1 000 messages) holds outbound syslog messages.
- One background daemon thread drains the queue and does all socket I/O.
emit()callsqueue.put_nowait()inside atry/except queue.Fulland returns immediately. It never raises into/v1/decide.- Socket errors, DNS failures, slow or unreachable endpoints, TLS handshake failures, and reconnection delays are all swallowed in the worker thread.
- When the queue is full, the event is dropped and the module-level
dropped_eventscounter is incremented. The counter is readable viaget_dropped_count()and is logged at engine shutdown. - When
REEFLEX_SYSLOG_ENABLEDisfalse(the default), no thread is spawned and everyemit()call is a one-line no-op — zero overhead on the decision path.
The audit JSONL remains the authoritative record of every decision. Syslog telemetry is a real-time observability feed, not a replacement for the audit log.
Consuming Reeflex in Wazuh¶
A ready-to-use community example integration lives in
integrations/wazuh/: a decoder (Wazuh-native JSON
decoding + a cheap prematch), one base rule (level 3, rule.id 100200), and an
example CISO/SOC dashboard — all driven by the standard syslog feed above, with
no custom connector, agent, or plugin. See
integrations/wazuh/README.md for the full
setup (emit → receive → decode → rule → dashboard), the decoded field list
(incl. namespace/agent_id for the calling module, params/target_ref for
the exact command, and srcip for the caller IP), and the optional GeoIP note.
Marked community example, unmaintained — parsing is legible; what you build on
it is yours.