Wazuh Detection Engineering, Part 1: How an Event Becomes an Alert
Trace a log line from a Wazuh agent through remoted, analysisd, decoders, and rules to an alert in the indexer, and learn where each hook for custom detection lives.
Copy for your AI agent. A condensed version of this entry written as a prompt, so Claude, Cursor, or Copilot can apply it to your own codebase.
View prompt · Wazuh pipeline mental model
Task: Explain and set up the Wazuh detection pipeline for a new environment.
Architecture (data flows top to bottom):
- Agent:
wazuh-agentd,logcollector(log files, journald, Windows events),syscheck(FIM),rootcheck,sca,wazuh-modulesd - Manager:
wazuh-remoted(TCP 1514) →wazuh-analysisd(pre-decode → decode → rules) →alerts.json wazuh-authd(TCP 1515) enrols agents. Manager API on TCP 55000.- Filebeat ships
alerts.jsonto the indexer (OpenSearch, 9200); dashboard reads the indexer.
Key paths on the manager:
/var/ossec/etc/ossec.conf # manager config
/var/ossec/etc/rules/local_rules.xml # custom rules (ids 100000+)
/var/ossec/etc/decoders/local_decoder.xml # custom decoders
/var/ossec/logs/alerts/alerts.json # alerts >= alert_level
/var/ossec/logs/archives/archives.json # every event, only if logall_json=yes
/var/ossec/bin/wazuh-logtest # test a log line against decoders+rulesLab:
git clone https://github.com/wazuh/wazuh-docker.git -b v4.9.0
cd wazuh-docker/single-node
docker compose -f generate-indexer-certs.yml run --rm generator
docker compose up -dKey concepts:
- Rule level 0 to 15; only levels >=
alert_level(default 3) are written to alerts.json. - Custom hooks: decoder (parse fields), rule (decide), active response (act).
- Use
wazuh-logtestto see decoder phases and the winning rule before deploying.
Most people meet Wazuh through its dashboard. They install it, agents start reporting, the “Security events” tab fills up with thousands of level 3 alerts about SSH sessions and package updates, and the tool gets filed under “noisy”. That is not a Wazuh problem. It is what happens when you use a detection pipeline without knowing what the pipeline does.
This series is about running Wazuh the way a detection engineer runs it: knowing exactly which component touches an event at each step, writing decoders and rules for your own applications, responding automatically without breaking production, and tuning volume down to what a human can act on. This first part is the map. Nothing in the later parts makes sense without it.
The components, and what each one is for
Wazuh is four processes pretending to be one product. Knowing which one does what tells you where a problem lives.
The agent runs on the host you want to watch. It is a bundle of collectors: logcollector tails files, journald, and Windows event channels; syscheck is file integrity monitoring; rootcheck looks for rootkits and policy violations; sca runs configuration assessment benchmarks; wazuh-modulesd hosts the vulnerability scanner, osquery, Docker listener, and cloud integrations. The agent does no detection. It ships events.
The manager is where detection happens. Two daemons matter. wazuh-remoted listens on TCP 1514, terminates the encrypted agent connections, and hands events onward. wazuh-analysisd is the engine: it decodes every event into fields, runs the ruleset over those fields, and writes anything that scores high enough to alerts.json. A third daemon, wazuh-authd on TCP 1515, only handles agent enrolment.
The indexer is an OpenSearch fork. Filebeat on the manager tails alerts.json and ships each alert as a document into a daily index named wazuh-alerts-4.x-YYYY.MM.DD. The indexer is storage and search. It does not decide anything.
The dashboard is an OpenSearch Dashboards fork with Wazuh plugins. It reads the indexer and calls the manager API on TCP 55000 for configuration. Everything you see in the dashboard was decided by analysisd seconds earlier.
When an alert is missing, the question is not “why is the dashboard not showing it”. It is one of: did the agent collect it, did remoted receive it, did analysisd decode it, did a rule match with a high enough level, did Filebeat ship it. Each is a different file to look at, and the dashboard is the last place to check, not the first.
Following one log line
Take a concrete event. A FastAPI service on a web server writes this to /var/log/api/app.log:
{"ts":"2026-08-29T10:14:02Z","service":"api-gateway","event":"auth_failed","user":"alice","src_ip":"203.0.113.9","path":"/login"}
Here is what happens to it, in order.
Collection
The agent’s logcollector is configured to watch that file. The configuration lives on the agent in ossec.conf, or, better, is pushed from the manager through an agent group. Either way it looks like this:
<localfile>
<log_format>json</log_format>
<location>/var/log/api/app.log</location>
</localfile>
log_format matters more than it looks. json tells the pipeline to hand the line to the JSON decoder later. syslog is the generic choice for plain text. Getting this wrong is the most common reason custom logs “never match anything”.
The agent wraps the line with metadata (agent id, name, IP, the location string) and sends it over the encrypted channel to the manager.
Reception
wazuh-remoted receives it, checks the agent’s key, and queues the event for analysis. If the agent shows as disconnected on the manager, you stop here. Check with:
sudo /var/ossec/bin/agent_control -l
Pre-decoding and decoding
wazuh-analysisd first pre-decodes: it extracts the timestamp, hostname, and program name if the line is syslog-shaped. Then it runs decoders. Decoders are ordered pattern matchers that turn the raw line into named fields. Because this line was marked json, the built-in JSON decoder flattens it, and the fields become available to rules as service, event, user, src_ip, path. Nested objects flatten with dots, so {"request":{"path":"/login"}} becomes request.path.
For a plain-text line you would write a decoder yourself. That is Part 2.
Rules
Rules are evaluated as a tree. A parent rule matches broadly, child rules narrow the match with if_sid, and the most specific rule that matches wins. Each rule carries a level from 0 to 15. Our line will, in Part 2, match a rule that looks like this:
<rule id="100101" level="5">
<if_sid>100100</if_sid>
<field name="event">auth_failed</field>
<description>API: failed login for $(user) from $(src_ip)</description>
</rule>
Level 5 is above the default alert_level of 3, so this becomes an alert. If the rule had been level 2, the event would be evaluated, matched, and then dropped silently. That is by design: not every event deserves a row in the database.
Alert output
The alert is written as one JSON document to /var/ossec/logs/alerts/alerts.json, with the rule id, level, description, decoded fields, agent details, and any MITRE ATT&CK mapping the rule declared. This file is the truth. When in doubt, tail it:
sudo tail -f /var/ossec/logs/alerts/alerts.json | jq -c '{rule: .rule.id, level: .rule.level, desc: .rule.description}'
Indexing
Filebeat picks the document up and posts it to the indexer. The dashboard shows it within a few seconds. Active response, if configured for that rule, has already fired by now; it is triggered by analysisd on rule match, not by anything downstream.
By default only alerts are kept. Set <logall_json>yes</logall_json> inside the <global> section of the manager’s ossec.conf and every event, matched or not, lands in /var/ossec/logs/archives/archives.json. It is the only way to answer “did the event arrive at all”. Turn it off again in production; it is large.
The three hooks
Every custom detection you will write attaches to one of three points in that pipeline.
| Hook | Question it answers | File on the manager |
|---|---|---|
| Decoder | ”What are the fields in this line?” | /var/ossec/etc/decoders/local_decoder.xml |
| Rule | ”Is this combination of fields interesting, and how much?” | /var/ossec/etc/rules/local_rules.xml |
| Active response | ”What should happen automatically when a rule fires?” | ossec.conf plus scripts in /var/ossec/active-response/bin/ |
Custom rule ids start at 100000. The stock ruleset owns everything below. Custom files are never overwritten by upgrades; the stock ruleset is. Do not edit files under /var/ossec/ruleset/, ever.
A lab you can break
Do not learn this on the production manager. The official Docker Compose project gives you the full stack on one machine in a few minutes.
git clone https://github.com/wazuh/wazuh-docker.git -b v4.9.0
cd wazuh-docker/single-node
docker compose -f generate-indexer-certs.yml run --rm generator
docker compose up -d
The dashboard comes up on https://localhost with admin / SecretPassword unless you changed the compose file. Give it two to three minutes; the indexer is slow to start the first time.
Enrol an agent from another container or a VM by pointing it at the manager and running the enrolment through wazuh-authd:
# on the agent host
curl -sO https://packages.wazuh.com/4.x/apt/pool/main/w/wazuh-agent/wazuh-agent_4.9.0-1_amd64.deb
sudo WAZUH_MANAGER="manager.lab" WAZUH_AGENT_GROUP="webservers" dpkg -i wazuh-agent_4.9.0-1_amd64.deb
sudo systemctl enable --now wazuh-agent
WAZUH_AGENT_GROUP matters. Groups let you push different localfile and syscheck configuration to different classes of host from the manager, in /var/ossec/etc/shared/<group>/agent.conf. You will lean on groups in Part 4 when tuning per host class.
The one tool you need before the dashboard
wazuh-logtest runs a log line through the exact decoders and rules the manager has loaded and prints every phase. It is how you develop detections.
sudo /var/ossec/bin/wazuh-logtest
Paste the JSON line from earlier and you get something like:
**Phase 1: Completed pre-decoding.
full event: '{"ts":"2026-08-29T10:14:02Z","service":"api-gateway",...}'
**Phase 2: Completed decoding.
name: 'json'
service: 'api-gateway'
event: 'auth_failed'
user: 'alice'
src_ip: '203.0.113.9'
path: '/login'
**Phase 3: Completed filtering (rules).
id: '100101'
level: '5'
description: 'API: failed login for alice from 203.0.113.9'
**Alert to be generated.
If Phase 2 shows no fields, your decoder is wrong. If Phase 3 shows no rule, or the wrong rule, your rule tree is wrong. If both look right and nothing appears in the dashboard, the problem is collection or shipping, not detection. This single tool removes most of the guessing.1
analysisd loads rules and decoders at start. Editing local_rules.xml does nothing until sudo /var/ossec/bin/wazuh-control restart (or systemctl restart wazuh-manager). wazuh-logtest also reloads on start, so restart it too after edits.
Where this series goes
Part 2 writes decoders and rules for that FastAPI log, including a frequency rule that turns eight failed logins from one address into a level 10 brute-force alert with a MITRE technique attached. Part 3 attaches an active response to that rule and makes sure it can never lock the on-call engineer out. Part 4 measures alert volume and brings it down with overwrites, level-0 children, and agent groups, so the alerts that remain are ones somebody will read.
Keep the lab running. Every part builds on it.
- Wazuh architecture Official component diagram and data flow between agent, manager, indexer, and dashboard
- Wazuh ruleset overview How decoders and rules are organised, and the reserved id ranges
- Testing decoders and rules with wazuh-logtest Reference for the phases printed by wazuh-logtest
- Alerts configuration reference (alert_level, logall_json) The manager settings that decide which events become alerts
- wazuh-docker single-node deployment The Compose project used for the lab in this series
- Log data collection (localfile) reference log_format values and how the agent collects files, journald, and Windows events
Footnotes
-
The tool replaced the older
ossec-logtestin Wazuh 4.2. If you find guides referencing the old name, the input and output are the same. ↩