Wazuh ships with thousands of built-in rules, but real environments always need custom detections. A new application, an unusual log format, or a threat specific to your infrastructure — eventually you’ll need to write your own decoder and rule.
This tutorial walks through the full process from scratch. By the end you’ll understand how Wazuh transforms a raw log line into a structured alert, and you’ll have a working decoder-rule pair you can deploy or use as a template.
If you want to follow along interactively, open the Wazuh Rule Builder in another tab. It runs a simplified version of the Wazuh engine entirely in your browser — no Wazuh installation required.
Understanding the pipeline
Wazuh processes every log line through two stages:
Raw log → Decoder (field extraction) → Rule (pattern matching) → AlertDecoders parse raw log lines and extract structured fields like source IP, username, and action. Rules evaluate those fields and decide whether to fire an alert. Without a matching decoder, rules have nothing to work with.
This is the most important concept in Wazuh rule development: the decoder comes first. A well-written decoder makes rules simple. A bad decoder makes everything downstream fragile.
Step 1: Read the log
Before writing anything, study the log format you want to detect. Here’s an SSH failed login from /var/log/auth.log:
Jun 14 15:16:01 web01 sshd[2001]: Failed password for invalid user admin from 10.0.0.50 port 22 ssh2Break it into parts:
| Field | Value | Source |
|---|---|---|
| Timestamp | Jun 14 15:16:01 | Syslog header |
| Hostname | web01 | Syslog header |
| Program | sshd | Syslog header |
| PID | 2001 | Syslog header |
| Message | Failed password for invalid user admin from 10.0.0.50 port 22 ssh2 | Application |
Wazuh automatically extracts the syslog header fields (timestamp, hostname, program name). Your decoder only needs to handle the application message.
Step 2: Write the decoder
A decoder tells Wazuh how to extract fields from the message portion of the log. Here’s the structure:
<decoder name="sshd-failed">
<program_name>sshd</program_name>
<prematch>Failed password</prematch>
<regex>Failed password for (\S+) from (\S+) port</regex>
<order>dstuser,srcip</order>
</decoder>Each element serves a specific purpose:
<program_name> — Filter by source
This restricts the decoder to logs from a specific program. Wazuh compares it against the program name extracted from the syslog header. In our case, only lines from sshd will reach this decoder.
This is important for performance. Without program_name, the decoder would evaluate against every log line the manager receives.
<prematch> — Fast pre-filter
Before running the full regex, Wazuh checks whether the log line contains this pattern. Think of it as a cheap first pass. If the line doesn’t contain Failed password, the decoder stops here.
<regex> — Capture fields
This is where field extraction happens. Each capture group () extracts a value. The regex above captures two fields:
(\S+)after “for ” → the target username(\S+)after “from ” → the source IP
Note that SSH logs have two formats: Failed password for admin from ... (valid user) and Failed password for invalid user admin from ... (invalid user). This simple regex captures admin for valid users and invalid for invalid users. To handle both formats cleanly, you’d use two decoders or a more specific regex — but for a first rule, catching the event is more important than perfect field extraction.
\S+ matches one or more non-whitespace characters. It’s the workhorse of Wazuh regex — more precise than .* because it stops at spaces.
<order> — Name the fields
The order element maps capture groups to field names, left to right. The first (\S+) becomes dstuser, the second becomes srcip.
Use standard Wazuh field names when possible:
| Field | Meaning |
|---|---|
srcip | Source IP address |
dstip | Destination IP address |
srcuser | User performing the action |
dstuser | Target user |
srcport | Source port |
protocol | Protocol name |
action | Action taken |
status | Result or status |
url | Request URI |
data | Generic data field |
First match wins
Wazuh evaluates decoders in order. The first decoder that matches a log line is used — no further decoders are checked. This means decoder order matters. Put more specific decoders before generic ones.
Step 3: Test the decoder
If you’re using the Wazuh Rule Builder, load the “SSH Auth Logs” preset, paste the decoder above, and click Test Rules. You should see:
- Lines with “Failed password” match the
sshd-faileddecoder - The
dstuserandsrcipfields are extracted - Lines with “Accepted” don’t match (no decoder hit)
On a production Wazuh manager, use wazuh-logtest to verify:
/var/ossec/bin/wazuh-logtestPaste a log line and the tool shows which decoder matched and what fields were extracted.
Step 4: Write a basic rule
Now that the decoder extracts fields, write a rule that fires when it matches:
<rule id="100001" level="5">
<decoded_as>sshd-failed</decoded_as>
<description>SSH failed password attempt</description>
</rule>id — Unique identifier
Custom rules should use IDs starting at 100000 to avoid colliding with built-in Wazuh rules.
level — Alert severity
Wazuh levels range from 0 to 16:
| Range | Meaning |
|---|---|
| 0 | No alert (suppression) |
| 1–4 | Low — system events, informational |
| 5–7 | Medium — authentication events, policy changes |
| 8–11 | High — anomalous behavior, repeated failures |
| 12+ | Critical — high-confidence attacks |
A single failed SSH login is suspicious but not critical. Level 5 is appropriate.
<decoded_as> — Link to the decoder
This condition restricts the rule to log lines that matched the named decoder. Without it, the rule would evaluate against every log line.
Step 5: Add a frequency rule
A single failed login isn’t necessarily an attack. Four failed logins from the same IP in two minutes probably is. Wazuh handles this with frequency rules and if_sid chaining:
<rule id="100002" level="10">
<if_sid>100001</if_sid>
<frequency>4</frequency>
<timeframe>120</timeframe>
<same_source_ip />
<description>SSH brute force detected — 4+ failures from same IP</description>
<mitre>
<id>T1110</id>
<tactic>Credential Access</tactic>
</mitre>
</rule><if_sid> — Rule chaining
This rule only evaluates when its parent rule (100001) has already fired. Chaining is how Wazuh builds multi-stage detections: a base rule catches individual events, and child rules detect patterns across events.
<frequency> and <timeframe>
The parent rule must fire at least 4 times within 120 seconds for this rule to trigger. This turns individual failed login alerts into a brute-force detection.
<same_source_ip />
Group the frequency count by source IP. Without this, four failed logins from four different IPs would trigger the rule — not the pattern you want.
<mitre>
Tag the alert with a MITRE ATT&CK technique. This is optional but valuable for SOC workflows and reporting. T1110 is “Brute Force” under the “Credential Access” tactic.
The complete detection
Here’s everything together:
<!-- Decoder: extract fields from SSH failed login lines -->
<decoder name="sshd-failed">
<program_name>sshd</program_name>
<prematch>Failed password</prematch>
<regex>Failed password for (\S+) from (\S+) port</regex>
<order>dstuser,srcip</order>
</decoder><!-- Rule 1: Individual failed login (medium severity) -->
<rule id="100001" level="5">
<decoded_as>sshd-failed</decoded_as>
<description>SSH failed password attempt</description>
</rule>
<!-- Rule 2: Brute force pattern (high severity) -->
<rule id="100002" level="10">
<if_sid>100001</if_sid>
<frequency>4</frequency>
<timeframe>120</timeframe>
<same_source_ip />
<description>SSH brute force detected — 4+ failures from same IP</description>
<mitre>
<id>T1110</id>
<tactic>Credential Access</tactic>
</mitre>
</rule>Deploying to a Wazuh manager
On a production system, custom decoders and rules go in specific directories:
# Custom decoders
/var/ossec/etc/decoders/local_decoder.xml
# Custom rules
/var/ossec/etc/rules/local_rules.xmlAfter adding your files, validate and restart:
# Test the configuration
/var/ossec/bin/wazuh-logtest
# Restart the manager
systemctl restart wazuh-managerCheck /var/ossec/logs/alerts/alerts.json to confirm your rules are firing on real traffic.
Common mistakes
Decoder regex doesn’t match. Test your regex against the actual log line, not what you think the log line looks like. Copy a real line from the log file and test it in wazuh-logtest or the Rule Builder.
Capture group count doesn’t match order. If your regex has three () groups but your order only lists two fields, the third group is silently dropped. Make sure they correspond 1:1.
Rule fires on everything. If you forget decoded_as, the rule evaluates against every log line. Always tie rules to a specific decoder unless you intentionally want broad matching.
Using match when you need regex. The <match> element does substring matching — no regex syntax. Use <regex> when you need pattern matching with special characters.
Custom rule IDs below 100000. These can collide with built-in Wazuh rules and cause unpredictable behavior. Always start custom IDs at 100000.
Next steps
The SSH brute-force detection is a starting point. The same decoder-rule pattern applies to any log source:
- Study the log format
- Write a decoder with
program_name,prematch,regex, andorder - Write a base rule with
decoded_as - Add frequency or chained rules for pattern detection
- Tag with MITRE ATT&CK
To practice without a Wazuh installation, try the challenges in the Wazuh Rule Builder — they cover web shell detection, privilege escalation, lateral movement, and log injection evasion.