Writing · security
Breaking up Wazuh: a small collector, NATS, TimescaleDB and Sigma
By Mattias Hemmingsson · · 5 min read
A standard Wazuh install has four big parts: the manager, a worker, the indexer (OpenSearch) and the dashboard. The manager and worker are light. OpenSearch is not. It wants gigabytes of heap and fast disks, and it is the part that keeps falling over on a small cluster.
In Samma we only need the part of Wazuh that talks to agents. Storage, search, detection and dashboards we already have in our own SIEM pipeline. So we broke Wazuh up.
Before and after
Before: agents ──► manager/worker ──► Filebeat ──► OpenSearch ──► Wazuh dashboard
After: agents ──► manager/worker ──► Vector ──► NATS JetStream ──┬──► siem-storage ──► TimescaleDB
(collector mode) sidecar samma.logs.wazuh │
└──► detection-engine (Sigma) ──► alerts
What we kept: agent enrollment (1515), agent events (1514), the Wazuh decoders and rules. Wazuh still turns raw logs into alerts like "sshd: authentication failed" (rule 5760) or "agent enrolled" (rule 501).
What we removed: the indexer, the dashboard, Filebeat, Keycloak and vulnerability detection.
Collector mode in the Helm chart
Our Wazuh Helm chart (samma-io/wazuh-kubernetes) got a new mode. The default still renders the full stack, byte for byte. mode: collector renders only the manager and the worker:
mode: collector
collector:
logallJson: false # true also ships archives (every event, not only alerts)
vector:
enabled: true
natsUrl: nats://nats.samma.svc:4222
subject: samma.logs.wazuh
ingress:
enabled: false
keycloak:
enabled: false
services:
external: false # no Wazuh API exposed
agentNodePorts:
enabled: true # only 1515 (enroll) and 1514 (events)
helm upgrade --install wazuh-collector chart/ -n samma \
-f chart/values-collector-v2.yaml -f my-private-credentials.yaml
In collector mode the chart turns off the indexer and vulnerability detection in ossec.conf, so the manager doesn't sit trying to reach an OpenSearch that isn't there. It also creates queue/tasks, otherwise the master's task manager exits on start.
agentNodePorts opens just the two ports an agent needs, on fixed NodePorts 30515 and 30514. The Wazuh API (55000) stays inside the cluster.
Vector: from alerts.json to NATS
Each manager pod gets a Vector sidecar. It tails /var/ossec/logs/alerts/alerts.json, adds a small envelope, and publishes one alert per message to NATS JetStream:
sources:
wazuh_alerts:
type: file
include: [/wazuh/alerts/alerts.json]
sinks:
nats_logs_wazuh:
type: nats
inputs: [wazuh_envelope]
url: ${NATS_URL}
subject: ${WAZUH_LANE_SUBJECT} # samma.logs.wazuh
jetstream: true
buffer:
type: disk
when_full: block
The envelope adds source: wazuh, feed: alerts, event_time (taken from the alert's own timestamp) and search_text (a copy of full_log). Everything else is the Wazuh alert as it was.
Two settings matter for not losing alerts:
- End-to-end acknowledgements + a disk buffer. Vector only moves its file position forward once the record is buffered for publish. The buffer and the checkpoints live on the manager's PVC, so a pod restart picks up where it left off.
- The sidecar runs as uid 999, the Wazuh user that owns
alerts.json, with a read-only root filesystem and all capabilities dropped.
Why NATS in the middle
Putting NATS JetStream between Wazuh and the rest is what makes this design work.
- Many Wazuh clusters, one pipeline. Every collector publishes to the same subject. Two, five or twenty Wazuh managers in different networks or for different customers all land in one stream. Storage and detection don't care how many there are.
- Scale each part on its own. Add Wazuh workers when you have more agents. Add detection-engine replicas when you have more rules. They never talk to each other directly.
- Several readers, one copy. The stream has two independent durable consumers:
storage-logswrites to TimescaleDB,detection-engine-wazuhruns the rules. A slow database never holds back detection. - Back pressure instead of data loss. If storage is down, messages wait in JetStream. If NATS is down, Vector's disk buffer fills and then blocks.
TimescaleDB instead of OpenSearch
siem-storage reads the stream and writes every record into one TimescaleDB hypertable, siem_events:
time | lane | subject | stream_seq | line | record (jsonb)
time is the alert's own event time. A redelivered message keeps its stream sequence, and inserts are ON CONFLICT DO NOTHING, so a retry never makes a duplicate row. Retention is a TimescaleDB policy, set from one environment variable.
For our volume, Postgres with Timescale does everything we used OpenSearch for: time-range queries, JSON filters on record, and Grafana panels. It runs in a fraction of the memory, and it is a database we already run anyway.
Sigma rules instead of a custom engine
Detection used to be our own YAML rule format. We replaced it with Sigma, the open, vendor-neutral rule format the rest of the industry is moving to, Wazuh included. The same detection-engine runs every lane (AWS, GitHub, Kubernetes, Wazuh, osquery). On Kubernetes it reads from NATS with ENGINE_TRANSPORT=nats and publishes matches to samma.alerts.wazuh.
A Wazuh alert is plain JSON, so a Sigma rule can match on any field, nested ones included:
title: Wazuh Agent Connected For The First Time Or Removed
status: experimental
level: low
tags: [attack.defense-evasion, attack.t1562.001]
logsource:
product: wazuh
service: alerts
detection:
selection:
feed: alerts
rule.id: ['501', '505']
condition: selection
falsepositives:
- Planned enrollment or decommissioning of a host.
The Wazuh lane ships with nine rules today: failed logins, SSH brute force, failed sudo, first-time sudo, user account changes, crontab changes, AppArmor denials, agent enrolled/removed and any alert at level 12 or above. Every rule has a .tests.yml next to it with records that must and must not match, and CI runs them.
Wazuh still does what it is good at: decoding hundreds of log formats into structured alerts. Sigma decides what is worth a human looking at. And the same Sigma rules work on alerts from every Wazuh cluster on the stream.
What you give up
Be honest about the trade-off. In collector mode there is:
- No Wazuh dashboard. You look at data in Grafana or the Samma dashboard.
- No vulnerability detection. It needs the indexer.
- No search inside Wazuh. Search is in TimescaleDB.
For us that is fine, because all of those live elsewhere in Samma. If you need the Wazuh UI, keep the default mode. The chart does both.
Result
The Wazuh part of our cluster went from four StatefulSets, with OpenSearch as the heaviest, to two small manager pods with a 20m CPU Vector sidecar each. Alerts from any number of Wazuh clusters now flow into one pipeline, get stored once, and are checked by the same Sigma rules as everything else.
Next posts: running the Wazuh agent in Docker and a new osquery lane into Samma.
Filed underwazuh · siem · nats · timescaledb · sigma · kubernetes · samma