Cheat sheet — Detection-as-Code Pipelines (Sigma + pytest)¶
Companion to Module 09 — Detection-as-Code Pipelines · CC BY 4.0 — print it, pin it, share it.
Last reviewed: 2026-07
sigma-cli — lint and compile rules¶
sigma check data/rules/ # validate syntax/structure of every rule (exit 1 on error)
sigma check rules/win_susp_enc.yml # one rule
sigma list backends # available conversion targets
sigma convert -t splunk data/rules/ # compile rules to a backend query language
sigma convert -t splunk -o out.txt rules/ # write compiled queries to a file
sigma check is the cheap first gate — it rejects a misspelled condition: key or a malformed
detection: block before anything else runs.
pytest — the intent table (does the rule fire on what it should?)¶
import pytest
from conftest import match_rule # in-process matcher: match_rule(rule, event) -> bool
CASES = [
# (rule, event, should_match)
("win_susp_enc.yml", ENC_POWERSHELL, True), # malicious → must fire
("win_susp_enc.yml", BENIGN_POWERSHELL, False), # near-miss benign → must NOT fire
("win_new_service.yml", SC_CREATE, True),
("win_new_service.yml", ROUTINE_SVC, False),
]
@pytest.mark.parametrize("rule,event,expected", CASES)
def test_detection_intent(rule, event, expected):
assert match_rule(rule, event) is expected
pytest data/tests/ -v # per-case pass/fail, human-readable
pytest data/tests/ -q # quiet — the CI form (exit code is the signal)
pytest data/tests/ -k enc # run only cases whose id matches 'enc'
pytest data/tests/ -x # stop on first failure
Give every case a stable id so a failure names the rule, not [case3]:
@pytest.mark.parametrize("rule,event,expected", CASES,
ids=[f"{c[0]}-{'hit' if c[2] else 'miss'}" for c in CASES])
The CI gate — chain the stages, fail fast¶
# ci-gate.sh — any stage exits non-zero → the merge is blocked
sigma check data/rules/ && pytest data/tests/ -q
# .github/workflows/sigma-ci.yml
on: [push, pull_request]
permissions: { contents: read }
jobs:
detect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<pinned-sha>
- run: sigma check data/rules/ # stage 1: lint
- run: pytest data/tests/ -q # stage 2: intent table
- run: python3 eval.py --gate recall=0.90 --gate fp_rate=0.10 # stage 3: metric regression gate
Gotchas worth remembering¶
- A rule that isn't measured isn't a detection — it's a guess with a YAML file and good demo luck. Score every rule against a held-out, labelled corpus it was never tuned on; tuning against your eval set launders overfitting into a green build.
- Recall first — accuracy lies on imbalanced data. With 1 malicious event per 10,000, a rule that never fires is 99.99% "accurate" and catches nothing. Gate on recall (did it catch the attack) and false-positive rate (will it drown the analyst), not accuracy.
- Every rule needs a matching benign case, not just a malicious one. The near-miss benign is what catches an over-widened selection — a rule that fires on everything passes a match-only test.
- Detections rot silently — a cosmetic refactor drops
-enc, a field rename matches nothing, and git still looks fine. The regression gate exists precisely because nothing else tells you. - Prove the gate both ways. GREEN on the good ruleset and RED on a planted regression — a gate you've only ever seen pass hasn't been shown to block anything.
sigma convertto your real backend as a stage — fail on an empty compiled query; a rule that lints fine can still compile to nothing on your SIEM.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).