Security · python · 25 copies
Sigma Rule Validator
Validates Sigma detection rules offline and catches the defects that make a rule silently dead: a condition referencing a selection that does not exist, a mistyped logsource category, a field name with the wrong case, an invalid modifier. Each of those converts cleanly to SPL or KQL and then matches nothing forever — no tool errors, no alert fires, and the coverage matrix still shows green. Also flags quality problems a syntax checker passes: exclusions built on a bare path an attacker can write to, falsepositives left as Unknown, unused selection blocks, aggregations with no timeframe, and duplicate UUIDs across a library. Findings are split ERROR / WARNING / INFO and exit 2 on an error, so it can gate a detection-as-code pipeline.
- Linux
- Windows
- macOS
pip install "pyyaml>=6.0"#!/usr/bin/env python3
"""
Sigma Rule Validator
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/sigma-rule-validator
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "pyyaml>=6.0"
"""
import sys
# A Windows console is cp1252: an arrow or a check mark raises
# UnicodeEncodeError and kills the run partway through a library scan.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
import argparse
import json
import os
import re
import uuid
from collections import defaultdict
try:
import yaml
except ImportError:
print("PyYAML is required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
# --------------------------------------------------------------------------
# Reference data
# --------------------------------------------------------------------------
REQUIRED_TOP = ("title", "logsource", "detection")
RECOMMENDED_TOP = ("id", "status", "description", "author", "date",
"level", "falsepositives", "tags", "references")
VALID_STATUS = {"stable", "test", "experimental", "deprecated", "unsupported"}
VALID_LEVEL = {"informational", "low", "medium", "high", "critical"}
# The Sigma logsource taxonomy. A category outside this list is not wrong by
# definition — back-ends accept custom ones — but it is almost always a typo,
# and a typo here means the rule queries nothing.
KNOWN_CATEGORIES = {
"process_creation", "process_access", "process_termination",
"image_load", "driver_load", "file_event", "file_change", "file_delete",
"file_rename", "file_access", "file_executable_detected",
"registry_event", "registry_add", "registry_set", "registry_delete",
"registry_rename",
"network_connection", "dns_query", "dns", "firewall", "proxy", "webserver",
"pipe_created", "wmi_event", "ps_module", "ps_script", "ps_classic_start",
"create_remote_thread", "create_stream_hash", "raw_access_thread",
"sysmon_error", "sysmon_status", "clipboard_capture",
"antivirus", "application", "database", "vpn",
}
KNOWN_PRODUCTS = {
"windows", "linux", "macos", "aws", "azure", "gcp", "m365", "okta",
"onelogin", "github", "gworkspace", "kubernetes", "cisco", "paloalto",
"fortinet", "juniper", "sql", "zeek", "qualys", "velocity", "rpc_firewall",
"python", "django", "spring", "nodejs", "opencanary", "bitwarden",
}
# Field names that exist per category. Used to catch the single most costly
# typo class. Not exhaustive — an unknown field is a WARNING, never an ERROR.
KNOWN_FIELDS = {
"process_creation": {
"Image", "OriginalFileName", "CommandLine", "CurrentDirectory", "User",
"LogonGuid", "LogonId", "TerminalSessionId", "IntegrityLevel", "Hashes",
"md5", "sha1", "sha256", "imphash", "ParentProcessId", "ParentImage",
"ParentCommandLine", "ParentUser", "ProcessId", "ProcessGuid",
"Description", "Product", "Company", "FileVersion", "RuleName",
},
"process_access": {
"SourceImage", "TargetImage", "GrantedAccess", "CallTrace",
"SourceProcessId", "TargetProcessId", "SourceProcessGUID",
"TargetProcessGUID", "SourceUser", "TargetUser", "SourceThreadId",
},
"image_load": {
"Image", "ImageLoaded", "Hashes", "md5", "sha1", "sha256", "imphash",
"Signed", "Signature", "SignatureStatus", "OriginalFileName",
"Description", "Product", "Company", "FileVersion", "User", "ProcessId",
},
"network_connection": {
"Image", "User", "Protocol", "Initiated", "SourceIp", "SourceHostname",
"SourcePort", "SourcePortName", "DestinationIp", "DestinationHostname",
"DestinationPort", "DestinationPortName", "DestinationIsIpv6",
"SourceIsIpv6", "ProcessId", "ProcessGuid",
},
"dns_query": {"Image", "QueryName", "QueryStatus", "QueryResults",
"ProcessId", "ProcessGuid", "User", "record_type"},
"file_event": {"Image", "TargetFilename", "CreationUtcTime", "User",
"ProcessId", "ProcessGuid", "Hashes"},
"registry_event": {"Image", "TargetObject", "Details", "EventType",
"NewName", "User", "ProcessId", "ProcessGuid"},
"registry_set": {"Image", "TargetObject", "Details", "EventType", "User",
"ProcessId", "ProcessGuid"},
"pipe_created": {"Image", "PipeName", "ProcessId", "ProcessGuid", "User"},
"proxy": {"c-uri", "c-uri-query", "c-uri-stem", "c-uri-extension",
"c-useragent", "cs-method", "cs-host", "cs-bytes", "sc-bytes",
"sc-status", "r-dns", "cs-referrer", "cs-cookie-var"},
"webserver": {"c-uri", "c-uri-query", "c-useragent", "cs-method",
"cs-referrer", "sc-status", "cs-host", "c-ip"},
}
# Valid Sigma field modifiers. A typo here changes the match semantics or is
# ignored, depending on the backend.
VALID_MODIFIERS = {
"contains", "startswith", "endswith", "all", "base64", "base64offset",
"utf16", "utf16le", "utf16be", "wide", "re", "cidr", "lt", "lte", "gt",
"gte", "windash", "expand", "fieldref", "exists", "cased",
}
CONDITION_KEYWORDS = {"and", "or", "not", "of", "them", "all", "1", "any"}
# Strings so short or so common that a rule matching on them alone will fire
# constantly. Flagged, not blocked.
WEAK_VALUES = {"cmd", "exe", "dll", "ps", "powershell.exe", "cmd.exe",
"svchost.exe", "rundll32.exe", "explorer.exe", "a", "1", "test"}
class Finding:
__slots__ = ("level", "code", "message", "where")
def __init__(self, level, code, message, where=""):
self.level = level
self.code = code
self.message = message
self.where = where
# --------------------------------------------------------------------------
# Checks
# --------------------------------------------------------------------------
def check_metadata(rule, f):
for key in REQUIRED_TOP:
if key not in rule:
f.append(Finding("ERROR", "missing-required",
f"missing required key '{key}'"))
for key in RECOMMENDED_TOP:
if key not in rule:
f.append(Finding("INFO", "missing-recommended",
f"no '{key}' — recommended by the Sigma spec"))
title = rule.get("title", "")
if title:
if len(title) > 120:
f.append(Finding("WARNING", "title-long",
f"title is {len(title)} chars; it has to be readable "
f"in an alert queue"))
if re.fullmatch(r"(?i)\s*(suspicious|malicious|possible)\s+activity\s*", title):
f.append(Finding("WARNING", "title-vague",
f"title '{title}' says nothing to the analyst who "
f"receives the alert"))
rid = rule.get("id")
if rid is not None:
try:
uuid.UUID(str(rid))
except (ValueError, AttributeError, TypeError):
f.append(Finding("ERROR", "bad-id",
f"id '{rid}' is not a valid UUID"))
status = rule.get("status")
if status is not None and str(status).lower() not in VALID_STATUS:
f.append(Finding("ERROR", "bad-status",
f"status '{status}' is not one of "
f"{', '.join(sorted(VALID_STATUS))}"))
level = rule.get("level")
if level is not None and str(level).lower() not in VALID_LEVEL:
f.append(Finding("ERROR", "bad-level",
f"level '{level}' is not one of "
f"{', '.join(sorted(VALID_LEVEL))}"))
date = rule.get("date")
if date is not None:
# The Sigma spec uses YYYY/MM/DD; YAML silently parses an unquoted
# YYYY-MM-DD into a date object, which is why both shapes appear.
text = date.isoformat() if hasattr(date, "isoformat") else str(date)
if not re.fullmatch(r"\d{4}[/-]\d{2}[/-]\d{2}", text):
f.append(Finding("WARNING", "bad-date",
f"date '{text}' is not YYYY/MM/DD"))
fps = rule.get("falsepositives")
if fps is not None:
items = fps if isinstance(fps, list) else [fps]
if not items or all(str(x).strip().lower() in ("unknown", "none", "")
for x in items):
f.append(Finding("WARNING", "fp-unknown",
"falsepositives is 'Unknown' — the rule has not "
"been run against real data yet"))
tags = rule.get("tags") or []
if isinstance(tags, list):
has_technique = any(re.match(r"(?i)attack\.t\d{4}", str(t)) for t in tags)
has_tactic = any(re.match(r"(?i)attack\.[a-z_]+$", str(t)) for t in tags)
if tags and not has_technique:
f.append(Finding("INFO", "no-technique-tag",
"no attack.tXXXX technique tag — coverage mapping "
"will not pick this rule up"))
if tags and not has_tactic:
f.append(Finding("INFO", "no-tactic-tag", "no attack.<tactic> tag"))
if str(rule.get("status", "")).lower() == "stable" and not rule.get("falsepositives"):
f.append(Finding("WARNING", "stable-untested",
"status is 'stable' but there are no documented false "
"positives; stable should mean it has run in production"))
def check_logsource(rule, f):
ls = rule.get("logsource")
if ls is None:
return None
if not isinstance(ls, dict):
f.append(Finding("ERROR", "logsource-shape", "logsource must be a mapping"))
return None
if not any(k in ls for k in ("category", "product", "service")):
f.append(Finding("ERROR", "logsource-empty",
"logsource has none of category/product/service — "
"this converts to a query against nothing"))
return None
cat = ls.get("category")
if cat and str(cat) not in KNOWN_CATEGORIES:
f.append(Finding("WARNING", "unknown-category",
f"logsource category '{cat}' is not in the Sigma "
f"taxonomy — a typo here makes the rule query the "
f"wrong index and return nothing, silently"))
prod = ls.get("product")
if prod and str(prod) not in KNOWN_PRODUCTS:
f.append(Finding("INFO", "unknown-product",
f"logsource product '{prod}' is not a common value"))
return str(cat) if cat else None
def walk_fields(node, out):
"""Collect every field name used anywhere in a detection selection."""
if isinstance(node, dict):
for key, value in node.items():
out.append(key)
walk_fields(value, out)
elif isinstance(node, list):
for item in node:
walk_fields(item, out)
def walk_values(node, out):
if isinstance(node, dict):
for value in node.values():
walk_values(value, out)
elif isinstance(node, list):
for item in node:
walk_values(item, out)
elif node is not None:
out.append(node)
def check_detection(rule, category, f):
det = rule.get("detection")
if det is None:
return
if not isinstance(det, dict):
f.append(Finding("ERROR", "detection-shape", "detection must be a mapping"))
return
condition = det.get("condition")
if condition is None:
f.append(Finding("ERROR", "no-condition",
"detection has no 'condition' — the rule cannot be "
"evaluated"))
return
conditions = condition if isinstance(condition, list) else [condition]
cond_text = " ".join(str(c) for c in conditions)
selections = [k for k in det if k != "condition"]
if not selections:
f.append(Finding("ERROR", "no-selection",
"detection has a condition but no selection blocks"))
return
# Identifiers referenced by the condition, minus the operators.
referenced = {
tok for tok in re.findall(r"[A-Za-z_][A-Za-z0-9_]*\*?", cond_text)
if tok.lower() not in CONDITION_KEYWORDS
}
def matches_any(token, names):
if token.endswith("*"):
return [n for n in names if n.startswith(token[:-1])]
return [n for n in names if n == token]
# A condition referencing a selection that does not exist is the defect
# that produces a rule which converts cleanly and matches nothing.
for token in sorted(referenced):
if not matches_any(token, selections):
f.append(Finding("ERROR", "undefined-selection",
f"condition references '{token}', which is not a "
f"defined selection ({', '.join(selections)})"))
for name in selections:
if not any(matches_any(tok, [name]) for tok in referenced):
f.append(Finding("WARNING", "unused-selection",
f"selection '{name}' is defined but never used in "
f"the condition — it has no effect"))
# Field names and modifiers. Walk each selection block's CONTENTS: passing
# the detection dict itself would collect the selection names as if they
# were field names.
fields = []
for name in selections:
walk_fields(det[name], fields)
known = KNOWN_FIELDS.get(category or "", set())
seen_bare = set()
for raw in fields:
if not isinstance(raw, str):
continue
parts = raw.split("|")
bare = parts[0]
for mod in parts[1:]:
if mod not in VALID_MODIFIERS:
f.append(Finding("ERROR", "bad-modifier",
f"'{raw}' uses modifier '{mod}', which is not "
f"a valid Sigma modifier"))
if bare in seen_bare or not known or bare in known:
seen_bare.add(bare)
continue
seen_bare.add(bare)
# Case-insensitive near-miss is almost always the real cause.
near = [k for k in known if k.lower() == bare.lower()]
if near:
f.append(Finding("ERROR", "field-case",
f"field '{bare}' should be '{near[0]}' — field "
f"names are case-sensitive in most backends"))
else:
f.append(Finding("WARNING", "unknown-field",
f"field '{bare}' is not a known field for "
f"logsource category '{category}'"))
# Value quality
values = []
walk_values({k: v for k, v in det.items() if k != "condition"}, values)
for v in values:
if isinstance(v, str):
if v.strip() == "":
f.append(Finding("ERROR", "empty-value",
"a detection value is an empty string, which "
"matches unpredictably"))
elif v.strip().lower() in WEAK_VALUES:
f.append(Finding("WARNING", "weak-value",
f"value '{v}' on its own is too common to be "
f"a discriminator"))
elif v.strip() == "*":
f.append(Finding("WARNING", "wildcard-only",
"a bare '*' value matches everything"))
# Exclusions built on a bare path are an evasion handed to the attacker if
# the directory is writable.
for name in selections:
if not name.lower().startswith(("filter", "exclu")):
continue
sub_fields = []
walk_fields(det[name], sub_fields)
path_only = [x for x in sub_fields
if isinstance(x, str) and x.split("|")[0] in
("Image", "SourceImage", "ParentImage", "TargetFilename")]
has_strong = any(isinstance(x, str) and x.split("|")[0] in
("Hashes", "md5", "sha1", "sha256", "imphash",
"Signed", "Signature", "SignatureStatus")
for x in sub_fields)
if path_only and not has_strong:
f.append(Finding("WARNING", "weak-exclusion",
f"exclusion '{name}' matches on a path alone; if "
f"that directory is writable this is an evasion. "
f"Add a signature or hash constraint"))
if "timeframe" in det and not re.search(r"\b(count|near|sum|min|max|avg)\b",
cond_text, re.I):
f.append(Finding("INFO", "unused-timeframe",
"a timeframe is set but the condition has no "
"aggregation that uses it"))
if re.search(r"\b(count|near|sum|avg)\b", cond_text, re.I) and "timeframe" not in det:
f.append(Finding("ERROR", "aggregation-no-timeframe",
"the condition aggregates but no 'timeframe' is set"))
def validate(rule, path, index):
f = []
if not isinstance(rule, dict):
f.append(Finding("ERROR", "not-a-mapping",
"document is not a YAML mapping"))
return f
check_metadata(rule, f)
category = check_logsource(rule, f)
check_detection(rule, category, f)
for item in f:
item.where = f"{path}" + (f" [doc {index}]" if index else "")
return f
# --------------------------------------------------------------------------
def collect_files(paths):
out = []
for p in paths:
if os.path.isdir(p):
for root, _dirs, files in os.walk(p):
for name in sorted(files):
if name.lower().endswith((".yml", ".yaml")):
out.append(os.path.join(root, name))
elif os.path.isfile(p):
out.append(p)
else:
print(f" ! not found: {p}", file=sys.stderr)
return out
COLOUR = {"ERROR": "ERROR ", "WARNING": "WARN ", "INFO": "INFO "}
def main():
ap = argparse.ArgumentParser(
description="Validate Sigma detection rules offline.",
epilog="Exit 0 = clean, 1 = warnings only, 2 = errors (or could not run).",
)
ap.add_argument("paths", nargs="+", help="Rule files or directories")
ap.add_argument("--strict", action="store_true",
help="Treat warnings as failures (exit 2)")
ap.add_argument("--quiet", action="store_true",
help="Only show rules that have findings")
ap.add_argument("--no-info", action="store_true",
help="Suppress INFO findings")
ap.add_argument("--output", help="Write the full report to a .json file")
args = ap.parse_args()
files = collect_files(args.paths)
if not files:
print("No .yml/.yaml rule files found.", file=sys.stderr)
return 2
all_findings = []
ids_seen = defaultdict(list)
titles_seen = defaultdict(list)
rules_ok = 0
parse_failures = 0
print()
print("=" * 78)
print(" SIGMA RULE VALIDATION")
print("=" * 78)
for path in files:
try:
with open(path, "r", encoding="utf-8") as fh:
docs = list(yaml.safe_load_all(fh))
except yaml.YAMLError as exc:
parse_failures += 1
finding = Finding("ERROR", "yaml-parse", f"invalid YAML: {exc}", path)
all_findings.append(finding)
print(f"\n {path}")
print(f" ERROR [yaml-parse] invalid YAML: "
f"{str(exc).splitlines()[0]}")
continue
except OSError as exc:
parse_failures += 1
print(f" ! cannot read {path}: {exc}", file=sys.stderr)
continue
for i, doc in enumerate(d for d in docs if d is not None):
findings = validate(doc, path, i if len(docs) > 1 else 0)
if args.no_info:
findings = [x for x in findings if x.level != "INFO"]
if isinstance(doc, dict):
if doc.get("id"):
ids_seen[str(doc["id"])].append(path)
if doc.get("title"):
titles_seen[str(doc["title"]).strip().lower()].append(path)
all_findings.extend(findings)
if not findings:
rules_ok += 1
if not args.quiet:
print(f"\n {path}")
print(f" clean")
continue
print(f"\n {path}")
for item in sorted(findings, key=lambda x:
{"ERROR": 0, "WARNING": 1, "INFO": 2}[x.level]):
print(f" {COLOUR[item.level]}[{item.code}] {item.message}")
# Library-wide checks: only meaningful across files.
dupes = []
for rid, paths_ in ids_seen.items():
if len(paths_) > 1:
dupes.append(Finding("ERROR", "duplicate-id",
f"id {rid} is used by {len(paths_)} rules: "
f"{', '.join(paths_)}"))
for title, paths_ in titles_seen.items():
if len(paths_) > 1:
dupes.append(Finding("WARNING", "duplicate-title",
f"title '{title}' is used by {len(paths_)} "
f"rules: {', '.join(paths_)}"))
if dupes:
print(f"\n LIBRARY-WIDE")
for item in dupes:
print(f" {COLOUR[item.level]}[{item.code}] {item.message}")
all_findings.extend(dupes)
errors = [x for x in all_findings if x.level == "ERROR"]
warnings = [x for x in all_findings if x.level == "WARNING"]
infos = [x for x in all_findings if x.level == "INFO"]
print()
print("=" * 78)
print(f" {len(files)} file(s), {rules_ok} clean")
print(f" {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info")
if errors:
by_code = defaultdict(int)
for e in errors:
by_code[e.code] += 1
print(f" errors by type: " +
", ".join(f"{k}={v}" for k, v in sorted(by_code.items())))
print("=" * 78)
print()
if args.output:
with open(args.output, "w", encoding="utf-8") as fh:
json.dump({
"files": len(files),
"clean": rules_ok,
"findings": [
{"level": x.level, "code": x.code,
"message": x.message, "where": x.where}
for x in all_findings
],
}, fh, indent=2)
print(f" Report written to {args.output}\n")
# Exit 2 on a broken rule so this can gate a detection-as-code pipeline.
# Exiting 0 regardless would make the check decorative.
if errors or parse_failures:
return 2
if warnings:
return 2 if args.strict else 1
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
# Explicit, so Ctrl-C still works while walking a large rule library.
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
pathstextrequiredRule files or directories to validate.
--strictbooleanTreat warnings as failures. Use it as a CI gate.
--quietbooleanOnly show rules that have findings.
--no-infobooleanSuppress INFO findings.
--outputtextWrite the full report to a .json file.
What This Solves
A Sigma rule that is wrong does not announce itself. It converts cleanly, deploys without complaint, sits in your SIEM looking like coverage, and matches nothing. You find out when the thing it was supposed to catch happens and nobody is paged. The failures are mundane and all of them are silent: A typo in the logsource category. `proces_creation` instead of `process_creation`. The rule converts, queries the wrong index, and returns nothing forever. A condition referencing a selection that does not exist. Or a selection defined and never used in the condition, which has no effect at all. An invalid field modifier. `|contain` instead of `|contains`. Converts, matches nothing. `falsepositives: Unknown`. The honest admission that the rule has never been run against real data. Deploy it and you will find out what its noise level is in production, during an incident. A title that tells the analyst nothing. "Suspicious Activity" at 3am is an alert nobody can triage. This validates rules against the Sigma specification and against the mistakes that produce a rule which looks deployed and detects nothing.
Is It Safe to Run
Completely. It reads YAML files and prints findings. No network connections, no SIEM involved, nothing deployed, nothing queried, and the rule files are never modified. It is a linter - the safest thing in this catalogue, and the right thing to put in front of every rule change.
Before You Start
1. Python 3.9 or newer: python --version No dependencies beyond the standard library. 2. The rules, as `.yml` files in a directory or named individually. That is all. There is deliberately nothing to configure - a validator you have to set up is a validator nobody runs.
Your First Run
Point it at your rules directory: python sigma_rule_validator.py rules/ It prints every rule with findings, worst first, and says nothing about the rules that are clean. Expect findings on rules you consider finished. The `fp-unknown` and `title-vague` checks in particular will flag rules that work, because they are about whether the rule is **operable** by the analyst who receives its alerts - which is a different question from whether it converts. Then narrow it down: # only the rules with problems, no INFO noise python sigma_rule_validator.py rules/ --quiet --no-info # as a CI gate, where a warning should fail the build too python sigma_rule_validator.py rules/ --strict --output report.json
What the Output Means
$ python sigma_rule_validator.py rules/ SIGMA RULE VALIDATION rules/broken.yml ERROR [bad-id] id 'not-a-uuid' is not a valid UUID ERROR [bad-level] level 'urgent' is not one of critical, high, informational, low, medium ERROR [undefined-selection] condition references 'filter_missing', which is not a defined selection (selection, orphan) ERROR [bad-modifier] 'commandline|contain' uses modifier 'contain', which is not a valid Sigma modifier WARN [title-vague] title 'Suspicious Activity' says nothing to the analyst who receives the alert WARN [fp-unknown] falsepositives is 'Unknown' - the rule has not been run against real data yet WARN [unknown-category] logsource category 'proces_creation' is not in the Sigma taxonomy - a typo here makes the rule query the wrong index and return nothing, silently WARN [unused-selection] selection 'orphan' is defined but never used in the condition - it has no effect INFO [missing-recommended] no 'description' - recommended by the Sigma spec Reading it: The three levels, and what they mean for deployment ERROR the rule is invalid or will not do what it says. `bad-modifier` and `undefined-selection` are the ones that produce a rule which converts and matches nothing. Do not deploy WARN the rule will work and something about it is wrong - most often that it will be unusable in production, or that it queries the wrong place INFO spec recommendations. Worth fixing, not blocking The rule id in brackets `[bad-modifier]`, `[unknown-category]`. Stable, so you can grep the JSON output for one you have decided to accept, or filter it in a pipeline. The warnings that matter most, and why they are only warnings unknown-category a typo in the logsource. The rule is syntactically fine and queries an index that does not exist. This is the single most expensive silent failure in detection engineering, and it is a WARN rather than an ERROR because a legitimate custom taxonomy also lands here unused-selection defined, never referenced. Usually the remains of an edit - and sometimes the filter somebody meant to apply and forgot, which means the rule is noisier than intended fp-unknown the rule has never met real data. Deploy it into a SIEM and you discover its noise level in production title-vague the alert arrives at 3am and says "Suspicious Activity". The rule works and the analyst cannot act on it A clean rule prints nothing Absence of output is the pass. With `--quiet`, only rules with findings appear at all. What it cannot tell you: whether the rule **detects the thing you meant**. Valid syntax, a real logsource and a sensible title do not make a correct detection. That still needs the rule run against data - which is what `fp-unknown` is reminding you about. Invocation AND options Requires PyYAML: pip install pyyaml Then: python sigma_rule_validator.py <rule.yml> python sigma_rule_validator.py ./rules/ # walks the tree python sigma_rule_validator.py ./rules/ --strict --no-info Multi-document YAML files are supported -- each document is validated separately and reported with its index. What it checks ERROR -- the rule is broken, or will silently match nothing missing-required no title, logsource or detection bad-id id is not a valid UUID bad-status status outside stable/test/experimental/ deprecated/unsupported bad-level level outside informational/low/medium/high/critical logsource-shape logsource is not a mapping logsource-empty logsource has no category, product or service -- this converts to a query against nothing detection-shape detection is not a mapping no-condition detection has no condition no-selection a condition with no selection blocks undefined-selection the condition references a selection that does not exist bad-modifier a field modifier that is not valid Sigma (|contain instead of |contains, and similar) field-case a field name that differs only in case from the correct one -- field names are case-sensitive in most backends empty-value a detection value that is an empty string aggregation-no-timeframe count/near/sum in the condition with no timeframe duplicate-id the same UUID on two rules in the library yaml-parse the file is not valid YAML WARNING -- it will run, but it is unsafe or unmaintainable unknown-category logsource category not in the Sigma taxonomy -- almost always a typo, and a typo here is silent unknown-field a field that does not exist for that logsource category unused-selection a selection block the condition never references weak-exclusion a filter_* block matching on a path alone, with no signature or hash constraint. If that directory is writable, the exclusion is an evasion you built for the attacker weak-value a value too common to discriminate (cmd, exe, powershell.exe on its own) wildcard-only a bare "*" value, which matches everything fp-unknown falsepositives is "Unknown" -- the rule has not been run against real data stable-untested status is stable with no documented false positives title-vague "Suspicious Activity" and similar title-long over 120 characters -- it has to be readable in an alert queue bad-date date is not YYYY/MM/DD duplicate-title two rules with the same title INFO -- hygiene missing-recommended no description, author, references, tags... no-technique-tag no attack.tXXXX -- coverage mapping will miss it no-tactic-tag no attack.<tactic> unknown-product an uncommon logsource product unused-timeframe a timeframe with no aggregation using it Field-name checking Field names are validated against the logsource category for the categories where the schema is well known: process_creation Image, OriginalFileName, CommandLine, ParentImage, ParentCommandLine, User, IntegrityLevel, Hashes, ... process_access SourceImage, TargetImage, GrantedAccess, CallTrace, ... image_load ImageLoaded, Signed, Signature, SignatureStatus, ... network_connection DestinationIp, DestinationPort, Initiated, Protocol, ... dns_query QueryName, QueryStatus, QueryResults, ... file_event TargetFilename, CreationUtcTime, ... registry_event TargetObject, Details, EventType, ... pipe_created PipeName, ... proxy / webserver c-uri, c-useragent, cs-method, sc-status, ... A field that differs only by case from a known one is an ERROR, because that is a real bug with a known fix. A field that is simply unrecognised is a WARNING -- the list is not exhaustive and custom pipelines add fields. This catches the most expensive typo class in detection engineering: commandline|contains: ... -> ERROR should be CommandLine TargetFileName|endswith: ... -> ERROR should be TargetFilename
Exit Codes for Cron and CI
0 Clean -- no errors, no warnings 1 Warnings only 2 Errors found, a file would not parse, or no rule files were given 130 Interrupted with Ctrl-C --strict makes warnings exit 2 as well. Exit 2 on a broken rule is what makes this usable as a gate. A validator that exits 0 regardless is decorative. Detection-as-code pipeline Pre-commit hook (.git/hooks/pre-commit): #!/bin/sh python sigma_rule_validator.py ./rules/ --no-info || exit 1 GitHub Actions: - name: Validate Sigma rules run: | pip install pyyaml python sigma_rule_validator.py ./rules/ --strict --output report.json - uses: actions/upload-artifact@v4 if: always() with: name: sigma-validation path: report.json The JSON report has one entry per finding with its level, code, message and file, so a dashboard or a PR comment can be generated from it: { "files": 142, "clean": 118, "findings": [ { "level": "ERROR", "code": "undefined-selection", "message": "condition references 'filter_legit', which is not a defined selection (selection, filter_legitimate)", "where": "rules/windows/proc_creation_susp_rundll32.yml" } ] } What it does NOT do This is a static validator. It does not, and cannot: * Tell you whether the rule is NOISY. Only running it against 30 days of your own data tells you that, and no amount of static analysis substitutes. See the Detection Engineering skill for that workflow. * Tell you whether the rule actually detects the technique. A perfectly valid rule can key on an artefact an attacker changes trivially. * Convert the rule. Use pySigma / sigma-cli for that -- this runs before conversion, to catch what conversion will not complain about. * Validate backend-specific custom field mappings. Passing this validator means the rule is well-formed and free of the known silent-failure patterns. It does not mean the rule is good.
When Something Fails
"unknown-field on a field I know exists" The field list covers the common Sysmon and Windows categories. If your pipeline adds fields, the warning is expected -- use --no-info and treat unknown-field as advisory, or stick to the documented names. "bad-date on a date that looks fine" An unquoted YYYY-MM-DD in YAML is parsed into a date object, not a string. The Sigma spec uses YYYY/MM/DD. Both are accepted here; anything else is flagged. "It flags a filter block that is correct for us" weak-exclusion is a warning, not an error. If the directory genuinely is not writable by unprivileged users, the exclusion is fine -- but confirm that rather than assuming it. "Duplicate id after copying a rule as a template" Generate a fresh UUID for every rule. A duplicate id breaks rule tracking and deduplication in most rule-management tooling. Sigma Detection Rule Builder a prompt that writes the rule Detection Engineering the skill covering the whole lifecycle SIEM Query Translator convert the logic between backends