I Automated Threat Hunting on Wazuh with n8n - Here's the Workflow, Node by Node

2026-07-28 · Neetrox

Threat hunting is the task every SOC agrees on and never gets to. It needs a senior analyst, a free afternoon, and a written hypothesis and the alert queue eats that afternoon every week.

So I built the whole loop as one n8n workflow against my Wazuh stack. Every Monday at 07:00 it runs a rotating slice of a 59-hunt library on the Wazuh Indexer, enriches whatever it finds with threat intel, has a local LLM write the triage notes, and emails a report whose first line says whether anything needs a human.

The scheduled threat hunting workflow in n8n, from weekly schedule and hunt library through IOC extraction, VirusTotal, OTX and AbuseIPDB lookups, an Ollama AI agent, and a dry-run gate ahead of Slack, Discord and Telegram delivery

Here’s the full canvas, node by node, in execution order.

1. Weekly Schedule: the trigger

A plain Schedule Trigger with cron 0 7 * * 1: Monday, 07:00. That's the entire scheduling story. Want Friday instead? Change the cron. Want daily? Change the cron. Nothing else in the workflow cares when it runs the next node will figure out what to run on its own.

2. Configuration: every setting, one node

A Code node that returns a single object. Indexer URL, report recipient, timezone, lookback window, whitelists, feature flags, all of it:

const config = {
  DRY_RUN: true,           // build the report, send nothing
  WAZUH_INDEXER_URL: 'https://YOUR-WAZUH-INDEXER:9200',
  LOOKBACK_DAYS: 7,
  RUN_ALL: false,          // true = ignore rotation, run all 59 hunts
  IP_WHITELIST: [],        // your VPN / office IPs - excluded from every hunt
  REPORT_RECIPIENT: 'soc@yourcompany.com',
  BRAND_NAME: 'NeetroX',   // white-label the report header
  IOC_ENRICH: true,
  ...
};
return [{ json: { config } }];

Why a Code node instead of scattering settings across the canvas? Because every downstream node reads from here with $('Configuration').first().json.config, which means tuning the workflow never involves hunting through 31 nodes. Change one object, done.

Two flags matter most. DRY_RUN: true is the shipped default the workflow does everything except send. And RUN_ALL: true is the "fire everything" switch: skip the rotation and run all 59 hunts in one activation, which is exactly what you want for a first-run baseline of your environment.

3. Hunt Library: 59 hunts as plain data

The heart of the workflow, and it contains zero logic just an array of 59 objects, one per hunt:

{
  id: 'H-029', title: 'Office applications spawning shells',
  hypothesis: 'A malicious document spawns a shell from Word, Excel or Outlook',
  mitre: { tactic: 'Initial Access', technique: 'T1566.001 Spearphishing Attachment' },
  platform: 'windows', rotation_group: 1, always_run: false, enabled: true,
  severity_hint: 'high', min_hits: 1,
  query: { /* OpenSearch bool DSL: parent = winword/excel/outlook, child = cmd/powershell/wscript */ },
  triage_hint: 'Office spawning cmd/powershell is almost never legitimate - pull the document name from the parent commandLine.',
  fp_notes: 'Some macro-heavy workbooks shell out legitimately - exclude by document path rather than disabling the hunt.'
}

Each hunt carries its detection query, its MITRE ATT&CK mapping, and three fields that do the operational heavy lifting:

  • rotation_group (0–3) - which week of the monthly cycle it runs in.

  • min_hits - the noise threshold. "Failed sudo" means nothing at 1 event and something at 5. The file-change ransomware canary reports at 500+/week - deliberately above a patch-day baseline.

  • fp_notes - a written admission of what benign activity trips this hunt, printed in the report. A hunt that pretends it's never wrong gets ignored within a month.

Adding hunt #60 means appending one object here. No new nodes, no wiring.

4. Select Hunts: the rotation brain

The first node with real logic. It decides which hunts run this week and builds their queries:

const week  = DateTime.now().setZone(cfg.TIMEZONE).weekNumber;  // ISO week
const group = week % 4;                                          // this week's rotation group
const selected = hunts.filter(h =>
  h.enabled && (cfg.RUN_ALL || h.always_run || h.rotation_group === group)
);

That’s the whole scheduler: ISO week number modulo 4. Group 0 runs one week, group 1 the next, and the full library gets covered every four weeks with no database, no state file, no memory of previous runs. The schedule is computed from the calendar itself, so you can import this workflow into a fresh n8n instance and the rotation is already correct.

Two exceptions pass the filter regardless of group: hunts marked always_run (the two ransomware canaries mass file changes and shadow-copy deletion, run every week), and everything when RUN_ALL is on.

Then, for each selected hunt, this node injects the runtime context into the query: the now-7d time range, the optional agent filter, and the IP whitelist as must_not clauses so whitelisted VPN and office IPs are invisible to every hunt without any hunt knowing about it. Finally it stacks all the queries into a single NDJSON body for the next node.

5. Run Hunts: one HTTP request for everything

An HTTP Request node that POSTs the whole batch to the Indexer’s _msearch endpoint OpenSearch's multi-search API. Thirteen hunts or all fifty-three, it's one round trip; a full-library run takes about 20 seconds.

The node uses a Basic Auth credential (a read-only indexer user), allows self-signed certs (stock Wazuh), and is set to neverError with a retry because a transport hiccup should produce an "errored" report section, not a dead workflow.

6. Map Results: hits, clean, or error

_msearch returns responses[i] in the same order the queries went in, so this Code node zips response i back to hunt i and classifies each into exactly one of three states:

const state = r.error                          ? 'error'
            : total >= (h.min_hits || 1)       ? 'hits'
            : 'clean';
  • hits - matches reached the hunt’s threshold. Carries sample events plus two aggregations: events per agent and per rule. The per-agent breakdown is the real signal “500 file changes” is patch Tuesday; “500 file changes, 490 on one host” is ransomware.

  • clean - below threshold. The count is still shown in the report, so you see what’s simmering.

  • error - usually a field that doesn’t exist in your index, which means that telemetry isn’t being collected. This turned out to be a feature: my first full run was effectively a coverage audit. Seventeen hunts had nothing to look at a precise list of my blind spots (no Sysmon, no auditd execve).

7. Has Hits?: the fork

A simple IF node. Hunts with hits go down the enrichment-and-AI path; clean and errored hunts skip straight to the merge. No point burning VirusTotal quota or LLM tokens on hunts that found nothing.

8. Extract IOCs → Have IOCs?: what’s worth looking up

A Code node walks the sample events of every hit hunt and collects three IOC types: source IPs, domains, and file hashes. It drops anything not worth an API call, private ranges (10.x, 192.168.x, 172.16–31.x, loopback), .local/.lan domains, whitelisted IPs deduplicates, and caps the count per type (IOC_MAX_PER_TYPE: 4) to stay inside free API tiers. A small IF node after it routes around the lookups entirely if nothing survived.

9. VT Lookup → OTX Lookup → Is IP? → AbuseIPDB: the intel chain

Three HTTP Request nodes in sequence, one service each:

  • VirusTotal - IPs, domains, and hashes. The free tier allows 4 requests/minute, so the node uses n8n’s built-in batching: 4 at a time, 61 seconds between batches.

  • AlienVault OTX - same IOC types; returns how many threat-intel “pulses” reference the indicator.

  • AbuseIPDB - IPs only, which is why an Is IP? IF node sits in front of it; domains and hashes bypass to the merge.

Every lookup node is set to neverError and onError: continueRegularOutput. A dead API, a rate limit, a missing key, none of it stops the run. The affected indicator just shows n/a in the report.

10. Merge Intel → Attach Intel: one verdict per indicator

A Merge node collects the three lookup streams, then a Code node joins everything back onto the hunts and computes a combined verdict per IOC:

verdict = (vt.malicious > 0 || abuse.score >= 50) ? 'MALICIOUS'
        : (vt.suspicious > 0 || abuse.score >= 25 || otx.pulses >= 5) ? 'SUSPICIOUS'
        : (any lookup answered) ? 'clean' : 'unknown';

Note that unknown is not clean. If every lookup failed, the report says the indicator wasn't checked , it never implies it was checked and passed.

11. AI Agent + Ollama Chat Model: the analyst note

An AI Agent node backed by a local Ollama model (nothing leaves the network). For each hit hunt it receives only the data the pipeline produced totals, per-agent counts, top rules, three sample events, the intel verdicts, and the hunt’s own triage hint and FP notes and the prompt pins the rules: base every statement on the supplied data, never invent hosts or users, and treat null intel as “lookup unavailable,” not “clean.”

It must return JSON with three keys: triage (2–4 sentences), fp_likelihood (low/medium/high), and next_step (one concrete action). The AI writes prose about decisions the thresholds already made, it gates nothing.

12. Parse Triage: trust but verify

Local models occasionally wrap their JSON in commentary no matter what you tell them, so this Code node parses leniently: regex out the first {...} block, JSON.parse it, and if anything fails, fall back to the hunt's hand-written triage_hint. The report gets an analyst note either way; the AI can only ever upgrade it.

13. Merge Results: everything back in one stream

The AI-annotated hit hunts rejoin the clean and errored hunts from node 7’s other branch. From here on, the workflow is working with the complete picture again: every hunt that ran this week, each labeled hits/clean/error, hits carrying intel and triage.

14. Build Report: one node, three outputs

The biggest Code node in the workflow. From the merged results it builds:

  • The HTML email - inline styles and table layout (it has to survive Outlook), opening with a one-line posture bar: 🟢 All 13 hunts clean or 🔴 2 of 59 hunts scored hits. Then KPI tiles, a card per hit hunt (MITRE mapping, agents, rules, sample events, intel verdicts, the AI note), an errored table, and a clean table. Three color themes selectable from Configuration, and BRAND_NAME in the header so MSPs can ship it as their own report.

  • slack_text - a Slack-mrkdwn summary.

  • chat_text - a plain-text summary for Discord/Telegram (no parse mode, nothing to escape).

Plus two booleans the delivery nodes key off: dry_run and has_hits.

15. Preview Report: see it without sending it

An HTML node that renders the email inside the n8n editor. During setup you execute the workflow, click this node, and read the exact report before any credential for sending even exists.

The weekly threat hunt report, showing 59 hunts run, 3 with hits, 56 clean and 0 errored, with hunt H-021 flagged as a mass file-change ransomware canary and the AI triage judging it a likely false positive from a kernel updateHunt H-020 in the report, web attack signatures below alert threshold, listing 6381 matching events and three source IPs flagged malicious by VirusTotal and OTX, with an AI triage verdict and a suggested next step

16. Dry-Run Gate → Send Report: the safety valve

An IF node on the dry_run flag. True (the default): the run ends at a No-Op node, report built, nothing sent. False: a Gmail node sends the HTML to REPORT_RECIPIENT. Swapping Gmail for SMTP, Outlook or SendGrid is a one-node replacement, subject, HTML and recipient are all expressions pointing at Build Report and Configuration.

17. Slack Needed? / Discord Needed? / Telegram Needed?

After the email, three independent IF gates one per chat channel, each firing only on hit-weeks. The email is the record; chat is the nudge, and a nudge that fires on “all clean” trains people to mute it.

One n8n quirk shaped this design: n8n refuses to activate a workflow that contains a node with a missing required credential. If the Discord and Telegram nodes shipped enabled, nobody could activate the workflow without creating Discord and Telegram credentials first even people who only want email. So both ship disabled (disabled nodes don’t block activation), and enabling the node plus attaching your own credential is the opt-in. Slack avoids the problem entirely by using a plain HTTP node against an incoming-webhook URL, no credential type, so a simple config flag controls it.

Video Demo

What I’d tell you to steal

If you build your own version of this and honestly, you could in a weekend take the shape rather than the specifics:

  1. Put detections in data, logic in three nodes. The other 28 nodes are transport and delivery.

  2. Compute schedule state from the calendar. week % 4 replaced a database.

  3. Batch the queries. One _msearch call instead of 59 requests.

  4. Let thresholds decide, let the AI describe. Deterministic min_hits picks what's reportable; the LLM only writes the paragraph.

  5. Make every external dependency optional at runtime. Dead API → n/a. Dead LLM → canned hint. Missing telemetry → an "errored" row. The Monday email always arrives.

Get the Workflow

I’ve packaged the whole thing, the workflow JSON, all 59 hunts documented with hypotheses, MITRE mappings and known false positives, a 15-minute setup guide, a 30-day tuning playbook, and the Sysmon/auditd configs that feed the advanced hunts as a ready-to-import product

👉 Get it from  Here

Building something similar, or fighting Wazuh telemetry gaps? Find me there

← Back to blog