In this section

0.4 One Detection, Four Artifacts

Module 0

Introduction

A detection isn't one file. The query is the part that runs, but a query alone can't say what it's for, which attack it is meant to catch, what ordinary work it must leave alone, or how anyone would know it still works next month. Those belong to the detection too, and when they are missing, every person who touches the rule has to rediscover them.

This course writes every detection as a set of artifacts that agree with each other: a specification of what it catches and must not, the query in KQL for Sentinel and Defender XDR, the same logic in SPL for Splunk, and the tests that prove each form does what the specification says.

Where a detection is meant to travel, as in Module 7 and from Module 14 onward, a Sigma rule carries it to any other platform. This lesson walks through one detection from the course's library in all of them.

One detection, the artifacts it is written as The specification what it catches, and must not KQL Sentinel and Defender XDR SPL Splunk Sigma portable, then converted The tests one test set, every form The specification says what all three forms must do; the tests say whether each one does.

The figure is the order of authority. The specification decides; every form answers to the tests.

1

The Situation: A Persistence Rule Two Platforms Need

situation: one behavior, more than one SIEM

The course writes every rule for Sentinel and for Splunk, because detection teams work in either and many organizations run both, a SIEM of their own and another at a managed service.

A rule that exists in one language is rewritten by hand when it moves, and a hand-written second copy drifts from the first as soon as either is changed. The detection here is one of the library's: a scheduled task that runs a program from a folder an ordinary user can write to.

The behavior is common in intrusions for a simple reason. A program in the user's profile or in Temp can be dropped without administrative rights, and a scheduled task makes Windows start it again at each logon or on a timer, so the attacker's foothold survives a reboot.

Windows starts scheduled tasks through the Task Scheduler service, which runs inside svchost.exe, and Defender for Endpoint records each program the service starts, with svchost as the parent and the service's own command line, ending in -s Schedule, as the parent's command line.

That gives the rule something precise to key on: the service as the parent, and a folder path that ordinary software rarely runs from. The corpus holds one case of it and one look-alike:

attack      A logon task runs a binary from the user's Temp folder
            ne-lewis-lt, odupd.exe, from 26 February
look-alike  Chrome installs its machine-wide update task
            ne-pemberton2-lt, GoogleUpdate.exe, from 5 March

The attack is a persistence, not a single event: the task ran the program from r.lewis's Temp folder again and again for more than two weeks, each run a new chance to be seen and each one missed by the rules Northgate had. The look-alike is a scheduled task too, created the same way and started by the same service.

What it doesn't do is run from a folder a user can write to. That one difference, Program Files against a folder the user owns, is what every form of the rule has to encode, and the specification is where it is written down first, before any query exists.

2

The Specification: What It Catches and What It Must Not

the rule file's metadata, before its query

The specification lives in the rule's file in the repository, beside the query, so the two can't be separated. The metadata of this rule's file:

title: Scheduled Task Runs a Program From a User-Writable Folder
id: 83e861da-faac-5b71-86eb-c4fe6c7b28b0
status: experimental
description: Detects a program started by the Task Scheduler service from a folder
  a user can write to.
tags:
- attack.execution
- attack.t1053.005
level: medium
falsepositives:
- Per-user updaters installed under the profile, such as browsers
test_set: scheduled-task
preconditions: Defender for Endpoint process events
limitations: A task that runs a system binary with hostile arguments is not caught
  here (lesson 15.3 adds interpreters)
sentinel:
  display_name: Scheduled Task Runs a Program From a User-Writable Folder
  severity: Medium
  enabled: false
  frequency: PT1H
  period: PT1H
  trigger:
    operator: GreaterThan
    threshold: 0
  grouping: AlertPerResult
  tactics:
  - Execution
  techniques:
  - T1053
  incident:
    create: true

Each field answers a question somebody will ask. The id is a fixed identifier, kept when the title changes, and it is what the acceptance file and the deployment both key on. The status says the rule hasn't yet been promoted past experimental. The title and description say what the rule detects in a sentence an analyst can read in a queue.

The tags tie it to ATT&CK, T1053.005, Scheduled Task, so coverage can be counted; the Sentinel block lists the parent technique its deployment sets, and the tag keeps the sub-technique for the course's own counts. The level and the Sentinel severity say how urgent an alert is. The false positives name the look-alike class an analyst should expect, per-user updaters installed under the profile.

The last three fields are the ones most rule files lack, and they are what make this one engineering. The test set names the labeled cases the rule is judged on, so "does it work" has a mechanical answer.

The preconditions name the data it needs, Defender for Endpoint's process events, so a silent rule can be traced to a missing source. And the limitations say what it doesn't catch: a task that runs a system binary with hostile arguments, which a later rule adds.

The schedule belongs here too. The rule runs hourly over the previous hour and alerts on any row, one alert per row, and it ships disabled, so deploying the file doesn't switch it on until someone has reviewed it in the workspace. Each of those is a decision Module 9 explains, and having them in the file means a reviewer sees them rather than finding them later in the portal.

3

The KQL: The Rule as Sentinel Runs It

the query, and what it returns on the month

The query is short because the specification did the thinking:

DeviceProcessEvents
| where InitiatingProcessFileName =~ "svchost.exe"
    and InitiatingProcessCommandLine has "-s Schedule"
| where FolderPath matches regex @"(?i)\\(users|programdata|temp)\\"
| project Timestamp, DeviceName, AccountName, FileName, FolderPath,
    ProcessIntegrityLevel, ProcessCommandLine

The first filter finds programs the Task Scheduler service started, by the parent's name and its command line together, because svchost.exe runs dozens of services and only the one ending in -s Schedule is the scheduler. The second keeps those whose path runs through a user profile, ProgramData or a Temp folder, matched as a case-insensitive regular expression, since Windows paths are not case-sensitive and attackers don't type them consistently.

Matching the parent by both name and command line is the precision that matters most.

A rule that took any child of svchost.exe would alert on hundreds of service starts a day, and a rule that took any program from a user folder would alert on every application a user runs from Downloads. Neither half is unusual on its own; the two together are, because the scheduler starting a program the user could have written is what persistence looks like.

The projection then keeps the columns an analyst needs to decide without opening another query: the machine, the account, the program, its path, its integrity and the command line it was started with.

Summarized by machine and path, the month's results are a single program:

DeviceProcessEvents
| where InitiatingProcessFileName =~ "svchost.exe"
    and InitiatingProcessCommandLine has "-s Schedule"
| where FolderPath matches regex @"(?i)\\(users|programdata|temp)\\"
| summarize Runs = count(), Earliest = min(Timestamp), Latest = max(Timestamp)
    by DeviceName, FolderPath, ProcessIntegrityLevel

The one row it returns, which is one program on one laptop:

ne-lewis-lt.corp.ne.com  C:\Users\r.lewis\AppData\Local\Temp\odupd.exe
12 runs, High integrity, 02-26 15:15 to 03-13 06:33

Twelve runs of one program from r.lewis's Temp folder, once a day or so from 26 February to 13 March, every one at high integrity. That last detail matters for triage: a task registered to run with the highest privileges from a folder the user owns is a privilege boundary being crossed, and the rule's projection keeps the integrity level so the analyst sees it without a second query.

Every task the scheduler started in the month, for comparison:

 12  C:\Users\r.lewis\AppData\Local\Temp\odupd.exe
  9  C:\Program Files (x86)\Google\Update\GoogleUpdate.exe
  5  C:\Windows\System32\wevtutil.exe

The other tasks run from Program Files or System32, the update task among them. The rule leaves them alone by construction, not by exclusion, which is why it needs no list of approved programs to stay quiet.

4

The SPL: The Same Logic for Splunk

the same fields, and a regular expression

Defender's events reach Splunk with their advanced hunting field names, so the SPL can read the same fields the KQL does:

index=defender category="AdvancedHunting-DeviceProcessEvents"
    InitiatingProcessFileName="svchost.exe"
    InitiatingProcessCommandLine="*-s Schedule*"
| regex FolderPath="(?i)\\\\(users|programdata|temp)\\\\"
| table _time DeviceName AccountName FileName FolderPath
    ProcessIntegrityLevel ProcessCommandLine

The parent's name and command line are field matches in the base search, which Splunk evaluates as it reads events, and the folder test is a regex command after it.

Splunk's regex is case-sensitive unless told otherwise, so the pattern opens with (?i), and the backslashes are doubled once for the search language and again for the regular expression, which is the easiest place in a translation to make a mistake that still parses.

It returns the same 12 rows at the same times as the KQL, which is the first test any translation must pass. A rule that returns different rows in each language isn't one rule, and the difference usually turns out to be a field name, a case rule or an escape.

Agreement on the rows is also a check on the times. Both forms return the same twelve timestamps, so a scheduled rule built from either would see each run in the same hour, and an alert raised in Sentinel and one raised in Splunk would point at the same events.

The two forms agree here because the data is the same data with the same names. When they can't share field names, a Sysmon-based Splunk estate where the parent is ParentImage and the path is Image, say, the logic stays the same and the names change, and that translation is exactly what Sigma exists to do once rather than by hand for every rule.

Hand-written SPL is still worth having beside a converted form. It is the version a Splunk engineer reads and maintains, it can use Splunk's own idioms where a converter writes the lowest common form, and its agreement with the KQL is a check on both.

5

The Sigma Rule: The Logic Without a Platform

a rule any SIEM can be given

Sigma is a generic rule format: it describes what to match in terms of a kind of log and generic field names, and leaves the table and field names of a particular SIEM to a converter. The same detection as a Sigma rule:

title: Scheduled Task Runs a Program From a User-Writable Folder
id: 29bcce9b-1df6-57d4-895c-8257ecc59fca
status: experimental
description: >-
  Detects a program started by the Task Scheduler service from a folder a user
  can write to.
author: Ridgeline Cyber
date: 2026-03-16
tags:
  - attack.execution
  - attack.t1053.005
logsource:
  category: process_creation
  product: windows
detection:
  selection_parent:
    ParentImage|endswith: '\svchost.exe'
    ParentCommandLine|contains: '-s Schedule'
  selection_folder:
    Image|contains:
      - '\Users\'
      - '\ProgramData\'
      - '\Temp\'
  condition: all of selection_*
falsepositives:
  - Per-user updaters installed under the profile, such as browsers
level: medium

The logsource says this reads process creation on Windows, whichever product records it. That category is Sigma's own taxonomy: on one estate it is Sysmon's event 1, on another the Security log's 4688, on Northgate's Defender's DeviceProcessEvents, and the rule doesn't need to know which, because choosing is the pipeline's job. The detection has two selections, the parent and the folder, and the condition requires all of them.

ParentImage, ParentCommandLine and Image are Sigma's names for the parent's path, the parent's command line and the new process's path; endswith and contains are modifiers, and Sigma matches strings without regard to case unless a rule says otherwise. The folder test is a list of contains, which the converter turns into an or.

The Sigma rule is checked before it is converted, with sigma check from sigma-cli, which validates the structure, the modifiers and the condition:

Found 0 errors, 0 condition errors and 0 issues.

The rule passes.

The check runs with the tag validators that download ATT&CK and D3FEND data switched off, as the repository's pipeline runs it in lesson 7.5, and the tags are checked instead by the course's Quality Checker, which lesson 5.6 introduces, against its own copy of ATT&CK. sigma check catches a misspelled modifier, a condition naming a selection that doesn't exist, or a duplicate id, each of which would otherwise surface as a converter error or, worse, a query that converts and matches nothing.

Its folder test is slightly different from the KQL's regular expression, three substrings rather than one pattern, and that is deliberate: Sigma's modifiers convert to every backend, while a regular expression's flavor differs between them. On this estate the two tests select exactly the same events, which section 7 proves rather than assumes.

6

Do: Convert the Sigma Rule for Each Platform

sigma-cli, a backend and a pipeline

Conversion is where a portable rule becomes a working one, and where most of its errors appear.

A converter needs two things: a backend, which writes the query language, and a processing pipeline, which maps Sigma's generic names to the SIEM's. sigma-cli drives both from the command line, and backends and pipelines install as plugins, so the same command converts for a different SIEM by naming a different pair. Two conversions, one per platform:

sigma convert -t kusto -p microsoft_xdr task-runs-user-folder.yml
sigma convert -t splunk -p northgate-defender-splunk.yml task-runs-user-folder.yml

For Defender XDR, the microsoft_xdr pipeline maps process creation to DeviceProcessEvents, ParentImage to InitiatingProcessFolderPath and Image to FolderPath:

DeviceProcessEvents
| where (InitiatingProcessFolderPath endswith "\\svchost.exe"
    and InitiatingProcessCommandLine contains "-s Schedule")
| where (FolderPath contains "\\Users\\"
    or FolderPath contains "\\ProgramData\\"
    or FolderPath contains "\\Temp\\")

For Splunk, there is no public pipeline for Defender's events in this layout, so lesson 7.4 wrote one for Northgate, mapping each Sigma name to its Defender field and adding the index and category.

This rule needs one mapping that lesson didn't, ParentCommandLine, added here. Everything else is the same file, which is the point of a pipeline: it is written once for an estate's data, and every rule that reads process creation converts through it:

name: Northgate Defender process events in Splunk
priority: 30
transformations:
  - id: defender_process_fields
    type: field_name_mapping
    mapping:
      Image: FolderPath
      OriginalFileName: ProcessVersionInfoOriginalFileName
      CommandLine: ProcessCommandLine
      ParentImage: InitiatingProcessFolderPath
      ParentCommandLine: InitiatingProcessCommandLine
      User: AccountName
    rule_conditions:
      - type: logsource
        category: process_creation
        product: windows
  - id: defender_process_source
    type: add_condition
    conditions:
      index: defender
      category: AdvancedHunting-DeviceProcessEvents
    rule_conditions:
      - type: logsource
        category: process_creation
        product: windows

The converted Splunk search, written with that pipeline from the same rule:

index="defender" category="AdvancedHunting-DeviceProcessEvents"
    InitiatingProcessFolderPath="*\\svchost.exe"
    InitiatingProcessCommandLine="*-s Schedule*"
    FolderPath IN ("*\\Users\\*", "*\\ProgramData\\*", "*\\Temp\\*")

Both converted queries return the same 12 rows as the hand-written forms. Notice what conversion changed and what it didn't.

The parent is matched by its full path ending in svchost.exe rather than by file name, which is equivalent here and would differ if an attacker named a program svchost.exe in another folder. The folder test is three contains clauses rather than a regular expression. The logic survived; its expression is the converter's.

That is the trade Sigma offers. Written once, a rule reaches every SIEM a pipeline exists for, and a pipeline is written once per estate rather than once per rule. In exchange, the converted query is a little less idiomatic than one written by hand, which is why the course keeps both and tests both.

7

Do: Test Every Form on the Same Set

four queries, one set of cases

The specification says what every form must do: catch the Temp folder task, stay quiet on Chrome's update task, and return nothing else. The two KQL forms, scored on the test set:

Test runOne detection in every formtest set scheduled-task, scored on the labeled corpus
Candidate rule
the Temp folder taskmust alert
Chrome's update taskmust stay quiet
Other rowsallowed: 0
Result
The rule file's KQLFolderPath matches regex on the folder
Alerted
Quiet
0
Meets
Sigma, converted for Defender XDRFolderPath contains, from the Sigma rule
Alerted
Quiet
0
Meets
Every cell was scored when this page was built. The Splunk forms are scored on the same set in the text below.

The Splunk forms are scored on the same set, the hand-written search and the converted one. Each catches the case with 12 rows, flags no look-alike and returns no other rows, the same result as the KQL. Four queries, two languages, one verdict, and every one of them was run rather than read.

A score on a test set is the logic test. The repository holds the bar each rule must meet, in tests/acceptance.yml, keyed by the rule's id:

83e861da-faac-5b71-86eb-c4fe6c7b28b0:   # de10-3-task-runs
  recall: 1
  lookalikes: 0
  other: 0

The repository's own tools run the rule file the way its pipeline does on every pull request. Validate checks the file against the schema; the test runs the query on its test set and then again as Sentinel's scheduler would run it, from the frequency, lookback and trigger in the file:

RULE=rules/windows/scheduled-task-runs-a-program-from-a-user-writable-folder
python3 tools/validate.py $RULE
python3 tools/test_rules.py $RULE

The validator passes the file, and the test prints:

ok    rules/windows/scheduled-task-runs-a-program-from-a-user-writable-folder/rule.yml
      logic      ok   recall 1, look-alikes 0, other 0  (scheduled-task)
      schedule   ok   recall 1, look-alikes 0, other 0  (707 runs, 12 alerted)
1 rules, 0 failed

The second line is the one a query alone can't give you. It is also where a rule's operational cost shows. Twelve alerts for one persistence is a rule doing its job and a queue doing the same work twelve times, once a day for the same task on the same laptop.

Grouping alerts by host, or suppressing a repeat for a day, is a deployment decision Module 9 makes with the specification in hand, and the test run is where the count that informs it comes from.

Run on its schedule, 707 hourly runs across the month, the rule alerted 12 times and still met its bar, so the schedule in the specification is one the logic can live with. Lesson 17.3 builds this test, and Module 17's pipeline refuses a change that fails it.

8

Verify: Why the Forms Agree, and When They Wouldn't

verify: what the tests prove, and what they don't

Every form passed, and it's worth being exact about why, because the agreement is a result of three things the estate gave the rule, not a property of the rule. The KQL and SPL read the same fields with the same names, because Defender's events carry their advanced hunting names into Splunk.

The regular expression and the three contains clauses select the same events, because no task at Northgate runs from a path that one test matches and the other doesn't. And the parent's name and its full path identify the same process, because nothing else on these laptops is called svchost.exe.

Change the estate and each of those can break independently. A Splunk that receives Sysmon rather than Defender's events needs a different pipeline, and the hand-written SPL needs different field names.

A program run from a path such as C:\Users-Shared\, if one existed, would match one folder test and not the other. The tests catch each of these the moment they happen, provided the tests are run, which is why the course puts them in the repository rather than in a lesson.

The tests also have limits. They prove the rule catches this case and stays quiet on this look-alike; they don't prove it catches every scheduled task an attacker could create.

The specification's limitations field says so in a sentence, a system binary run with hostile arguments isn't caught, and Module 15 adds cases that probe further. A passing test is evidence about the cases in the set, and the set is a claim about which cases matter.

Keeping all four forms costs something, and the course pays that cost deliberately, for every rule it builds. Each form is another file to change when the logic changes, and the tests are what make that affordable: a change to the specification is made once, in every form, and the same test set says at once whether every form still agrees.

Without the tests, four forms would be four chances to drift.

That is the whole shape of a detection in this course: a specification that decides, forms that implement it, tests that check every form against the same cases, and a repository that runs those tests on every change. Modules 5, 6, 7, 15 and 17 each build one part of it in depth.

Practice

The detection in this lesson is the model for the task: one behavior, written in every form and tested on the same cases.

Do this Write one detection in every form
  1. Start from the specification. Title, the behavior and its technique, the data it needs, the test set, the look-alike it must stay quiet on, and its limits.
  2. Write the KQL and the SPL. The same logic in each, reading the same fields; run both and confirm they return the same rows.
  3. Write the Sigma rule. The logsource and the selections, then sigma check, then convert it for each SIEM you run.
  4. Score every form on the test set. The native queries and the converted ones, malicious case and look-alike, with the acceptance criteria written down.
  5. Put it in the repository. The rule file, its acceptance entry, and a test run on the schedule, so the next change is tested the same way.
What you should end up with: one detection as a specification, KQL, SPL and Sigma, every form scored on the same test set, and the rule file with its acceptance entry ready to review.

Next, lesson 0.5 lays out the course: the nineteen modules and their phases, the two kinds of lesson, the tools, the resources, the Project and the exam.