detkit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- detkit/__init__.py +1 -0
- detkit/__main__.py +6 -0
- detkit/cli.py +226 -0
- detkit/evaluator.py +224 -0
- detkit_cli-0.1.0.dist-info/METADATA +129 -0
- detkit_cli-0.1.0.dist-info/RECORD +10 -0
- detkit_cli-0.1.0.dist-info/WHEEL +5 -0
- detkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- detkit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- detkit_cli-0.1.0.dist-info/top_level.txt +1 -0
detkit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
detkit/__main__.py
ADDED
detkit/cli.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""detkit command line: test / validate / generate."""
|
|
2
|
+
import argparse
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .evaluator import event_matches_rule, scan_unsupported
|
|
9
|
+
|
|
10
|
+
REQUIRED_KEYS = ("title", "logsource", "detection")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_yaml(path):
|
|
14
|
+
try:
|
|
15
|
+
import yaml
|
|
16
|
+
except ImportError:
|
|
17
|
+
sys.exit("detkit needs PyYAML: pip install pyyaml")
|
|
18
|
+
with open(path) as f:
|
|
19
|
+
return yaml.safe_load(f)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _discover_rules(path):
|
|
23
|
+
if os.path.isfile(path):
|
|
24
|
+
return [path]
|
|
25
|
+
found = []
|
|
26
|
+
for pat in ("**/*.yml", "**/*.yaml"):
|
|
27
|
+
found += glob.glob(os.path.join(path, pat), recursive=True)
|
|
28
|
+
return sorted({f for f in found if not f.endswith((".test.yml", ".test.yaml"))})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _test_file_for(rule_path):
|
|
32
|
+
base = rule_path.rsplit(".", 1)[0]
|
|
33
|
+
for ext in (".test.yml", ".test.yaml"):
|
|
34
|
+
if os.path.exists(base + ext):
|
|
35
|
+
return base + ext
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_test(args):
|
|
40
|
+
rules = _discover_rules(args.path)
|
|
41
|
+
if not rules:
|
|
42
|
+
print(f"no rules found under {args.path!r}")
|
|
43
|
+
return 1
|
|
44
|
+
passed = failed = untested = 0
|
|
45
|
+
for rule_path in rules:
|
|
46
|
+
rule = _load_yaml(rule_path)
|
|
47
|
+
detection = (rule or {}).get("detection")
|
|
48
|
+
if isinstance(detection, dict):
|
|
49
|
+
for warn in scan_unsupported(detection):
|
|
50
|
+
print(f" ! {rule_path} WARN: {warn} (result may be unreliable)")
|
|
51
|
+
test_path = _test_file_for(rule_path)
|
|
52
|
+
if not test_path:
|
|
53
|
+
untested += 1
|
|
54
|
+
print(f" ? {rule_path} (no .test.yml)")
|
|
55
|
+
continue
|
|
56
|
+
cases = (_load_yaml(test_path) or {}).get("tests", [])
|
|
57
|
+
for case in cases:
|
|
58
|
+
want = case.get("expect", "match") == "match"
|
|
59
|
+
try:
|
|
60
|
+
got = event_matches_rule(detection, case.get("event", {}))
|
|
61
|
+
except Exception as exc: # a rule error should fail the test, not crash the run
|
|
62
|
+
failed += 1
|
|
63
|
+
print(f" x {rule_path} :: {case.get('name', '?')} ERROR: {exc}")
|
|
64
|
+
continue
|
|
65
|
+
if got == want:
|
|
66
|
+
passed += 1
|
|
67
|
+
print(f" . {rule_path} :: {case.get('name', '?')}")
|
|
68
|
+
else:
|
|
69
|
+
failed += 1
|
|
70
|
+
verb = "fired" if got else "did not fire"
|
|
71
|
+
print(f" x {rule_path} :: {case.get('name', '?')} (rule {verb}, expected {case['expect']})")
|
|
72
|
+
print(f"\n{passed} passed, {failed} failed, {untested} rule(s) without tests")
|
|
73
|
+
return 1 if failed else 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _validate_rule(rule):
|
|
77
|
+
errors = []
|
|
78
|
+
for key in REQUIRED_KEYS:
|
|
79
|
+
if key not in rule:
|
|
80
|
+
errors.append(f"missing top-level key '{key}'")
|
|
81
|
+
detection = rule.get("detection", {})
|
|
82
|
+
if isinstance(detection, dict):
|
|
83
|
+
if "condition" not in detection:
|
|
84
|
+
errors.append("detection block missing 'condition'")
|
|
85
|
+
else:
|
|
86
|
+
defined = {k for k in detection if k != "condition"}
|
|
87
|
+
referenced = set(re.findall(r"[A-Za-z0-9_]+\*?", str(detection["condition"])))
|
|
88
|
+
keywords = {"and", "or", "not", "of", "them", "all"}
|
|
89
|
+
for ref in referenced - keywords:
|
|
90
|
+
if ref.isdigit():
|
|
91
|
+
continue
|
|
92
|
+
if ref.endswith("*"):
|
|
93
|
+
if not any(d.startswith(ref[:-1]) for d in defined):
|
|
94
|
+
errors.append(f"condition references '{ref}' but no identifier matches")
|
|
95
|
+
elif ref not in defined:
|
|
96
|
+
errors.append(f"condition references undefined identifier '{ref}'")
|
|
97
|
+
return errors
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_validate(args):
|
|
101
|
+
rules = _discover_rules(args.path)
|
|
102
|
+
if not rules:
|
|
103
|
+
print(f"no rules found under {args.path!r}")
|
|
104
|
+
return 1
|
|
105
|
+
total_errors = total_warnings = 0
|
|
106
|
+
for rule_path in rules:
|
|
107
|
+
rule = _load_yaml(rule_path) or {}
|
|
108
|
+
errors = _validate_rule(rule)
|
|
109
|
+
detection = rule.get("detection")
|
|
110
|
+
warns = scan_unsupported(detection) if isinstance(detection, dict) else []
|
|
111
|
+
total_errors += len(errors)
|
|
112
|
+
total_warnings += len(warns)
|
|
113
|
+
print(f" {'x' if errors else '!' if warns else '.'} {rule_path}")
|
|
114
|
+
for e in errors:
|
|
115
|
+
print(f" - error: {e}")
|
|
116
|
+
for w in warns:
|
|
117
|
+
print(f" - warn: {w}")
|
|
118
|
+
print(f"\n{total_errors} error(s), {total_warnings} warning(s)")
|
|
119
|
+
return 1 if total_errors else 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_INIT_RULE = """\
|
|
123
|
+
title: Whoami Execution
|
|
124
|
+
id: 3a2b1c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
|
|
125
|
+
status: experimental
|
|
126
|
+
description: Example rule from `detkit init` — detects whoami, a common recon command. Replace with your own.
|
|
127
|
+
logsource:
|
|
128
|
+
category: process_creation
|
|
129
|
+
product: windows
|
|
130
|
+
detection:
|
|
131
|
+
selection:
|
|
132
|
+
CommandLine|contains: 'whoami'
|
|
133
|
+
condition: selection
|
|
134
|
+
level: low
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
_INIT_TEST = """\
|
|
138
|
+
# Sample events proving the rule fires (and stays quiet). This is the point of detkit.
|
|
139
|
+
tests:
|
|
140
|
+
- name: fires on whoami
|
|
141
|
+
event: { CommandLine: "cmd /c whoami /all" }
|
|
142
|
+
expect: match
|
|
143
|
+
- name: ignores an unrelated command
|
|
144
|
+
event: { CommandLine: "ipconfig /all" }
|
|
145
|
+
expect: no_match
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
_INIT_WORKFLOW = """\
|
|
149
|
+
name: Detections
|
|
150
|
+
on: [pull_request, push]
|
|
151
|
+
|
|
152
|
+
jobs:
|
|
153
|
+
detkit:
|
|
154
|
+
runs-on: ubuntu-latest
|
|
155
|
+
steps:
|
|
156
|
+
- uses: actions/checkout@v4
|
|
157
|
+
- uses: actions/setup-python@v5
|
|
158
|
+
with:
|
|
159
|
+
python-version: '3.x'
|
|
160
|
+
- run: pip install detkit
|
|
161
|
+
- run: detkit test rules
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_init(args):
|
|
166
|
+
files = {
|
|
167
|
+
os.path.join("rules", "example_rule.yml"): _INIT_RULE,
|
|
168
|
+
os.path.join("rules", "example_rule.test.yml"): _INIT_TEST,
|
|
169
|
+
os.path.join(".github", "workflows", "detections.yml"): _INIT_WORKFLOW,
|
|
170
|
+
}
|
|
171
|
+
created = skipped = 0
|
|
172
|
+
for rel, content in files.items():
|
|
173
|
+
dest = os.path.join(args.path, rel)
|
|
174
|
+
if os.path.exists(dest):
|
|
175
|
+
skipped += 1
|
|
176
|
+
print(f" skipped (exists): {dest}")
|
|
177
|
+
continue
|
|
178
|
+
parent = os.path.dirname(dest)
|
|
179
|
+
if parent:
|
|
180
|
+
os.makedirs(parent, exist_ok=True)
|
|
181
|
+
with open(dest, "w") as f:
|
|
182
|
+
f.write(content)
|
|
183
|
+
created += 1
|
|
184
|
+
print(f" created: {dest}")
|
|
185
|
+
print(f"\n{created} file(s) created, {skipped} skipped.")
|
|
186
|
+
if created:
|
|
187
|
+
print("Next: run `detkit test rules` — you should see it pass.")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_generate(args):
|
|
192
|
+
print(
|
|
193
|
+
"`detkit generate` is not implemented yet.\n"
|
|
194
|
+
"Planned: draft a Sigma rule AND its test cases from a natural-language\n"
|
|
195
|
+
"threat description via an LLM; you review and commit the code.\n"
|
|
196
|
+
"Design rule: generation must always emit tests, so every AI-drafted\n"
|
|
197
|
+
"detection ships with a runnable check. LLM client stays swappable."
|
|
198
|
+
)
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv=None):
|
|
203
|
+
parser = argparse.ArgumentParser(prog="detkit", description="Test Sigma detection rules as code.")
|
|
204
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
205
|
+
|
|
206
|
+
p_test = sub.add_parser("test", help="run rule tests against sample events")
|
|
207
|
+
p_test.add_argument("path", nargs="?", default=".")
|
|
208
|
+
p_test.set_defaults(func=cmd_test)
|
|
209
|
+
|
|
210
|
+
p_val = sub.add_parser("validate", help="structurally validate rules")
|
|
211
|
+
p_val.add_argument("path", nargs="?", default=".")
|
|
212
|
+
p_val.set_defaults(func=cmd_validate)
|
|
213
|
+
|
|
214
|
+
p_init = sub.add_parser("init", help="scaffold a starter rule + test + CI workflow")
|
|
215
|
+
p_init.add_argument("path", nargs="?", default=".")
|
|
216
|
+
p_init.set_defaults(func=cmd_init)
|
|
217
|
+
|
|
218
|
+
p_gen = sub.add_parser("generate", help="(planned) AI-draft a rule + tests")
|
|
219
|
+
p_gen.set_defaults(func=cmd_generate)
|
|
220
|
+
|
|
221
|
+
args = parser.parse_args(argv)
|
|
222
|
+
return args.func(args)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main())
|
detkit/evaluator.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Evaluate a Sigma rule's detection block against a single log event.
|
|
2
|
+
|
|
3
|
+
This is the core of detkit: unit-testing detection rules locally, the way
|
|
4
|
+
dbt tests data models. It implements the common Sigma subset.
|
|
5
|
+
|
|
6
|
+
# ponytail: common-subset evaluator (modifiers contains/startswith/endswith/re/cased/all/cidr,
|
|
7
|
+
# value wildcards * and ?, list-OR, `N of them | N of prefix*`, and/or/not/parens). Ceilings:
|
|
8
|
+
# no nested-field (dot) access, no |base64/|windash modifiers, no count()/timeframe aggregation,
|
|
9
|
+
# matching is case-insensitive by default (real SIEM backends differ). Anything unsupported is
|
|
10
|
+
# reported by scan_unsupported() so it fails loud instead of returning a wrong boolean. Upgrade
|
|
11
|
+
# path: delegate to pySigma pipelines or a per-backend evaluator when a rule needs the full spec.
|
|
12
|
+
"""
|
|
13
|
+
import ipaddress
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
__all__ = ["event_matches_rule", "scan_unsupported"]
|
|
17
|
+
|
|
18
|
+
_SUPPORTED_MODS = {"contains", "startswith", "endswith", "re", "cased", "all", "cidr"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def event_matches_rule(detection: dict, event: dict) -> bool:
|
|
22
|
+
"""Return True if `event` triggers the Sigma `detection` block."""
|
|
23
|
+
if "condition" not in detection:
|
|
24
|
+
raise ValueError("detection block missing 'condition'")
|
|
25
|
+
condition = detection["condition"]
|
|
26
|
+
identifiers = {k: v for k, v in detection.items() if k != "condition"}
|
|
27
|
+
matches = {name: _matches_identifier(spec, event) for name, spec in identifiers.items()}
|
|
28
|
+
conditions = condition if isinstance(condition, list) else [condition]
|
|
29
|
+
return any(_eval_condition(str(c), matches) for c in conditions)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _matches_identifier(spec, event) -> bool:
|
|
33
|
+
if isinstance(spec, list):
|
|
34
|
+
# list of maps => OR of maps; list of scalars => keyword search (any)
|
|
35
|
+
return any(
|
|
36
|
+
_matches_map(item, event) if isinstance(item, dict) else _keyword_match(item, event)
|
|
37
|
+
for item in spec
|
|
38
|
+
)
|
|
39
|
+
if isinstance(spec, dict):
|
|
40
|
+
return _matches_map(spec, event)
|
|
41
|
+
return _keyword_match(spec, event)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _matches_map(spec: dict, event: dict) -> bool:
|
|
45
|
+
return all(_matches_field(key, expected, event) for key, expected in spec.items())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _matches_field(key: str, expected, event: dict) -> bool:
|
|
49
|
+
parts = key.split("|")
|
|
50
|
+
field, mods = parts[0], [m.lower() for m in parts[1:]]
|
|
51
|
+
if field not in event:
|
|
52
|
+
return False
|
|
53
|
+
ev = event[field]
|
|
54
|
+
if isinstance(expected, list):
|
|
55
|
+
results = [_match_scalar(ev, x, mods) for x in expected]
|
|
56
|
+
return all(results) if "all" in mods else any(results)
|
|
57
|
+
return _match_scalar(ev, expected, mods)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _match_scalar(ev, expected, mods) -> bool:
|
|
61
|
+
cased = "cased" in mods
|
|
62
|
+
if "cidr" in mods:
|
|
63
|
+
return _cidr_match(ev, expected)
|
|
64
|
+
if "re" in mods:
|
|
65
|
+
return re.search(str(expected), str(ev), 0 if cased else re.IGNORECASE) is not None
|
|
66
|
+
if expected is None:
|
|
67
|
+
return ev is None
|
|
68
|
+
if isinstance(expected, bool):
|
|
69
|
+
return ev == expected
|
|
70
|
+
string_mod = any(m in mods for m in ("contains", "startswith", "endswith"))
|
|
71
|
+
if isinstance(expected, (int, float)) and not string_mod:
|
|
72
|
+
try:
|
|
73
|
+
return float(ev) == float(expected)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return str(ev) == str(expected)
|
|
76
|
+
a, b = str(ev), str(expected)
|
|
77
|
+
if not cased:
|
|
78
|
+
a, b = a.lower(), b.lower()
|
|
79
|
+
if "contains" in mods:
|
|
80
|
+
return b in a
|
|
81
|
+
if "startswith" in mods:
|
|
82
|
+
return a.startswith(b)
|
|
83
|
+
if "endswith" in mods:
|
|
84
|
+
return a.endswith(b)
|
|
85
|
+
if _has_glob(b): # Sigma value wildcards: * = any run, ? = one char
|
|
86
|
+
return re.fullmatch(_glob_to_regex(b), a) is not None
|
|
87
|
+
return a == b
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _has_glob(value: str) -> bool:
|
|
91
|
+
return re.search(r"(?<!\\)[*?]", value) is not None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _glob_to_regex(pattern: str) -> str:
|
|
95
|
+
out, i = [], 0
|
|
96
|
+
while i < len(pattern):
|
|
97
|
+
c = pattern[i]
|
|
98
|
+
if c == "\\" and i + 1 < len(pattern) and pattern[i + 1] in "*?\\":
|
|
99
|
+
out.append(re.escape(pattern[i + 1]))
|
|
100
|
+
i += 2
|
|
101
|
+
continue
|
|
102
|
+
out.append(".*" if c == "*" else "." if c == "?" else re.escape(c))
|
|
103
|
+
i += 1
|
|
104
|
+
return "".join(out)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cidr_match(ev, expected) -> bool:
|
|
108
|
+
try:
|
|
109
|
+
return ipaddress.ip_address(str(ev)) in ipaddress.ip_network(str(expected), strict=False)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def scan_unsupported(detection: dict) -> list:
|
|
115
|
+
"""Report Sigma features this evaluator cannot handle, so a rule fails loud
|
|
116
|
+
instead of silently returning a wrong result. Returns human-readable strings."""
|
|
117
|
+
findings, seen = [], set()
|
|
118
|
+
|
|
119
|
+
def check_key(key):
|
|
120
|
+
parts = key.split("|")
|
|
121
|
+
field = parts[0]
|
|
122
|
+
for mod in (m.lower() for m in parts[1:]):
|
|
123
|
+
if mod not in _SUPPORTED_MODS and ("m", mod) not in seen:
|
|
124
|
+
seen.add(("m", mod))
|
|
125
|
+
findings.append(f"unsupported modifier '|{mod}' (on '{field}')")
|
|
126
|
+
if "." in field and ("f", field) not in seen:
|
|
127
|
+
seen.add(("f", field))
|
|
128
|
+
findings.append(f"nested/dotted field '{field}' (matched only as a flat key)")
|
|
129
|
+
|
|
130
|
+
def walk(spec):
|
|
131
|
+
if isinstance(spec, dict):
|
|
132
|
+
for key in spec:
|
|
133
|
+
check_key(key)
|
|
134
|
+
elif isinstance(spec, list):
|
|
135
|
+
for item in spec:
|
|
136
|
+
walk(item)
|
|
137
|
+
|
|
138
|
+
for name, spec in detection.items():
|
|
139
|
+
if name != "condition":
|
|
140
|
+
walk(spec)
|
|
141
|
+
return findings
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _keyword_match(keyword, event: dict) -> bool:
|
|
145
|
+
needle = str(keyword).lower()
|
|
146
|
+
return any(needle in str(v).lower() for v in event.values())
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _eval_condition(condition: str, matches: dict) -> bool:
|
|
150
|
+
def _select(target):
|
|
151
|
+
if target == "them":
|
|
152
|
+
return list(matches)
|
|
153
|
+
if target.endswith("*"):
|
|
154
|
+
return [n for n in matches if n.startswith(target[:-1])]
|
|
155
|
+
return [target]
|
|
156
|
+
|
|
157
|
+
def _repl_quant(m):
|
|
158
|
+
quant, target = m.group(1), m.group(2)
|
|
159
|
+
vals = [bool(matches.get(n, False)) for n in _select(target)]
|
|
160
|
+
if quant == "all":
|
|
161
|
+
return "true" if vals and all(vals) else "false"
|
|
162
|
+
return "true" if sum(vals) >= int(quant) else "false"
|
|
163
|
+
|
|
164
|
+
expr = re.sub(r"\b(\d+|all)\s+of\s+([A-Za-z0-9_*]+)", _repl_quant, condition)
|
|
165
|
+
tokens = []
|
|
166
|
+
for tok in re.findall(r"\(|\)|[A-Za-z0-9_*]+", expr):
|
|
167
|
+
low = tok.lower()
|
|
168
|
+
if low in ("and", "or", "not", "true", "false"):
|
|
169
|
+
tokens.append(low)
|
|
170
|
+
elif tok in ("(", ")"):
|
|
171
|
+
tokens.append(tok)
|
|
172
|
+
else:
|
|
173
|
+
tokens.append("true" if matches.get(tok, False) else "false")
|
|
174
|
+
return _parse_bool(tokens)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_bool(tokens) -> bool:
|
|
178
|
+
pos = 0
|
|
179
|
+
|
|
180
|
+
def peek():
|
|
181
|
+
return tokens[pos] if pos < len(tokens) else None
|
|
182
|
+
|
|
183
|
+
def eat():
|
|
184
|
+
nonlocal pos
|
|
185
|
+
tok = tokens[pos]
|
|
186
|
+
pos += 1
|
|
187
|
+
return tok
|
|
188
|
+
|
|
189
|
+
def parse_or():
|
|
190
|
+
v = parse_and()
|
|
191
|
+
while peek() == "or":
|
|
192
|
+
eat()
|
|
193
|
+
v = parse_and() or v
|
|
194
|
+
return v
|
|
195
|
+
|
|
196
|
+
def parse_and():
|
|
197
|
+
v = parse_not()
|
|
198
|
+
while peek() == "and":
|
|
199
|
+
eat()
|
|
200
|
+
v = parse_not() and v
|
|
201
|
+
return v
|
|
202
|
+
|
|
203
|
+
def parse_not():
|
|
204
|
+
if peek() == "not":
|
|
205
|
+
eat()
|
|
206
|
+
return not parse_not()
|
|
207
|
+
return parse_atom()
|
|
208
|
+
|
|
209
|
+
def parse_atom():
|
|
210
|
+
tok = eat() if peek() is not None else None
|
|
211
|
+
if tok == "(":
|
|
212
|
+
v = parse_or()
|
|
213
|
+
if peek() == ")":
|
|
214
|
+
eat()
|
|
215
|
+
return v
|
|
216
|
+
if tok == "true":
|
|
217
|
+
return True
|
|
218
|
+
if tok == "false":
|
|
219
|
+
return False
|
|
220
|
+
raise ValueError(f"cannot parse condition near {tok!r}")
|
|
221
|
+
|
|
222
|
+
if not tokens:
|
|
223
|
+
raise ValueError("empty condition")
|
|
224
|
+
return parse_or()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: detkit-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: dbt for detections — test, validate, and CI-gate Sigma detection rules as code.
|
|
5
|
+
Author: detkit
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ELSATOAH/detkit
|
|
8
|
+
Project-URL: Repository, https://github.com/ELSATOAH/detkit
|
|
9
|
+
Project-URL: Issues, https://github.com/ELSATOAH/detkit/issues
|
|
10
|
+
Keywords: sigma,detection-engineering,detection-as-code,siem,security,detections,blue-team,cybersecurity
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pyyaml>=6
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# detkit — dbt for detections
|
|
24
|
+
|
|
25
|
+
[](https://github.com/ELSATOAH/detkit/actions/workflows/ci.yml) [](LICENSE)
|
|
26
|
+
|
|
27
|
+
Test, validate, and CI-gate your Sigma detection rules **as code**, before they
|
|
28
|
+
ever reach production.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
Detection engineers write rules in [Sigma](https://sigmahq.io/), commit them to
|
|
33
|
+
Git, and ship them to a SIEM — but there's no standard way to *unit-test* a rule
|
|
34
|
+
locally: does it actually fire on the attack it targets, and stay quiet on benign
|
|
35
|
+
traffic? Today that's checked by hand, or in prod. detkit closes that loop.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
detkit test ./rules # run every rule against its sample events
|
|
39
|
+
detkit validate ./rules # structural + condition-reference checks
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
detkit is **open source (MIT)**, **self-hostable**, and **vendor-neutral** — it
|
|
43
|
+
sits *alongside* your SIEM (Wazuh, Elastic, Splunk, Sentinel), not in front of
|
|
44
|
+
it. Your logs and rules never leave your machine or CI runner.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pipx install git+https://github.com/ELSATOAH/detkit.git # PyPI: `pipx install detkit-cli` (coming soon)
|
|
50
|
+
|
|
51
|
+
detkit init # scaffold a starter rule + test + a CI workflow
|
|
52
|
+
detkit test rules # -> green on the first run
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each rule gets a sibling `*.test.yml` describing sample events and whether the
|
|
56
|
+
rule should match:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
# whoami_execution.test.yml
|
|
60
|
+
tests:
|
|
61
|
+
- name: fires on whoami run by a normal user
|
|
62
|
+
event: { EventID: 4688, CommandLine: "cmd /c whoami /all", User: "alice" }
|
|
63
|
+
expect: match
|
|
64
|
+
- name: suppressed for SYSTEM
|
|
65
|
+
event: { EventID: 4688, CommandLine: "whoami", User: "SYSTEM" }
|
|
66
|
+
expect: no_match
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`detkit test` exits non-zero on any failure, so you can drop it straight into CI
|
|
70
|
+
and block a pull request that breaks a detection.
|
|
71
|
+
|
|
72
|
+
## Use it in CI (GitHub Actions)
|
|
73
|
+
|
|
74
|
+
Drop this into your **rules** repo to gate every pull request:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
# .github/workflows/detections.yml
|
|
78
|
+
name: Detections
|
|
79
|
+
on: [pull_request]
|
|
80
|
+
jobs:
|
|
81
|
+
detkit:
|
|
82
|
+
runs-on: ubuntu-latest
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@v4
|
|
85
|
+
- uses: ELSATOAH/detkit@v0 # this repo's composite action
|
|
86
|
+
with:
|
|
87
|
+
path: rules # where your Sigma rules live
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A rule whose `*.test.yml` no longer passes now fails the check and blocks the
|
|
91
|
+
merge — the same safety net dbt gives analytics engineers.
|
|
92
|
+
|
|
93
|
+
## Why this, why now
|
|
94
|
+
|
|
95
|
+
- **Detection-as-code is mainstream** (SigmaHQ, Elastic detection-rules, Splunk
|
|
96
|
+
ESCU) but the *test* step is missing — the same gap dbt filled for analytics.
|
|
97
|
+
- **Self-host is a hard requirement, not a preference:** security telemetry can't
|
|
98
|
+
be shipped to someone else's cloud. That's the wedge closed SaaS SOC tools
|
|
99
|
+
(Dropzone, Prophet, Intezer) structurally can't serve.
|
|
100
|
+
- **The community distributes it:** good detection tooling spreads bottom-up on
|
|
101
|
+
GitHub. detkit is built to be forked, extended, and shared.
|
|
102
|
+
|
|
103
|
+
## Roadmap
|
|
104
|
+
|
|
105
|
+
- `detkit generate` — AI-draft a rule **and its tests** from a natural-language
|
|
106
|
+
threat description (tests are mandatory, never optional).
|
|
107
|
+
- More log schemas / field-mapping so one rule tests against multiple log sources.
|
|
108
|
+
- Close the remaining gaps detkit currently warns on: nested/dotted field access
|
|
109
|
+
(~3% of rules) and `base64` modifiers — likely via
|
|
110
|
+
[pySigma](https://github.com/SigmaHQ/pySigma) for full-spec parsing.
|
|
111
|
+
- Managed cloud (hosted runs, SSO, shared rule/test libraries) — the paid tier.
|
|
112
|
+
The tool stays free forever.
|
|
113
|
+
|
|
114
|
+
## Status
|
|
115
|
+
|
|
116
|
+
Early, but the core is real. detkit evaluates a rule's `detection`/`condition`
|
|
117
|
+
against log events and runs tests around it — covering the features used by the
|
|
118
|
+
**large majority of published SigmaHQ rules**: `contains`/`startswith`/`endswith`/
|
|
119
|
+
`re` modifiers, value wildcards (`*`/`?`), `|cidr`, list-as-OR, keyword lists, and
|
|
120
|
+
`X of` / `all of` conditions.
|
|
121
|
+
|
|
122
|
+
What it can't yet evaluate (nested/dotted fields, `base64` modifiers) it **flags
|
|
123
|
+
loudly** — `detkit validate` and `detkit test` print a `WARN` rather than return a
|
|
124
|
+
confident wrong answer. A detection tool that's silently wrong is worse than none.
|
|
125
|
+
|
|
126
|
+
Run the checks: `python tests/test_evaluator.py`. Limits are marked with
|
|
127
|
+
`# ponytail:` comments in `detkit/evaluator.py`.
|
|
128
|
+
|
|
129
|
+
MIT licensed. Contributions welcome.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
detkit/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
detkit/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
detkit/cli.py,sha256=H8zxEK63CdNFzKa3NuQNdoOIf6QCouklVdWGSHa7jvk,7596
|
|
4
|
+
detkit/evaluator.py,sha256=RUlcXl0bbtuHTws0CX5Qoaa4_vziOsNOmHHnkYEjwCs,7612
|
|
5
|
+
detkit_cli-0.1.0.dist-info/licenses/LICENSE,sha256=gUTR-yuqi6lMRwzBBsZco7CRlynNl5Sacc65he7gxP8,1076
|
|
6
|
+
detkit_cli-0.1.0.dist-info/METADATA,sha256=2EPmo2WriGKH9hiMOWTAREE4x-GN66Zhua42qq5-2Xk,5224
|
|
7
|
+
detkit_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
detkit_cli-0.1.0.dist-info/entry_points.txt,sha256=5Ss6DD3bahTO51SnpVXYnb2QMHmS2BhptwdJYUagHBQ,43
|
|
9
|
+
detkit_cli-0.1.0.dist-info/top_level.txt,sha256=wOgUHJ-EyBtlo72xOnX_71N9K08jLcsXnmNlF3qCD6E,7
|
|
10
|
+
detkit_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 detkit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
detkit
|