Enrichment Has to Come First: Adding IOC Lookups to the n8n Alert Router

The last n8n post ended with a list of natural follow-ups, and the first one on that list was “an IOC enrichment step ahead of the routing decision.” I want to take that seriously rather than just build a VirusTotal node and bolt it on wherever’s convenient, because where the enrichment sits changes what the workflow is actually capable of catching.

Enrichment has to come before the threshold, not after

The existing workflow filters on Wazuh’s own rule level: anything under 10 gets dropped before it ever reaches a human. That’s a sound default when the only signal you have is the rule that fired. But it stops being sound the moment you have a second signal available, because a level-5 “failed SSH login” from an IP AbuseIPDB has flagged with a 100% confidence score and 1,800+ reports is a more urgent event than a level-9 alert from a clean residential address — and the original pipeline would silently drop the first one and forward the second.

So enrichment can’t be bolted onto the end of the pipeline as a “nice extra detail” on alerts that already cleared the bar. It has to run before the threshold decision, because the exact case where it earns its keep — a boring-looking alert from a known-bad actor — is precisely the case the raw rule level would otherwise filter out. That’s a one-line requirement with a real consequence: the new node has to sit upstream of last time’s Dedup + Threshold Filter, feeding it an adjusted severity rather than reading its output.

The build

Five visible nodes this time, not six — and that’s a deliberate change, not a simplification for its own sake. Once you’re doing an external lookup with cache-or-skip logic, graceful failure handling, and severity math all in one step, wiring that across three or four IF/Merge nodes on the canvas turned out to be far more brittle to build and debug than writing it as one Code node using helpers.httpRequest — the same HTTP client the HTTP Request node itself uses, just callable directly from JavaScript. I only found out this is even possible by checking what n8n’s Code node sandbox actually exposes; it’s not advertised on the node’s canvas the way HTTP Request is, but it’s there under helpers.httpRequest(...), await-able like any other fetch call.

Webhook → Enrich IOC → Dedup + Threshold Filter → IF (forward?) → Format Chat Message → Post to Chat → Respond to Webhook
                                                                 ↘ (skip) ──────────────────────────────────────────↗

Enrich IOC pulls data.srcip off the incoming Wazuh alert, short-circuits on anything in RFC 1918 space (my lab’s own traffic is almost entirely internal — AbuseIPDB has nothing useful to say about 10.10.10.x, so there’s no point spending a lookup on it), and otherwise calls AbuseIPDB’s /api/v2/check endpoint:

const PRIVATE_RANGES = [/^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[0-1])\./];
const ESCALATE_HIGH = 75; // abuseConfidenceScore threshold for a hard bump
const ESCALATE_MED = 25;

if (!ip) {
  enrichmentStatus = 'no_ip';
} else if (PRIVATE_RANGES.some((re) => re.test(ip))) {
  enrichmentStatus = 'skipped_private';
} else {
  // cache check, then:
  const res = await helpers.httpRequest({
    method: 'GET',
    url: 'https://api.abuseipdb.com/api/v2/check',
    qs: { ipAddress: ip, maxAgeInDays: 90 },
    headers: { Key: ABUSEIPDB_KEY, Accept: 'application/json' },
    json: true,
    ignoreHttpStatusErrors: true, // see below
  });
  // ...
}

let bump = 0;
if (abuseScore !== null) {
  if (abuseScore >= ESCALATE_HIGH) bump = 6;
  else if (abuseScore >= ESCALATE_MED) bump = 3;
}

ignoreHttpStatusErrors: true matters more than it looks. AbuseIPDB’s free tier caps out at 1,000 checks a day, and the honest way to build this is to assume you’ll hit that limit sooner or later — a 429 shouldn’t take the whole workflow down with it. Without that flag, helpers.httpRequest throws on a non-2xx response and the execution just dies; with it, the response comes back as data I can inspect (res.errors[0].status), fall back gracefully on, and still let the alert through on its original rule level if it was already above threshold anyway.

effectiveLevel — the value that actually reaches the dedup/threshold node — is ruleLevel + bump, not a replacement for it. That was a deliberate choice: I didn’t want a bad reputation score to be the decision, just a reason to look twice at something the rule level alone would have dropped.

Testing it — and the bug I’d already written about

I built and ran this against the same kind of sandboxed n8n instance as last time, with a stand-in for AbuseIPDB serving fixed, documented-schema responses for a handful of test IPs (a known Tor-exit-style address, a clean one, and one rigged to return a 429), plus the same chat-webhook stand-in as before.

First real result, and an annoying one: I made the exact mistake the previous post warned about. Respond to Webhook referenced $json.decision directly, and every single response came back {} — because by the time execution reaches Respond to Webhook on the forwarded path, it’s passed through Post to Chat, and $json is that node’s response body, not the alert data. I wrote an entire section about this trap last time and still walked straight into it while extending the same workflow. The fix is the same one as before, just applied more defensively this time — reference the node by name from anywhere in the graph, regardless of which branch actually ran:

$('Dedup + Threshold Filter').item.json.decision

Dedup + Threshold Filter executes exactly once on every path through this workflow, forwarded or not, so anchoring to it by name instead of relying on whatever $json happens to mean at that point in the canvas is the version of this fix that can’t drift out of sync again.

The bigger one: static data quietly stopped persisting

The previous post’s dedup cache leaned on $getWorkflowStaticData('node') — an in-place object n8n hands your Code node, that you mutate directly, and that survives between separate executions (even a full process restart, as I confirmed last time). I built the IP-reputation cache the same way. It didn’t work. Not “threw an error” — it silently did nothing. Sending the same bad IP twice, three seconds apart, triggered two real HTTP calls to the AbuseIPDB stand-in and posted two identical chat messages, as if no caching code existed at all.

Worth chasing down properly rather than shrugging and working around it, so: querying the SQLite database backing this n8n instance directly showed the workflow’s staticData column sitting at NULL even after several executions that should have written to it. Turning on debug logging showed why — n8n-core’s post-execution hook only persists static data when the engine sets a newStaticData flag, and that flag only gets set when a __dataChanged marker trips on the object $getWorkflowStaticData() returns. That marker lives on a recursive Proxy wrapping the real object (n8n-workflow’s observable-object.js — worth a read if you want to see the mechanism, it’s a neat piece of code), and every set anywhere in the object tree is supposed to flip it.

Except the debug log for every single Code node execution was full of lines like [IsolatedVmBridge] Runtime bundle loaded and [IsolatedVmBridge] Disposed. Current n8n runs Code node scripts and expressions inside a disposable V8 isolate via @n8n/expression-runtime, and the bridge that connects your script to the workflow’s data is explicit about how it works: host and isolate only exchange structured-clone-able data through one-way RPC calls, never live references or Proxy objects — the library’s own source comment says as much. So $getWorkflowStaticData() inside a Code node now hands you a disconnected snapshot copy. You can mutate it all you like; nothing you do to it is reachable from the real, Proxy-wrapped object sitting on the host side, so __dataChanged never trips, and the hook that would save it never fires. No error, no warning — it just quietly stops being a cache.

I didn’t take this on faith — I set the cache to global scope instead of node scope to rule out a scoping mistake on my part (same result), then went and read both the sandbox bridge’s source and the Proxy implementation to confirm the mechanism rather than guess at it. I can’t say for certain this holds across every n8n version or deployment mode — it’s an internal implementation detail, not documented behaviour, so it’s worth a quick sanity check against your own instance before you rely on it either way. But on the version I’m running (n8n 2.35.7 via npm), it’s real, it’s silent, and it would have shipped a workflow that looked correct in testing and quietly stopped deduplicating or caching anything the moment it went live.

The fix: stop treating $getWorkflowStaticData() as a cache from inside a Code node, and give the workflow an actual external store instead — the same helpers.httpRequest pattern used for AbuseIPDB itself, pointed at a tiny key-value service (Redis or a SQLite-backed API in production; I used a minimal stand-in for testing) instead of an object that only pretends to persist:

const cacheRes = await helpers.httpRequest({
  method: 'GET', url: `${CACHE_URL}/get`, qs: { key: cacheKey }, json: true,
});
if (cacheRes.found) {
  ({ abuseScore, abuseCountry, abuseIsp, abuseReports } = cacheRes.value);
  enrichmentStatus = 'cached';
} else {
  // ...look it up, then:
  await helpers.httpRequest({
    method: 'POST', url: `${CACHE_URL}/set`, json: true,
    body: { key: cacheKey, value: { abuseScore, abuseCountry, abuseIsp, abuseReports }, ttl_seconds: 3600 },
  });
}

The dedup check from the previous post moved to the same store, for the same reason. It’s a slightly heavier dependency than “n8n just handles it for you,” but it’s an honest one — the workflow’s state now lives somewhere that says so, rather than in an object whose persistence turned out to be conditional on an implementation detail three layers down.

Five alerts through the fixed pipeline

$ curl -s -X POST .../webhook/ioc-enrich -d '{"rule":{"level":5,"id":"5710","description":"sshd: Attempt to login using a non-existent user"},"agent":{"name":"target-win2022"},"data":{"srcip":"185.220.101.45"}}'
{"decision":"forwarded","ip":"185.220.101.45","effectiveLevel":11,"enrichmentStatus":"looked_up","abuseScore":100}

# identical alert, 3 seconds later
{"decision":"suppressed_duplicate","ip":"185.220.101.45","effectiveLevel":11,"enrichmentStatus":"cached","abuseScore":100}

# a clean IP, level 6
{"decision":"suppressed_below_threshold","ip":"198.51.100.9","effectiveLevel":6,"enrichmentStatus":"looked_up","abuseScore":0}

# an internal/lab source address, level 8
{"decision":"suppressed_below_threshold","ip":"10.10.10.99","effectiveLevel":8,"enrichmentStatus":"skipped_private","abuseScore":null}

# a level-12 alert whose IOC lookup gets rate-limited
{"decision":"forwarded","ip":"192.0.2.99","effectiveLevel":12,"enrichmentStatus":"lookup_failed:429","abuseScore":null}

Level 5 came in as sshd: Attempt to login using a non-existent user — routine noise on its own — and left as effective level 11, forwarded, with AbuseIPDB’s 100%-confidence, 1,847-report verdict attached, exactly the case the whole design exists for. The repeat three seconds later hit the cache (one AbuseIPDB call total for that IP across both requests, confirmed against the stand-in’s own request log) and got suppressed as a duplicate, correctly this time. The clean IP and the private IP both stayed below threshold on their own merits. The rate-limited lookup didn’t take the workflow down with it, and still made the right call because its rule level alone already cleared the bar — enrichment is a reason to escalate, never a requirement to proceed.

Two chat messages out of five alerts in, both real escalation calls, with a properly-populated response the whole way through this time.

Wiring in a real key

Everything above ran against a stand-in for AbuseIPDB, same as the Wazuh side ran against simulated alerts last time. For the real thing: sign up for a free AbuseIPDB key (1,000 checks/day), and put it in n8n as an HTTP Header Auth credential on the HTTP Request-equivalent call rather than the placeholder string sitting in the code above — the free tier’s daily cap is exactly why the cache isn’t optional set dressing here. A lab that’s mostly internal traffic won’t come close to it, but a real edge-facing Wazuh deployment forwarding every above-threshold alert’s source IP would burn through 1,000 checks a lot faster than a day if every repeat offender triggered a fresh lookup.

Where this leaves things

The list from last time still has 99 more ideas on it, and this one confirmed something worth remembering across all of them: n8n’s Code node is genuinely powerful — helpers.httpRequest meant the whole enrichment-with-caching-and-error-handling step needed one node instead of four — but it’s also sandboxed a layer deeper than its own documentation entirely explains, and building something for real is still the only way that surfaces. The dedup pattern from the previous post wasn’t wrong when I wrote it; it just stopped being true in this build without saying so, and only running it end to end — twice, with a debugger and the source open the second time — caught that before it caught me in production.