detkit-cli 0.1.0__tar.gz

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.
@@ -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,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
+ [![CI](https://github.com/ELSATOAH/detkit/actions/workflows/ci.yml/badge.svg)](https://github.com/ELSATOAH/detkit/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
26
+
27
+ Test, validate, and CI-gate your Sigma detection rules **as code**, before they
28
+ ever reach production.
29
+
30
+ ![detkit catching a broken detection before it ships](detkit-demo.gif)
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,107 @@
1
+ # detkit — dbt for detections
2
+
3
+ [![CI](https://github.com/ELSATOAH/detkit/actions/workflows/ci.yml/badge.svg)](https://github.com/ELSATOAH/detkit/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
+
5
+ Test, validate, and CI-gate your Sigma detection rules **as code**, before they
6
+ ever reach production.
7
+
8
+ ![detkit catching a broken detection before it ships](detkit-demo.gif)
9
+
10
+ Detection engineers write rules in [Sigma](https://sigmahq.io/), commit them to
11
+ Git, and ship them to a SIEM — but there's no standard way to *unit-test* a rule
12
+ locally: does it actually fire on the attack it targets, and stay quiet on benign
13
+ traffic? Today that's checked by hand, or in prod. detkit closes that loop.
14
+
15
+ ```
16
+ detkit test ./rules # run every rule against its sample events
17
+ detkit validate ./rules # structural + condition-reference checks
18
+ ```
19
+
20
+ detkit is **open source (MIT)**, **self-hostable**, and **vendor-neutral** — it
21
+ sits *alongside* your SIEM (Wazuh, Elastic, Splunk, Sentinel), not in front of
22
+ it. Your logs and rules never leave your machine or CI runner.
23
+
24
+ ## Quickstart
25
+
26
+ ```bash
27
+ pipx install git+https://github.com/ELSATOAH/detkit.git # PyPI: `pipx install detkit-cli` (coming soon)
28
+
29
+ detkit init # scaffold a starter rule + test + a CI workflow
30
+ detkit test rules # -> green on the first run
31
+ ```
32
+
33
+ Each rule gets a sibling `*.test.yml` describing sample events and whether the
34
+ rule should match:
35
+
36
+ ```yaml
37
+ # whoami_execution.test.yml
38
+ tests:
39
+ - name: fires on whoami run by a normal user
40
+ event: { EventID: 4688, CommandLine: "cmd /c whoami /all", User: "alice" }
41
+ expect: match
42
+ - name: suppressed for SYSTEM
43
+ event: { EventID: 4688, CommandLine: "whoami", User: "SYSTEM" }
44
+ expect: no_match
45
+ ```
46
+
47
+ `detkit test` exits non-zero on any failure, so you can drop it straight into CI
48
+ and block a pull request that breaks a detection.
49
+
50
+ ## Use it in CI (GitHub Actions)
51
+
52
+ Drop this into your **rules** repo to gate every pull request:
53
+
54
+ ```yaml
55
+ # .github/workflows/detections.yml
56
+ name: Detections
57
+ on: [pull_request]
58
+ jobs:
59
+ detkit:
60
+ runs-on: ubuntu-latest
61
+ steps:
62
+ - uses: actions/checkout@v4
63
+ - uses: ELSATOAH/detkit@v0 # this repo's composite action
64
+ with:
65
+ path: rules # where your Sigma rules live
66
+ ```
67
+
68
+ A rule whose `*.test.yml` no longer passes now fails the check and blocks the
69
+ merge — the same safety net dbt gives analytics engineers.
70
+
71
+ ## Why this, why now
72
+
73
+ - **Detection-as-code is mainstream** (SigmaHQ, Elastic detection-rules, Splunk
74
+ ESCU) but the *test* step is missing — the same gap dbt filled for analytics.
75
+ - **Self-host is a hard requirement, not a preference:** security telemetry can't
76
+ be shipped to someone else's cloud. That's the wedge closed SaaS SOC tools
77
+ (Dropzone, Prophet, Intezer) structurally can't serve.
78
+ - **The community distributes it:** good detection tooling spreads bottom-up on
79
+ GitHub. detkit is built to be forked, extended, and shared.
80
+
81
+ ## Roadmap
82
+
83
+ - `detkit generate` — AI-draft a rule **and its tests** from a natural-language
84
+ threat description (tests are mandatory, never optional).
85
+ - More log schemas / field-mapping so one rule tests against multiple log sources.
86
+ - Close the remaining gaps detkit currently warns on: nested/dotted field access
87
+ (~3% of rules) and `base64` modifiers — likely via
88
+ [pySigma](https://github.com/SigmaHQ/pySigma) for full-spec parsing.
89
+ - Managed cloud (hosted runs, SSO, shared rule/test libraries) — the paid tier.
90
+ The tool stays free forever.
91
+
92
+ ## Status
93
+
94
+ Early, but the core is real. detkit evaluates a rule's `detection`/`condition`
95
+ against log events and runs tests around it — covering the features used by the
96
+ **large majority of published SigmaHQ rules**: `contains`/`startswith`/`endswith`/
97
+ `re` modifiers, value wildcards (`*`/`?`), `|cidr`, list-as-OR, keyword lists, and
98
+ `X of` / `all of` conditions.
99
+
100
+ What it can't yet evaluate (nested/dotted fields, `base64` modifiers) it **flags
101
+ loudly** — `detkit validate` and `detkit test` print a `WARN` rather than return a
102
+ confident wrong answer. A detection tool that's silently wrong is worse than none.
103
+
104
+ Run the checks: `python tests/test_evaluator.py`. Limits are marked with
105
+ `# ponytail:` comments in `detkit/evaluator.py`.
106
+
107
+ MIT licensed. Contributions welcome.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -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())
@@ -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
+ [![CI](https://github.com/ELSATOAH/detkit/actions/workflows/ci.yml/badge.svg)](https://github.com/ELSATOAH/detkit/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
26
+
27
+ Test, validate, and CI-gate your Sigma detection rules **as code**, before they
28
+ ever reach production.
29
+
30
+ ![detkit catching a broken detection before it ships](detkit-demo.gif)
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,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ detkit/__init__.py
5
+ detkit/__main__.py
6
+ detkit/cli.py
7
+ detkit/evaluator.py
8
+ detkit_cli.egg-info/PKG-INFO
9
+ detkit_cli.egg-info/SOURCES.txt
10
+ detkit_cli.egg-info/dependency_links.txt
11
+ detkit_cli.egg-info/entry_points.txt
12
+ detkit_cli.egg-info/requires.txt
13
+ detkit_cli.egg-info/top_level.txt
14
+ tests/test_evaluator.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ detkit = detkit.cli:main
@@ -0,0 +1 @@
1
+ pyyaml>=6
@@ -0,0 +1 @@
1
+ detkit
@@ -0,0 +1,38 @@
1
+ [project]
2
+ # PyPI distribution name (`detkit` was taken); the import package and CLI command
3
+ # both stay `detkit`, so users still run `detkit ...` after `pipx install detkit-cli`.
4
+ name = "detkit-cli"
5
+ version = "0.1.0"
6
+ description = "dbt for detections — test, validate, and CI-gate Sigma detection rules as code."
7
+ readme = "README.md"
8
+ requires-python = ">=3.9"
9
+ license = { text = "MIT" }
10
+ authors = [{ name = "detkit" }]
11
+ keywords = [
12
+ "sigma", "detection-engineering", "detection-as-code",
13
+ "siem", "security", "detections", "blue-team", "cybersecurity",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Information Technology",
18
+ "Topic :: Security",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Operating System :: OS Independent",
22
+ ]
23
+ dependencies = ["pyyaml>=6"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/ELSATOAH/detkit"
27
+ Repository = "https://github.com/ELSATOAH/detkit"
28
+ Issues = "https://github.com/ELSATOAH/detkit/issues"
29
+
30
+ [project.scripts]
31
+ detkit = "detkit.cli:main"
32
+
33
+ [build-system]
34
+ requires = ["setuptools>=61"]
35
+ build-backend = "setuptools.build_meta"
36
+
37
+ [tool.setuptools.packages.find]
38
+ include = ["detkit*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,110 @@
1
+ """Runnable check for the Sigma evaluator core.
2
+
3
+ No framework needed: `python tests/test_evaluator.py` runs every assert and
4
+ prints OK, or raises on the first failure. pytest also discovers it.
5
+ """
6
+ import os
7
+ import sys
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
10
+
11
+ from detkit.evaluator import event_matches_rule, scan_unsupported # noqa: E402
12
+
13
+
14
+ def test_equals_contains_and_not():
15
+ det = {
16
+ "selection": {"EventID": 4688, "CommandLine|contains": "whoami"},
17
+ "filter": {"User": "SYSTEM"},
18
+ "condition": "selection and not filter",
19
+ }
20
+ assert event_matches_rule(det, {"EventID": 4688, "CommandLine": "cmd /c whoami", "User": "alice"})
21
+ assert not event_matches_rule(det, {"EventID": 4688, "CommandLine": "whoami", "User": "SYSTEM"})
22
+ assert not event_matches_rule(det, {"EventID": 4688, "CommandLine": "ipconfig", "User": "alice"})
23
+
24
+
25
+ def test_numeric_string_coercion():
26
+ det = {"sel": {"EventID": 4688}, "condition": "sel"}
27
+ assert event_matches_rule(det, {"EventID": "4688"}) # log field arrives as string
28
+ assert not event_matches_rule(det, {"EventID": 4689})
29
+
30
+
31
+ def test_list_value_is_or():
32
+ det = {"sel": {"Image|endswith": ["\\cmd.exe", "\\powershell.exe"]}, "condition": "sel"}
33
+ assert event_matches_rule(det, {"Image": "C:\\Windows\\System32\\cmd.exe"})
34
+ assert not event_matches_rule(det, {"Image": "C:\\Windows\\explorer.exe"})
35
+
36
+
37
+ def test_all_modifier_is_and():
38
+ det = {"sel": {"CommandLine|contains|all": ["-enc", "bypass"]}, "condition": "sel"}
39
+ assert event_matches_rule(det, {"CommandLine": "powershell -enc AAAA -ExecutionPolicy bypass"})
40
+ assert not event_matches_rule(det, {"CommandLine": "powershell -enc AAAA"})
41
+
42
+
43
+ def test_missing_field_no_match():
44
+ det = {"sel": {"CommandLine|contains": "whoami"}, "condition": "sel"}
45
+ assert not event_matches_rule(det, {"EventID": 4688})
46
+
47
+
48
+ def test_one_of_them_and_all_of_prefix():
49
+ det = {
50
+ "selection_a": {"EventID": 1},
51
+ "selection_b": {"EventID": 2},
52
+ "condition": "1 of them",
53
+ }
54
+ assert event_matches_rule(det, {"EventID": 2})
55
+ det2 = {
56
+ "selection_a": {"EventID": 1},
57
+ "selection_b": {"UserName": "root"},
58
+ "condition": "all of selection*",
59
+ }
60
+ assert event_matches_rule(det2, {"EventID": 1, "UserName": "root"})
61
+ assert not event_matches_rule(det2, {"EventID": 1})
62
+
63
+
64
+ def test_keyword_list_search():
65
+ det = {"keywords": ["mimikatz", "sekurlsa"], "condition": "keywords"}
66
+ assert event_matches_rule(det, {"CommandLine": "run sekurlsa::logonpasswords"})
67
+ assert not event_matches_rule(det, {"CommandLine": "dir /s"})
68
+
69
+
70
+ def test_regex_modifier():
71
+ det = {"sel": {"CommandLine|re": r"whoami(\.exe)?\s"}, "condition": "sel"}
72
+ assert event_matches_rule(det, {"CommandLine": "whoami.exe /all"})
73
+ assert not event_matches_rule(det, {"CommandLine": "whoamidaemon"})
74
+
75
+
76
+ def test_value_wildcard():
77
+ # regression for dogfood bug #1: `v*.compute.firewalls.delete` was matched literally
78
+ det = {"sel": {"MethodName": "v*.compute.firewalls.delete"}, "condition": "sel"}
79
+ assert event_matches_rule(det, {"MethodName": "v1.compute.firewalls.delete"})
80
+ assert not event_matches_rule(det, {"MethodName": "v1.compute.networks.delete"})
81
+ det_q = {"sel": {"Code": "AC?E"}, "condition": "sel"}
82
+ assert event_matches_rule(det_q, {"Code": "ACME"})
83
+ assert not event_matches_rule(det_q, {"Code": "ACabE"})
84
+
85
+
86
+ def test_cidr_modifier():
87
+ # regression for dogfood bug #3: |cidr degraded to string equality, so filters never fired
88
+ det = {"filter": {"IpAddress|cidr": "10.0.0.0/8"}, "condition": "filter"}
89
+ assert event_matches_rule(det, {"IpAddress": "10.20.30.40"})
90
+ assert not event_matches_rule(det, {"IpAddress": "8.8.8.8"})
91
+ assert not event_matches_rule(det, {"IpAddress": "not-an-ip"})
92
+
93
+
94
+ def test_scan_flags_unsupported():
95
+ det = {
96
+ "sel": {"CommandLine|base64offset|contains": "abc", "id.orig_h": "1.2.3.4"},
97
+ "condition": "sel",
98
+ }
99
+ findings = scan_unsupported(det)
100
+ assert any("base64offset" in f for f in findings)
101
+ assert any("id.orig_h" in f for f in findings)
102
+ assert scan_unsupported({"sel": {"EventID": 1}, "condition": "sel"}) == []
103
+
104
+
105
+ if __name__ == "__main__":
106
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
107
+ for fn in fns:
108
+ fn()
109
+ print(f"ok {fn.__name__}")
110
+ print(f"\n{len(fns)} checks passed")