Homelab SIEM / SOC

A full SOC in my homelab for a team study project: the SOCFortress stack on Proxmox, a port mirror for Suricata and Zeek, and Wazuh rules and Graylog pipelines written for the lab.

Overview

In my master's programme, three of us are running a study project: designing a ready-to-use open source SOC platform for SMEs and mid-sized companies. Within the team, I own integration and industrialisation. The demo lab runs in my homelab, on Dell servers under Proxmox. An isolated lab zone, which the team attacks through dedicated VPNs, brings together a vulnerable web server (DVWA), Linux and Windows workstations and a future domain controller. One VM carries the SOCFortress stack: 39 containers in 6 Docker Compose projects (Wazuh, Graylog, Grafana, Velociraptor, TheHive, Cortex, MISP, OpenCTI, Shuffle, Suricata, Zeek, CoPilot). I worked with Claude Code as an execution agent: I set the scope and made the calls, the agent wrote and applied the scripts, with every intervention dated in the repository's log. I installed the agents and Sysmon on the Windows workstations myself.

The SOC's technical view in Grafana: Wazuh alerts by severity, Suricata alerts, Graylog detections and MITRE ATT&CK tactics over 24 hours, on the lab's real data.

A stack that gets back up on its own

I first moved the stack from a school VM into the homelab: images pulled by sha256 digest (exact versions), 47 volumes copied over. With set -e, the original start-all.sh stopped on a misp-core that was still "unhealthy" and left MISP, OpenCTI, Shuffle and the sensors down: I rewrote it with three attempts per project and explicit waits. The Wazuh manager loops when its container restarts, but a fresh container starts fine: siem-demarrer recreates it at boot, and the siem-surveiller timer restarts any incomplete project every 5 minutes. After a hard power cut, the stack came back on its own in about 4 minutes.

A firewall that does not write its name

With no Wazuh agent package for pfSense, its logs arrive as BSD syslog, without a hostname. Wazuh took filterlog[pid]: for the host and the official decoder never applied. Since this version of Wazuh does not read the RFC 5424 format, I fixed it on reception: a local copy of the decoders replaces the original, excluded with <decoder_exclude>, with a second parent that recognises this prefix.

<decoder name="pf">
  <program_name>filterlog</program_name>
</decoder>
<decoder name="pf">
  <prematch>^filterlog[\d+]: </prematch>
</decoder>
<decoder name="pf-fields">
  <parent>pf</parent>
  <regex offset="after_parent">^\S*,\S*,\S*,(\S*),\S*,\S*,(\S*),</regex>
  <order>id,action</order>
</decoder>

A rule 87702 alert (18 blocks from the same source within 45 seconds) then goes to Shuffle and on to TheHive. Local rule 100700 ignores sources outside the homelab, so that internet scans do not flood TheHive. In Graylog, a pipeline fixes the source and the time: with no year and no time zone, the BSD timestamp was stored two hours in the future. After the manager was recreated, a UDP conntrack entry not redirected by Docker caused the syslog to be refused: siem-syslog-conntrack deletes it.

Graylog: internet connections blocked by pfSense over 24 hours, geolocated and broken down by country from the firewall's syslog messages.

A port mirror for the sensors

Suricata and Zeek only saw the SIEM's own traffic. On the Proxmox host, I added a capture bridge with no uplink and a second network card on the SIEM, in promiscuous mode, with no IP address. The siem-span.sh script attaches a clsact qdisc and matchall filters to the lab VMs' network cards, whose mirred action copies the traffic to that card; a timer reapplies it every minute. Zeek now sees traffic between lab machines, which never goes through the firewall: 162 connections to 153 ports during a scan. Once the mirror was extended to the pfSense WAN, about 40 Suricata alerts coming from the internet reached TheHive in 15 minutes: I restricted the Graylog event definition to alerts involving the internal network. The other zones stay out of the mirror: the SIEM sits in the attackers' zone.

Zeek tab of the network dashboard: connections seen on the port mirror, by service, and a scan index ranking sources by number of distinct ports (here internet scanners, addresses masked).

What happens after the intrusion

On the lab's Linux machines, auditd watches identity files, the SSH configuration, cron, systemd units, kernel modules and tools such as nc or nmap. By default, Wazuh puts auditd events at level 0; my local rules 100710 to 100716 raise these events to levels 8 to 12 and map them to MITRE ATT&CK. Logging every execution (-S execve) produced about 1,000 events per minute and caused events to be lost: I went back to path-based watches. Reloading the rules emitted CONFIG_CHANGE records carrying the watched key, hence the requirement for the audit.exe field, which is only present on a real action. Finally, homelab_graylog.py idempotently builds a common enrichment pipeline (zone, severity, MITRE tactic, GeoIP) and three dashboards, then runs their 41 widgets to catch errors.

Wazuh over seven days: local rule 100712 raises writes to /root/.ssh to level 10, alongside an executable created by PowerShell on the Windows workstation (machine names masked).

What the test campaign showed

I validated the whole setup with standard attacks on my own machines, after taking snapshots: scanning, SSH brute force, SQL injection on DVWA, persistence through cron, Windows reconnaissance launched through Velociraptor. Everything was detected, and alerts of level 8 and above reached TheHive. But Suricata does not alert on a slow "connect" scan with the ET open rules: only Zeek and a Graylog event see it. Web attacks stay below the TheHive threshold. The VM has no backup yet, and I have not checked how the dashboards render visually. My takeaway is that a running SIEM stack only sees part of the attacks: most of the work was closing blind spots and cutting noise, then checking the detections against real attacks.