Wazuh Custom Rules for Alert Suppression (Part 5)
How I wrote Wazuh suppression rules that silenced 300+ noise alerts per hour after four failed approaches.
Published · Republished on Medium

A fresh Wazuh deployment can be noisy. In this lab, common sudo, Ansible temporary-file, and PAM session events generated the following alerts within a ten-minute observation window:
text
5501: PAM session opened — 65+ times
5502: PAM session closed
5402: Successful sudo to ROOT
554: File added (Ansible temp) — 50+ times
550: Checksum changed
553: File deletedFiles in this Post
bash
files/wazuh/config/rules/
└── wazuh_custom_rules.xml ← 100xxx suppression rules (18 rules)roles/wazuh_custom_rules/ ← deploys XML, restarts manager
Depends on: nothing (standalone — rules are loaded by manager at startup)
Real threats drown in the noise.

How Suppression Works in Wazuh
When a child rule with if_sid matches and has level 0, the resulting event is ignored and does not appear as a security alert. Level-0 events are discarded immediately, so they cannot contribute to later if_matched_* correlation. Suppression therefore removes detection evidence; it is not merely a dashboard filter.
The trick is getting the rule to match.
What I Tried First (All Failed)
Attempt 1 — Top-level rule (no **if_sid**):
xml
<rule id="100032" level="0">
<program_name>sudo</program_name>
<match>session opened for user root</match>
</rule>Result in this test fixture: it did not match the intended event. That does not prove top-level level-0 rules are universally invalid—Wazuh's official custom-rule example uses one. The reliable conclusion is narrower: inheriting from the observed parent rule constrained this suppression to the relevant rule path.
Attempt 2 — Multiple match elements:
xml
<rule id="100032" level="0">
<if_sid>5501</if_sid>
<program_name>sudo</program_name>
<match>session opened for user root</match>
<match>by ubuntu</match>
</rule>Result for this captured PAM event and installed ruleset: it did not match. Decoder output and rule behavior are version- and input-dependent, so inspect Phase 1–3 output rather than treating this as a universal limitation.
Attempt 3 — **<field>** (crashed Wazuh):
xml
<rule id="100032" level="0">
<if_sid>5501</if_sid>
<field name="srcuser">ubuntu</field>
</rule>Result:
text
ERROR: Failure to read rule 100032. Field 'srcuser' is static.
CRITICAL: Error loading rules: 'etc/rules/wazuh_custom_rules.xml'.
wazuh-analysisd: Configuration error. Exiting.<field> only works with dynamic decoder fields. PAM uses static fields.
What Actually Works
xml
<rule id="100032" level="0">
<if_sid>5501</if_sid>
<match>session opened for user root</match>
<description>Suppress PAM session opened noise.</description>
</rule>For this exact fixture, one <match> under the observed parent rule was sufficient. It must not be generalized to other log formats without tests.
Debugging with wazuh-logtest
shell
$ echo 'session opened for user root(uid=sh0) by ubuntu(uid=1000)' \
| /var/ossec/bin/wazuh-logtest -v 2>&1 | grep "matched"config
Trying rule: 5501 - PAM: Login session opened.
*Rule 5501 matched
Trying rule: 100032 - Suppress PAM session opened noise.
*Rule 100032 matched ← SUPPRESSION WORKS
No * before a rule? It didn't match. Simple.

Complete Suppression Ruleset
PAM Sessions (5501, 5502)
xml
<rule id="100032" level="0">
<if_sid>5501</if_sid>
<match>session opened for user root</match>
</rule>
<rule id="100033" level="0">
<if_sid>5502</if_sid>
<match>session closed for user root</match>
</rule>Sudo Commands (5402)
xml
<!-- Ansible become -->
<rule id="100034" level="0">
<if_sid>5402</if_sid>
<match>BECOME-SUCCESS</match>
</rule>
<!-- Root shell -->
<rule id="100035" level="0">
<if_sid>5402</if_sid>
<match>root : TTY=</match>
</rule>Manual sudo (
*ubuntu : TTY=*) still appears — useful for audit trails.
Ansible FIM Noise (553, 554, 550)
Two patterns: legacy (/tmp/ansible_*) and collection (.ansible/tmp/ansible-tmp-*):
xml
<!-- Legacy -->
<rule id="100027" level="0"><if_sid>554</if_sid><match>/tmp/ansible_</match></rule>
<rule id="100028" level="0"><if_sid>550</if_sid><match>/tmp/ansible_</match></rule>
<rule id="100029" level="0"><if_sid>553</if_sid><match>/tmp/ansible_</match></rule>config
<!-- Collection -->
<rule id="100036" level="0"><if_sid>554</if_sid><match>.ansible/tmp/ansible-tmp-</match></rule>
<rule id="100038" level="0"><if_sid>550</if_sid><match>.ansible/tmp/ansible-tmp-</match></rule>
<rule id="100039" level="0"><if_sid>553</if_sid><match>.ansible/tmp/ansible-tmp-</match></rule>Dashboard HTTP Noise
xml
<rule id="100040" level="0">
<program_name>opensearch-dashboards</program_name>
</rule>Why Each Suppression is Safe
Every suppression rule must answer: “What attack would this hide, and why am I confident I won’t miss it?”
PAM Sessions (100032, 100033)
Suppressed: ubuntu → root session opened/closed.
Why it was accepted temporarily in this lab: SSH was configured for public-key authentication and the observed sample covered 16 hours. That is limited evidence, not proof of safety. A stolen key, compromised trusted source, local privilege escalation, or changed PAM/SSH configuration can still make session telemetry valuable.
text
Every SSH auth success (5715) came from x.x.x.x — the single trusted IP.
Zero failed password attempts (5503) — 0 events.
Zero invalid users (5504) — 0 events.
Zero brute force (5712) — 0 events.
The compensating rule for a new source IP reduces risk but is not equivalent evidence. Keep raw logs outside the alert stream where retention policy requires them, and re-evaluate this suppression whenever SSH, PAM, source ranges, or Wazuh rules change.
Still watching: 5503 (failed login), 5504 (invalid user), 5715 from non-trusted IPs, FIM on all system files.
Sudo Commands (100034, 100035)
Suppressed: Ansible become (BECOME-SUCCESS) and root shell sudo (root : TTY=).
Risk decision: Ansible become is expected automation, but an attacker can imitate strings such as BECOME-SUCCESS, and interactive root shells are security-relevant. Scope suppression to managed service accounts, approved sources, and maintenance windows where decoded fields support it. If those constraints cannot be expressed reliably, retain the alert and reduce noise downstream instead.
Still watching: Manual sudo by ubuntu (TTY sessions), sudo by any other user.
Ansible FIM (100027–100029, 100036–100039)
Suppressed: Temporary files created by Ansible during playbook execution.
Risk decision: Expected Ansible temporary files are ephemeral, but their names are not an authenticity boundary—malware can deliberately use the same paths. Confirm ownership, parent process, timing, and the decoded event set. A path-only level-0 rule is appropriate only when the residual risk is explicitly accepted.
Still watching: File creation in /usr/local/bin, /usr/local/sbin (rule 101004), all other /tmp activity.
OpenSearch Dashboards (100040)
Suppressed: All HTTP request logs from opensearch-dashboards.
High-risk suppression: A rule containing only <program_name>opensearch-dashboards</program_name> suppresses every event from that program, including future security-relevant messages. Replace it with tested message patterns for known benign requests, or filter/reroute access logs at collection time while preserving authentication and security-plugin logs.
Rule ID Allocation

Results
Regression Tests and Ownership
Store sanitized event fixtures beside each suppression rule. For every intended match, add near-miss and malicious-looking samples that must retain their original alert. wazuh-logtest -U <rule-id>:<level>:<decoder> provides an automation-friendly exit status for one event; run the matrix against the pinned Wazuh ruleset in CI or staging.
Each rule needs an owner, rationale, linked evidence, creation date, review or expiry date, and compensating detection. Measure alert counts before and after by rule, host, user, and source—not only the visual cleanliness of the dashboard. Re-test after Wazuh upgrades because parent rules and decoders can change.
Deploy to a canary manager, validate rule loading, then replay both positive and negative fixtures. If an expected alert disappears or wazuh-analysisd fails, restore the previous rule file and restart the manager. Keep custom IDs in Wazuh's recommended 100000–120000 range and never edit /var/ossec/ruleset/rules, which upgrades replace.


Clean feed. Real threats visible.
Deploy
bash
ansible-playbook -i inventories/lab/hosts.ini \
playbooks/wazuh-custom-rules.yml --limit aioThe role copies the reviewed file and restarts the manager after validation; saving a rule is enough for wazuh-logtest, but Wazuh documents a manager restart to activate changes for live alert generation.
References
Next: Part 6 — Building a Centralized ClamAV Database Mirror