Lab build log · September 2026 · Wazuh · Shuffle · n8n · DFIR-IRIS · pfSense
A SOAR layer between the SIEM detection and the firewall block
Wazuh was already blocking flood sources on the pfSense WAN for 24 hours. The block fired with no context: the same rule treated a scanner and a large CDN the same way, and one night it blocked Google, Meta and my own server. This build puts enrichment, an allowlist, a score and a human gate in front of that action, and records every outcome in a case manager.
Walkthrough
Eighty-six seconds, one alert, three outcomes
The pipeline
Five stages, one alert
pfSense filterlog is decoded on the manager. The moderate flood rule, level 10, posts the alert as JSON to a Shuffle webhook instead of blocking. A severe flood rule still blocks directly, as a fail-safe if the SOAR path is down.
RDAP organization, the Tor exit list, Spamhaus DROP and the lab's own 40,000-entry Wazuh IOC list, read through the Wazuh API. AbuseIPDB and VirusTotal join when keys are present. Feeds are cached for an hour.
Allowlist first: private ranges, CGNAT, CDN and tunnel ranges, the media server. Then an out-of-state check, because source port 443 to a high destination port is return traffic, not an attack. Only then do weighted signals become a score.
The verdict goes back into Wazuh as an event. Three rules turn it into block, close or escalate alerts, and only the block rule is wired to the existing pfSense response. One script, one 24 hour expiry, one audit trail.
Every block and escalation lands as an IRIS alert with the IOC, the enrichment and the Wazuh context. Escalations carry a one-click, token-gated approve link served by n8n and a push to the analyst's phone.
pfSense syslog -> Wazuh (rule 100503, L10) -> integratord -> Shuffle webhook
|
parse_alert -> wazuh_auth -> enrich_ip -> score_verdict
|
+-------------------+-------------------+---+---------------------+
| block | close | escalate |
v v v |
POST /events POST /events iris_open_alert -> message_analyst -> log_escalation
(rule 100530) (rule 100531) (rule 100532)
|
pfsense-block AR -> pfctl -t Blocked_IPs -T add (24 h, execd deletes on timeout)
|
iris_record_block (High, Closed)Verdicts
Three outcomes. Only one blocks.
Score at or above the threshold and a public IP. Rule 100530 adds the address to the pfSense Blocked_IPs table, WAN inbound only, 24 hour expiry.
Allowlisted range or organization, or return traffic (source port 80/443 to a high port). Logged at a low level. Nothing happens.
No findings and not allowlisted, or residential ISP space where a friend’s Plex client might live. IRIS alert, phone push, one-click approve link. A human decides.
| Source | Signals | Score | Verdict | Effect |
|---|---|---|---|---|
| 142.251.117.132 | Google LLC, src port 443 to a high port | 0 | close | logged, no action |
| 185.220.101.100 | on the IOC list (+40), Tor exit (+50) | 90 | block | in the pfSense table within one second |
| 203.0.113.5 | nothing found, RDAP 404 | 0 | escalate | IRIS alert 3, analyst notified |
Human gate
Escalate means a human decides
An escalated alert is a decision the playbook declined to make. The analyst makes it by following a link; n8n does the rest. 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.
- Link. The escalation carries GET /webhook/soar-approve?t=<token>&ip=<src>&rule=<id>. The same link is in the IRIS alert and the phone push.
- Check. n8n compares the token to the stored secret and validates the IP as public unicast. Either fails: 403, stop, no side effects.
- Authenticate. n8n obtains a short-lived JWT from the Wazuh API.
- Post. n8n posts a block verdict with the reason "analyst approved via approve link". Wazuh raises rule 100530 at score 100.
- Block. The response adds the address to the pfSense table in the same second. IRIS records the action next to the alert.
Case management
Alert to case in DFIR-IRIS
Block verdicts arrive as High and already Closed, with the action recorded. Escalations arrive as Medium and New, with the enrichment output, the Wazuh context (rule, agent, interface, traffic) and the approve link in the description, and the source IP as an ip-src IOC. One escalation, walked through: the analyst escalates the alert to a case and merges a related block from the same hour; notes record what fired, what the playbook did and the assessment; three tasks, four timeline events and one asset are attached; the summary states the outcome and the follow-up, which was to extend the allowlist with documentation ranges so harness runs close on their own.
Write-up
What the build taught me
Guardrails before scoring. Private ranges, the tailnet, CDN and tunnel ranges and the media server are allowlisted before any signal is weighed. Residential ISP space is never auto-blocked: friends stream Plex from there, so it escalates instead. The block rule applies to WAN inbound only, so established sessions survive a wrong block, and every block expires after 24 hours.
Verdicts belong in the SIEM. Posting the verdict back as a Wazuh event, rather than calling the firewall from the playbook, kept one response script, one timeout, one dedup cache and one audit trail. Verdict rules are excluded from the Shuffle integration so a verdict can never trigger a second run.
Fail-safe and fail-open, on purpose. The severe flood still blocks directly if Shuffle is down. The moderate flood fails open to a logged alert. Both are deliberate, and I can argue either side.
Health checks are a false-positive source. The lab dashboard’s 30-second TCP probe of the media server produced “sshd: connection reset” alerts, that rule was in the host’s active-response list, and Wazuh banned the dashboard. The fix was a whitelist for the tailnet range and one rule fewer in the trigger list.
Count the right thing. The old 12 hour report said “0 firewall blocks” for months because it counted a rule below the logging threshold. The new report counts the response log and the verdict rules, and shows every time as UTC and Eastern.
Small platform lessons. 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.
All addresses on this page are documentation ranges or public infrastructure. Internal hostnames and identifiers are omitted.
Where this build started
Three posts before this one
The SOAR layer only makes sense on top of the SIEM, the firewall tuning and the automation that came first. If you want the whole story, or you are building the same stack, start here.
The detection side of this pipeline. Sixteen hosts into one Wazuh manager, about 60 custom rules, and the level-0 suppressions that took the console from hundreds of alerts a day to under twenty. The two active-response rules in that post are the ones this SOAR layer now sits in front of.
Where the false-positive problem was first measured. 67 pfSense rules audited and 21 WAN pass rules removed, Zeek C2 whitelists rewritten for roughly a 90 percent cut, and a batch of Wazuh rules with XML errors and colliding IDs fixed. Allowlist before you score is a habit from that pass.
How the hosts in this lab get built and kept consistent. One control node, 14 hosts in nine inventory groups, one deployment role for Docker, UFW, systemd and health checks, and Ludus ranges from Packer templates. The reason a rebuild here is an afternoon, not a weekend.