DEV Community

Nguyen Dong
Nguyen Dong

Posted on

What tells you a Wazuh rule has gone quiet?

What tells you a Wazuh rule has gone quiet?

An MSSP detection lead described a gap to us this week. Use cases are validated before go-live and re-tested by a BAS tool every six months. In between, a log source changes. A parser update renames a field, or someone switches off command-line auditing on a group of Windows hosts, and a production rule stops firing. Nothing reports an error.

We measured what that looks like on a Wazuh 4.14.7 manager, and what can tell you it happened. Everything ran in disposable Docker containers with no network. Each claim is tagged [MEASURED] (we ran it, raw output kept) or [NOT MEASURED].

Setup, and a first surprise

We pushed Windows events into wazuh-analysisd's input queue the way the agent forwards them: JSON whose Event key holds the EventChannel XML, which the windows_eventchannel decoder consumes.

Our first try was wazuh-logtest -l EventChannel. [MEASURED] For the same 4688 event, logtest reported decoder json, and no rule matched in Phase 3. The Windows chain starts at rule 60000, which requires decoded_as windows_eventchannel. If your pre-go-live check for Windows rules is "paste it into logtest", it may test nothing.

Test 1: the command line disappears

This is a lab use case. It flags PowerShell started with -EncodedCommand, keyed on the 4688 command line. The payload in our events is the harmless placeholder ATK_LAB_HARMLESS_PLACEHOLDER_0000.

<rule id="100200" level="10">
  <if_sid>67027</if_sid>
  <field name="win.eventdata.newProcessName" type="pcre2">(?i)\\powershell\.exe$</field>
  <field name="win.eventdata.commandLine" type="pcre2">(?i)\s-EncodedCommand\s</field>
  <description>UC-PS-ENC: PowerShell started with -EncodedCommand (lab use case)</description>
</rule>
Enter fullscreen mode Exit fullscreen mode

(The parent is 67027 on purpose: it is already a child of 60103 and loads first, so a sibling under 60103 would never be evaluated.)

We replayed the same event three ways. First with CommandLine. Then with the element missing. Then with it present but empty, which is what you see when "Include command line in process creation events" is off.

Event Rule that fired Level
with CommandLine 100200 10
CommandLine missing 67027 "A process was created." 3
CommandLine empty 67027 3

[MEASURED] wazuh-analysisd -t exited 0. Each of the three replays added 0 lines to ossec.log. The decoder drops the empty field rather than keeping an empty string: 13 eventdata fields with the command line, 12 for both other variants. Downstream, "auditing off" and "field never sent" look the same.

The event still produces an alert, just a level 3 one, so a dashboard counting Windows events sees no dip.

Test 2: a parser update renames a field

This is a custom decoder and rule for a simple app log. The "parser update" changes one word:

-  <order>app.user, srcip</order>
+  <order>app.username, srcip</order>
Enter fullscreen mode Exit fullscreen mode

The rule keys on <field name="app.user">^svc_lab_backup$</field> under a level-0 grouping rule 100299. We replayed the same 3 lines before and after the change.

[MEASURED]

  • Decoder v1: 3 of 3 lines raised rule 100300, and wazuh-analysisd -t exited 0.
  • Decoder v2: 0 of 3 lines raised an alert, and wazuh-analysisd -t still exited 0. All 3 events were still decoded by labapp and reached the archives. wazuh-logtest showed rule 100299 at level 0.
  • wazuh-analysisd logged 0 warnings or errors after either restart.

The log was not quiet, though: [MEASURED] the v1 restart wrote 18 WARNING/ERROR lines, all about our container having no network. A real ossec.log has noise; a rule that matches nothing adds none.

So neither the config check nor ossec.log tells you. Here are two things that can.

Option A: compare today's count with the rule's own baseline

We indexed synthetic alerts into the wazuh-indexer:4.14.7 image. It contains OpenSearch with plugin version 2.19.5, and we ran it with the security plugin disabled. [MEASURED] We loaded 381 alerts over 8 days for 3 rule IDs. 100300 went from a daily 34, 34, 49, 33, 43, 49, 50 to 0 on the last day.

The obvious query, "alerts per rule today", returns only rule 100200 [MEASURED]. The silent rule is not a zero row. It is simply absent. You have to ask over a window where the rule still exists:

{"size": 0,
 "query": {"range": {"timestamp": {"gte": "2026-10-29T00:00:00Z", "lt": "2026-11-06T00:00:00Z"}}},
 "aggs": {"by_rule": {"terms": {"field": "rule.id", "size": 1000},
   "aggs": {
     "today": {"filter": {"range": {"timestamp": {"gte": "2026-11-05T00:00:00Z", "lt": "2026-11-06T00:00:00Z"}}}},
     "baseline": {"filter": {"range": {"timestamp": {"gte": "2026-10-29T00:00:00Z", "lt": "2026-11-05T00:00:00Z"}}},
       "aggs": {
         "per_day": {"date_histogram": {"field": "timestamp", "calendar_interval": "1d", "time_zone": "UTC",
             "min_doc_count": 0,
             "extended_bounds": {"min": "2026-10-29T00:00:00Z", "max": "2026-11-04T00:00:00Z"}}},
         "median_per_day": {"percentiles_bucket": {"buckets_path": "per_day>_count", "percents": [50]}}}},
     "gone_quiet": {"bucket_selector": {
         "buckets_path": {"today": "today>_count", "median": "baseline>median_per_day[50.0]"},
         "script": "params.today == 0 && params.median >= 5"}}
   }}}}
Enter fullscreen mode Exit fullscreen mode

The dates are the ones from our lab. To run it on your own indexer, change all four date ranges: the outer range to your last 8 days, today to the current day, and both baseline and the histogram's extended_bounds to the 7 days before it. Miss extended_bounds and the median can drop to 0, so a silent rule is not flagged. Run it against your alerts index (wazuh-alerts-* by default).

[MEASURED] Output: exactly one bucket, 100300 with today 0 and median 43.0. The query took 14 ms. Rule 100200 (today 10, median 11) was not flagged. Neither was the rare rule 100401, whose median is 0. We also wrapped the query in an Alerting plugin query monitor and ran _execute?dryrun=true: it triggered and listed [100300].

Two honest notes. We needed 3 failed attempts to get here [MEASURED]: Painless rejected Arrays.copyOf, then moving_fn plus bucket_selector inside the date_histogram returned no buckets with either gap policy. And the median >= 5 floor that keeps rare rules from paging you also means this method cannot watch rare rules, often the ones that matter most.

Option B: a heartbeat per use case

[NOT MEASURED, documented] Wazuh 4.x rules cannot fire on the absence of an event. The rules XML reference gives frequency as "Any number from 2 to 9999": it counts up, and we found no option that fires when a count stays below a threshold. So the "missing" logic lives outside the rule engine and comes back in as a normal log line.

Three pieces, all in our container [MEASURED]:

  1. A heartbeat writes one harmless canary line every 60 s into the same file, through the same decoder and rule. On a real estate this belongs on the log source, as a cron job or systemd timer.
   echo "$(date '+%b %e %H:%M:%S') lab-srv01 labapp[4242]: login_failed user=svc_lab_backup src=192.0.2.250" >> /var/log/labapp.log
Enter fullscreen mode Exit fullscreen mode
  1. A child rule turns the canary into a low-level heartbeat alert:
   <rule id="100301" level="3">
     <if_sid>100300</if_sid>
     <srcip>192.0.2.250</srcip>
     <description>UC-SVC-LOGIN heartbeat: use case still fires</description>
   </rule>
Enter fullscreen mode Exit fullscreen mode
  1. A watchdog runs every 60 s as a wodle name="command". It finds the newest 100301 in alerts.json and writes OK or MISSING to a log that Wazuh reads. Rule 100310 (level 12) alerts on MISSING. The threshold is 180 s.

The timeline we recorded [MEASURED]:

  • Healthy: the watchdog reported heartbeat ages of 57, 59 and 59 s.
  • 09:39:29 UTC: we deployed decoder v2. wazuh-analysisd -t returned 0, and analysisd logged 0 warnings.
  • The canary kept arriving and kept being decoded, 5 times, but raised nothing. The watchdog still said OK at ages 26, 86 and 146 s.
  • The first MISSING line came at age 206 s. Rule 100310 fired 180.0 s after the first canary that no longer matched.

One false alarm [MEASURED]: on startup the watchdog ran before the first heartbeat arrived and raised 100310 with last_seen=never. Give it a grace period.

The same failure mode in Wazuh 5.0

We published the upgrade version of this problem on our Wazuh 5.0 Migration page. [NOT MEASURED today, quoted] "Our 4.x local_rules.xml and local_decoder.xml, dropped into etc/rules and etc/decoders: of the new log lines after restart (124 on rc1, 116 on rc2), not one mentioned them, and the config check still returned success." The cause is different, since 5.0 replaces the rule engine rather than changing a log source. The symptom is the same: success from the config check, silence from the rule. The baseline and heartbeat checks above look at output rather than configuration, so they should catch both. We have not run them on 5.0.

What we did not test

  • Real Windows hosts or a real agent: our 4688 events were hand-built XML in the agent's forwarding format. Side observation: our own malformed test events (double-quoted XML attributes) warned twice, and five more decoded with zero eventdata fields and no warning.
  • Sysmon EID 1. We used Security 4688 only.
  • A real BAS tool, or a production-sized index. The baseline ran on 381 synthetic alerts.
  • A watchdog querying the indexer instead of alerts.json, or a cluster.
  • Seasonality: weekends and maintenance windows will break a plain 7-day median.
  • Wazuh 5.0 for either option.

Neither option proves a use case catches the attack it was written for. They answer something smaller and more urgent: does the rule still fire at all?

If you want a second pair of eyes, run the query without the gone_quiet block, so it returns every rule rather than only the silent ones, and send the output to support@atkvn.com: rule IDs and daily counts only, no hostnames, no logs. We reply within 24 hours, at no charge, with the rules that look silent or are dropping sharply, and the causes worth checking first, and delete the table once we have answered.

Top comments (0)