In this section

0.5 Four Ways to Write a Detection

Module 0

Introduction

Every detection in this course is written four times, and always in the same order: Sentinel KQL, Splunk SPL, PowerShell with Get-WinEvent, and Sigma. That isn't repetition for its own sake. The four forms are four places an Active Directory event can be read, and the place you read it from depends on where you work, what your organization bought, and whether the SIEM got the event at all.

This lesson takes one simple detection, a member added to a security group on a domain controller, and writes it in all four, so that every later lesson's detection block reads as one rule in four languages rather than four things to learn.

The rule is deliberately small. A group membership change is one of the most common things a controller records and one of the most useful, because Active Directory grants nearly everything through groups. In the month this course reads, the writable controllers recorded six such changes, and the lesson finds them each way:

One change, one event, every reader a-mwebb tier 0 administrator NE-DC01 Security log Sentinel and Splunk through an agent or forwarder An analyst host Get-WinEvent, no SIEM add a member r.crompton to Finance-Share-RW event 4728, shipped a row in each SIEM Get-WinEvent -FilterHashtable asks the controller's own log the same 4728 read where it was written KQL, SPL and PowerShell read the same record in three places. Sigma describes it once and is compiled into the others.

The figure is the reason there are four surfaces. One administrator makes one change, the controller writes one event, and that record is then read in three different places: a Sentinel workspace and a Splunk index, each fed by an agent, and the controller's own log, read directly with PowerShell.

Sigma sits beside them as a description of the rule that compiles into whichever language you need. By the end you'll have run the rule in KQL and SPL, read its PowerShell output, and seen the Sigma rule converted.

1

One Rule, Four Places to Run It

understand: what a surface is

A surface, in this course, is a place a detection runs and the language it is written in there. The course fixes four of them and an order, so a lesson's detection block always opens on the same tab and a student who works in one product always knows where to look:

The four surfaces, in the order every lesson shows them

spec D5

Sentinel KQL

Kusto Query Language over the SecurityEvent table and the Defender XDR identity tables, in a Log Analytics workspace

Splunk SPL

Search Processing Language over the Windows event log index, with the field names Splunk's Windows add-on extracts

PowerShell

Get-WinEvent against a controller's Security log, a collector's forwarded events or an exported file; no SIEM involved

Sigma

a YAML rule that names the log source and the conditions, converted by a backend into the query language of the SIEM you run

The rule between them

KQL and SPL must return the same rows on the corpus before the PowerShell and Sigma forms are written

The order follows the market rather than a preference. Microsoft Sentinel and Splunk are the two SIEMs a Windows security team is most likely to have, and the course's corpus is projected into both, so every query you see in KQL or SPL runs in the page against the same month of events.

PowerShell comes third because it needs nothing but a controller and permission to read its log, which makes it the surface for a small estate with no SIEM and for an incident in which the SIEM is the thing in doubt. Sigma comes last because it is a description rather than a query: it says which log and which conditions, and a converter writes the query.

2

The Event Every Surface Reads

observe: event 4728 and its fields

Before writing a rule in any language, read the event it's built on in the form the controller wrote it. Event 4728 records a member added to a security-enabled global group, and it is written only by domain controllers, under the Audit Security Group Management subcategory. The first one of the month, read from NE-DC01's exported account management events, field by field:

PS C:\> Get-WinEvent -ComputerName NE-DC01 -FilterHashtable @{ LogName = "Security"; Id = 4728 } -MaxEvents 1 -Oldest |
>>     ForEach-Object { ([xml]$_.ToXml()).Event.EventData.Data } | Format-Table Name, '#text' -AutoSize
Name              #text
----              -----
SubjectUserSid    S-1-5-21-3623811015-3361044348-30300820-1587
SubjectUserName   a-mwebb
SubjectDomainName NE
TargetUserName    Finance-Share-RW
TargetDomainName  NE
MemberName        CN=r.crompton,OU=Staff,DC=corp,DC=ne,DC=com
MemberSid         S-1-5-21-3623811015-3361044348-30300820-1541

Seven fields, and three of them carry the whole meaning. SubjectUserName is who made the change, a-mwebb, one of Northgate's tier 0 administrators. TargetUserName is the group that changed, Finance-Share-RW. MemberName is the account that was added, written as a distinguished name, which tells you r.crompton sits in the Staff organizational unit.

The two SIDs identify the same people by number, and a SID is the safer thing to match on when names can be reused, but the names are what an analyst reads first and what the course's rules match.

Group scope decides the event number. A global group's change is 4728, a domain local or builtin group's is 4732, and a universal group's is 4756, and the fields are the same in all three.

A rule that reads only 4728 misses a change to Administrators, which is a builtin domain local group, so the rule in this lesson reads all three numbers even though the month holds only 4728. The subcategory also writes no failure events, so a membership change that was refused leaves nothing for any of the four surfaces to read.

What the event doesn't say matters as much as what it does. It names the group and nothing about what membership of that group grants.

Finance-Share-RW reads like a share permission, and it probably is one, but the only way to know is to look at the places the group is used: the share's permissions, a GPO that adds it to a local Administrators group, an ACL on a directory object.

Two events that differ only in the group's name can mean a routine request and a takeover, which is why every narrowed rule in the course starts from a list of groups whose grants have been read, rather than from the event alone.

Each surface names those fields its own way, and most of the difference sits in one place:

The event, and the name each surface gives each field 4728, NE-DC01, 18 Feb 09:32:03 KQL SPL PowerShell Sigma EventID 4728 EventID EventCode $_.Id EventID Computer NE-DC01.corp.ne.com Computer host -ComputerName logsource SubjectUserName a-mwebb SubjectUserName src_user $d.SubjectUserName SubjectUserName TargetUserName Finance-Share-RW TargetUserName Group_Name $d.TargetUserName TargetUserName MemberName CN=r.crompton,OU=Staff,... MemberName Member_Name $d.MemberName MemberName KQL, PowerShell and Sigma keep the event's own field names. The Splunk index renames them, and that is where a translated query goes wrong.

KQL, PowerShell and Sigma keep the event's own field names, because the Sentinel table and the event XML both carry them through. The Splunk index renames them at search time, so TargetUserName becomes Group_Name and SubjectUserName becomes src_user. Neither naming is wrong.

The trap is a query translated by hand from one to the other, which returns nothing without any error when a single field name is left in the wrong language.

3

In Sentinel: KQL

detect: the SecurityEvent table

Sentinel stores Windows security events in the SecurityEvent table, one row per event, with the event's fields as columns. The rule is a filter on the event number and the controller, then a projection that renames the three fields that matter into words an analyst reads at a glance:

SecurityEvent
| where EventID in (4728, 4732, 4756) and Computer startswith "NE-DC"
| project TimeGenerated, Computer, EventID, Group = TargetUserName, Member = MemberName, Operator = SubjectUserName
| sort by TimeGenerated asc

Run it, and six rows come back, every one a 4728 from NE-DC01 and every one by a tier 0 administrator. The where line does all the selecting. EventID in (4728, 4732, 4756) reads all three group scopes, and Computer startswith "NE-DC" keeps the two writable controllers, because Computer holds the full DNS name. NE-RODC01, the read-only controller, is left out on purpose.

A read-only controller holds no writable copy of the domain, so a membership change made through it is passed to a writable controller, and the 4728 is written there. Reading the read-only controller for group changes costs a query and can never return a row. The project line is presentation: it renames TargetUserName, MemberName and SubjectUserName to Group, Member and Operator, so the result reads as a sentence.

Six rows over a month is a small number, and it's worth seeing what they are before deciding what the rule is for:

Members added to security groups in the month, by group VPN-Users all by a-pgreaves 3 added Finance-Share-RW a-mwebb once, a-pgreaves once 2 added Engineering-Share-RW by a-mwebb 1 added 4728 on NE-DC01, 14 February to 15 March; no 4732 or 4756 in the month Three groups that open a share or the VPN. Not one grants administration of anything.

All six are additions to groups that open a file share or the VPN, made by the two people whose job it is. A detection that fires on them is working exactly as written, and none of its rows needs a response.

That is the honest description of the simple form: it is an inventory of membership changes, useful for a monthly review and as the base for a sharper rule, and it is not yet an alert. The last section of this lesson shows how far the course's own rule narrows it, and lesson 11.3 builds that narrower rule from an attack that needs it.

4

In Splunk: SPL

detect: the same rows, different names

In Splunk the same events sit in the Windows event log index, and the rule has the same three parts: select by event code and host, then lay out and rename the fields. Run it against the course's Splunk projection of the same month:

index=wineventlog sourcetype="WinEventLog:Security" EventCode IN (4728, 4732, 4756) host="NE-DC*"
| table _time, host, EventCode, Group_Name, Member_Name, src_user
| rename Group_Name AS Group, Member_Name AS Member, src_user AS Operator
| sort _time

The same six rows, at the same times, for the same groups and operators. The search reads differently because Splunk's names are different, and the card below sets the two languages against each other field by field, which is the comparison every later lesson's detection block relies on:

The same rule, read field by field

KQL and SPL on the course corpus

Table or index

SecurityEvent in KQL; index=wineventlog sourcetype="WinEventLog:Security" in SPL

Event number

EventID in KQL; EventCode in SPL

Which controller

Computer, the full DNS name, matched with startswith "NE-DC"; host, matched with the wildcard "NE-DC*"

The group

TargetUserName in KQL; Group_Name in SPL

The member and the operator

MemberName and SubjectUserName in KQL; Member_Name and src_user in SPL

Time

TimeGenerated in KQL; _time in SPL, which every Splunk event carries

Two differences deserve attention beyond the names. Splunk matches host with a wildcard pattern where KQL uses startswith, and the two agree only because every controller name begins with NE-DC; a host field holding a short name in one estate and a full DNS name in another changes what the pattern matches.

The second is the sourcetype. This course's index files the Security log as WinEventLog:Security, an older convention. Splunk's Windows add-on, from version 5.0.0, assigns sourcetype WinEventLog to classic channels and XmlWinEventLog to XML ones and keeps the channel in the source field, so check which form your own index uses before you paste a search in.

That agreement is the rule in the last row of the first card, and the course holds every detection to it. A detection that returns six rows in one SIEM and five in the other has a translation fault in it, usually a field name, and finding that fault on a quiet month is far cheaper than finding it during an incident.

The generator that writes this lesson runs both queries against the corpus and stops if their rows differ by time, group or operator, and every detection block from Module 1 onward has passed the same check. When you write your own detections in two languages, the same comparison is the quickest test you have.

5

With No SIEM: PowerShell

detect: reading the controller directly

PowerShell's surface reads the event where it was written. Get-WinEvent takes a filter hashtable that names the log and the event numbers, so the controller does the filtering and returns only the matching records; the loop then turns each record's XML into an object with the same three fields the SIEM rules project. Shown as typed on an analyst workstation, with its output produced from NE-DC01's exported account management events:

PS C:\> $ev = foreach ($dc in "NE-DC01", "NE-DC02") {
>>     Get-WinEvent -ComputerName $dc -FilterHashtable @{ LogName = "Security"; Id = 4728, 4732, 4756 } | ForEach-Object {
>>         $d = @{}; ([xml]$_.ToXml()).Event.EventData.Data | ForEach-Object { $d[$_.Name] = $_.InnerText }
>>         [pscustomobject]@{ Time = $_.TimeCreated.ToUniversalTime(); Controller = $dc; Id = $_.Id; Group = $d.TargetUserName; Member = $d.MemberName; Operator = $d.SubjectUserName } } }
>> $ev | Sort-Object Time | Select-Object @{ n = "Time"; e = { $_.Time.ToString("yyyy-MM-dd HH:mm:ss") } }, Controller, Id, Group,
>>     @{ n = "Member"; e = { ($_.Member -split ",")[0] -replace "^CN=" } }, Operator | Format-Table -AutoSize
Time                Controller   Id Group                Member     Operator
----                ----------   -- -----                ------     --------
2026-02-18 09:32:03 NE-DC01    4728 Finance-Share-RW     r.crompton a-mwebb
2026-02-19 14:31:45 NE-DC01    4728 VPN-Users            e.bevan    a-pgreaves
2026-02-20 13:05:57 NE-DC01    4728 Engineering-Share-RW q.fenwick  a-mwebb
2026-02-24 16:20:50 NE-DC01    4728 VPN-Users            a.davis    a-pgreaves
2026-03-10 16:29:41 NE-DC01    4728 Finance-Share-RW     h.crompton a-pgreaves
2026-03-11 11:47:45 NE-DC01    4728 VPN-Users            i.jarvis   a-pgreaves

The same six rows again, now with the member shortened to its account name, because the distinguished name's first part is the useful one at a terminal. The export holds eighteen events, the six group changes and the twelve password resets NE-DC01 recorded, and the filter returned only the six, which is the filter hashtable doing what the where line did in KQL.

Where the filter runs matters. The filter hashtable is handed to the event log service on the controller, which returns only the matching records, so the rule moves six events across the network rather than every Security event the controller holds.

The same rule written as Get-WinEvent piped to Where-Object would read the whole log first and discard almost all of it, which on a controller writing thousands of events an hour turns a query of seconds into one of minutes, and the results are identical either way.

When a lesson shows a PowerShell detection, the filtering that the log service can do is always in the hashtable, and Get-WinEvent offers more than one way to put it there:

Where Get-WinEvent does its filtering

Microsoft's Get-WinEvent reference

-FilterHashtable

the log name, event numbers and a time window, selected by the controller's event log service; only matches cross the network

Get-WinEvent | Where-Object

every record is read and sent, then discarded on the analyst host; correct, and slow on a busy controller

-FilterXml

the same selection at the source in the event log's XML query form, for conditions on EventData values

-Path

an exported .evtx file instead of a live log, which is how a log from a domain you don't administer reaches you

The PowerShell form has limits the SIEM forms don't. Get-WinEvent is available only on Windows, and its -ComputerName parameter takes one computer at a time, so the rule loops over the controllers by name, and it needs the event log service reachable through each controller's firewall. It also reads only what the log still holds, and a busy controller's Security log can roll over in days.

Those limits are also its strength. It needs no agent, no workspace and no index, so it works on the day a team discovers the controllers' events were never shipped, and it works on an exported file from a domain you don't administer, which is how this course's evidence reaches you.

An estate with many controllers and no SIEM usually adds Windows Event Forwarding, which forwards the events you choose from every controller to a Windows Event Collector server that logs them locally, and then the same rule runs once against the collector's log instead of looping over the controllers.

6

Written Once: Sigma

detect: a rule that compiles

Sigma describes the rule rather than searching for it. A Sigma rule is a YAML file with a log source, made of a product, a service and sometimes a category, a detection section of named selections, and a condition that combines them. The rule for this lesson names the Windows Security log and the three event numbers, and nothing else:

title: Member Added to a Security Group on a Domain Controller
id: 9e2d4b71-3a6c-4f05-8b1e-6c7a0d3f2e58
status: experimental
description: A domain controller recorded a member added to a security-enabled group, global (4728), domain local or builtin (4732) or universal (4756). The simple form of the rule, with no watched list and no exclusions, shown in lesson 0.5 of Defending Active Directory beside its KQL, SPL and PowerShell forms; lesson 11.3's ADS-DET-1104 narrows it to the groups that grant access and the operators outside tier 0.
references:
    - https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/audit-security-group-management
    - https://attack.mitre.org/techniques/T1098/007/
author: Ridgeline Cyber
date: 2026-10-11
tags:
    - attack.persistence
    - attack.privilege-escalation
    - attack.t1098.007
logsource:
    product: windows
    service: security
detection:
    selection:
        EventID:
            - 4728
            - 4732
            - 4756
    condition: selection
falsepositives:
    - Every routine membership change an administrator makes; on its own this rule is an inventory of changes, not an alert
level: low

The rule carries its own metadata: the ATT&CK technique it detects, T1098.007, Additional Local or Domain Groups, the references it rests on, and a false positive note that says plainly what this simple form is. That metadata travels with the rule into whichever repository you keep detections in, which is a large part of why teams adopt Sigma for detections they maintain over years.

Two fields describe the rule's state rather than its logic. Status experimental says the rule hasn't yet been through a team's testing, and level low says that a match, on its own, is information rather than an incident; the narrowed rule in lesson 11.3 carries level high, because every row it returns is a change nobody approved.

The id is a fixed identifier, so a rule renamed or rewritten over the years is still recognizably the same rule in a SIEM's alert history. Converting it for Splunk with sigma-cli and the Splunk Windows pipeline:

PS C:\Detections> sigma convert -t splunk -p splunk_windows .\ads-det-005-member-added-to-security-group.yml
Parsing Sigma rules
source="WinEventLog:Security" EventCode IN (4728, 4732, 4756)

The converter turns the log source into source="WinEventLog:Security" and the selection into EventCode IN (4728, 4732, 4756). Pasted into the course's Splunk index it returns nothing, because this index names the Security log in sourcetype and carries no source field; change that one word and the search finds the same six events.

That is the conversion working as designed: the pipeline encodes one estate's conventions, and yours decide whether they fit. The output also shows what Sigma leaves to you. There is no index, no restriction to controllers, no table and no renaming, because those depend on your estate rather than the rule.

A converted Sigma rule is a starting search, and the course's Sigma files are written to convert cleanly with exactly this command, so the step from rule to working search is small.

7

Why the Course Keeps All Four

decide: which surface you reach for

Writing every detection four times costs the course effort, and it's worth saying what that buys you. The answer is that the surface you use isn't your choice alone: your employer's SIEM chooses the first, an incident can take it away, and a detection you write today may need to run somewhere else next year. The decision as a practitioner meets it:

Which surface to reach for Do the controllers' events reach Sentinel? SecurityEvent, or Defender XDR tables KQL the form every lesson shows first Do they reach Splunk? a WinEventLog index SPL the same rows, Splunk field names Do they reach another SIEM? Elastic, QRadar, Chronicle and the rest Sigma, converted a pySigma backend for that SIEM PowerShell on the controllers Get-WinEvent, or an exported log yes yes yes no no no Every branch ends in a rule that runs. The last one is also where an investigation lands when the SIEM never got the events.

The first two branches are where most readers stop, and the course writes KQL and SPL with equal care so that neither reader is translating. The third branch is Sigma's reason to exist: a team on Elastic, QRadar or Chronicle converts the rule with that SIEM's backend instead of rewriting it.

The last branch matters more than its position suggests. An investigation often begins with the discovery that the controllers' events never reached the SIEM, or that the SIEM's retention ended before the intrusion did, and then PowerShell on the controllers or on an exported log is the only surface left.

Each surface also has a cost the course designs around, and knowing them is part of choosing well:

What each surface costs you

the trade-offs the course works around

KQL

only as complete as the data collection rule that ships the events; a subcategory the agent drops never becomes a row

SPL

field names depend on the add-on version and the sourcetype it assigns, so a search copied between estates can return nothing without an error

PowerShell

Windows only, one computer per call, and remote reads need the event log service reachable through the firewall

Sigma

a description, not a search: a backend and a pipeline decide the field names, and the output still needs your index and your table

Defender for Identity sources

exist only in its own tables, so those detections are written in KQL and SPL and say so

The last row is the one exception to four surfaces. Some evidence exists only in Defender for Identity's own tables, which record directory queries and authentication as its sensors see them, and has no Windows event behind it. Those detections are written in KQL and SPL only, and the lesson that writes one says so, rather than inventing a PowerShell or Sigma form that would read something else.

Most rules are written four times; the few that compare a month against a baseline or read the directory's own state use the surfaces that can do that, and their entries say why. The course's detection pack in lesson 19.1 ships each rule in every surface its source allows.

8

From a Simple Rule to the Course's Rule

verify: what narrowing removes

A simple rule is the right place to learn the surfaces and the wrong place to stop. Before narrowing it, practice reading it a level up: count the month's changes by group, which is the summary a monthly review starts from.

Three groups, and not one of them grants administration. That observation is the whole of the next step.

The course's own version of this rule, built in lesson 11.3 from an attack that abuses group membership, adds two conditions: the group must be on a watched list of the groups that grant something, and the operator must be someone other than a tier 0 administrator. The month seen through each condition in turn:

From every event to the rows that need a person 22,051 Security events in the month NE-DC01, NE-DC02 and NE-RODC01 21,877 on the two writable controllers where group changes are written 6 members added to a security group this lesson's rule, ADS-DET-005 0 to a watched group, by an operator outside tier 0 lesson 11.3's rule, ADS-DET-1104 The simple rule finds every change. The course's rule finds the ones nobody approved, and in this month there were none.

The funnel runs from every Security event the three controllers recorded to the rows a person should look at, and the narrowed rule returns none of the six, because each was a routine change by the right people to a group that grants nothing dangerous. That empty result is not a failure.

A detection that is silent through a quiet month and fires on the change nobody approved is the goal of every detection in this course, and the lessons ahead each start from a simple form like this one, prove it in KQL and SPL against the corpus, and narrow it until its rows are the ones worth a person's time.

The finished rule, as every lesson's detection block will show it:

SecurityEvent
| where EventID in (4728, 4732, 4756) and Computer startswith "NE-DC"
| project TimeGenerated, Computer, EventID, Group = TargetUserName, Member = MemberName, Operator = SubjectUserName
| sort by TimeGenerated asc
index=wineventlog sourcetype="WinEventLog:Security" EventCode IN (4728, 4732, 4756) host="NE-DC*"
| table _time, host, EventCode, Group_Name, Member_Name, src_user
| rename Group_Name AS Group, Member_Name AS Member, src_user AS Operator
| sort _time
$ev = foreach ($dc in "NE-DC01", "NE-DC02") {
    Get-WinEvent -ComputerName $dc -FilterHashtable @{ LogName = "Security"; Id = 4728, 4732, 4756 } | ForEach-Object {
        $d = @{}; ([xml]$_.ToXml()).Event.EventData.Data | ForEach-Object { $d[$_.Name] = $_.InnerText }
        [pscustomobject]@{ Time = $_.TimeCreated.ToUniversalTime(); Controller = $dc; Id = $_.Id; Group = $d.TargetUserName; Member = $d.MemberName; Operator = $d.SubjectUserName } } }
$ev | Sort-Object Time | Select-Object @{ n = "Time"; e = { $_.Time.ToString("yyyy-MM-dd HH:mm:ss") } }, Controller, Id, Group,
    @{ n = "Member"; e = { ($_.Member -split ",")[0] -replace "^CN=" } }, Operator | Format-Table -AutoSize
title: Member Added to a Security Group on a Domain Controller
id: 9e2d4b71-3a6c-4f05-8b1e-6c7a0d3f2e58
status: experimental
description: A domain controller recorded a member added to a security-enabled group, global (4728), domain local or builtin (4732) or universal (4756). The simple form of the rule, with no watched list and no exclusions, shown in lesson 0.5 of Defending Active Directory beside its KQL, SPL and PowerShell forms; lesson 11.3's ADS-DET-1104 narrows it to the groups that grant access and the operators outside tier 0.
references:
    - https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/audit-security-group-management
    - https://attack.mitre.org/techniques/T1098/007/
author: Ridgeline Cyber
date: 2026-10-11
tags:
    - attack.persistence
    - attack.privilege-escalation
    - attack.t1098.007
logsource:
    product: windows
    service: security
detection:
    selection:
        EventID:
            - 4728
            - 4732
            - 4756
    condition: selection
falsepositives:
    - Every routine membership change an administrator makes; on its own this rule is an inventory of changes, not an alert
level: low

Four tabs, one rule. From Module 1 onward the block looks like this in every attack lesson, opening on KQL, and the rows each tab returns on the corpus are the rows the lesson's prose describes.

Practice

Do this One rule, four times, in your estate
  1. Find the event. Read one 4728 from a controller's Security log with Get-WinEvent and note the five fields in this lesson's figure.
  2. Write it in your SIEM. Run the KQL or the SPL form against your own data for the last 30 days and count the rows.
  3. Write it in the other one. If your estate has only one SIEM, run the same rule as PowerShell on a controller and compare the rows with your SIEM's.
  4. Convert the Sigma rule. Download the rule, convert it with sigma-cli for your SIEM, and add whatever the converter leaves out: index, table, sort.
  5. Read the result. Every row is a change somebody made. For each, name who approved it; a row nobody can name is the start of lesson 11.3.
What you should end up with: the same membership changes from two surfaces that agree, a converted Sigma rule you have run, and a list of the month's group changes with an owner beside each.

Next, lesson 0.6: what the course builds, module by module, and the tools, resources and project you'll finish with.