Defensive Security · lesson 4 of 8
Intermediate
6- 2 Sigma Detection Rules: Catch SSH Brute Force and Map It to ATT&CK
- 3 Suricata on a Packet Capture: Write an IDS Rule That Fires
- 4 Alert Triage and Tuning: From 41 Alerts to the 3 That Matter
- 5 Incident Response: Preserve the Evidence, Then Contain
- 6 Building an Incident Timeline from Auth, Web and IDS Logs
- 7 Static File Analysis with EICAR and YARA: Hashes, file, strings and Rules
Also part of Defensive Security
6- + A Firing Alert Doesn't Mean Anyone Was Notified from Monitoring & Observability
- + MTTR Isn't a Guess — Compute It From Alertmanager's Own Timestamps from Monitoring & Observability
- + Logging Security Events Without Logging Secrets from Security Fundamentals
- + Watching the TCP Handshake with tcpdump from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Hunting in AWS CloudTrail Logs with Python from Cloud Security
Alert Triage and Tuning: From 41 Alerts to the 3 That Matter
One noisy Suricata rule, 41 alerts and 38 of them benign. Triage by grouping, then tune with suppress and threshold, and measure what each change hides.
By SkillByExample · Published
A rule that catches something real usually catches plenty that isn’t. Here you’ll produce a day of
traffic to your own web server: a monitoring check, a deploy job, and three requests from a probe. One
policy rule turns that into 41 alerts. You’ll triage them by grouping, then tune the rule three ways and
count exactly what each tuning removes. Tested with Docker Engine 29.8.2, Suricata 8.0.7
(jasonish/suricata:8.0), Alpine 3.22.6 with tcpdump 4.99.5 and curl 8.14.1, and Python 3.12.15
(python:3.12-slim).
A day of traffic
The web server now serves two real files, so the automation gets 200s.
mkdir site && echo ok > site/health && echo 1.4.2 > site/version
docker run -d --name web --network ds-lab --ip 172.30.0.11 -v "$PWD/site":/site -w /site python:3.12-slim python -m http.server 8000
docker run -d --name tap --network container:web -v "$PWD":/work capture-lab tcpdump -i eth0 -U -w /work/day.pcap port 8000
The ds-lab network and capture-lab image come from the
Suricata lesson. --network container:web puts the capture
in the web server’s own network namespace, so it sees every client, the way a sensor in front of a server
would.
Each client runs the same script with a different role.
# traffic.sh: one client's requests; the role is the first argument
url=http://172.30.0.11:8000
case "$1" in
monitor) for i in $(seq 30); do curl -s -o /dev/null $url/health; done ;;
deploy) for i in $(seq 8); do curl -s -o /dev/null $url/version; done ;;
probe) for p in .env admin backup.zip; do curl -s -o /dev/null $url/$p; done ;;
esac
echo "$1 done"
$ docker run --rm --network ds-lab --ip 172.30.0.30 -v "$PWD":/work -w /work capture-lab sh traffic.sh monitor
monitor done
$ docker run --rm --network ds-lab --ip 172.30.0.31 -v "$PWD":/work -w /work capture-lab sh traffic.sh deploy
deploy done
$ docker run --rm --network ds-lab --ip 172.30.0.20 -v "$PWD":/work -w /work capture-lab sh traffic.sh probe
probe done
$ docker stop tap && docker logs tap 2>&1 | tail -3
tap
492 packets captured
492 packets received by filter
0 packets dropped by kernel
172.30.0.30 is the monitoring server, 172.30.0.31 the deploy job, and 172.30.0.20 something
looking for files that shouldn’t be there. All three use curl.
One rule, 41 alerts
The rule flags command-line HTTP clients. Rules like it exist in real rule sets, because malware and attackers’ tools often use curl or similar clients. It’s also exactly the kind of rule that automation trips all day.
# local.rules
alert http any any -> any any (msg:"LAB command-line HTTP client (curl)"; flow:established,to_server; http.user_agent; content:"curl/"; startswith; classtype:policy-violation; sid:1000002; rev:1;)
Reading 41 lines of fast.log one by one is how alert fatigue starts. Group them instead: by source,
path and status.
# triage.py: group alerts by source and path, so the noise stands out
import json, sys
from collections import Counter
out = sys.argv[1] if len(sys.argv) > 1 else "out"
alerts = [e for line in open(f"{out}/eve.json")
if (e := json.loads(line))["event_type"] == "alert"]
groups = Counter((a["src_ip"], a["http"]["url"], a["http"].get("status")) for a in alerts)
print(f"{len(alerts)} alerts in {len(groups)} groups")
for (ip, url, status), n in groups.most_common():
print(f"{n:>4} {ip:<13} {url:<12} {status}")
$ rm -rf out && mkdir out
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r day.pcap -S local.rules -k none -l out
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1 Engine started.
i: suricata: Signal Received. Stopping engine.
i: pcap: read 1 file, 492 packets, 45964 bytes
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python triage.py
41 alerts in 5 groups
30 172.30.0.30 /health 200
8 172.30.0.31 /version 200
1 172.30.0.20 /admin 404
1 172.30.0.20 /backup.zip 404
1 172.30.0.20 /.env 404
41 alerts are really five decisions. Two groups are the same request repeated, with a 200, from hosts you can identify: that’s expected automation. The last three come from one source, each asks for a different sensitive path, and each got a 404. That pattern is reconnaissance. 38 of the 41 alerts are benign, a 93% false-positive rate for this rule on this traffic, and the three that matter were at the bottom of the list.
Tuning 1: suppress the monitoring host
Suricata reads tuning from a threshold.config file. suppress stops one rule from alerting for one
source.
# threshold.config: the monitoring host polls /health with curl all day
suppress gen_id 1, sig_id 1000002, track by_src, ip 172.30.0.30
$ rm -rf out && mkdir out
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r day.pcap -S local.rules -k none -l out --set threshold-file=threshold.config
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1 Engine started.
i: suricata: Signal Received. Stopping engine.
i: pcap: read 1 file, 492 packets, 45964 bytes
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python triage.py
11 alerts in 4 groups
8 172.30.0.31 /version 200
1 172.30.0.20 /backup.zip 404
1 172.30.0.20 /.env 404
1 172.30.0.20 /admin 404
From 41 to 11, and all three probe alerts survived. --set threshold-file= points Suricata at the file
for this run; on a sensor you’d set threshold-file in suricata.yaml.
It’s tempting to annotate the line where it is. Don’t:
# threshold.config
suppress gen_id 1, sig_id 1000002, track by_src, ip 172.30.0.30 # monitoring host
$ rm -rf out && mkdir out
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r day.pcap -S local.rules -k none -l out --set threshold-file=threshold.config
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
E: threshold-config: pcre2_match parse error, ret -1, string , track by_src, ip 172.30.0.30 # monitoring host
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1 Engine started.
i: suricata: Signal Received. Stopping engine.
i: pcap: read 1 file, 492 packets, 45964 bytes
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python triage.py
41 alerts in 5 groups
30 172.30.0.30 /health 200
8 172.30.0.31 /version 200
1 172.30.0.20 /backup.zip 404
1 172.30.0.20 /.env 404
1 172.30.0.20 /admin 404
The trailing comment makes the line unparseable. Suricata logs one E: line, drops the suppression and
carries on, and you’re back at 41 alerts. On a real sensor that error scrolls past in a start-up log
nobody reads. Keep comments on their own lines, and compare the alert count after every tuning change.
Tuning 2: rate-limit per source
The deploy job still produces eight identical alerts. Instead of naming another host, cap the rule at one alert per source per hour.
# threshold.config: the monitoring host polls /health with curl all day
suppress gen_id 1, sig_id 1000002, track by_src, ip 172.30.0.30
# at most one alert per source per hour
threshold gen_id 1, sig_id 1000002, type limit, track by_src, count 1, seconds 3600
$ rm -rf out && mkdir out
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r day.pcap -S local.rules -k none -l out --set threshold-file=threshold.config
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1 Engine started.
i: suricata: Signal Received. Stopping engine.
i: pcap: read 1 file, 492 packets, 45964 bytes
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python triage.py
2 alerts in 2 groups
1 172.30.0.20 /admin 404
1 172.30.0.31 /version 200
Two alerts, and it looks tidy. But the probe now produces one alert that says /admin, and nothing about
/.env or /backup.zip. The analyst sees a single curl request for /admin and may well close it.
Which of the three requests survives isn’t even fixed: an earlier run of exactly this command kept
/.env, because Suricata spreads flows across worker threads and the limit keeps whichever match it
counts first. The other requests are still in the http events in eve.json, but only if someone goes
looking. A limit is fine for a source you’ve already investigated; as a general noise filter it hides the
shape of an attack.
Tuning 3: name the automation
Go back to suppression, and name both automated hosts, each with a comment on its own line saying what it is.
# threshold.config: known automation that uses curl on purpose
# monitoring host, polls /health
suppress gen_id 1, sig_id 1000002, track by_src, ip 172.30.0.30
# deploy job, reads /version
suppress gen_id 1, sig_id 1000002, track by_src, ip 172.30.0.31
$ rm -rf out && mkdir out
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r day.pcap -S local.rules -k none -l out --set threshold-file=threshold.config
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1 Engine started.
i: suricata: Signal Received. Stopping engine.
i: pcap: read 1 file, 492 packets, 45964 bytes
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python triage.py
3 alerts in 3 groups
1 172.30.0.20 /.env 404
1 172.30.0.20 /admin 404
1 172.30.0.20 /backup.zip 404
Three alerts, all from the probe, all three paths visible. That’s the result you want from this traffic.
The price is a blind spot: this rule can no longer see anything 172.30.0.30 or 172.30.0.31 does. If
someone takes over the monitoring host, its curl requests are invisible to this rule. The first practice
task shows that happening, and the second shows a narrower tuning.
| Tuning | Alerts | Probe paths visible | Blind spot |
|---|---|---|---|
| none | 41 | 3 of 3 | none, but buried in noise |
| suppress monitor | 11 | 3 of 3 | the monitor host |
| suppress monitor + limit 1/h | 2 | 1 of 3 | the monitor host, and most of every source’s activity |
| suppress both hosts | 3 | 3 of 3 | both automation hosts |
The next lesson picks up when an alert is real: what to do with the affected container before you stop it.
Practice
- Capture a second pcap,
day2.pcap, where the monitoring host runs the probe:--ip 172.30.0.30 ... sh traffic.sh probe. Run Suricata over it with the finalthreshold.config. How many alerts do you get?
Solution
Remove the stopped tap with docker rm tap, start a new one writing to /work/day2.pcap (the web server
must still be running), run the probe with
--ip 172.30.0.30, stop the tap, and run Suricata with -r day2.pcap. fast.log is empty: 0 alerts for
three requests to /.env, /admin and /backup.zip. The suppression applies to everything from that
IP, not just /health. This is the cost of allow-listing a host, and why the list needs an owner and a
review date.
- Instead of suppressing hosts, narrow the rule so it ignores the two automation paths, using negated
contentonhttp.uri. Run it with nothreshold.configoverday.pcapand overday2.pcap.
Solution
# path.rules: the curl rule, minus the two automation paths
alert http any any -> any any (msg:"LAB command-line HTTP client (curl)"; flow:established,to_server; http.user_agent; content:"curl/"; startswith; http.uri; content:!"/health"; content:!"/version"; classtype:policy-violation; sid:1000002; rev:2;)
It gives 3 alerts on day.pcap and 3 on day2.pcap, so the probe is caught even from the monitoring
host. It has its own weakness: a negated content matches anywhere in the URI, so a request for
/.env?x=/health would also be skipped. Every exception is a gap someone can aim for, so keep each one
as narrow as you can and test its edge cases as well as the traffic it’s meant to silence.
- Replace the tuning with
threshold gen_id 1, sig_id 1000002, type threshold, track by_src, count 5, seconds 60and nothing else. Predict the alert count per source, then run it.
Solution
type threshold alerts on every fifth match per source within 60 seconds. The monitor’s 30 requests give
6 alerts, the deploy job’s 8 give 1, and the probe’s 3 give 0. The total is 7, and every one of them is
benign. This setting suits rules where a single event is meaningless and volume is the signal, such as
failed logins. On a rule where one event matters, it hides exactly the small, quiet activity you care
about.
Clean up
docker rm -f web tap
Keep the ds-lab network, the capture-lab image and traffic.sh; the timeline lesson reuses them.