Alert Fatigue Is a Routing Problem: A Wazuh-to-Chat Pipeline in n8n

I’ve been meaning to try n8n properly for a while — it kept coming up as the tool people reach for to wire security tools together without writing a bespoke integration for each one. JoasASantos’ n8n-CyberSecurity-Workflows repo is a catalogue of 100 workflow ideas rather than ready-made exports, which is actually the more honest starting point: no JSON to import and pretend I built it, just a one-line brief per idea and a blank canvas. Number 35 on the list — SIEM → Slack Alert Router, “direct, deduplicated alert routing to messaging platforms” — is the one I picked, because wazuh-lab-manager is already sat in the lab producing exactly the raw material this needs.

The problem with the naive version

The naive version of this integration is one line: point Wazuh’s alert output at a Slack or Discord webhook and let every alert land in a channel. It takes ten minutes to wire up and it’s a mistake, for the same reason an EDR or a firewall dumping every event into a channel is a mistake — a channel that fires on everything trains the humans reading it to stop reading it. That’s not a hypothetical; it’s the whole premise behind alert fatigue as a named failure mode in SOC literature, and it’s a worse outcome than not having the integration at all, because a silently unmonitored SIEM at least doesn’t create the false confidence that someone’s watching a channel nobody actually reads any more.

So “route alerts to chat” isn’t really the job. The job is deciding which alerts earn a human’s attention, and routing only those. That’s a small amount of logic — a severity floor and a repeat-suppression window — but it’s the entire difference between a tool that gets used and one that gets muted three days in.

The workflow

Six nodes, in a straight line with one branch:

  1. Webhook — receives a POST containing one Wazuh alert as JSON, at /webhook/wazuh-alert.
  2. Code (Dedup + Threshold Filter) — the actual logic, covered below.
  3. IF — branches on whether the alert should be forwarded.
  4. Code (Format Chat Message) — builds a human-readable line from the alert fields.
  5. HTTP Request — POSTs that line to a chat webhook (Slack, Discord, Teams — anything that accepts an incoming webhook).
  6. Respond to Webhook — one on each branch, so Wazuh’s integration script gets an HTTP 200 back either way.

The filtering logic is the only part worth reading closely:

const item = $input.first().json.body || $input.first().json;

const ruleLevel = item?.rule?.level ?? 0;
const ruleId = item?.rule?.id ?? 'unknown';
const ruleDesc = item?.rule?.description ?? 'no description';
const agentName = item?.agent?.name ?? 'unknown-agent';
const timestamp = item?.timestamp ?? new Date().toISOString();
const THRESHOLD = 10;
const SUPPRESS_WINDOW_MS = 15 * 60 * 1000; // 15 minutes

const dedupKey = `${agentName}:${ruleId}`;
const belowThreshold = ruleLevel < THRESHOLD;

// Checked before the dedup cache is touched — see below for why that order matters.
let duplicate = false;
if (!belowThreshold) {
  const staticData = $getWorkflowStaticData('node');
  if (!staticData.lastSeen) staticData.lastSeen = {};
  const now = Date.now();
  const lastSeenAt = staticData.lastSeen[dedupKey];
  if (lastSeenAt && (now - lastSeenAt) < SUPPRESS_WINDOW_MS) {
    duplicate = true;
  } else {
    staticData.lastSeen[dedupKey] = now;
  }
}

const forward = !belowThreshold && !duplicate;

return [{
  json: {
    agentName, ruleId, ruleDesc, ruleLevel, timestamp, dedupKey,
    duplicate, belowThreshold, forward,
    decision: forward ? 'forwarded' : (belowThreshold ? 'suppressed_below_threshold' : 'suppressed_duplicate')
  }
}];

Wazuh’s own rule levels already do the hard work of triage — level 3 is routine, level 12 is “multiple auth failures followed by a success.” THRESHOLD = 10 says only genuinely notable rule levels get as far as the dedup check at all. $getWorkflowStaticData('node') is n8n’s built-in per-node key-value store, persisted with the workflow itself rather than per-execution — it’s what makes “have I already told someone about this exact agent-plus-rule combination in the last 15 minutes” a two-line check instead of a database.

Testing it — and the two things it got wrong first

I built and ran this against a real n8n instance in a sandbox, not the production wazuh-lab-manager — the alerts below are simulated Wazuh JSON, not live ones, but the workflow, the HTTP calls, and the output are all genuine.

First bug: the HTTP Request node eats your context. My first version of the “Respond to Webhook” node referenced $json.decision directly. It came back empty — {} — every time an alert actually got forwarded, and only worked on the suppressed branch. The reason: once execution passes through an HTTP Request node, $json becomes that node’s response, not whatever was flowing through beforehand. The dedup node’s output — decision, dedupKey, the lot — was still there, just not reachable as $json any more. The fix is referencing the source node explicitly: $('Dedup + Threshold Filter').item.json.decision instead of $json.decision. Obvious in hindsight; the kind of thing that’s invisible until you’ve watched an object silently swap out from under you mid-branch.

Second bug: a suppressed alert was still allowed to poison the dedup cache. My first pass ran the dedup check before the threshold check, on every alert regardless of level. That meant a routine level-3 login event would still occupy the agent:rule dedup slot — and if a genuinely important alert with the same rule ID ever repeated shortly after a run of routine noise, it would get reported as suppressed_duplicate when the real reason was something else entirely, or worse, get its own dedup window started by an event that was never going to be forwarded anyway. Reordering so the threshold check happens first, and the dedup cache is only touched for alerts that already clear the bar, fixed it — but it only showed up because I tested with a repeated low-severity event, not just the happy path.

With both fixed, three simulated alerts through the live webhook:

$ curl -s -X POST http://127.0.0.1:5678/webhook/wazuh-alert -H "Content-Type: application/json" -d '{
  "timestamp": "2026-09-16T08:45:04.112+0000",
  "rule": {"level": 12, "id": "100213", "description": "Multiple authentication failures followed by success (possible brute force)"},
  "agent": {"id": "003", "name": "target-win2022"},
  "manager": {"name": "wazuh-lab-manager"}
}'
{"decision":"forwarded","dedupKey":"target-win2022:100213","ruleLevel":12,"postedToChat":true}

# the identical alert, 3 seconds later
$ curl -s -X POST http://127.0.0.1:5678/webhook/wazuh-alert -H "Content-Type: application/json" -d '{ ... same body ... }'
{"decision":"suppressed_duplicate","dedupKey":"target-win2022:100213","ruleLevel":12}

# an unrelated routine event
$ curl -s -X POST http://127.0.0.1:5678/webhook/wazuh-alert -H "Content-Type: application/json" -d '{
  "timestamp": "2026-09-16T08:45:11.500+0000",
  "rule": {"level": 3, "id": "5502", "description": "PAM: Login session opened"},
  "agent": {"id": "002", "name": "target-wordpress"},
  "manager": {"name": "wazuh-lab-manager"}
}'
{"decision":"suppressed_below_threshold","dedupKey":"target-wordpress:5502","ruleLevel":3}

And, at the chat webhook standing in for Slack/Discord/Teams, exactly one message — for the one alert that actually earned it:

{
  "text": "🚨 [12] target-win2022 — Multiple authentication failures followed by success (possible brute force) (rule 100213, 2026-09-16T08:45:04.112+0000)"
}

Three alerts in, one message out. That ratio is the entire point of the exercise — a SOC channel that gets one message for a real, novel, above-threshold event is one people keep reading; a SOC channel that gets three messages for one event is a SOC channel people mute by Friday.

One more thing worth noting: the dedup state survived a full restart of the n8n process without missing a beat, because $getWorkflowStaticData is written back into the workflow’s own row in n8n’s database, not held in memory. That’s not something I set out to test — I only noticed because a rule ID I’d used in an earlier run still reported as a duplicate several minutes and one restart later. Worth knowing either way: the suppression window is a property of the workflow, not the process.

Wiring in the real Wazuh side

None of the above touches wazuh-lab-manager yet — everything above ran against simulated alert bodies. The actual connection is Wazuh’s integration mechanism: a block in ossec.conf naming a script Wazuh will invoke for matching alerts, plus that script.

<ossec_config>
  <integration>
    <name>custom-n8n</name>
    <hook_url>http://<n8n-host>:5678/webhook/wazuh-alert</hook_url>
    <level>3</level>
    <alert_format>json</alert_format>
  </integration>
</ossec_config>

The <level>3</level> here is deliberately lower than the workflow’s own THRESHOLD = 10 — Wazuh’s integrator daemon is just the transport, so it can hand everything from level 3 up to n8n and let the workflow do the actual filtering. Keeping the decision logic in one place (the workflow) rather than split across ossec.conf and n8n is the difference between one thing to tune and two things that can quietly drift out of sync.

A name prefixed custom- tells wazuh-integratord to exec a script by that exact name from /var/ossec/integrations/, rather than use one of its built-in handlers (Slack, PagerDuty and a handful of others are native; everything else needs a script). A minimal one:

#!/var/ossec/framework/python/bin/python3
import sys, json, requests

def main(args):
    alert_file = args[1]
    hook_url = args[3]
    with open(alert_file) as f:
        alert = json.load(f)
    requests.post(hook_url, json=alert, timeout=10)

if __name__ == "__main__":
    main(sys.argv)

That argument order (alert_file first, hook_url third, with the API key slot at position two sitting unused here) matches the shipped slack.py/pagerduty.py integrations on a current Wazuh manager — worth a quick diff against whatever’s actually in /var/ossec/integrations/ on wazuh-lab-manager before trusting it, since this is exactly the kind of interface that’s shifted across Wazuh major versions before. Made executable, owned root:wazuh, mode 750, referenced by name, restart the manager, and every Wazuh alert from level 3 up lands on the n8n webhook this post already proved works.

Where this leaves things

Ninety-nine more ideas sit in that same list, and quite a few of them are natural follow-ups to this exact workflow rather than unrelated builds — an IOC enrichment step ahead of the routing decision, a SOC Daily Briefing Builder that reads the same dedup cache this one writes, a Brute-Force Heatmap fed by exactly the alert class the level-12 test above simulated. This is the shape the demo format settles into: pick one idea, build it against something real, let the build itself surface what the one-line brief didn’t mention.

It’s also the same conclusion the home lab honeypot post landed on from the opposite direction — a honeypot’s value is that every single hit is signal, by construction, because nothing legitimate should ever touch it. A production SIEM can’t get that guarantee for free; the alert volume is real and mostly not actionable, which means the signal has to be built deliberately, one threshold and one dedup key at a time, rather than assumed.