TX·06 · BLUE TEAM / DETECTION · LOGGED 2026·08 · 14 MIN
Building a one-person SOC: twelve detections, not twelve thousand
TX·05 ended with an instruction I had only half followed myself: get the logs off the box, because a root intruder edits anything local. This is the other half, built and running: a write-only collector the VPS can't reach back into, a free SIEM that fits on my bench rig, twelve detections mapped to MITRE ATT&CK, and a test proving each one actually fires. Total cloud spend: zero dollars.
Where the hardening article left off
Hardening reduces how often something bad happens. Detection is for the
day it happens anyway, and the threat model stays the same one from
TX·05: automated attackers, plus the small set of things they all do
after landing. They add a cron job or a systemd unit, create a user or
quietly bless one with UID 0, drop a key into
authorized_keys, open a listener, and start beaconing home
on a timer. That short list is the entire detection surface I care
about, and it's why this build has twelve detections instead of an
imported pack of twelve thousand.
Calling it a SOC is a joke at this scale, one analyst, two real hosts, but the workflow is the same one a paid SOC runs: collect, detect, triage, tune. The design constraint that shapes everything below is that exactly one person reads these alerts, and that person has homework. A pipeline that pages me forty times a day doesn't get read, and an unread alert pipeline is a very elaborate way of having no alerts at all.
Push, don't pull
Logs leave every host by two separate roads, on purpose. Road one is the SIEM agent, which exists to detect things. Road two is a plain journald push to a dedicated collector VM, which exists to be the copy nobody can edit afterward. Redundancy is the point: an intruder who kills one road is still being recorded by the other, and the kill itself is an event that ships.
The security property that makes shipping work is direction. The journald upload protocol only appends: the sending host has no verb for reading back, listing, or deleting what the collector already holds. A compromised source can push garbage or go quiet, but yesterday's trail is out of its reach. Debian 13 has the whole mechanism native to systemd, one package away:
[Upload]
URL=https://collector.lab:19532
ServerKeyFile=/etc/ssl/journal/client.key
ServerCertificateFile=/etc/ssl/journal/client.crt
TrustedCertificateFile=/etc/ssl/journal/ca.crt
apt install systemd-journal-remote
mkdir -p /var/log/journal/remote
chown systemd-journal-remote: /var/log/journal/remote
systemctl enable --now systemd-journal-remote.socket
# each source shows up as its own remote-.journal:
journalctl --directory=/var/log/journal/remote --since "1 hour ago"
Upload keeps a cursor in /var/lib/systemd/journal-upload/,
so an outage resumes where it stopped instead of dropping the gap, and
structured fields like _SYSTEMD_UNIT survive the trip,
which matters later when rules want to match on them. If you have
classic syslog sources, or want guaranteed delivery semantics, the
equivalent road is rsyslog's RELP module over TLS. One note from
setting it up on my bench: the docs steer you toward
tls.authmode="name" with an explicit
permittedpeer list, and away from plain certificate
validation, and they're right to:
module(load="omrelp" tls.tlslib="openssl")
action(type="omrelp" target="collector.lab" port="2514"
tls="on" tls.cacert="/etc/rsyslog-certs/ca.pem"
tls.mycert="/etc/rsyslog-certs/client.pem"
tls.myprivkey="/etc/rsyslog-certs/client.key"
tls.authmode="name" tls.permittedpeer="collector.lab")
A collector you can't talk back to
The collector is only worth building if it lives in a separate trust domain. Mine is a small Debian VM on the bench rig with its own admin key that exists nowhere else, a firewall that accepts inbound log traffic from my two source addresses and nothing more, and no reason to ever initiate a connection outward. The SSH key that administers the VPS cannot log into the collector. If the VPS falls, the attacker is standing in a room where the only door is a mail slot.
On top of that, the received journal files get the append-only
attribute, chattr +a, which makes even root on the
collector unable to truncate or rewrite them without first removing the
attribute, an action that needs CAP_LINUX_IMMUTABLE and
leaves its own trail. Be honest about what this is: on the monitored
host, append-only is decoration, because a root intruder clears it
with one command. It only means something on a box the intruder isn't
on. Two operational notes: log rotation has to drop the attribute
before it can rename files, so the rotate script needs a pre-step, and
the whole trick assumes a real filesystem, not tmpfs.
Picking a SIEM you can actually feed
I evaluated four self-hosted, zero-dollar options against the hardware I actually own, and the honest sizing numbers decided most of it for me. Security Onion is a genuinely complete network security monitoring distro, and its own docs put a standalone install at a minimum of 24 GB of RAM and 200 GB of disk, which rules it out of a student lab that isn't built around it. Elastic Security's free tier has strong detection content, but Elasticsearch is JVM-hungry and realistic sizing starts around 8 GB before you've done anything. Grafana Loki with alerting runs in a few hundred megabytes, lightest of all, but it arrives knowing nothing: every detection is yours to write from scratch.
Wazuh won on detection per gigabyte. A single node carrying the
manager, indexer, and dashboard is documented at 4 CPUs, 8 GB of RAM,
and 50 GB of disk for roughly 25 agents with 90 days of history, which
is comfortable headroom for my fleet of three. More importantly, it
ships batteries: file integrity monitoring, CIS configuration checks,
and a Linux ruleset already mapped to MITRE ATT&CK, so the twelve
detections below are mostly configuration rather than construction.
One honesty note on vendor sizing everywhere: these numbers come from
enterprise fleets of hundreds of agents, and at three hosts you're so
far below the knee of every curve that the published event-per-second
benchmarks are trivia. The number that matters on a small box is
events_dropped in
/var/ossec/var/run/wazuh-analysisd.state, and it should
stay at zero.
What I deliberately did not do is import a community mega-pack of rules. A SIEM with ten thousand untuned rules isn't security, it's a noise generator with a dashboard, and the three failure modes that drown solo operators are all self-inflicted: installing a SIEM and calling it done, importing everything, and alerting on everything.
Twelve behaviors, mapped
Each detection is a behavior an intruder can't easily skip, tagged with its ATT&CK technique so coverage is checkable. First, the prerequisite: real-time file integrity monitoring pointed at the small set of paths where Linux persistence actually lives:
<syscheck>
<directories check_all="yes" realtime="yes">/etc</directories>
<directories check_all="yes" realtime="yes">/root/.ssh,/home/deploy/.ssh</directories>
<directories check_all="yes" realtime="yes">/var/spool/cron/crontabs</directories>
<directories check_all="yes" realtime="yes">/lib/systemd/system</directories>
</syscheck>
The authentication pair comes stock. Wazuh's rule 5712 fires on SSH brute force, eight failures from one source inside 120 seconds, tagged T1110, no work needed. The one worth adding beside it is the composite that matters more: a successful login from the same source that was just failing, T1078, because that's not noise anymore, that's a compromise in progress. On a key-only box it should be nearly impossible, which is exactly why I want to hear about it.
The identity cluster is FIM plus one custom rule: new or modified
users (T1136.001), anything gaining UID 0 or landing in
sudoers (T1078.003), writes to the identity files
themselves, and any touch of an authorized_keys file
(T1098.004, its own ATT&CK technique because it is that popular
with real intruders). The custom rule promotes identity-file changes
out of the generic FIM stream:
<group name="local,persistence,">
<rule id="100115" level="10">
<if_sid>550</if_sid>
<field name="file" type="pcre2">/etc/(passwd|shadow|gshadow|group|sudoers)$</field>
<description>Identity file $(file) modified</description>
<mitre><id>T1136.001</id><id>T1078.003</id></mitre>
</rule>
</group>
TX·05's auditd ruleset already watches writes to those files; the
addition here is the read side, because a non-root process reading
/etc/shadow is credential dumping (T1003.008) whether it
succeeds or not, and the attempt itself is the signal:
-a always,exit -F arch=b64 -F path=/etc/shadow -F perm=r -F auid!=-1 -F euid!=0 -k cred_access
The persistence cluster is pure FIM on the paths from the syscheck
fragment: cron drops in /etc/cron.d and the spool
(T1053.003), new or edited systemd units (T1543.002), and shell
profile edits, .bashrc and friends (T1546.004), which is
the low-rent persistence nobody watches and every commodity intruder
tries.
The network pair took actual work. A new listening socket, a web shell's bind port or a rogue service (T1571), has no clean single log event anywhere, so this is the one detection you build yourself: a timer that diffs the current listener set against a baseline you re-bless manually after every legitimate change:
#!/bin/sh
# alert if the set of listening sockets drifts from the blessed baseline
ss -tulpenH | awk '{print $1, $5}' | sort -u > /run/listeners.now
diff /var/lib/soc/listeners.baseline /run/listeners.now \
|| logger -p auth.warning -t listener-diff "listener set changed"
The logger line lands it in the journal, the journal
ships, and a two-line Wazuh rule matching the
listener-diff tag raises it. Last is outbound beaconing
(T1071, T1573), the regular-interval heartbeat of implant traffic. The
right tool at this scale is Zeek generating connection logs and RITA
analyzing them for interval regularity and consistent payload sizes.
That analysis runs on the bench rig, not the VPS, and I'd file it
under the deepest of the twelve: worth having, last to build, and the
one with the most caveats, which the blind spots section owns up to.
Prove every rule fires
An untested detection is a hypothesis with a dashboard. The fix is
Atomic Red Team, Red Canary's library of small per-technique tests
with explicit cleanup steps, which turns "I think FIM covers cron" into
a log line proving rule such-and-such fired at such-and-such time. My
rule for where tests run is about exposure, not caution theater: a
test that's reversible and creates no new attack surface runs on the
real VPS, because that's the telemetry path I actually need to prove.
Creating and deleting a test user, dropping and removing a benign cron
entry, adding a throwaway key to authorized_keys and
yanking it, reading /etc/shadow as an unprivileged user
and watching it fail loudly, and hammering SSH auth from my own second
machine: all VPS-safe, all reversible in seconds.
Anything that opens a listener, starts a service that binds a port, or fetches a payload runs on a snapshotted lab VM only. On an internet-facing host, even a sixty-second test listener is real exposure, sixty seconds is several scanner visits at current background rates, and the lab VM proves the detection logic just as well. Either way the discipline is the same: run the atomic, confirm the specific rule fired, run the cleanup, confirm the state is back. The output of this phase is a table of twelve rows, behavior, test, rule that fired, and it's the single most portfolio-worthy artifact the whole project produces.
Tuning against background radiation
Here's what normal looks like for an internet-facing box, from one practitioner's published field report covering a single day: 5,732 login attempts across 876 distinct usernames, over 40,000 failures catalogued, more than 12,000 addresses banned. My own VPS's numbers are the same species. This is background radiation, and after TX·05 none of it can succeed, which leads to the tuning rule that makes solo operation survivable: never alert on failure volume. Failure volume is the weather.
What earns an alert is the short list that's both rare and load
bearing: a successful login from an address or network I've never used,
success immediately following a failure burst from the same source,
any identity or UID-0 change, any new cron entry, systemd unit, or
listener. Those fire a few times a month between them, and every one
deserves eyes. The working target I hold myself to is three to five
actionable alerts a day, tops, and most days it's zero. The first two
weeks run in observe mode: no paging, just cataloguing what fires and
how often, then for each noisy rule choosing to suppress, raise the
threshold, or scope it. In Wazuh the clean mechanism is a child rule
with level="0" that whitelists the known-benign pattern
while the parent stays intact for everything else.
Enrichment: asking who's knocking
When an alert does fire, the first question is always the same: is this address a mass scanner hitting the whole internet, or something aimed at me? That's an enrichment lookup, and the pipeline I built in TX·02 bolts onto the alert path almost unchanged. Placement is the part people get wrong: enrich after detection, on the handful of alerts, never on the firehose of raw events, because rate limits and API quotas die instantly against 40,000 failures a day. A small webhook on the alert takes the source address and appends three verdicts: GreyNoise's scanner classification, which at current background rates is the single highest-leverage bit of context there is, an AbuseIPDB confidence score, and ASN plus country from a local GeoLite2 database. A full-blown threat intel platform would be enterprise cosplay at this scale; three lookups on three alerts a day is the whole requirement.
What none of this sees
Writing down what a control can't do is the difference between a security posture and a security feeling, so, plainly. A host agent can't detect a kernel-level rootkit, because the agent asks the kernel what's running and the kernel lies. It can't detect an intruder who stops the agent before doing anything interesting, though the off-box journal keeps the stop event itself, provided it shipped in time, which is why the push interval matters. Memory-only implants leave nothing for file integrity monitoring to see. The collector protects what already shipped, not what an attacker prevents from shipping, and it can be flooded with junk by anyone who can reach the port, which the field note above makes uncomfortably easy. Beacon analysis catches regular heartbeats and struggles with encrypted C2 that genuinely mimics normal HTTPS, with long-jitter low-and-slow profiles, and with the awkward fact that on a fleet with no network tap, the sensor lives on the host it's watching, so a fully compromised host can blind its own witness.
And none of this stack, none, is an answer to a targeted, resourced adversary, a supply-chain compromise arriving through a trusted update, or a hostile hypervisor under the VPS. Those need programs, not projects. The honest scope of a one-person SOC is the same as TX·05's: make the automated attacker's job not worth the electricity, and know precisely where that guarantee ends.
The checklist
The build, compressed into the list I actually ran, roughly in order:
- Two log roads per host: SIEM agent for detection, journald push (or RELP over TLS) for the tamper-evident copy
- Collector in its own trust domain: unique admin key, inbound allow-list of source IPs only, initiates nothing outward
chattr +aon collector journals; rotation drops the attribute first; no reliance on it anywhere an attacker could be root- journald-remote client-cert gap mitigated: firewall allow-list, or an mTLS proxy in front;
TrustedCertificateFiletreated as encryption only - Wazuh single node at 4 CPU / 8 GB / 50 GB; agents enrolled;
events_droppedholding at zero - Real-time FIM on
/etc, SSH key dirs, cron spool and drop-ins, systemd unit paths, shell profiles - auditd read rule on
/etc/shadowkeyed and raised to an alert, alongside TX·05's write watches - Listener-diff timer alerting on any drift from a manually blessed socket baseline
- All twelve detections ATT&CK-tagged; coverage reviewable as a list of technique IDs, not vibes
- Every detection proven with an Atomic Red Team test: reversible atomics on the VPS, listeners and payloads on lab VMs only
- Two weeks observe mode, then tuning by suppress / threshold / scope; own admin behavior baselined out first
- Alert volume held to three to five actionable per day; failure-volume alerts deleted, not muted
- Enrichment (GreyNoise, AbuseIPDB, GeoLite2) bolted post-detection onto alerts, never onto raw events
- Blind spots written down next to the detections they qualify; targeted-adversary evidence means professional IR, not more rules
The certification ladder on my credentials page points at blue-team work, and the current CySA+ objectives lean hardest on exactly this: SIEM operation, log correlation, ATT&CK mapping, beaconing as an indicator, alert tuning. Every study guide says to build a mini SOC lab and forward real logs into it. This is that lab, except the logs are real, the noise is real, and the twelve rules have receipts. The auth log is still filling up with guesses from the whole internet, same as it was at the start of TX·05. The difference now is that I'd know within a minute if one of them ever stopped being a guess.