Detection Validation & Closing Coverage Gaps
This is Chapter 3 of the Purple Team notebook. Chapter 2 built the offensive engine — Atomic Red Team for granular per-technique tests and CALDERA for chained autonomous operations — and ended holding a pile of results: techniques that were not logged, techniques that were logged but never alerted, and a handful that fired an alert. That pile is raw material, not an outcome. This chapter is about the other half of the loop: turning each of those results into a deployed, tuned, regression-tested detection, and honestly measuring how much of the adversary's playbook you can actually see.
The same discipline that governed the emulation chapters governs this one. Firing the techniques required explicit authorization; the detection work here is defensive and runs on telemetry you already own, but the validation of every detection means re-executing real adversary behaviour, so everything in the worked labs assumes the same authorized, snapshotted lab from Chapter 2.
Why This Matters
Most organisations dramatically overestimate their detection coverage. They have hundreds of rules in a SIEM, a green dashboard, and a compliance report that says "we have detection." Then a purple exercise fires twenty catalogued techniques and fourteen produce no alert at all — not because the rules are bad, but because the data source was never onboarded, the rule matched a field the log does not contain, or the alert was tuned into silence two years ago after a false-positive storm and nobody turned it back on.
Detection validation is the practice that replaces belief with evidence. It rests on one uncomfortable distinction that this whole chapter is built around:
Having a log is not having a detection. Having a detection is not having an alert. Having an alert is not having a response.
Each arrow in that chain is a place where coverage silently leaks. A technique can be perfectly visible in your telemetry and still go completely unnoticed, because visibility is a property of your data and detection is a property of your analytics, and the two are wired together by a rule that may or may not actually work. The job of this chapter is to make every one of those arrows explicit, testable, and measured — and then to close the gaps the measurement exposes.
flowchart LR
A[Adversary technique executed] --> B{Data source<br/>captures it?}
B -->|no| G1[GAP: no visibility<br/>fix = onboard log source]
B -->|yes| C{Detection rule<br/>matches it?}
C -->|no| G2[GAP: no detection<br/>fix = write/repair rule]
C -->|yes| D{Alert actually<br/>fires + routes?}
D -->|no| G3[GAP: logged-only<br/>fix = tune + enable]
D -->|yes| E{Analyst / SOAR<br/>responds?}
E -->|no| G4[GAP: no response<br/>fix = playbook + routing]
E -->|yes| F[Covered]
style F fill:#22543d,color:#fff
style G1 fill:#742a2a,color:#fff
style G3 fill:#744210,color:#fff
Part 1: The Detection Lifecycle
A detection is not a rule you write once. It is an artifact with a lifecycle, and treating it as a lifecycle is what separates detection engineering from ad-hoc rule-writing.
stateDiagram-v2
[*] --> Hypothesis: emulation gap or intel
Hypothesis --> Draft: write analytic
Draft --> Validated: fire the technique, confirm it alerts
Validated --> Tuned: reduce false positives to acceptable rate
Tuned --> Deployed: promote to production
Deployed --> Monitored: track FP rate, hit rate, MTTD
Monitored --> Regression: re-fire on schedule
Regression --> Deployed: still works
Regression --> Draft: broke - log change, bypass found
Deployed --> Retired: technique obsolete / superseded
Retired --> [*]
Each stage answers a specific question:
| Stage | Question | Evidence produced |
|---|---|---|
| Hypothesis | What behaviour do I want to catch, and why? | A named ATT&CK technique/procedure + threat rationale |
| Draft | What logic expresses that behaviour? | A Sigma rule (portable, reviewable) |
| Validated | Does the rule fire when the technique runs? | Emulation run + the resulting alert |
| Tuned | Does it fire only when it should? | False-positive rate over a baseline window |
| Deployed | Is it live and routed to someone? | Rule in production, alert destination confirmed |
| Monitored | Is it still healthy? | Hit rate, FP rate, MTTD trend |
| Regression | Does it still work after environment change? | Scheduled re-fire result |
The two stages everyone skips are Validated and Regression, and they are the two that matter most. A rule that was never validated is a hypothesis wearing a badge. A rule that is never regression-tested rots — a log-format change, an EDR agent upgrade, or a new bypass quietly turns a green rule red, and without scheduled re-firing you find out during the incident.
1.1 Where hypotheses come from
Three sources, in rough priority order:
- Emulation gaps (Chapter 2). The highest-quality source, because it is grounded in your own environment's measured reality. A "not logged" or "logged-only" result is a detection hypothesis with the technique already named.
- Threat intelligence. A report says an actor relevant to your sector uses a specific procedure; you write a detection for that procedure before they reach you.
- Incident retrospectives. Something got through. Every incident should produce at least one detection that would have caught it earlier.
This chapter focuses on the first, because it is the direct continuation of the emulation loop and because it produces detections you can immediately validate by re-running the exact technique that exposed the gap.
Part 2: Visibility Before Detection — The Data-Source Inventory
You cannot detect what you do not log. Before writing a single rule, you need to know what your telemetry actually contains, because the most common reason a technique produces no alert is not a bad rule — it is that the data source was never onboarded, or is onboarded but missing the field the detection needs.
2.1 The visibility question, made concrete
Take one technique: OS Credential Dumping: LSASS Memory (T1003.001). For a detection to be possible, something must record one of these:
| Signal | Data source | Event |
|---|---|---|
A process opened a handle to lsass.exe |
Sysmon | Event ID 10 (ProcessAccess) |
| A process was created with a suspicious command line | Sysmon / Security | EID 1 / 4688 |
| A minidump file was written | Sysmon | EID 11 (FileCreate) |
| EDR flagged credential access behaviour | EDR telemetry | vendor-specific |
| A sensitive handle was granted | Security | 4656 / 4663 (needs SACL) |
If none of these is being collected and forwarded to your SIEM, then T1003.001 is undetectable in principle in your environment, and no amount of rule-writing changes that. The fix is a data-source project, not a detection project — and knowing which of those two problems you have is exactly what the inventory tells you.
2.2 Building the inventory
Map every ATT&CK data source you collect to the log that provides it and the fields it actually contains. A minimal, honest version:
| ATT&CK data source | Provided by | Key fields present? | Coverage |
|---|---|---|---|
| Process creation | Sysmon EID 1 + Security 4688 | Image, CommandLine, ParentImage, Hashes | Full (with cmdline auditing on) |
| Process access | Sysmon EID 10 | SourceImage, TargetImage, GrantedAccess, CallTrace | Full |
| Network connection | Sysmon EID 3 | Image, DestinationIp, DestinationPort | Full |
| File creation | Sysmon EID 11 | Image, TargetFilename | Full |
| Registry | Sysmon EID 12/13/14 | TargetObject, Details | Partial (config-dependent) |
| Authentication | Security 4624/4625/4768/4769 | LogonType, TargetUserName, ServiceName | Full on DCs |
| Command execution | PowerShell 4104, cmdline auditing | ScriptBlockText, CommandLine | Partial (depends on config) |
| Cloud API | CSP audit logs | eventName, sourceIPAddress, userIdentity | Varies by CSP |
The single most important column is the third one. "We collect Sysmon" is not the same as "we collect Sysmon Event ID 10 with CallTrace populated." Detections fail constantly because a data source is nominally present but the specific field the rule keys on is missing, empty, or truncated. Verify at the field level, not the source level.
2.3 Command-line auditing: the highest-leverage single fix
If one visibility gap is worth fixing before all others, it is process command-line logging. An enormous fraction of ATT&CK techniques manifest as a distinctive command line — procdump -ma lsass.exe, nltest /dclist, reg save hklm\sam, vssadmin delete shadows — and without the command line captured, process-creation events tell you a process ran but not what it did.
On Windows, this requires two settings, and both are commonly half-configured:
# 1. Audit process creation (produces Security Event ID 4688)
Computer Config > Policies > Windows Settings > Security Settings >
Advanced Audit Policy > Detailed Tracking > Audit Process Creation = Success
# 2. Include the command line in 4688 (this is the part everyone forgets)
Computer Config > Policies > Administrative Templates > System >
Audit Process Creation > Include command line in process creation events = Enabled
Without the second setting, 4688 events fire but the CommandLine field is absent, and a large swathe of your detections silently match nothing. Sysmon EID 1 captures the command line without the second GPO, which is one reason Sysmon is worth deploying alongside native auditing rather than instead of it. Verify it is actually working, do not assume:
# Confirm 4688 events carry a command line
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4688} -MaxEvents 5 |
ForEach-Object { ([xml]$_.ToXml()).Event.EventData.Data |
Where-Object {$_.Name -eq 'CommandLine'} } | Format-ListIf that returns empty CommandLine values, the second GPO is not applied, and fixing it will light up more detections than any rule you could write this week.
Part 3: The Four-Outcome Model
Chapter 2 sorted results into "not logged / logged-only / alerted." That is a good start; this chapter sharpens it to four outcomes, because the fix for each is different and conflating them wastes effort.
| Outcome | Meaning | The gap | The fix |
|---|---|---|---|
| Not logged | No telemetry captured the behaviour at all | Visibility | Onboard a data source; enable a field |
| Logged-only | Telemetry exists, but no rule alerts on it | Detection | Write or repair an analytic |
| Alerted | A rule fired and routed to a human/SOAR | — (validate quality) | Tune to keep precision acceptable |
| Prevented | A control blocked the behaviour outright | — (validate it is logged too) | Confirm the block is also alerted |
Two subtleties that trip up teams:
- Prevented is not automatically the best outcome. A prevention that produces no telemetry means you blocked this attempt but have no signal that the adversary is present and trying. Prevention without detection is a blind block: it stops the technique but tells you nothing about the campaign. Always confirm that a prevented technique also generates an alert, so you learn that someone tried.
- Logged-only is the highest-value bucket to work. The data is already there; the cost of turning it into an alert is a rule, not an infrastructure project. When you triage a purple exercise's results, work logged-only first — it is the cheapest coverage you will ever buy.
flowchart TD
R[Emulation result] --> Q1{Any telemetry?}
Q1 -->|no| NL[NOT LOGGED<br/>visibility project]
Q1 -->|yes| Q2{Rule alerts?}
Q2 -->|no| LO[LOGGED-ONLY<br/>cheapest fix: write rule]
Q2 -->|yes| Q3{Was it blocked?}
Q3 -->|no| AL[ALERTED<br/>validate precision]
Q3 -->|yes| PR[PREVENTED<br/>confirm it also alerts]
style LO fill:#744210,color:#fff
style NL fill:#742a2a,color:#fff
style AL fill:#22543d,color:#fff
Part 4: Writing Detections From Scratch in Sigma
Sigma is the portable, vendor-neutral detection format — a YAML schema that expresses "what to look for" independently of any specific SIEM query language, then compiles down to Splunk SPL, Elastic KQL/EQL, Microsoft Sentinel KQL, and dozens of others. It is the right place to author detections because it makes them reviewable, version-controllable, and portable across the tools you will inevitably change.
4.1 Sigma taught from zero
A Sigma rule is a YAML document with a fixed structure. Here is the anatomy, field by field, using an LSASS-access detection as the worked example:
title: Suspicious LSASS Process Access
id: 9a1b7c42-6f3e-4a5d-8b2c-1e0f9d7a6c50 # stable UUID, never reused
status: experimental # experimental | test | stable | deprecated
description: Detects a process opening a handle to lsass.exe with access rights
consistent with credential dumping (read of process memory).
references:
- https://attack.mitre.org/techniques/T1003/001/
author: purple-team
date: 2027/05/12
tags:
- attack.credential-access
- attack.t1003.001
logsource:
product: windows
category: process_access # maps to Sysmon EID 10
detection:
selection:
TargetImage|endswith: '\lsass.exe'
GrantedAccess|contains:
- '0x1010' # PROCESS_VM_READ | PROCESS_QUERY_INFORMATION
- '0x1410'
- '0x1438'
- '0x143a'
- '0x1fffff' # PROCESS_ALL_ACCESS
filter_legit:
SourceImage|endswith:
- '\wmiprvse.exe'
- '\MsMpEng.exe' # Defender legitimately reads lsass
condition: selection and not filter_legit
falsepositives:
- Endpoint protection products
- Some backup and monitoring agents
level: high # informational | low | medium | high | criticalWhat each block does:
title/id/description— human-readable identity. Theidis a UUID that must remain stable for the rule's life so that tracking, deduplication, and coverage mapping work across renames.logsource— declares what kind of log this rule reads.category: process_accessis the abstract Sigma category that the Sysmon backend maps to Event ID 10. This is what makes the rule portable: you write the abstract category, the backend knows the concrete event.detection.selection— the match logic.TargetImage|endswith: '\lsass.exe'uses a Sigma modifier (endswith) so it matches regardless of the full path.GrantedAccess|containslists the access masks associated with reading process memory.detection.filter_legit— a named exclusion block for known-benign sources.condition— the boolean that ties selections together:selection and not filter_legit.falsepositives/level— metadata that downstream tooling uses for triage priority and that documents what you already know will misfire.
4.2 The Sigma modifiers you will use constantly
| Modifier | Meaning | Example |
|---|---|---|
contains |
Substring match | `CommandLine |
startswith / endswith |
Anchored substring | `Image |
all |
All listed values must match | `CommandLine |
re |
Regular expression | `CommandLine |
| `base64offset | contains` | Match base64-encoded content at any alignment |
windash |
Match both - and / flag styles |
` |
cidr |
IP range match | `DestinationIp |
The all and windash modifiers matter more than they look. CommandLine|contains|all: ['-ma', 'lsass'] requires both substrings, which is far more precise than matching either alone — it is the difference between "any command line mentioning lsass" (noisy) and "a full-memory dump targeting lsass" (specific). windash catches the trivial evasion of swapping -ma for /ma.
4.3 Translating Sigma to your SIEM
Sigma compiles with the sigma CLI (the pySigma toolchain). Install and convert:
pip install sigma-cli pysigma-backend-splunk pysigma-backend-elasticsearchsigma-cli— the command-line front end.pysigma-backend-splunk/-elasticsearch— backend plugins; each target SIEM needs its backend installed.
# Sigma -> Splunk SPL
sigma convert -t splunk -p sysmon lsass_access.yml`sysmon` EventCode=10 TargetImage="*\\lsass.exe"
(GrantedAccess="*0x1010*" OR GrantedAccess="*0x1410*" OR GrantedAccess="*0x1438*"
OR GrantedAccess="*0x143a*" OR GrantedAccess="*0x1fffff*")
NOT (SourceImage="*\\wmiprvse.exe" OR SourceImage="*\\MsMpEng.exe")
# Sigma -> Elastic (Lucene/KQL via the elasticsearch backend)
sigma convert -t elasticsearch -p sysmon -f dsl_lucene lsass_access.ymlwinlog.event_id:10 AND winlog.event_data.TargetImage:*\\lsass.exe AND
(winlog.event_data.GrantedAccess:*0x1010* OR winlog.event_data.GrantedAccess:*0x1410*
OR winlog.event_data.GrantedAccess:*0x1438* OR winlog.event_data.GrantedAccess:*0x143a*
OR winlog.event_data.GrantedAccess:*0x1fffff*) AND
NOT (winlog.event_data.SourceImage:*\\wmiprvse.exe OR
winlog.event_data.SourceImage:*\\MsMpEng.exe)
-t— target backend.-p sysmon— a pipeline that maps Sigma's abstract field names to the concrete field names your log shipper produces. This is the piece that makes the same rule work whether your Sysmon data lands asTargetImage(raw) orwinlog.event_data.TargetImage(Winlogbeat). Getting the pipeline right is 90% of making translation work.-f dsl_lucene— output format within the backend.
The pipeline is where translation succeeds or fails. A Sigma rule that is logically perfect will match nothing if the pipeline maps TargetImage to a field your indexer does not use. When a converted rule returns zero results against data you know contains the event, suspect the field mapping before the logic.
Part 5: The Detection-Quality Bar
A rule that fires is not a good rule. A good rule fires when it should and stays quiet when it should not, and you need vocabulary and numbers to reason about that trade-off.
5.1 The confusion matrix, applied to detections
| Malicious activity present | No malicious activity | |
|---|---|---|
| Rule fires | True positive (TP) — the goal | False positive (FP) — alert fatigue |
| Rule silent | False negative (FN) — the miss | True negative (TN) — correct silence |
Two derived numbers govern detection quality:
- Precision =
TP / (TP + FP)— of the alerts that fired, what fraction were real? Low precision means analysts drown in false alarms and start ignoring the rule. - Recall (detection rate) =
TP / (TP + FN)— of the real activity, what fraction did we catch? Low recall means the technique slips past.
There is a permanent tension between them. Broaden a rule to catch more variants (raise recall) and you usually catch more benign activity too (lower precision). Tighten it for precision and you miss edge cases. Detection engineering is the management of that trade-off, per rule, with the acceptable point set by how the alert is consumed.
5.2 The alert-fatigue economy
Precision is not an abstract virtue; it has an operational cost that compounds. A rule that fires 50 times a day at 4% precision produces 48 false alerts daily. Analysts learn, correctly, that this rule is noise, and they start dismissing it without investigation — at which point its recall is effectively zero regardless of what the matrix says, because a true positive that gets reflexively closed is a miss.
A high-recall, low-precision rule that trains analysts to ignore it is worse than no rule, because it also degrades attention to the rules around it.
This is why "we have a detection for that" is a claim to interrogate, not accept. A detection tuned into the noise floor is a liability wearing the costume of a control.
5.3 Setting the acceptable false-positive rate by consumer
The tolerable FP rate depends entirely on what happens when the alert fires:
| Alert destination | Tolerable FP rate | Rationale |
|---|---|---|
| Auto-isolate host (SOAR, no human) | Near zero | A false positive takes a production host offline |
| Page an on-call analyst | Very low | Each FP is an interrupted human at cost |
| SOC queue for triage | Low-moderate | Humans filter, but volume still causes fatigue |
| Enrichment / correlation only (no standalone alert) | High acceptable | Feeds a broader analytic; never fires alone |
The same underlying analytic can live at multiple points on this table. A noisy behavioural signal that is useless as a standalone page can be extremely valuable as a correlation input — "LSASS access" alone is moderate, but "LSASS access followed within 60s by an outbound connection to a new IP from the same process" is a high-precision alert built from two individually noisier signals. Composition buys precision without sacrificing recall, and it is the main tool for escaping the precision/recall tension.
Part 6: Tuning Methodology
Tuning is the process of moving a validated-but-noisy rule to acceptable precision without destroying its recall. Done carelessly, tuning is just "add exclusions until it stops firing," which is how rules get tuned into silence. Done well, it is evidence-driven.
6.1 The tuning loop
flowchart LR
A[Deploy rule in<br/>monitor-only mode] --> B[Collect N days<br/>of firings]
B --> C[Triage every firing:<br/>TP or FP?]
C --> D{FP rate<br/>acceptable?}
D -->|no| E[Analyse FP cluster:<br/>what do FPs share?]
E --> F{Can exclude without<br/>losing TPs?}
F -->|yes| G[Add targeted exclusion]
F -->|no| H[Add correlation /<br/>raise specificity]
G --> B
H --> B
D -->|yes| I[Promote to alerting]
style I fill:#22543d,color:#fff
The critical discipline: run in monitor-only mode first, and triage every firing. You cannot tune what you have not characterised. A rule pushed straight to alerting is tuned reactively, under pressure, during a false-positive storm — which produces over-broad exclusions that punch holes in coverage.
6.2 Exclusion patterns, good and bad
Not all exclusions are equal. The goal is to exclude the benign cause, as narrowly as possible, never the malicious behaviour.
# BAD: excludes by broad attribute - punches a hole an attacker walks through
filter_bad:
SourceImage|endswith: '\powershell.exe' # attackers use powershell too!
# BETTER: exclude a specific benign parent+child+path combination
filter_backup_agent:
SourceImage|endswith: '\backup-agent.exe'
SourceImage|startswith: 'C:\Program Files\VendorBackup\'
ParentImage|endswith: '\services.exe'
# BEST for a hash-stable tool: exclude by signed publisher + path, and
# document why, so the exclusion can be reviewed later
filter_known_good:
Signed: 'true'
Signature: 'Trusted Vendor Inc'
SourceImage|startswith: 'C:\Program Files\VendorBackup\'The difference is the size of the hole. Excluding "all PowerShell" tells the attacker exactly which interpreter to use. Excluding "this specific signed binary, from this path, launched by services.exe" removes the one benign cause while leaving the malicious variants fully covered. Every exclusion is an attack surface; size it accordingly and document the benign cause so a future reviewer can tell whether it is still needed.
6.3 Thresholds and baselining
Some behaviours are only suspicious in volume. A single Kerberos service-ticket request (Event 4769) is normal; hundreds from one account in a minute is Kerberoasting. This is a threshold detection, and thresholds must be baselined against your environment, not guessed:
title: Potential Kerberoasting - Bulk RC4 TGS Requests
logsource:
product: windows
service: security
detection:
selection:
EventID: 4769
TicketEncryptionType: '0x17' # RC4 - downgrade favoured by Kerberoasting
ServiceName|endswith: '$' # computer/service accounts
filter_normal:
ServiceName: 'krbtgt'
timeframe: 5m
condition: selection and not filter_normal | count() by IpAddress > 20
level: highThe > 20 is not a magic number — it is a value you set after measuring your own baseline. Query 30 days of 4769 events, find the 99th-percentile per-source request rate for legitimate activity, and set the threshold above it:
# Baseline query (Splunk) - what does NORMAL look like?
`security` EventCode=4769 Ticket_Encryption_Type=0x17
| bucket _time span=5m
| stats count by _time, Client_Address
| stats p95(count) p99(count) max(count)
p95(count) p99(count) max(count)
3 7 14
With a legitimate 99th percentile of 7 and a max of 14 over 30 days, a threshold of 20 sits comfortably above normal while still catching the hundreds-per-minute signature of an actual Kerberoasting sweep. Guessing the threshold produces either a rule that never fires or one that fires constantly; measuring it produces a rule that fires on the attack and nothing else.
Part 7: The Analytic Robustness Ladder
Not all detections for the same technique are equally durable. The same behaviour can be detected by logic that an attacker trivially bypasses or by logic that is expensive to evade, and knowing where your rule sits on that ladder tells you how much you actually trust it.
flowchart TD
L1[Level 1: Exact string / hash match<br/>'procdump.exe' + '-ma lsass'] -->|rename binary,<br/>change flags| L2
L2[Level 2: Tool-behaviour match<br/>any process, lsass handle + dump file] -->|use a different<br/>dumping method| L3
L3[Level 3: Technique invariant<br/>read of lsass memory by unusual process] -->|use a technique<br/>that avoids the invariant| L4
L4[Level 4: Behavioural / anomaly<br/>credential-access pattern in context]
style L1 fill:#742a2a,color:#fff
style L4 fill:#22543d,color:#fff
| Level | Detects | Bypassed by | Durability |
|---|---|---|---|
| 1. String/hash | procdump.exe -ma lsass.exe literally |
Rename binary, change flags, use /ma |
Brittle |
| 2. Tool behaviour | Any process opening lsass + writing a .dmp |
A dumping method that does not write a file (direct syscall, MiniDump variants) | Moderate |
| 3. Technique invariant | An unusual process reading lsass memory at all | Techniques that avoid touching lsass (DCSync, from a legit process) | Strong |
| 4. Behavioural | Credential-access pattern in context, regardless of tool | Genuinely novel TTPs; expensive for the attacker | Strongest |
The lesson is not "always build level 4." Level-1 rules are cheap, precise, and genuinely useful against unsophisticated actors and commodity tooling — most real intrusions use off-the-shelf tools with default names. The lesson is to know which level each rule is at, and not to mistake a level-1 rule for coverage of the technique. A green "T1003.001 detected" that rests on a string match for procdump.exe is real coverage against the actor who runs procdump unmodified and zero coverage against the one who renamed it. Coverage scoring (Part 9) must account for this, or it lies to you.
Red-team value of the ladder: when you are the offensive half of the purple exercise, deliberately climbing the ladder — run the technique first with the default tool, then renamed, then with a file-less variant — is exactly how you discover which level your defenders' rules actually sit at. Each rung that still alerts is a rung of genuine coverage; the first rung that goes silent is the boundary of real detection.
Part 8: Detection-as-Code
Detections are software. Treating them as software — versioned, reviewed, tested, and continuously deployed — is what turns a pile of SIEM rules into a maintainable detection program.
8.1 The repository structure
detections/
├── rules/
│ ├── credential_access/
│ │ ├── T1003.001_lsass_access.yml
│ │ └── T1003.006_dcsync.yml
│ └── discovery/
│ └── T1087_account_discovery.yml
├── tests/
│ ├── T1003.001_lsass_access.test.yml # expected match / no-match cases
│ └── ...
├── pipelines/
│ └── sysmon_winlogbeat.yml # field-mapping pipeline
└── .ci/
└── validate.sh
Everything is YAML, everything is in version control, every change goes through review. The id UUID in each rule makes renames and moves safe. This structure is what makes coverage measurable, because the repository is the source of truth for what you detect.
8.2 Unit-testing a detection
A detection test asserts that the rule matches known-malicious log lines and does not match known-benign ones. This is what catches a broken rule before it reaches production.
# T1003.001_lsass_access.test.yml
rule: rules/credential_access/T1003.001_lsass_access.yml
should_match:
- name: procdump reads lsass
event:
EventID: 10
TargetImage: 'C:\Windows\System32\lsass.exe'
SourceImage: 'C:\tools\procdump.exe'
GrantedAccess: '0x1fffff'
should_not_match:
- name: defender reads lsass legitimately
event:
EventID: 10
TargetImage: 'C:\Windows\System32\lsass.exe'
SourceImage: 'C:\Program Files\Windows Defender\MsMpEng.exe'
GrantedAccess: '0x1010'
- name: unrelated process access
event:
EventID: 10
TargetImage: 'C:\Windows\System32\svchost.exe'
SourceImage: 'C:\tools\procdump.exe'
GrantedAccess: '0x1fffff'The should_not_match cases are the important ones. They encode the precision requirements as executable assertions: if a future edit to broaden recall accidentally starts matching Defender's legitimate access, the test fails and the regression never ships.
8.3 The CI pipeline
#!/usr/bin/env bash
# .ci/validate.sh - run on every pull request to the detections repo
set -euo pipefail
echo "== 1. Sigma syntax and schema validation =="
sigma check rules/
echo "== 2. Every rule must compile to our production backend =="
for rule in $(find rules -name '*.yml'); do
sigma convert -t splunk -p pipelines/sysmon_winlogbeat.yml "$rule" >/dev/null \
|| { echo "COMPILE FAIL: $rule"; exit 1; }
done
echo "== 3. Unit tests: match/no-match assertions =="
python3 .ci/run_tests.py tests/
echo "== 4. Metadata policy: every rule has an ATT&CK tag and an owner =="
for rule in $(find rules -name '*.yml'); do
grep -q 'attack\.' "$rule" || { echo "NO ATTACK TAG: $rule"; exit 1; }
grep -q '^author:' "$rule" || { echo "NO AUTHOR: $rule"; exit 1; }
done
echo "All detection checks passed."== 1. Sigma syntax and schema validation ==
Checking 3 rules... OK
== 2. Every rule must compile to our production backend ==
== 3. Unit tests: match/no-match assertions ==
T1003.001_lsass_access: 1 match / 2 no-match ... PASS
T1003.006_dcsync: 2 match / 1 no-match ... PASS
== 4. Metadata policy: every rule has an ATT&CK tag and an owner ==
All detection checks passed.
Every one of those four checks corresponds to a way rules break in production: invalid syntax, a rule that does not compile against your actual field mapping, a rule whose logic regressed, and a rule with no ATT&CK tag (which means it is invisible to coverage scoring) or no owner (which means nobody maintains it). The metadata policy check is the one that keeps coverage measurement honest — a rule with no attack. tag detects something, but you cannot count it, so as far as your coverage map is concerned it does not exist.
Part 9: Measuring Coverage Honestly
Coverage measurement is where purple programs most often lie to themselves. A Navigator heat-map with lots of green feels like progress; whether it is progress depends entirely on what the green means.
9.1 Technique coverage vs procedure coverage
ATT&CK is organised as techniques (T1003) and sub-techniques (T1003.001), but a technique is executed through many procedures — concrete implementations. LSASS dumping (T1003.001) can be done with procdump, comsvcs.dll, Task Manager, direct MiniDumpWriteDump calls, nanodump, and a dozen more. A rule that catches procdump gives you procedure coverage of one procedure and technique coverage of exactly nothing beyond it.
Colouring T1003.001 green because you catch procdump overstates your coverage by however many procedures you do not catch. Honest coverage scoring counts procedures, or at minimum annotates each technique with which procedures are covered and at which robustness level (Part 7).
9.2 A scoring scheme that does not lie
Score each technique on two axes, not one:
| Score | Detection breadth | Robustness (Part 7) |
|---|---|---|
| 0 | No detection | — |
| 1 | One procedure | Level 1 (string/hash) |
| 2 | Several procedures | Level 2 (tool behaviour) |
| 3 | Most known procedures | Level 3 (technique invariant) |
| 4 | Technique-invariant + behavioural backstop | Level 4 |
A technique at breadth-1/robustness-1 is touched, not covered. Reserve "covered" (green) for breadth 3+ at robustness 3+. Everything in between is amber, and amber is honest.
9.3 Generating the Navigator layer from your rules
Because detection-as-code tags every rule with its ATT&CK technique, you can generate the coverage layer mechanically rather than colouring by vibe:
#!/usr/bin/env python3
"""coverage_layer.py - build an ATT&CK Navigator layer from tagged Sigma rules.
Counts DISTINCT rules per technique as a breadth proxy, and reads a robustness
annotation if present. Mechanical generation removes wishful colouring."""
import glob, re, json, yaml, collections
tech = collections.defaultdict(lambda: {"rules": 0, "robustness": 0})
for path in glob.glob("rules/**/*.yml", recursive=True):
doc = yaml.safe_load(open(path))
tags = doc.get("tags", []) or []
robu = int((doc.get("custom", {}) or {}).get("robustness", 1))
for t in tags:
m = re.match(r"attack\.t(\d{4})(\.\d{3})?", t.lower())
if not m:
continue
tid = "T" + m.group(1) + (m.group(2).upper() if m.group(2) else "")
tech[tid]["rules"] += 1
tech[tid]["robustness"] = max(tech[tid]["robustness"], robu)
def score(v):
breadth = min(v["rules"], 3)
return min(breadth, v["robustness"]) if v["robustness"] else breadth
layer = {
"name": "Purple Team Coverage",
"versions": {"layer": "4.5", "attack": "15"},
"domain": "enterprise-attack",
"techniques": [
{"techniqueID": tid,
"score": score(v),
"comment": f"{v['rules']} rule(s), robustness L{v['robustness']}"}
for tid, v in sorted(tech.items())
],
"gradient": {"colors": ["#742a2a", "#744210", "#22543d"], "minValue": 0, "maxValue": 4},
}
json.dump(layer, open("coverage.json", "w"), indent=2)
print(f"wrote coverage.json covering {len(tech)} techniques")
for tid, v in sorted(tech.items()):
print(f" {tid:12} score={score(v)} ({v['rules']} rules, L{v['robustness']})")wrote coverage.json covering 5 techniques
T1003.001 score=1 (1 rules, L1)
T1003.006 score=2 (1 rules, L2)
T1053.005 score=2 (2 rules, L1)
T1069.002 score=1 (1 rules, L1)
T1087 score=1 (1 rules, L1)
That output is uncomfortable, and it is meant to be. Five techniques have rules, but only one scores above a 1 — the rest are single string-match rules that catch the default tool and nothing else. This is a far more useful artifact than a wall of green, because every score-1 line is a specific, prioritisable piece of work: either add procedures (breadth) or climb the robustness ladder. Load coverage.json into ATT&CK Navigator and the gaps are visible at a glance, mapped onto the same matrix your threat intelligence uses.
9.4 Coverage against a specific adversary
Generic ATT&CK coverage is less useful than coverage against the actors who actually target your sector. Overlay your coverage layer on an emulation-plan layer (the CTID plans from Chapter 2) and the intersection is what matters: techniques this actor is known to use that you cannot detect. That intersection, not the whole matrix, is your prioritised backlog.
Part 10: Closing the Gap — Choosing the Right Fix
A gap is not always a rule-writing problem. Diagnosing which kind of gap you have determines the fix, and picking the wrong fix wastes a quarter.
flowchart TD
G[Technique not covered] --> Q1{Is it logged?}
Q1 -->|no| V{Can we log it<br/>at reasonable cost?}
V -->|yes| ONB[Onboard data source<br/>/ enable field]
V -->|no, too costly| PREV{Can we prevent<br/>it instead?}
PREV -->|yes| BLK[Deploy a preventive control<br/>+ ensure it alerts]
PREV -->|no| ACC[Accept + document risk<br/>compensating controls]
Q1 -->|yes| Q2{Rule exists?}
Q2 -->|no| WR[Write rule<br/>logged-only = cheapest]
Q2 -->|yes, but silent| Q3{Tuned off or<br/>broken mapping?}
Q3 -->|tuned off| RT[Re-tune, re-enable]
Q3 -->|broken| FX[Fix field mapping / logic]
style WR fill:#744210,color:#fff
style ONB fill:#742a2a,color:#fff
The decision hierarchy, in order of cost:
- Logged-only → write a rule. Cheapest. The data is already there. Always exhaust this bucket first.
- Not logged but cheaply loggable → onboard the source. Moderate cost, but it often lights up many techniques at once (turning on command-line auditing is one config change that improves dozens of detections).
- Not logged, expensive to log → consider prevention. If detecting something requires a costly new telemetry pipeline, a preventive control (application allow-listing, LSASS protection via RunAsPPL, disabling a legacy protocol) may be cheaper and stronger — provided it also emits an alert (Part 3).
- None of the above viable → accept and document. Some techniques are genuinely uneconomic to detect in a given environment. The correct action is an explicit, documented risk acceptance with compensating controls, not a green square that pretends the gap is closed.
Prevention deserves more weight than detection-centric teams usually give it. A detection tells you the attacker succeeded; a prevention stops them. For high-severity, well-understood techniques — LSASS access is the canonical example — turning on RunAsPPL (Protected Process Light for LSASS) or Credential Guard blocks the entire class more reliably than any detection catches it. The purple exercise then validates the prevention: fire the technique, confirm it is blocked, and confirm the block alerts. Prevention and detection are complements, and the strongest coverage uses both.
Part 11: Lab 1 — Validate and Harden an LSASS-Access Detection
Authorization and scope: the snapshotted Windows lab VM from Chapter 2, with Sysmon (a modern config such as sysmon-modular) and log forwarding to your SIEM. Nothing here runs outside that lab.
11.1 Baseline — confirm the gap
Fire the technique and observe the current state:
# From the authorized lab VM, using Invoke-AtomicRedTeam (Chapter 2)
Invoke-AtomicTest T1003.001 -TestNumbers 1 -GetPrereqs
Invoke-AtomicTest T1003.001 -TestNumbers 1Atomic Test #1 runs a procdump-style memory dump of lsass. Now check what telemetry appeared:
# Splunk - did Sysmon EID 10 capture it?
`sysmon` EventCode=10 TargetImage="*lsass.exe" | table _time SourceImage GrantedAccess CallTrace
_time SourceImage GrantedAccess CallTrace
2027-05-12 10:14:03 C:\tools\procdump64.exe 0x1fffff C:\Windows\SYSTEM32\ntdll.dll+9d...
The event exists (so this is logged-only, not not-logged), but if no alert fired, the detection gap is a missing rule. Deploy the Part 4.1 Sigma rule and re-fire.
11.2 Validate the rule fires
Invoke-AtomicTest T1003.001 -TestNumbers 1# Splunk - did the DEPLOYED RULE alert?
index=notable source="Suspicious LSASS Process Access" | table _time SourceImage GrantedAccess
_time SourceImage GrantedAccess
2027-05-12 10:31:22 C:\tools\procdump64.exe 0x1fffff
Validated: the rule fires on the technique. But Part 7 says this may be a level-1/2 detection. Test its robustness by climbing the ladder.
11.3 Climb the robustness ladder
# Rung 2: rename the binary - does a hash/name-based rule survive?
Copy-Item C:\tools\procdump64.exe C:\tools\svchost-update.exe
C:\tools\svchost-update.exe -accepteula -ma lsass.exe C:\temp\out.dmp
# Rung 3: a different dumping method entirely - comsvcs.dll MiniDump
$p = (Get-Process lsass).Id
rundll32.exe C:\Windows\System32\comsvcs.dll, MiniDump $p C:\temp\out2.dmp fullRe-check the alert after each:
index=notable source="Suspicious LSASS Process Access"
| eval technique_variant=case(
SourceImage LIKE "%procdump%","default-tool",
SourceImage LIKE "%svchost-update%","renamed-binary",
SourceImage LIKE "%rundll32%","comsvcs-minidump",
true(),"other")
| stats count by technique_variant
technique_variant count
comsvcs-minidump 1
default-tool 1
renamed-binary 1
All three variants alerted — because the Part 4.1 rule keys on TargetImage=lsass.exe + GrantedAccess (a level-3 technique-invariant signal: something read lsass memory with dump-capable rights), not on the source binary's name. Had the rule instead matched SourceImage|endswith: '\procdump.exe', the renamed and comsvcs variants would both have slipped through. That is the difference the robustness ladder measures, made concrete: the same green "detected" hides completely different real coverage.
11.4 Add the prevention and re-validate
# Enable LSASS as a Protected Process Light (RunAsPPL) - blocks the read entirely
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' `
-Name RunAsPPL -Value 1 -PropertyType DWORD -Force
# reboot, then re-fire
Invoke-AtomicTest T1003.001 -TestNumbers 1# procdump now fails to open lsass with the needed access
[10:58:14] Error: Unable to open process 5 (0x5, Access is denied)
The technique is now prevented. Confirm per Part 3 that the block also alerts (Sysmon EID 10 with a denied access, or the EDR's PPL-violation event), so you learn that someone tried. Prevention plus a technique-invariant detection plus an alert on the prevented attempt — that is what full coverage of one sub-technique actually looks like.
Part 12: Lab 2 — Turn a Logged-Only Kerberoasting Result into a Tuned Alert
Scope: the lab domain controller from Chapter 2, with Security event forwarding.
12.1 The starting result
Chapter 2's emulation fired T1558.003 (Kerberoasting) and produced a logged-only result: Security Event 4769 events appeared, but no alert. The naive fix — alert on every 4769 with RC4 encryption — would fire thousands of times a day, because RC4 service tickets are normal in many environments. This is the precision problem from Part 5, and it is why the rule needs a threshold, baselined per Part 6.3.
12.2 Baseline the environment
`security` EventCode=4769 Ticket_Encryption_Type=0x17 Service_Name!="krbtgt"
| bucket _time span=5m
| stats dc(Service_Name) as distinct_services count by _time, Client_Address
| stats p95(distinct_services) p99(distinct_services) max(distinct_services)
p95(distinct_services) p99(distinct_services) max(distinct_services)
2 4 6
Legitimate clients request RC4 tickets for at most a handful of distinct services in any 5-minute window. Kerberoasting sweeps request them for dozens — one per SPN in the domain. The distinguishing feature is distinct service count, not raw volume, which is a more precise signal than the count-only version in Part 6.3.
12.3 The tuned detection
title: Kerberoasting - Bulk Distinct SPN RC4 Ticket Requests
id: 3f8c1d90-2a44-4e6b-9c17-88b0e2a4f611
status: test
logsource:
product: windows
service: security
detection:
selection:
EventID: 4769
TicketEncryptionType: '0x17'
filter_krbtgt:
ServiceName: 'krbtgt'
filter_machine_self:
ServiceName|endswith: '$'
AccountName|endswith: '$' # machine requesting its own class - benign pattern
timeframe: 5m
condition: selection and not filter_krbtgt and not filter_machine_self
| count(ServiceName) by AccountName, IpAddress > 10
level: high
custom:
robustness: 312.4 Validate and confirm precision
# Re-fire the technique from the authorized attacker VM
Invoke-AtomicTest T1558.003 -TestNumbers 1 # Rubeus/PowerView kerberoast sweepindex=notable source="Kerberoasting - Bulk Distinct SPN RC4 Ticket Requests"
| table _time AccountName IpAddress distinct_spn_count
_time AccountName IpAddress distinct_spn_count
2027-05-12 11:42:07 svc-attacker 10.0.14.55 37
Fires on the sweep (37 distinct SPNs, far above the baseline max of 6). Now confirm it stays quiet during normal operation: run the rule in monitor-only mode across a normal business day and confirm zero firings from legitimate accounts. The threshold of 10 sits above the 99th-percentile-of-4 baseline with margin, so precision should be high — but you measure it rather than assume it, which is the entire discipline of Part 6.
Part 13: Lab 3 — A Detection-as-Code Pipeline With Tests
Scope: a local git repository; no target systems touched. This lab builds the Part 8 pipeline end to end.
13.1 Set up the repository
mkdir -p detect-lab/{rules/credential_access,tests,pipelines,.ci} && cd detect-lab
git init -q
# The rule from Part 4.1
cp ~/lsass_access.yml rules/credential_access/T1003.001_lsass_access.yml
# The test from Part 8.2
cp ~/T1003.001_lsass_access.test.yml tests/13.2 A minimal test runner
#!/usr/bin/env python3
"""run_tests.py - evaluate Sigma rules against should_match / should_not_match
sample events. A tiny matcher that understands the modifiers used in the rules."""
import sys, glob, yaml
def field_match(rule_val, event_val):
if isinstance(rule_val, dict):
return True # nested handled by caller
if isinstance(rule_val, list):
return any(field_match(v, event_val) for v in rule_val)
return str(rule_val).lstrip("*").rstrip("*").lower() in str(event_val).lower()
def matches(detection, event):
sel = detection.get("selection", {})
for key, want in sel.items():
field = key.split("|")[0]
ev = event.get(field, "")
if not field_match(want, ev):
return False
for fname, filt in detection.items():
if not fname.startswith("filter"):
continue
if all(field_match(v, event.get(k.split("|")[0], "")) for k, v in filt.items()):
return False # a filter block matched -> excluded
return True
fails = 0
for tf in glob.glob(f"{sys.argv[1]}/*.test.yml"):
spec = yaml.safe_load(open(tf))
rule = yaml.safe_load(open(spec["rule"]))
det = rule["detection"]
name = rule["title"]
m = nm = 0
for case in spec.get("should_match", []):
if matches(det, case["event"]): m += 1
else: fails += 1; print(f" FAIL should_match: {name} / {case['name']}")
for case in spec.get("should_not_match", []):
if not matches(det, case["event"]): nm += 1
else: fails += 1; print(f" FAIL should_not_match: {name} / {case['name']}")
print(f"{name}: {m} match / {nm} no-match ... {'PASS' if not fails else 'FAIL'}")
sys.exit(1 if fails else 0)python3 .ci/run_tests.py tests/Suspicious LSASS Process Access: 1 match / 2 no-match ... PASS
13.3 Prove the pipeline catches a regression
Now break the rule the way a careless "reduce false positives" edit would, and watch CI catch it:
# Someone over-broadens an exclusion to silence a noisy vendor tool
cat >> rules/credential_access/T1003.001_lsass_access.yml <<'EOF'
filter_toobroad:
GrantedAccess|contains: '0x1fffff' # BAD: excludes full-access reads = the attack
EOF
# update condition to include the bad filter
sed -i 's/condition: selection and not filter_legit/condition: selection and not filter_legit and not filter_toobroad/' \
rules/credential_access/T1003.001_lsass_access.yml
python3 .ci/run_tests.py tests/ FAIL should_match: Suspicious LSASS Process Access / procdump reads lsass
Suspicious LSASS Process Access: 0 match / 2 no-match ... FAIL
The should_match case for procdump now fails, because the over-broad exclusion swallowed the actual attack. CI blocks the merge. This is the whole point of detection-as-code: the regression that would have silently gutted your LSASS coverage in production is caught by a test in a pull request, before it ever ships. Revert the bad filter and the pipeline goes green again.
Part 14: Program Metrics — MTTD, MTTR, and Coverage Trend
Individual detections are the unit of work; the program is measured in aggregate. Four metrics, tracked over time, tell you whether the purple loop is actually improving your posture.
| Metric | Definition | What it tells you | Watch out for |
|---|---|---|---|
| MTTD | Mean time to detect: technique execution → alert | Speed of the detection layer | Only meaningful for techniques you can detect; improving MTTD while coverage is low is measuring the wrong thing |
| MTTR | Mean time to respond: alert → containment | Speed of the response layer | Gaming by auto-closing alerts; measure to containment, not to acknowledgement |
| Coverage | Score-3+ techniques / relevant-actor techniques | Breadth and depth of detection | Technique vs procedure coverage (Part 9.1) |
| Regression rate | Detections that broke since last test / total | Health / rot of the rule base | A rising rate means environment change is outpacing maintenance |
The metric most often reported and least often useful is a raw MTTD number with no coverage context. MTTD only measures the things you already detect; a team can have a superb 4-minute MTTD and catch 30% of relevant techniques. Report MTTD and coverage together, always, or the number flatters you.
The most under-reported and most diagnostic metric is regression rate. It is the one that reveals whether your detections are rotting faster than you fix them — a log-source migration, an EDR upgrade, or a Sysmon config change can quietly break dozens of rules at once, and without scheduled regression testing (Part 1) you learn about it from a missed incident. A healthy program re-fires its detection corpus on a schedule and tracks how many still work.
flowchart LR
E[Purple exercise] --> M1[MTTD per technique]
E --> M2[Coverage delta]
E --> M3[New gaps found]
SCHED[Scheduled re-fire] --> M4[Regression rate]
M1 --> DASH[Program dashboard]
M2 --> DASH
M3 --> DASH
M4 --> DASH
DASH --> NEXT[Prioritise next<br/>detection backlog]
NEXT --> E
style DASH fill:#2c5282,color:#fff
Part 15: Common Pitfalls and Myths
15.1 Pitfalls
| # | Pitfall | Consequence | Fix |
|---|---|---|---|
| 1 | Counting rules instead of validated detections | Green dashboard, real gaps | Validate by firing the technique (Part 11) |
| 2 | Confusing visibility with detection | "We have Sysmon" ≠ coverage | Data-source inventory at field level (Part 2) |
| 3 | Tuning a rule into silence to stop FPs | Recall drops to zero unnoticed | Narrow, documented exclusions; monitor-only first (Part 6) |
| 4 | String/hash rules mistaken for technique coverage | Bypassed by a rename | Score robustness; climb the ladder (Part 7, 11.3) |
| 5 | Colouring techniques green on one procedure | Overstated coverage | Procedure-aware scoring (Part 9.1) |
| 6 | No regression testing | Rules rot silently | Scheduled re-fire (Part 1, 14) |
| 7 | Alerting on prevention without confirming telemetry | Blind block; no campaign signal | Confirm prevented techniques also alert (Part 3) |
| 8 | Guessing thresholds | Rule never fires or fires constantly | Baseline against your own data (Part 6.3) |
| 9 | Broad exclusions (exclude all powershell) |
Attacker walks through the hole | Exclude the benign cause, narrowly (Part 6.2) |
| 10 | MTTD reported without coverage | Flattering, misleading | Always report MTTD and coverage together (Part 14) |
| 11 | Detections not in version control | No review, no tests, no history | Detection-as-code (Part 8) |
| 12 | Rules with no ATT&CK tag | Invisible to coverage scoring | Enforce metadata in CI (Part 8.3) |
| 13 | Fixing "not logged" by writing a rule | Rule matches nothing | Diagnose the gap type first (Part 10) |
| 14 | Command-line auditing half-configured | Half your rules match empty fields | Verify 4688 carries CommandLine (Part 2.3) |
15.2 Myths
"We have hundreds of rules, so we have good coverage." Rule count is uncorrelated with coverage. A hundred string-match rules for commodity tools can leave every technique-invariant path open. Coverage is measured by validated detection at a known robustness level, not by rule count.
"If it's logged, we'll catch it." Logging is necessary, not sufficient. Logged-only is an entire outcome category (Part 3) precisely because vast amounts of relevant telemetry are collected and never alerted on.
"Prevention means we don't need detection." A prevention that does not alert is a blind block — it stops one attempt and tells you nothing about the adversary's presence. Prevention and detection are complements (Part 10).
"A green ATT&CK Navigator means we're covered." Only if the green is earned at procedure breadth and robustness depth. A heat-map coloured by "we have a rule tagged with this technique" is a map of hope, not coverage (Part 9).
"Tuning means making the alerts stop." Tuning means raising precision without losing recall. Making alerts stop by broad exclusion is how you tune a working detection into a decorative one (Part 6).
"Detection engineering is a SIEM-content task." It is a software-engineering discipline: version control, review, unit tests, CI, regression testing. A detection program run as ad-hoc SIEM edits rots at the speed of environment change (Part 8).
Final Revision / Summary
The chain that leaks. Having a log is not having a detection; having a detection is not having an alert; having an alert is not having a response. Each arrow is a place coverage silently leaks, and the job is to make every arrow explicit, testable, and measured.
Visibility first. You cannot detect what you do not log. Build a data-source inventory at the field level — "we collect Sysmon" is not "we collect EID 10 with GrantedAccess populated." The single highest-leverage visibility fix is command-line auditing (Security 4688 with the "include command line" GPO, plus Sysmon EID 1), and you must verify it is actually populating fields rather than assume it.
Four outcomes, four fixes. Not logged → onboard a source (visibility project). Logged-only → write a rule (cheapest; work these first). Alerted → validate precision. Prevented → confirm it also alerts, because a blind block gives no campaign signal.
Author in Sigma. Portable, reviewable, version-controllable YAML that compiles to Splunk/Elastic/Sentinel. The pipeline (field mapping) is where translation succeeds or fails — a logically perfect rule matches nothing if the pipeline maps to the wrong field.
Quality is precision and recall. Precision = TP/(TP+FP); recall = TP/(TP+FN). A high-recall, low-precision rule trains analysts to ignore it and is worse than no rule. Tolerable FP rate is set by the consumer — near-zero for auto-isolation, high for a correlation-only input. Composition (chain two noisier signals) buys precision without sacrificing recall.
Tune with evidence. Monitor-only first, triage every firing, exclude the benign cause as narrowly as possible, and baseline thresholds against your own data rather than guessing. Every exclusion is attack surface.
Robustness ladder. String/hash (brittle) → tool behaviour → technique invariant → behavioural (strongest). Know which rung each rule sits on; a green "detected" resting on a string match is zero coverage against a renamed binary. The offensive half of the exercise climbs the ladder deliberately to find the boundary of real detection.
Detection-as-code. Rules in git, tagged with ATT&CK, unit-tested with should-match/should-not-match cases, validated in CI against your real field-mapping pipeline, regression-tested on a schedule. The metadata check keeps coverage honest; the tests catch the "reduce false positives" edit that would have gutted a detection.
Measure honestly. Score techniques on breadth (how many procedures) and robustness (which ladder rung), reserve green for 3+/3+, and generate the Navigator layer mechanically from tagged rules rather than colouring by vibe. Overlay actor emulation plans; the intersection of "actor uses this" and "we can't detect it" is your backlog.
Close the right gap. Diagnose gap type before fixing: logged-only → rule, cheaply-loggable → onboard, expensively-loggable → prevent (and alert), otherwise → document risk acceptance. Prevention often beats detection for well-understood high-severity techniques (RunAsPPL, Credential Guard), provided it also alerts.
Program metrics. Report MTTD and coverage together — MTTD alone only measures what you already catch. Track regression rate; it reveals whether detections rot faster than you fix them.
Cheat Sheet / Quick Reference
The leak chain and its fixes
log? -> no = NOT LOGGED -> onboard data source / enable field
rule? -> no = LOGGED-ONLY -> write a rule (cheapest coverage)
alert? -> no = tuned-off/broke-> re-enable / fix field mapping
respond?-> no = no response -> playbook + routing
Highest-leverage visibility fixes
Windows process cmdline: Audit Process Creation = Success
+ "Include command line in process creation events" = Enabled
+ Sysmon EID 1 (belt and braces)
Verify: Get-WinEvent -FilterHashtable @{LogName='Security';Id=4688} | check CommandLine non-empty
LSASS access: Sysmon EID 10 with GrantedAccess + CallTrace
Sigma rule skeleton
title: ...
id: <stable-uuid>
status: experimental|test|stable
logsource: { product: windows, category: process_access }
detection:
selection: { TargetImage|endswith: '\lsass.exe' }
filter_x: { SourceImage|endswith: '\MsMpEng.exe' }
condition: selection and not filter_x
level: high
tags: [ attack.credential-access, attack.t1003.001 ]Sigma modifiers
contains startswith endswith all re cidr windash base64offset|contains
|contains|all: ['-ma','lsass'] <- BOTH substrings (precise)
|windash|contains: '-ma' <- matches -ma and /ma
Sigma -> SIEM
pip install sigma-cli pysigma-backend-splunk pysigma-backend-elasticsearch
sigma convert -t splunk -p sysmon rule.yml
sigma convert -t elasticsearch -p sysmon -f dsl_lucene rule.yml
# zero results on known-good data? suspect the PIPELINE (field mapping) firstDetection quality
precision = TP / (TP + FP) -> low = alert fatigue
recall = TP / (TP + FN) -> low = missed technique
Tolerable FP by consumer: auto-isolate ~0 | page very-low | SOC queue low-mod | correlation high
Robustness ladder (know your rung)
L1 string/hash -> renamed binary bypasses
L2 tool behaviour-> file-less variant bypasses
L3 technique inv -> only avoiding the invariant bypasses <- aim here for high-value techniques
L4 behavioural -> novel TTP only
Coverage scoring
score = min(breadth<=3, robustness)
green (covered) = breadth 3+ AND robustness 3+
amber = touched but not covered (single procedure or L1/L2)
Generate the Navigator layer FROM tagged rules; never colour by vibe.
Detection-as-code CI checks
1 sigma check rules/ (syntax/schema)
2 sigma convert every rule to prod backend (compiles against real pipeline)
3 run_tests.py (should_match / should_not_match)
4 metadata policy: every rule has attack.* tag + author
Program metrics
MTTD (report WITH coverage, never alone) MTTR (to containment, not ack)
Coverage (score-3+ / relevant-actor techniques) Regression rate (broke / total)
Glossary
| Term | Meaning |
|---|---|
| Visibility | Whether telemetry captures a behaviour at all (property of data) |
| Detection | Whether an analytic alerts on captured behaviour (property of rules) |
| Logged-only | Telemetry present, no rule fires; cheapest gap to close |
| Sigma | Vendor-neutral YAML detection format; compiles to SIEM queries |
| Pipeline (Sigma) | Field-name mapping from abstract Sigma fields to your log schema |
| Precision | TP/(TP+FP) — fraction of alerts that are real |
| Recall | TP/(TP+FN) — fraction of real activity caught |
| Robustness ladder | String/hash → tool → technique-invariant → behavioural |
| Procedure coverage | Coverage of specific implementations, not just the technique |
| Detection-as-code | Detections managed as versioned, tested, CI-validated software |
| Regression testing | Scheduled re-firing to confirm rules still work after change |
| RunAsPPL | Protected Process Light for LSASS; prevents memory-read credential dumping |
| MTTD / MTTR | Mean time to detect / respond |
| Alert fatigue | Analysts ignoring a low-precision rule, nulling its effective recall |
Practice Labs & Resources
- Reproduce Labs 1-3 in DetectionLab or Splunk Attack Range. These prebuilt Windows-domain + Sysmon + Splunk environments are purpose-made for the fire-technique/validate-detection loop. Work T1003.001 end to end: confirm logged-only, deploy the rule, climb the robustness ladder (default → renamed → comsvcs), then add RunAsPPL and re-validate the prevention.
- Sigma + pySigma hands-on. Clone the SigmaHQ rules repository, install
sigma-cliwith the Splunk and Elasticsearch backends, and convert real rules against a Sysmon pipeline. Deliberately break a field mapping and watch a logically-correct rule return nothing — the lesson of Part 4.3 that no reading replaces. - Atomic Red Team → detection loop. For each technique you emulated in Chapter 2, run the four-outcome triage (Part 3) and write one detection for the highest-value logged-only result. Track them in a git repo with the Part 8 structure.
- Build the detection-as-code pipeline for real. Take the Part 13 skeleton, replace the toy matcher with the actual
sigmaCLI and a proper test harness (e.g. the communitysigmatest tooling), and wire it into GitHub Actions. Add the regression job that re-fires a technique on a schedule. - ATT&CK Navigator coverage layer. Generate
coverage.jsonfrom your tagged rules with the Part 9.3 script, load it into Navigator, and overlay a CTID emulation-plan layer (APT29, FIN7) to find the actor-specific gaps. This is the single most useful artifact to bring to a program review. - Kerberoasting threshold baselining. On a lab DC, generate normal 4769 traffic, baseline the distinct-SPN-per-window distribution (Part 12.2), then fire Rubeus/PowerView kerberoasting and confirm your threshold rule separates the two cleanly. Then try to evade it by slowing the sweep below the threshold — and design the low-and-slow detection that catches that.
- TryHackMe — "Sigma", "Investigating with Splunk", "Threat Hunting", "Aurora EDR" and HackTheBox Sherlocks (blue-team investigations) for guided practice pairing technique execution with detection authoring and tuning.
- Detection Engineering reading. Work through the Palantir Alerting and Detection Strategy (ADS) framework and the MITRE "Summiting the Pyramid" work on analytic robustness — both formalise the robustness-ladder intuition from Part 7 and give you a vocabulary for scoring durability.
- VECTR for tracking. Log the results of successive purple exercises in VECTR (community edition) to build the MTTD/coverage/regression trend from Part 14 across runs, so you can show the program improving rather than just report a snapshot.
The next chapter puts the two halves together: with emulation (Chapter 2) and detection validation (this chapter) both in hand, we run a full purple team exercise end to end — planning, execution, real-time collaboration, scoring, and the report that turns a day of testing into a prioritised program of work.