Tutorial

Writing Your First Wazuh Custom Decoder and Rule

A step-by-step guide to writing custom Wazuh decoders and rules, from reading a raw syslog line to triggering an alert.

2 min read beginner

Prerequisites

  • Basic familiarity with Linux log formats
  • Understanding of regular expressions
Table of Contents

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) → Alert

Decoders 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 ssh2

Break it into parts:

FieldValueSource
TimestampJun 14 15:16:01Syslog header
Hostnameweb01Syslog header
ProgramsshdSyslog header
PID2001Syslog header
MessageFailed password for invalid user admin from 10.0.0.50 port 22 ssh2Application

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:

FieldMeaning
srcipSource IP address
dstipDestination IP address
srcuserUser performing the action
dstuserTarget user
srcportSource port
protocolProtocol name
actionAction taken
statusResult or status
urlRequest URI
dataGeneric 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-failed decoder
  • The dstuser and srcip fields 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-logtest

Paste 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:

RangeMeaning
0No alert (suppression)
1–4Low — system events, informational
5–7Medium — authentication events, policy changes
8–11High — anomalous behavior, repeated failures
12+Critical — high-confidence attacks

A single failed SSH login is suspicious but not critical. Level 5 is appropriate.

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.xml

After adding your files, validate and restart:

# Test the configuration
/var/ossec/bin/wazuh-logtest

# Restart the manager
systemctl restart wazuh-manager

Check /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:

  1. Study the log format
  2. Write a decoder with program_name, prematch, regex, and order
  3. Write a base rule with decoded_as
  4. Add frequency or chained rules for pattern detection
  5. 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.