Skip to content
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after any top-up. Until Nov 30.

How it works

Writing a Sigma Rule: Your First Detection, Tested

Write a Sigma rule for failed Windows logons, validate it, convert it to Splunk, and test it against known-good and known-bad events.

Intermediate14 min readBy NotHoly

What a Sigma rule is

Sigma is an open, vendor-neutral format for describing a detection. You write the logic once, in a small YAML file, and a converter turns it into the query language of the SIEM or log platform you actually run. Splunk, Elastic, Microsoft Sentinel and other backends all have converters, and the same rule can target several of them.

The format is maintained in the SigmaHQ project. The specification lives in the sigma-specification repository, and the rules documentation on sigmahq.io explains the fields one at a time. If you can read a Windows event, you already know the hard part. A Sigma rule is only a way to say "this event, with these field values" in a form that other people and other tools can read.

Why it matters

Most detection logic is written directly in one product's query language. A Splunk search does not run in Sentinel, and a KQL query does not run in Splunk. When a team moves platforms, or when a community shares a detection, the logic has to be rewritten from scratch. That is slow, and the rewrite introduces mistakes.

Sigma separates the detection logic from the query language. The rule describes the log source and the condition. The backend turns that into a query. Two consequences follow:

  • A rule can be shared, reviewed and versioned as text, the same way you review code.
  • The same rule can be tested against sample events before it reaches a production SIEM.

The second point is the one this article is about. A Sigma rule that parses and converts is not finished. It is only syntactically correct. It becomes useful when you can show that it fires on the events it should fire on and stays quiet on the events it should ignore.

The parts of a rule

Here is the rule you will build in the lab, with each field explained.

yaml
title: Failed Logon Event
id: bd37e49d-15ab-4e88-a3fb-53594e831b27
status: experimental
description: Detects Windows Security event 4625 (an account failed to log on).
logsource:
    product: windows
    service: security
detection:
    selection:
        EventID: 4625
    condition: selection
falsepositives:
    - Users mistyping passwords
level: low
tags:
    - attack.credential-access
    - attack.t1110

Your UUID will be different. Generate your own with any UUID library, for example python -c "import uuid; print(uuid.uuid4())", and keep it when you edit the rule later. The id is how other tools track the same rule across versions.

  • title is a short human name. It appears in alerts and in rule lists.
  • id is a UUID that identifies the rule, not the content. Keep it stable when you change the logic.
  • status tells readers how much to trust the rule. The allowed values are stable, test, experimental, deprecated and unsupported. A new rule that has not been tested in a real environment belongs under experimental.
  • description says what the rule looks for, in one or two sentences.
  • logsource says where the events come from. product: windows and service: security point at the Windows Security event log. Other fields, such as category, let you describe a class of logs, for example process creation, without naming the exact channel.
  • detection holds the matching logic. Each named block, here selection, is a set of field conditions. Field names are the ones the log source uses. Values are matched against those fields.
  • condition says how the blocks combine. selection alone means "events that match the selection block." Later you will add filter blocks and combine them with and and not.
  • falsepositives lists the benign cases that will also match. Writing this down is part of the rule, not an afterthought.
  • level is the severity a rule gets when it fires. The allowed values are informational, low, medium, high and critical. Pick the level based on what a hit means for your environment, not on how exciting the technique sounds.
  • tags map the rule to frameworks. The attack. prefix points at a MITRE ATT&CK tactic, and attack.t1110 points at the technique T1110, Brute Force.

Two notes on this rule. First, EventID is the field name in the Sigma world, and it refers to the Event ID you read in Event Viewer. Second, level: low is a choice. A single failed logon is common. The rule as written fires on every 4625, so a low level is the honest starting point until you add a threshold.

Mapping Sigma to a SIEM query language

Sigma does not know your SIEM. A backend does. The converter reads the detection, applies a processing pipeline that renames fields and adds index or source restrictions, and writes a query in the target language.

A processing pipeline is a set of mapping rules. It answers questions such as: what is the Splunk field name for the Event ID, which index holds Windows Security events, and what sourcetype should the query search. Without a pipeline, a converter may output the Sigma field name as written. That output is often not usable on the platform you run.

Each backend has its own pipelines. Some are required, and the converter refuses to continue without one unless you explicitly ask it to skip the pipeline. You will see this in the lab.

The idea is simple: the rule stays the same, and the pipeline adapts it to the platform. When you move from Splunk to another SIEM, you change the backend and the pipeline, not the detection.

Hands-on lab: write, check and convert the rule

The lab runs on your own machine. You do not need a domain, a domain controller or any target system. You are writing and converting a rule. You are not generating failed logons against anything, and you should not try to do that against a system you do not own or have written permission to test.

This lab was run on Windows 11 with Git Bash, Python 3.12, and the default Windows Security log. The commands are the same on Linux and macOS, except for the path to the virtual environment.

Step 1: create a virtual environment and install sigma-cli

Create a folder for the lab, then run:

bash
python3 -m venv .venv
. .venv/bin/activate
pip install sigma-cli

On Windows with Git Bash, python3 may not be on your path. Use the full path to your Python 3.12 installation, or py -3.12. The virtual environment also puts its scripts in .venv/Scripts instead of .venv/bin, so activate it with:

bash
. .venv/Scripts/activate

Check the install:

bash
sigma version

On the machine used for this article, sigma-cli 4.0.0 installed with pysigma 2.0.2 and Python 3.12.10. Your versions may be newer. The version output is what you should record in your own notes, not the numbers above.

Step 2: write the rule

Create a file called failed_logon.yml with the content shown in the previous section. Generate your own UUID for the id field. Save the file as UTF-8 with no tabs in the YAML indentation.

Step 3: validate the rule

bash
sigma check failed_logon.yml

sigma check parses the file and runs validators. It catches a missing field, a malformed tag, an unknown status or level, and several style issues.

On the machine used for this article, the check step did not complete. The tags validator loads the MITRE ATT&CK data over the network. The request returned an HTTP 504 gateway timeout, and the error repeated on each retry. The output was a traceback ending with RuntimeError: Failed to load MITRE ATT&CK data, and the exit code was 1.

That is a network dependency, not a rule error. You should see a pass result, or a list of validator issues, once the data loads. If the check fails with the same MITRE message, run it again later, or from a network that can reach the upstream data. Do not mark the rule as validated until the check finishes.

If the check reports issues, read each one. Fix the rule, not the validator. An issue about a tag usually means a typo such as attack.t1100 instead of attack.t1110.

Step 4: install the Splunk backend

bash
sigma plugin install splunk

On the machine used for this article, the command printed Successfully installed plugin 'splunk' and pySigma version is compatible with sigma-cli.

Step 5: convert without a pipeline

bash
sigma convert -t splunk --without-pipeline failed_logon.yml

The output was:

text
Parsing Sigma rules
EventID=4625

This is what the rule looks like before any Splunk mapping. The field name is still the Sigma name, EventID. A Splunk search over Windows events usually uses EventCode and a source, so this query would not find the events you want on a standard Windows add-on setup.

Step 6: convert with the Splunk pipeline

Try the same conversion without --without-pipeline:

bash
sigma convert -t splunk failed_logon.yml

The converter stopped and explained that the Splunk backend requires a processing pipeline. The message told the user to add --without-pipeline only if they knew what they were doing. The pipeline is required here, so the converter refuses to produce a query without one. Read that message as a safety feature.

Now add the Windows pipeline:

bash
sigma list pipelines
sigma convert -t splunk -p splunk_windows failed_logon.yml

The list showed three Splunk pipelines: splunk_windows, splunk_sysmon_acceleration and splunk_cim. The conversion with splunk_windows printed:

text
Parsing Sigma rules
source="WinEventLog:Security" EventCode=4625

The pipeline changed two things. It mapped EventID to EventCode, and it added a source restriction to WinEventLog:Security. The result is a query that a Splunk instance with the Windows add-on can run. Your index and sourcetype may differ, so treat this as a starting point and check it against your own data.

Trying the CIM pipeline as well failed with Rule type not yet supported by the Splunk data model CIM pipeline!. Not every rule converts to every pipeline. A failure like that is useful information: the rule or the backend does not fit the mapping you asked for.

Step 7: compare with the event you generated

In the Windows event log article you looked at an event in Event Viewer. Open that event again and compare it with the query:

  • The Event ID field in Event Viewer should be 4625. That is the value the Sigma selection matches.
  • The field name in Event Viewer is Event ID. In the Splunk output it appears as EventCode, because the pipeline maps it to the name the Windows add-on uses.
  • The source in the query, WinEventLog:Security, corresponds to the Security channel you read in Event Viewer.

If your Event Viewer event has an Event ID other than 4625, the rule will not match it. That is correct behavior. The rule is about failed logons, not about every event in the log.

Test the rule

A rule that converts is still a guess until you test it. The minimum test has two halves:

  • Known-bad events. Events the rule must match. For this rule, that means 4625 events.
  • Known-good events. Events the rule must not match. Successful logons (4624), logoffs (4634) and process creation (4688) are good negatives.

Without the negatives, you have no evidence that the rule is specific. Without the positives, you have no evidence that it works at all.

For this lab, the test uses a small fixture file of synthetic events, events.json, with five entries: two 4625 events and three events that should not match. The script loads the rule's selection block and checks each event. It is a simplified test. It shows that the selection logic does what you wrote. It does not replace testing the converted query on real data.

python
import json, sys
import yaml

rule = yaml.safe_load(open("failed_logon.yml", encoding="utf-8"))
events = json.load(open("events.json", encoding="utf-8"))

selection = rule["detection"]["selection"]

def selection_matches(event):
    return all(str(event.get(k)) == str(v) for k, v in selection.items())

failures = 0
for ev in events:
    got = "match" if selection_matches(ev) else "no match"
    ok = got == ev["expect"]
    failures += 0 if ok else 1
    print(f"{'PASS' if ok else 'FAIL'}  {ev['name']:<40} expected={ev['expect']:<9} got={got}")

print(f"{len(events) - failures}/{len(events)} passed")
sys.exit(1 if failures else 0)

The fixture file looks like this:

json
[
  {"name": "known-bad: failed logon", "EventID": 4625, "expect": "match"},
  {"name": "known-bad: failed logon, second try", "EventID": 4625, "expect": "match"},
  {"name": "known-good: successful logon", "EventID": 4624, "expect": "no match"},
  {"name": "known-good: logoff", "EventID": 4634, "expect": "no match"},
  {"name": "known-good: process creation", "EventID": 4688, "expect": "no match"}
]

Run it:

bash
python test_failed_logon.py

On the machine used for this article, the output was:

text
PASS  known-bad: failed logon                  expected=match     got=match
PASS  known-bad: failed logon, second try      expected=match     got=match
PASS  known-good: successful logon             expected=no match  got=no match
PASS  known-good: logoff                       expected=no match  got=no match
PASS  known-good: process creation             expected=no match  got=no match
5/5 passed

Notice what this test shows and what it does not. It shows that the rule matches the events it should match and ignores the ones it should ignore. It does not show that the rule is good. A rule that matched every 4625 would pass this test, because every 4625 in the fixture is supposed to match. The test is useful, but only as a floor.

The next step, in a real environment, is to run the converted Splunk query against a period of Windows Security logs that you know contains failed logons and ordinary activity. Count the results, and open a sample of them. Each hit should be a 4625 event. If the query returns hits for other Event IDs, the pipeline or the field mapping is wrong.

The rule is not finished

This rule fires on every 4625. A single mistyped password produces a hit, and so does a service account with an expired password that retries every minute. A brute-force attempt is many failures against the same account in a short time, and the first version cannot express that.

Sigma handles this with correlation rules, defined in a separate specification. A correlation rule does not match events itself. It points at one or more base rules by name, groups their matches by a field, and applies a count over a time window. The event_count type counts matches, and condition: gte: 5 means five or more. Put both rules in one file, separated by ---, and give the base rule a name:

yaml
title: Failed Logon Event
name: failed_logon_event
id: bd37e49d-15ab-4e88-a3fb-53594e831b27
status: experimental
description: Detects Windows Security event 4625 (an account failed to log on).
logsource:
    product: windows
    service: security
detection:
    selection:
        EventID: 4625
    condition: selection
falsepositives:
    - Users mistyping passwords
level: low
tags:
    - attack.credential-access
    - attack.t1110
---
title: Many Failed Logons For One Account
id: 3f6c1d52-8a0e-4b7e-9d2a-6c41e0b7a915
status: experimental
description: Five or more failed logons for the same target account within ten minutes.
correlation:
    type: event_count
    rules:
        - failed_logon_event
    group-by:
        - TargetUserName
    timespan: 10m
    condition:
        gte: 5
falsepositives:
    - Service accounts with an expired password
level: medium

Save it as multi.yml and convert it with the same pipeline:

bash
sigma convert -t splunk -p splunk_windows multi.yml

The output was:

text
Parsing Sigma rules
source="WinEventLog:Security" EventCode=4625

| bin _time span=10m
| stats count as event_count by _time TargetUserName

| search event_count >= 5

The first line is the base rule. The three lines after it are the correlation: bucket events into ten-minute windows, count them per TargetUserName, keep the groups with five or more. The numbers 5 and 10 minutes are placeholders. Pick them from your own data, and test the result with the same known-good and known-bad events. Not every backend supports every correlation type, so check the converter's output before you rely on it.

Keep the first version of the rule. It is the baseline, and the comparison with the correlated version shows whether the threshold helps.

Common mistakes

  • Writing a rule and skipping the negatives. A rule with only positive tests can match everything and still pass. Add known-good events on purpose.
  • Naming the field after the Event Viewer label. Sigma uses EventID. Splunk uses EventCode after the pipeline. Mixing the two names in one rule causes silent misses.
  • Converting without a pipeline and trusting the output. --without-pipeline is a deliberate flag. It is not a shortcut. The output may not run on your platform.
  • Using level: high for a noisy rule. Severity should match what a hit means. A common event with no context should start low.
  • Forgetting the falsepositives field. If you cannot name a benign case, you probably have not thought about the environment well enough yet.
  • Changing the id when you edit the logic. The id should identify the rule over time. A new rule gets a new id. An edited rule keeps its id.
  • Treating a passed check as proof. sigma check tests the file's structure. It does not test whether the detection finds the right events.

How to go further

Once you have a rule that checks, converts and passes its tests, the next steps are straightforward:

  • Tune the correlation rule against real data: change the threshold and the time window, and compare hit counts and false positives with the base rule.
  • Read the rest of the Sigma specification to see filters, modifiers such as |contains and |endswith, and the category field.
  • Convert the same rule to a second backend, such as Elastic or Splunk SPL2, and compare the queries. Note where the pipeline changes the logic and where it only renames fields.
  • Build a larger fixture set, including events from a lab where you control the activity. Only generate failed logons on accounts and systems you own, and only in a lab you are allowed to test.

The short version: a rule is a claim about events. Write the claim, convert it, and then show it with events that should match and events that should not.

Sources

Sources

  1. SigmaHQ, Sigma specification
  2. SigmaHQ, Sigma rules documentation
  3. SigmaHQ, sigma-cli
  4. MITRE ATT&CK, T1110 Brute Force
  5. SigmaHQ, Sigma correlation rules specification

More in Detection and blue team

Detection and blue team

What Is a SIEM? Logs, Queries and Alerting

A SIEM collects logs, normalizes them, runs correlation rules and raises alerts for triage. Learn the pipeline with a runnable example.

Beginner13 min read