Files
artex/detections/tests/sigma_match/check.py
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

417 lines
17 KiB
Python

#!/usr/bin/env python3
#
# Live event-matching test for the ARTEX Sigma rules (../../sigma/). check.sh
# installs a pinned pySigma inside a container and runs this script with the rule
# tree mounted read-only at /sigma and this directory at /src.
#
# WHAT THIS PROVES, AND WHY IT IS DIFFERENT FROM THE sigma/ SUITE
# --------------------------------------------------------------
# The sigma/ suite proves each rule is structurally valid and COMPILES to a
# backend query, and that its indicator strings survive into that query. It does
# NOT prove the rule actually fires on a matching event, or stays quiet on a
# benign one: a field renamed to something the log never carries, a wildcard that
# silently dropped, or an over-broad token would all still compile cleanly. The
# README's own principle is that "a detection you cannot run is only a claim," and
# the Suricata suite already backs its network rule with a real pcap replay
# (fires on the probe UA, silent on a benign browser). This suite closes the same
# gap for the host/log-layer Sigma rules on two levels:
# - ATOMIC rules (../../sigma/*.yml): for each rule a representative malicious
# event MATCHES and a benign event DOES NOT.
# - CORRELATION rules (../../sigma/correlation/*.yml): for each rule a positive
# timeline (threshold met, inside the window, within one group) FIRES and
# negative timelines (below threshold, threshold met but spread beyond the
# window, split across groups, or missing a leg) stay QUIET.
#
# HOW IT MATCHES (trust model)
# ----------------------------
# It does not hand-parse the YAML or re-implement Sigma's modifier logic. pySigma
# parses each rule and compiles its modifiers and condition into a tree:
# `|contains` becomes a wildcard-wrapped value, `|all` becomes an AND over values,
# `1 of selection_*` becomes an OR over the selection groups. This script only
# walks that compiled tree (AND / OR / NOT / field-equals / keyword) and tests
# each leaf against the event, so the authoritative parsing stays in pySigma. A
# leaf value or condition node this script does not explicitly support raises
# rather than passing silently (fail-closed), so a future rule using an
# unsupported construct surfaces loudly here instead of being waved through.
#
# For a correlation rule, pySigma likewise parses the aggregation spec — type
# (event_count / value_count / temporal), group-by fields, timespan, the
# threshold condition, and the resolved references to the atomic base rules. This
# script walks that parsed spec and applies it to a timeline, deciding which
# events feed each referenced rule with the very same atomic matcher above, so the
# Sigma logic again stays in pySigma; only the windowed aggregation is applied
# here. The correlation rules reference their atomics by id, so each is parsed in a
# collection that also holds every atomic rule (pySigma resolves the reference).
#
# SCOPE AND HONESTY (read before trusting a green run)
# ----------------------------------------------------
# - The CORRELATION window is the standard sliding-window interpretation: a
# window of `timespan` seconds anchored at each matching event, with inclusive
# bounds. Each timeline event carries an integer `ts` in relative seconds. A
# real SIEM's windowing (tumbling vs sliding, bound inclusivity, late arrival)
# may differ; this is a regression test for the rule's group-by / timespan /
# threshold logic — that it fires when they are satisfied and not when they are
# not — rather than a bit-exact model of any one backend's correlation engine.
# - Matching is CASE-INSENSITIVE. This mirrors the default of the splunk backend
# the sigma/ suite targets, and the destructive rule's own false-positive note
# assumes it (it warns that lowercase coreutils `truncate` shares the uppercase
# `TRUNCATE ` token and must be allow-listed). Your SIEM's case handling and
# field normalisation may differ; this is a regression test for the rules'
# field/value/condition logic, not a substitute for validating in your stack.
# - Keyword matching (the audit-framing rule) is modelled as a full-text
# substring search across all event field values, the common interpretation of
# an unbound Sigma keyword.
#
# Exits non-zero on any failure. Standard library only beyond pySigma.
import glob
import json
import os
import re
import sys
from sigma.collection import SigmaCollection
from sigma.conditions import (
ConditionAND,
ConditionFieldEqualsValueExpression,
ConditionNOT,
ConditionOR,
ConditionValueExpression,
)
from sigma.types import (
SigmaNull,
SigmaNumber,
SigmaRegularExpression,
SigmaString,
SpecialChars,
)
SIGMA_DIR = os.environ.get("SIGMA_DIR", "/sigma")
EVENTS_DIR = os.environ.get("EVENTS_DIR", "/src/events")
CORR_DIR = os.path.join(SIGMA_DIR, "correlation")
CORR_EVENTS_DIR = os.path.join(EVENTS_DIR, "correlation")
fail = 0
def note(msg):
print(f" {msg}")
def passed(msg):
note(f"PASS {msg}")
def bad(msg):
global fail
note(f"FAIL {msg}")
fail = 1
# --- matcher -------------------------------------------------------------------
def sigmastring_to_regex(value):
"""Compile a pySigma SigmaString (literal text plus wildcards) to an anchored,
case-insensitive regex. `|contains` already wrapped the value in multi
wildcards upstream, so a plain string compiles to an exact match and a
contains-value compiles to a substring match — exactly the Sigma semantics."""
parts = []
for part in value.s:
if part == SpecialChars.WILDCARD_MULTI:
parts.append(".*")
elif part == SpecialChars.WILDCARD_SINGLE:
parts.append(".")
elif isinstance(part, str):
parts.append(re.escape(part))
else:
raise ValueError(f"unsupported SigmaString part: {part!r}")
return re.compile("^" + "".join(parts) + "$", re.DOTALL | re.IGNORECASE)
def field_match(field, value, event):
if field not in event:
return False
observed = str(event[field])
if isinstance(value, SigmaString):
return sigmastring_to_regex(value).search(observed) is not None
if isinstance(value, SigmaNumber):
return observed == str(value.number)
if isinstance(value, SigmaNull):
return event.get(field) is None
if isinstance(value, SigmaRegularExpression):
return re.search(value.regexp, observed) is not None
raise ValueError(f"unsupported field value type: {type(value).__name__}")
def keyword_match(value, event):
"""Unbound keyword: full-text substring search across all field values."""
if not isinstance(value, SigmaString):
raise ValueError("unsupported keyword value type")
if any(not isinstance(p, str) for p in value.s):
raise ValueError("wildcard in keyword is not supported by this matcher")
token = "".join(value.s)
haystack = " ".join(str(v) for v in event.values())
return token.lower() in haystack.lower()
def evaluate(node, event):
if isinstance(node, ConditionAND):
return all(evaluate(a, event) for a in node.args)
if isinstance(node, ConditionOR):
return any(evaluate(a, event) for a in node.args)
if isinstance(node, ConditionNOT):
return not evaluate(node.args[0], event)
if isinstance(node, ConditionFieldEqualsValueExpression):
return field_match(node.field, node.value, event)
if isinstance(node, ConditionValueExpression):
return keyword_match(node.value, event)
raise ValueError(f"unsupported condition node: {type(node).__name__}")
def rule_matches(rule, event):
return any(evaluate(c.parsed, event) for c in rule.detection.parsed_condition)
# --- correlation evaluator -----------------------------------------------------
def group_key(event, fields):
if any(f not in event for f in fields):
return None
return tuple(event[f] for f in fields)
def correlation_fires(corr, timeline):
"""Apply a parsed SigmaCorrelationRule's aggregation to a timeline of events
(each carrying an integer `ts` in seconds). pySigma has parsed the rule into a
type, group-by fields, a timespan, a threshold condition, and resolved rule
references; this walks that parsed structure. Membership in a referenced rule
is decided by the same rule_matches the atomic suite uses, so the Sigma
detection logic stays in pySigma. The window is the standard sliding window:
`timespan` seconds anchored at each matching event, inclusive bounds."""
ctype = str(corr.type)
span = corr.timespan.seconds
group_by = corr.group_by or []
refs = [ref.rule for ref in corr.rules]
if ctype in ("event_count", "value_count"):
# A count correlation may reference several base rules; an event feeds the
# count if it matches ANY of them — the same union the temporal branch
# applies below. Looking at refs[0] alone would silently drop events
# matching the other referenced rules, a fail-open this suite's header
# forbids. With a single reference this reduces to the one-rule case, so
# the existing rules (each referencing one base rule) are unchanged.
matched = [e for e in timeline if any(rule_matches(r, e) for r in refs)]
groups = {}
for e in matched:
key = group_key(e, group_by)
if key is None:
continue
groups.setdefault(key, []).append(e)
threshold = corr.condition.count
fieldref = corr.condition.fieldref
for members in groups.values():
members = sorted(members, key=lambda e: e["ts"])
for anchor in members:
window = [
e for e in members if anchor["ts"] <= e["ts"] <= anchor["ts"] + span
]
if ctype == "event_count":
if len(window) >= threshold:
return True
else:
distinct = {e[fieldref] for e in window if fieldref in e}
if len(distinct) >= threshold:
return True
return False
if ctype == "temporal":
groups = {}
for e in timeline:
key = group_key(e, group_by)
if key is None:
continue
groups.setdefault(key, []).append(e)
for members in groups.values():
members = sorted(members, key=lambda e: e["ts"])
for anchor in members:
window = [
e for e in members if anchor["ts"] <= e["ts"] <= anchor["ts"] + span
]
if all(any(rule_matches(r, e) for e in window) for r in refs):
return True
return False
raise ValueError(f"unsupported correlation type: {ctype}")
def require_ts(events, stem, label):
for e in events:
if not isinstance(e.get("ts"), int):
raise ValueError(
f"{stem} ({label}): every timeline event needs an integer 'ts' "
f"(seconds); got {e!r}"
)
# --- loaders -------------------------------------------------------------------
def load_atomic_rules():
rules = {}
for path in sorted(glob.glob(os.path.join(SIGMA_DIR, "*.yml"))):
stem = os.path.splitext(os.path.basename(path))[0]
collection = SigmaCollection.from_yaml(open(path, encoding="utf-8").read())
for rule in collection.rules:
# Only plain atomic rules; correlation rules carry a `.type` and are
# handled separately below.
if type(rule).__name__ != "SigmaRule":
continue
rules[stem] = rule
return rules
def load_correlation_rules():
"""A correlation rule references its atomic base rules by id, so it must be
parsed in a collection that also contains those atomics. For each correlation
file, merge every atomic YAML with that one correlation YAML, parse the
collection (pySigma resolves the reference), and key the resulting
SigmaCorrelationRule by filename stem so it pairs with
events/correlation/<stem>.json."""
atomic_docs = [
open(p, encoding="utf-8").read()
for p in sorted(glob.glob(os.path.join(SIGMA_DIR, "*.yml")))
]
corrs = {}
for path in sorted(glob.glob(os.path.join(CORR_DIR, "*.yml"))):
stem = os.path.splitext(os.path.basename(path))[0]
merged = "\n---\n".join(atomic_docs + [open(path, encoding="utf-8").read()])
collection = SigmaCollection.from_yaml(merged)
found = [r for r in collection.rules if type(r).__name__ == "SigmaCorrelationRule"]
if len(found) != 1:
raise ValueError(f"{stem}: expected exactly 1 correlation rule, got {len(found)}")
corrs[stem] = found[0]
return corrs
def load_events(directory):
events = {}
for path in sorted(glob.glob(os.path.join(directory, "*.json"))):
stem = os.path.splitext(os.path.basename(path))[0]
events[stem] = json.load(open(path, encoding="utf-8"))
return events
def main():
rules = load_atomic_rules()
events = load_events(EVENTS_DIR)
print("== atomic 1/3 every atomic rule is paired with a sample-event file ==")
rule_stems = set(rules)
event_stems = set(events)
orphan_rules = sorted(rule_stems - event_stems)
orphan_events = sorted(event_stems - rule_stems)
if orphan_rules:
bad(f"atomic rules with no events/<name>.json: {orphan_rules}")
if orphan_events:
bad(f"event files with no matching atomic rule: {orphan_events}")
if not orphan_rules and not orphan_events:
passed(
f"rule/sample pairing: {len(rules)} atomic rules, "
f"{len(events)} event files, no orphans"
)
print("== atomic 2/3 each rule matches its malicious sample events (true positives) ==")
for stem in sorted(rule_stems & event_stems):
rule = rules[stem]
positives = events[stem].get("positive", [])
if not positives:
bad(f"{stem}: no positive sample events")
continue
missed = [e for e in positives if not rule_matches(rule, e)]
if missed:
bad(f"{stem}: {len(missed)}/{len(positives)} positive events did NOT match")
for e in missed:
note(f" unmatched: {json.dumps(e, ensure_ascii=False)}")
else:
passed(f"{stem}: {len(positives)}/{len(positives)} positive events matched")
print("== atomic 3/3 each rule rejects its benign sample events (true negatives) ==")
for stem in sorted(rule_stems & event_stems):
rule = rules[stem]
negatives = events[stem].get("negative", [])
if not negatives:
bad(f"{stem}: no negative sample events")
continue
fired = [e for e in negatives if rule_matches(rule, e)]
if fired:
bad(f"{stem}: {len(fired)}/{len(negatives)} benign events WRONGLY matched")
for e in fired:
note(f" wrongly matched: {json.dumps(e, ensure_ascii=False)}")
else:
passed(
f"{stem}: {len(negatives)}/{len(negatives)} benign events correctly "
"not matched"
)
corr_rules = load_correlation_rules()
corr_events = load_events(CORR_EVENTS_DIR)
print("== correlation 1/3 every correlation rule is paired with a timeline file ==")
corr_stems = set(corr_rules)
ce_stems = set(corr_events)
orphan_corr = sorted(corr_stems - ce_stems)
orphan_tl = sorted(ce_stems - corr_stems)
if orphan_corr:
bad(f"correlation rules with no events/correlation/<name>.json: {orphan_corr}")
if orphan_tl:
bad(f"timeline files with no matching correlation rule: {orphan_tl}")
if not orphan_corr and not orphan_tl:
passed(
f"rule/timeline pairing: {len(corr_rules)} correlation rules, "
f"{len(corr_events)} timeline files, no orphans"
)
print("== correlation 2/3 each rule fires on its positive timelines (true positives) ==")
for stem in sorted(corr_stems & ce_stems):
corr = corr_rules[stem]
positives = corr_events[stem].get("positive", [])
if not positives:
bad(f"{stem}: no positive timelines")
continue
for tl in positives:
require_ts(tl["events"], stem, tl["label"])
if correlation_fires(corr, tl["events"]):
passed(f"{stem}: fired — {tl['label']}")
else:
bad(f"{stem}: did NOT fire on a positive timeline — {tl['label']}")
print("== correlation 3/3 each rule stays quiet on its negative timelines (true negatives) ==")
for stem in sorted(corr_stems & ce_stems):
corr = corr_rules[stem]
negatives = corr_events[stem].get("negative", [])
if not negatives:
bad(f"{stem}: no negative timelines")
continue
for tl in negatives:
require_ts(tl["events"], stem, tl["label"])
if correlation_fires(corr, tl["events"]):
bad(f"{stem}: WRONGLY fired on a benign timeline — {tl['label']}")
else:
passed(f"{stem}: quiet — {tl['label']}")
print()
try:
import importlib.metadata as md
print(f"reference: pySigma {md.version('pysigma')}, atomic + correlation rules")
except Exception:
pass
print("RESULT: PASS" if fail == 0 else "RESULT: FAIL")
sys.exit(fail)
if __name__ == "__main__":
main()