← SOAR Lab
Scott’s Lab

Lab build log · September 2026 · Wazuh · Shuffle · n8n · DFIR-IRIS · pfSense

Putting a SOAR layer between a SIEM detection and the firewall block

Scott Schlangen · scottslab.io · 12 min read · all addresses are documentation ranges

TL;DR

Wazuh was already detecting inbound floods on the pfSense WAN and blocking the source for 24 hours through an active response. The block fired with no context: the same rule treated a scanner and a large CDN the same way. This build inserts enrichment, an allowlist, a score and a human gate in front of that action, and records every outcome in a case manager. Everything below is running in the lab and was tested end to end.

The starting point

pfSense logs every blocked packet to syslog. Wazuh decodes those lines and two frequency rules watch them: twenty blocked packets from one source in thirty seconds (rule 100503, level 10) and fifty in sixty seconds (rule 100504, level 12). Both were wired to a server-side active response that SSHes to the firewall and adds the source to a pfctl table with a 24 hour timeout. It worked, and it had guardrails already: RFC1918 was excluded, Cloudflare and Tailscale ranges were suppressed, the torrent swarm port on the media server was carved out.

The night before this build the response log showed why that was not enough. In twelve hours the rule had blocked Google, Meta, Fastly, Microsoft, Cloudflare and one of my own Linode servers, mixed in with genuine Censys and hosting-provider scanners. The Google hit was TCP 443 return traffic to a high port on the WAN address: out-of-state packets from a legitimate session, not an attack. The rule had no way to know the difference, and nobody was asked.

The pipeline

01 · DETECT · Wazuh

pfSense filterlog. The moderate flood rule at level 10 now posts the alert as JSON to a Shuffle webhook instead of blocking. The severe flood rule still blocks directly as a fail-safe, so a Shuffle outage degrades to the old behaviour for the worst case only.

02 · ENRICH · Shuffle

RDAP organisation, the Tor exit list, Spamhaus DROP and the lab's own 40,000-entry Wazuh IOC list, read through the Wazuh API so there is one source of truth. AbuseIPDB and VirusTotal join when keys are present. Feeds are cached in the worker for an hour; the Tor list started rate-limiting within the first hour of real traffic.

03 · SCORE · Verdict

Allowlist first, and it can veto any score: private ranges, CGNAT, Cloudflare, Tailscale control and DERP, my own servers, and an organisation match on RDAP for the big CDNs and clouds. Then the out-of-state check: source port 80 or 443 to a high destination port is return traffic. Then weighted signals: the IOC list, Tor, DROP, known-scanner organisations, AbuseIPDB confidence and VirusTotal votes. Seventy blocks, forty to sixty-nine escalates, anything lower closes only when the traffic shape is benign.

04 · ACT · Wazuh rules

The verdict is posted back into Wazuh as an event: soar_verdict, action, source, score, the rule that started it and a reason. Three rules turn it into block (100530), close (100531) and escalate (100532) alerts. Only the block rule is wired to the pfSense response, so the timeout, the dedup cache and the audit trail all stay where they were. The verdict rules are excluded from the Shuffle integration, so a verdict can never trigger a second run.

05 · RECORD · DFIR-IRIS

Every block and escalation lands as an IRIS alert with the source IP as an ip-src IOC, the enrichment output and the Wazuh context. Blocks arrive High and already Closed with the action recorded. Escalations arrive Medium and New, with a one-click approve link served by n8n and a push to the analyst's phone.

Three outcomes, only one blocks

BLOCK Score at or above the threshold and a public IP. Rule 100530 drives the existing response: pfctl -t Blocked_IPs -T add, WAN inbound only, 24 hour expiry. Because the rule is WAN inbound and pf evaluates state before rules, an established session survives a wrong block.

CLOSE Allowlisted range or organisation, or return traffic. Logged at level 5 for the audit trail. Nothing happens.

ESCALATE No findings and not allowlisted, or a source in residential ISP space, where a friend’s Plex client might live. An IRIS alert opens, the analyst gets a message, and the block waits for a human.

SourceSignalsScoreVerdictEffect
142.251.117.132Google LLC, source port 443 to a high port0closelogged, no action
185.220.101.100on the IOC list (+40), Tor exit (+50)90blockin the pfSense table within one second
203.0.113.5nothing found, RDAP 4040escalateIRIS alert, analyst notified

The approval gate

An escalated alert is a decision the playbook declined to make. The analyst makes it by following a link; n8n does the rest.

  1. Link. The escalation carries GET /webhook/soar-approve?t=<token>&ip=<src>&rule=<id>. The same link appears in the IRIS alert description and in the phone push.
  2. Token and IP check. n8n compares the token against the stored secret and validates the IP as a public unicast address. Either failing returns 403 and the workflow stops.
  3. Wazuh token. n8n authenticates to the Wazuh API and receives a short-lived JWT.
  4. Post block verdict. n8n posts a block event with the reason “analyst approved via approve link”. Wazuh raises the block rule at score 100.
  5. pfSense. The response adds the address to the block table in the same second. The analyst gets an OK, the block is visible in Wazuh with the approval reason, and the IRIS alert records the action.

Tested with a wrong token (403, no side effects) and a right token (block on the firewall within one second). The approve path never touches pfSense directly; it can only ask Wazuh to raise the rule the automation already uses, so the firewall has one entry point.

IRIS: alert to case

Walkthrough of one escalation. The alert opens with verdict escalate, score 0, sources checked rdap, tor, spamhaus_drop and wazuh_cdb, no findings, RDAP 404 on the source. The analyst escalates the alert to a case and merges a related block from the same hour for context. Notes record what fired, what the playbook did and the assessment: the source is a documentation-range address from the test harness, so a benign test. Three tasks, four timeline events and one asset are attached, and the case summary states the outcome and a follow-up: extend the allowlist with documentation ranges so future harness runs close on their own.

Results

3 / 3
verdict paths tested end to end
< 1 s
alert to pfSense block, automatic and approved alike
24 · 16 · 8
first 12 h report: blocks, verdicts, escalations

What the build taught me

Health checks are a false-positive source. The lab dashboard probes the media server’s SSH port every thirty seconds. Each probe produced a “connection reset” alert, that rule was in the host’s active-response trigger list, and Wazuh banned the dashboard host. The fix was a whitelist for the tailnet range and one rule fewer in the list. Every monitor is also an alert source.

Automation should know who it might hurt. The block table held Comcast, AT&T, Verizon Wireless and Charter addresses: the ISPs friends stream Plex from. Those were unblocked, floods aimed at the Plex port are now suppressed before the response rules, and the playbook treats residential organisations as escalate-only.

Count the right thing. The old twelve-hour report said “0 firewall blocks” for months because it counted a rule below the logging threshold. The replacement counts the response log and the verdict rules, adds IRIS counts, and shows every time as UTC and Eastern.

Platform details that cost an hour each. Shuffle wraps every node result in a message envelope, so cross-node references are $node.message.field. Form-encoded bodies need real URL encoding; a raw plus in a phone number becomes a space. Public reputation feeds rate-limit fast once real alerts flow, so cache them. The Wazuh indexer listens on loopback only, so live counters go through the dashboard’s console proxy.

Guardrails, in one place

Watch the walkthroughCode on GitHubBack to the project

Where this build started

Three earlier posts carry the ground this one stands on. The Wazuh SIEM build is the detection side: sixteen hosts into one manager, about 60 custom rules, and the suppressions that made the console usable. The hardening pass is where the false-positive problem was first measured, with 67 pfSense rules audited and the Zeek whitelists rewritten. The Ansible and Ludus post is how the hosts here get built and rebuilt. Read those and the guardrails above will look less like caution and more like scar tissue.

Public IPs, hostnames and internal identifiers are omitted from this article.