Metrics & the loop

Coverage, MTTD, precision — measure and improve.

Advanced25 min · lesson 15 of 15

Take the stopwatch, the scale and the training log away from an athlete and what is left is exercise. Effort, with no way to tell whether it is building strength or only sweat. A detection program without numbers works the same way: rules get written, alerts get closed, dashboards glow green, and nobody can say whether your organization would catch a real intrusion faster this quarter than last. This closing lesson bolts on the instruments. Three families of numbers, coverage, precision and speed, turn everything you have built across this course into something you can steer. Measure, diagnose, improve, measure again.

The three numbers worth tracking

Coverage asks: of the attacker behaviors that could realistically hit *your* environment, how many can you spot? The measuring stick is MITRE ATT&CK (Adversarial Tactics, Techniques and Common Knowledge), the public catalog of attacker moves you met in the Pyramid of Pain lesson, trimmed down to the techniques that apply to your stack. Precision asks: when an alert fires, how often is something actually wrong? True positives divided by total alerts. Its flip side, the share of alerts that turn out to be nothing (statisticians call that the false discovery rate; a SOC, or security operations center, will call it the false-positive rate), is what buries analysts. Speed asks two things: how long does an intrusion run before you notice (mean time to detect, MTTD), and how long from noticing to shutting it down (mean time to respond, MTTR)?

Look at what is missing from that list. Rule count. Alerts per day. Events ingested. Tools bought. Those are vanity metrics: they measure activity, not outcome. A SIEM (security information and event management platform, the system that swallows your logs and runs your rules) holding 900 rules and firing 5,000 alerts a day can have thinner real coverage and far worse precision than a tuned program running 200. Every number in this lesson comes out of data you already hold: your rule repository, and your SIEM's incident records.

Coverage: your rules against your actual threat model

Start with *claimed* coverage. If you followed the detection-as-code lesson, every Sigma rule in your repo carries attack.tXXXX tags naming the techniques it goes after. Those tags are your numerator. The denominator is your threat profile: the set of techniques used by the crews that target your industry and your stack, usually exported from a CTI (cyber threat intelligence) platform or an ATT&CK Navigator layer. From there it is set arithmetic.

coverage-gap.sh
# Claimed coverage: unique ATT&CK technique IDs tagged across your Sigma repo
grep -rhoE 'attack\.t[0-9]{4}(\.[0-9]{3})?' rules/ \
| sed 's/^attack\.//' | tr '[:lower:]' '[:upper:]' | sort -u > covered.txt
wc -l < covered.txt
# 143
# Denominator: techniques in your threat profile (ATT&CK Navigator export)
jq -r '.techniques[] | select(.score > 0) | .techniqueID' threat-profile.json \
| sort -u > relevant.txt
wc -l < relevant.txt
# 212
# Intersection = coverage; difference = next quarter's backlog
comm -12 covered.txt relevant.txt | wc -l
# 121 -> 121 / 212 = 57% claimed coverage
comm -13 covered.txt relevant.txt | head -3
# T1055.001
# T1098.003
# T1567.002
# ...each gap becomes a hunting hypothesis or a new-rule ticket

Claimed coverage flatters you, always. A rule tagged t1059.001 might catch one flavor of encoded PowerShell while nine bypasses stroll straight past it. The tag records where you aimed, not what you hit. Validated coverage is the honest version, and it comes from the testing lesson: run the Atomic Red Team tests mapped to each technique and count the detections that actually fire. Then check the floor underneath both with DeTT&CT, which scores your *data source* coverage. No rule can catch credential dumping if the endpoint telemetry never reaches your pipeline. Publish the result as a Navigator heatmap so the holes are visible to everyone:

make-navigator-layer.py
import json
techs = [{"techniqueID": t.strip(), "score": 1, "color": "#31a354"}
for t in open("covered.txt")]
layer = {"name": "Detection coverage 2026-07", "domain": "enterprise-attack",
"versions": {"layer": "4.5", "navigator": "5.3"}, "techniques": techs}
json.dump(layer, open("coverage-layer.json", "w"), indent=2)
# Open https://mitre-attack.github.io/attack-navigator/
# -> Open Existing Layer -> upload coverage-layer.json
# Green cells = covered techniques; blank cells inside your threat profile = gaps.

Precision: read it off your closed incidents

You already own the precision data. Analysts write it every time they close an incident. In Microsoft Sentinel, the SecurityIncident table appends a fresh row on *every* update, and a Classification field gets set at close time: *TruePositive*, *FalsePositive*, *BenignPositive* (real activity, no threat, such as a pentest or an admin script), or *Undetermined*. Here is the detail that trips people up. Because each update is its own row, you have to collapse down to the latest state per incident with arg_max() before you count anything. Skip that step and an incident that was edited after close, or reopened and closed again, shows up as several "Closed" rows and gets counted several times.

precision-by-rule.kql
SecurityIncident
| where TimeGenerated > ago(90d)
| summarize arg_max(TimeGenerated, *) by IncidentNumber // latest state per incident
| where Status == "Closed"
| summarize total = count(),
tp = countif(Classification == "TruePositive"),
fp = countif(Classification == "FalsePositive"),
benign = countif(Classification == "BenignPositive")
by Title
| extend precision = round(todouble(tp) / total, 2)
| order by total desc
// Title total tp fp benign precision
// Suspicious service installed 412 9 361 42 0.02
// Impossible travel to atypical location 118 31 64 23 0.26
// Honeytoken AWS key used 6 6 0 0 1.00

Read that output like a portfolio review. The service-installation rule fired 412 times to find nine real hits, 2% precision, which makes it a candidate for the tuning workflow from the alert-quality lesson or for the bin. The honeytoken rule is tiny and perfect: deception buys a level of precision that behavioral logic never reaches. Remember the base-rate problem from the UEBA (user and entity behavior analytics) lesson. When real intrusions are rare, even an accurate detector produces mostly false alarms, so a high-volume rule sitting below roughly 30% precision is a running cost, not a rounding error. Every alert spends analyst minutes. Precision per rule tells you which rules are worth the spend.

Goodhart's law will eat these numbers
Once a measure becomes a target, it stops measuring anything useful. Analysts graded on MTTR learn fast: close the incident as *BenignPositive*, move on, never really look. That flatters the speed numbers and quietly rots precision too, because every query in this lesson trusts what someone typed at close time. Protect the inputs. Require a classification plus one line of justification on every close, spot-check 5–10% of closed incidents each week, and never tie an individual's performance review to MTTR or to false-positive counts.

Speed: MTTD and MTTR, reported as percentiles

MTTD is the gap between the attacker's first move you can see (FirstActivityTime, the earliest event time across the alerts correlated into the incident) and the moment the incident was created. MTTR runs from creation to containment or close. For scale: Mandiant's M-Trends reports have put the global median *dwell time*, first compromise to detection, at around ten days in recent years, while ransomware crews routinely go from foothold to encryption inside 24 hours. Squeeze MTTD and you squeeze the damage. One rule about how you report it. Use medians and 90th percentiles, never means. A single intrusion found after 60 days drags an average past any target you set, and tells you nothing about a normal week.

detection-speed.kql
SecurityIncident
| where TimeGenerated > ago(90d)
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status == "Closed" and isnotempty(FirstActivityTime)
| extend mttd_min = datetime_diff('minute', CreatedTime, FirstActivityTime),
mttr_min = datetime_diff('minute', ClosedTime, CreatedTime)
| summarize p50_mttd = percentile(mttd_min, 50), p90_mttd = percentile(mttd_min, 90),
p50_mttr = percentile(mttr_min, 50), p90_mttr = percentile(mttr_min, 90)
by Severity
// Severity p50_mttd p90_mttd p50_mttr p90_mttr (minutes)
// High 22 240 95 1370
// Medium 133 2880 310 4820
//
// Medium's p90 MTTD of 2,880 min (2 days) is the number to attack first:
// usually a missing log source or a correlation rule that fires too late.

Watch for survivorship bias. MTTD is only ever computed over the intrusions you found. The ones you missed contribute nothing to the number; they arrive later as a breach notification. Purple-team exercises and post-incident reviews are the correction. Every red-team action and every step on a post-mortem timeline gets the same question put to it, "did a detection fire?", and every *no* becomes a coverage gap with an owner and a ticket.

The loop that ties this course together

Stand those three measurements in front of the toolchain from this course and detection engineering collapses into one feedback loop. Coverage gaps turn into hunting hypotheses, and a hunt that finds something ends its life as a new Sigma rule, converted with sigma convert, proven against Atomic Red Team tests in your CI (continuous integration) pipeline, and merged: detection as code. Low-precision rules go through the tuning workflow or get swapped for a sharper signal, a honeytoken in place of a noisy heuristic, aimed at behaviors near the top of the Pyramid of Pain where evasion costs the attacker real effort. A slow MTTD points at telemetry you never collected, which is a log-pipeline fix. A slow MTTR points at manual toil, which is a SOAR (security orchestration, automation and response) playbook. Incidents flow into the cloud DFIR (digital forensics and incident response) process, where you acquire evidence and build the timeline, and the retrospective sends every missed step back as a tested detection. UEBA baselines cover the ground that enumerable rules cannot.

Work out which number moved, then act
One of the numbers moved. Which one?
Diagnose first, then pick your branch
Coverage gap
Hunt → new Sigma rule
The gap becomes a hunting hypothesis; a hunt that finds something ends as a rule proven against Atomic Red Team in CI (detection as code).
Low precision (below ~30%)
Tune or replace the rule
Push the noisy rule through the tuning workflow, or swap it for a sharper signal such as a honeytoken.
Slow MTTD
Fix the log pipeline
Usually telemetry you never collected. No rule detects activity you do not log.
Slow MTTR
Build a SOAR playbook
Slow response is manual toil. Automate the containment step.
Every branch ends at Validate & ship (atomic test → CI → deploy), and then you measure again, so the loop compounds coverage, precision and speed instead of piling up rules.

Run the loop every quarter and the program compounds. A year in, you should be able to show coverage climbing against a named threat profile, median per-rule precision above 50%, and p50 MTTD measured in minutes for the techniques that matter. The numbers argue your budget for you, because "we detect 71% of our threat profile, up from 57%" is a sentence a CISO (chief information security officer) can take into a board meeting and defend. That is where this course lands: a measured, versioned, tested, automated system that gets better every time you exercise it, rather than a pile of rules.

Try this

Build yourself a one-page scoreboard: precision on your paging rules over the last seven days, sitting next to a count of ATT&CK tags on the rules you have enabled. Save it as a dashboard you will actually open on a Monday morning.

terminal
// Precision across paging incidents (adapt classifications to your SIEM)
SecurityIncident
| where TimeGenerated > ago(7d) and Severity in ("High","Critical")
| summarize total=count(), tp=countif(Classification == "TruePositive")
| extend precision = todouble(tp) / todouble(total)
# total tp precision
# 28 17 0.61
# Coverage sketch from your detections repo:
$ grep -R "attack.t" -h rules/ | sort | uniq | wc -l
47
# Pair: 47 tagged techniques enabled ≠ 47 techniques proven by detonation.
# Add a column for last successful test date before you call it coverage.

Takeaway

Measure outcomes: coverage of the techniques that threaten you, precision, MTTD, and whether your canaries are still alive. Rule count is not one of them. The loop is detect, respond, learn, then change the program on purpose.

Next step: publish a weekly precision and canary dashboard, and make one pull request to the detection repo a condition of closing any Sev-1 incident ticket.

Quick check
01The precision-by-rule query opens with arg_max(TimeGenerated, *) by IncidentNumber before it counts a single TruePositive or FalsePositive. Why does that collapse step have to be there?
Correct — Each update writes its own row, so arg_max keeps only the latest state per incident and every incident contributes exactly once to the totals.
Incorrect — No. The separate where Status == "Closed" line handles open incidents. arg_max removes duplicate rows for the same incident; it does not filter on status.
Incorrect — No. Classification is written at close time on the latest state, and arg_max(TimeGenerated) returns the newest row, not the oldest.
Incorrect — No. arg_max reclassifies nothing. Gamed close-outs are caught by mandatory justifications and by QA-sampling 5–10% of closures each week, not by a query operator.
02Your coverage script reports 121 of 212 techniques covered, and a colleague wants "57% ATT&CK coverage" on the board slide. What is wrong with that number as it stands?
Incorrect — The arithmetic is exact. The claim built on top of it is not: the set operation counts tags, and a tag is an intention, not a working detection.
Incorrect — No. Scoping the denominator to the techniques used against your industry and your stack is the whole point. Measured against all of ATT&CK you get a number nobody can act on.
Correct — A rule tagged t1059.001 may catch one encoded-PowerShell variant while nine bypasses walk past it. Detonate the tests first, then quote a number.
Incorrect — No. Sub-technique IDs are valid entries in both the repo tags and a Navigator export, and the script compares like with like. The gap is detonation, not ID formatting.
03Your speed query returns p50 MTTD of 133 minutes and p90 MTTD of 2,880 minutes for Medium incidents, against 22 and 240 for High. Your manager asks for the average instead, plus a plan. What do you say?
Incorrect — Two problems. One intrusion found after 60 days drags a mean past any target while saying nothing about a normal week, and alert volume is a vanity metric, not an outcome.
Correct — A single outlier distorts a mean, the two-day p90 tail is what actually hurts, and the usual causes are telemetry you do not collect or a rule that only fires once the evidence has piled up.
Incorrect — Picking the statistic that makes each row look best is how a metric stops being a measurement. Use the same medians and p90s everywhere so the numbers stay comparable quarter to quarter.
Incorrect — Survivorship bias is real, and it is why purple-team exercises and post-mortems feed missed steps back as coverage gaps. That is a reason to pair MTTD with those exercises, not a reason to stop measuring speed.

Related