← scottslab.io
Scott’s Lab

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.

Watch the walkthrough · 1:26Code on GitHubRead the write-up
3 / 3
verdict paths tested end to end: return traffic closed, Tor exit blocked, unknown source escalated
< 1 s
from alert to the address in the pfSense block table, automatic and approved alike
24 · 16 · 8
first 12 hour report: firewall blocks, SOAR verdicts, escalations to an analyst

Walkthrough

Eighty-six seconds, one alert, three outcomes


The pipeline

Five stages, one alert

01 · DETECT · Wazuh

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.

02 · ENRICH · Shuffle

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.

03 · SCORE · Shuffle

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.

04 · ACT · Wazuh rules

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.

05 · RECORD · DFIR-IRIS

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.

BLOCK

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.

CLOSE

Allowlisted range or organization, or return traffic (source port 80/443 to a high port). Logged at a low level. Nothing happens.

ESCALATE

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.

SourceSignalsScoreVerdictEffect
142.251.117.132Google LLC, src 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 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.

  1. 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.
  2. Check. n8n compares the token to the stored secret and validates the IP as public unicast. Either fails: 403, stop, no side effects.
  3. Authenticate. n8n obtains a short-lived JWT from the Wazuh API.
  4. Post. n8n posts a block verdict with the reason "analyst approved via approve link". Wazuh raises rule 100530 at score 100.
  5. 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.

Code, scrubbed, MITMore from the lab

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.