Detection Engineering

What Is a Detection Rule? From Alerts to Analytics

HuntRule Team · · 9 min read

A machinist's go/no-go gauge on a dark steel bench with a single small part seated in the slot
On this page

ParentImage ends with \WINWORD.EXE and Image ends with \cmd.exe. That pair, plus a statement about which log it reads and what happens when it fires, is a complete detection rule. Everything else is packaging.

Rules get described by the tool that runs them. A Splunk search, a Sentinel analytic, an EDR custom detection, a Sigma YAML file. The packaging changes, the anatomy does not. This post takes a rule apart into the parts it actually has, then walks four rungs of a ladder from a hash match to a multi-event correlation, and says which rung breaks first when the attacker changes one thing.

The three parts every rule has

A rule reads something. In Sigma that is the logsource block, and it is the part people skip.

logsource:
    category: process_creation
    product: windows

Two lines, and both are load-bearing. process_creation on Windows resolves to Sysmon Event ID 1 or Security Event ID 4688, and those are not the same telemetry. Sysmon Event ID 1 carries CommandLine, ParentImage, OriginalFileName and a Hashes field. Security 4688 carries NewProcessName and ParentProcessName, and it only carries the command line if the "Include command line in process creation events" audit policy is enabled. A rule that keys on OriginalFileName reads as valid YAML on a 4688-only estate and matches nothing, forever, silently.

A rule matches something. This is the part everyone means when they say "rule": a boolean over fields.

A rule owes a response. In Sigma this is the level field plus the falsepositives list. In your SIEM it is the routing decision: does this page someone, open a case, or land in a hunting queue. A rule with no answer to "then what" is not finished.

The fourth part, and most rules skip it

A rule should state a hypothesis about attacker behaviour. Not a restatement of the logic. Most description fields read like this:

Detects winword.exe spawning cmd.exe

That is the condition written in prose. A hypothesis says what the attacker is forced to do and why:

Macro and exploit-based document delivery needs a child process to fetch
or run the next stage. Office products do not launch interpreters during
normal document use, so the parent-child edge is the constrained step.

The difference is testable. The second version tells you the rule dies the moment the payload runs in-process instead of spawning, which is exactly what shellcode injection from a macro does. The first version tells you nothing, so nobody notices when it goes blind. If you are new to writing these, detection engineering is largely the practice of making that hypothesis explicit before the YAML.

Rung one: the atomic IOC match

We hashed the EICAR anti-malware test file and wrote the narrowest possible rule against it.

logsource:
    category: process_creation
    product: windows
detection:
    selection:
        Hashes|contains: 'SHA256=275A021BBFB6489E54D471899F7DB9D1663FC695EC2FE2A2C4538AABF651FD0F'
    condition: selection

Sysmon writes the Hashes field uppercase and comma-delimited, which is why the match is a contains against a prefixed value rather than an equality test. This rule has a false positive rate of approximately zero and a shelf life of approximately one recompile. Change a single byte of padding and the SHA-256 changes. Change the packer and the IMPHASH changes too.

Atomic indicators are still worth running. They cost nothing to evaluate and they catch the operator who reused infrastructure. Just do not confuse coverage of a hash with coverage of a technique.

Rung two: the behavioural rule

Here is a rule for the Office child process case, written for this post and deliberately narrower than the SigmaHQ original (438025f9-5856-4663-83f7-52f878a70a50), which covers a much longer child list.

title: Office Application Spawning A Command Interpreter
id: 7c2b0d5e-3f41-4a8a-9d2e-6b1f0c4a8e77
status: experimental
description: Macro and exploit-based document delivery needs a child process to
    fetch or run the next stage, and Office products do not launch interpreters
    during normal document use.
author: HuntRule
date: 2026-07-31
references:
    - https://github.com/SigmaHQ/sigma/blob/master/rules/windows/process_creation/proc_creation_win_office_susp_child_processes.yml
tags:
    - attack.execution
    - attack.t1204.002
    - attack.t1059.001
    - attack.t1059.003
logsource:
    category: process_creation
    product: windows
detection:
    selection_parent:
        ParentImage|endswith:
            - '\EXCEL.EXE'
            - '\MSACCESS.EXE'
            - '\ONENOTE.EXE'
            - '\POWERPNT.EXE'
            - '\WINWORD.EXE'
    selection_child:
        OriginalFileName:
            - 'Cmd.Exe'
            - 'PowerShell.EXE'
            - 'pwsh.dll'
            - 'cscript.exe'
            - 'wscript.exe'
    condition: selection_parent and selection_child
falsepositives:
    - Line-of-business workbooks that shell out to a reporting script on open
    - Vendor add-ins and templates that call wscript.exe during initialisation
    - Packaging and QA automation that opens documents under a service account
level: high

Those false positives are real and they are the whole tuning problem. In most estates the finance team owns at least one workbook that runs cmd.exe /c on open, and it has run every weekday morning for six years. The rule is right and the alert is wrong.

Note the OriginalFileName matches. pwsh.dll is the original filename embedded in pwsh.exe, which is why renaming the binary to svchost.exe does not evade this field. That is the point of behavioural rules: they hang off something the attacker has less freedom to change.

This rung breaks when the attacker stops spawning. Macro shellcode injecting into an already-running process, or a loader using T1218.010 regsvr32 launched by a different parent, both walk straight past it.

Rung three: the statistical analytic

Nothing about "rare" fits in a boolean. Rarity needs a baseline, so it needs a query, not a rule.

WITH baseline AS (
  SELECT DISTINCT host, image_name
  FROM process_creation
  WHERE event_time BETWEEN now() - INTERVAL '30' DAY
                       AND now() - INTERVAL '1' DAY
),
today AS (
  SELECT host, image_name,
         min(command_line) AS sample_cmdline,
         count(*)          AS execs
  FROM process_creation
  WHERE event_time >= now() - INTERVAL '1' DAY
  GROUP BY host, image_name
),
spread AS (
  SELECT image_name, count(DISTINCT host) AS hosts
  FROM today
  GROUP BY image_name
)
SELECT t.host, t.image_name, t.sample_cmdline, t.execs, s.hosts
FROM today t
JOIN spread s      ON s.image_name = t.image_name
LEFT JOIN baseline b ON b.host = t.host AND b.image_name = t.image_name
WHERE b.image_name IS NULL
  AND s.hosts <= 3
ORDER BY s.hosts, t.execs;

New on this host in thirty days, and seen on three hosts or fewer estate-wide today. That second condition is what stops patch Tuesday from returning ten thousand rows. We have not run this against a production-scale dataset, and the thresholds are starting points rather than recommendations.

This rung breaks in two ways. It breaks when the attacker uses a binary that is already everywhere, which is the entire argument for living-off-the-land. It also breaks when your baseline window covers a period the attacker was already active in, at which point the malicious process is the baseline.

Rung four: correlation across events

Some behaviour only exists as a shape across multiple events. Password spraying is the clean example: individually every 4625 is boring, and the success at the end is a normal 4624.

title: Password Spray From A Single Source
id: 1e0f8b3d-7c42-4c9a-b0a5-2d8f6e4c1a90
name: password_spray_from_single_source
correlation:
    type: value_count
    rules:
        - failed_logon_4625
    group-by:
        - IpAddress
    timespan: 1h
    condition:
        field: TargetUserName
        gte: 30
---
title: Password Spray Followed By A Successful Logon From The Same Source
id: 9b4c7a21-5d38-4f60-8e11-3a7c2b9d6f05
correlation:
    type: temporal_ordered
    rules:
        - password_spray_from_single_source
        - successful_network_logon_4624
    group-by:
        - IpAddress
    timespan: 1h
falsepositives:
    - A VPN concentrator or NAT gateway presenting hundreds of users as one IpAddress
    - A scheduler retrying a service account with a stale password, then an admin resetting it
level: high

The chaining is real Sigma. A correlation can reference another correlation, so the spray rule feeds the ordered temporal rule rather than being duplicated inside it. Two honest caveats: 4625 does not populate IpAddress for every logon type, so console and some local failures arrive with a dash, and any correlation with a timespan is a published evasion budget. Spray at twenty-nine accounts an hour and this never fires.

Which rung breaks first

Rung

Matches on

Breaks when the attacker

Typical volume

Atomic IOC

SHA-256, IP, domain

Recompiles, repacks, rotates infrastructure

Near zero

Behavioural

Parent-child, OriginalFileName, argument shape

Changes the execution chain or injects in-process

Low to moderate

Statistical

Deviation from a per-host baseline

Uses a binary already common in the estate

Moderate to high

Correlation

A shape across events in a time window

Slows below the threshold or spreads the source

Low, but expensive to compute

Read the second column top to bottom. It is a difficulty gradient. Breaking rung one is free, breaking rung four costs the operator tempo and infrastructure. That is the actual return on moving up the ladder, and it is why a catalog full of hashes reports high coverage and delivers little.

Alerts, analytics and dashboard widgets

Every rule you deploy answers one question: what happens on a match.

An alert needs a response. It fires, a human or a playbook does a specific thing, and the thing is written down before deployment. If you cannot name the first action, it is not an alert.

An analytic needs an analyst. Rung three output is not wrong when it returns forty rows, it is working. The rows go to a hunt queue and someone reads them. Judged as an alert it looks like a 95 percent false positive rate. Judged as an analytic it looks like a Tuesday.

A rule with neither is a dashboard widget. It matches, it increments a counter, nobody acts on it, and it appears in the coverage report as green. This is the most common failure mode in a mature detection library, and it is invisible unless you audit rules by what happened after the last hundred matches rather than by whether they exist.

A single large brass alarm bell hanging in darkness with its clapper missing

Summary

A detection rule is a data source, matching logic and a response contract, and the good ones add a written hypothesis about what the attacker is forced to do. The four rungs are not better and worse versions of each other, they fail in different places, and a library needs all of them. Atomic indicators break on recompile, behavioural rules break when the execution chain changes, statistical analytics break against common binaries, and correlations break against patience. Before you deploy anything, write down what happens on a match. If the answer is "it shows up on a chart", delete it.

Collect these before writing anything on Windows:

Sysmon EID 1   process creation: Image, ParentImage, CommandLine, OriginalFileName, Hashes
Sysmon EID 3   network connection: Image, DestinationIp, DestinationPort, Initiated
Sysmon EID 11  file create: Image, TargetFilename
Security 4688  process creation (enable command line auditing)
Security 4624  successful logon: TargetUserName, LogonType, IpAddress
Security 4625  failed logon: TargetUserName, LogonType, IpAddress

Browse the catalog for rules that already cover these log sources at /rules?domain=windows, or search the parent-child cases directly with /rules?q=parent+process.

Related articles