sigma2sql 0.2.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.
- sigma2sql/__init__.py +21 -0
- sigma2sql/__main__.py +3 -0
- sigma2sql/cli.py +320 -0
- sigma2sql/collection.py +62 -0
- sigma2sql/condition.py +119 -0
- sigma2sql/correlation.py +82 -0
- sigma2sql/detection.py +230 -0
- sigma2sql/errors.py +6 -0
- sigma2sql/expr.py +140 -0
- sigma2sql/hunt.py +116 -0
- sigma2sql/profile.py +119 -0
- sigma2sql/profiles/__init__.py +0 -0
- sigma2sql/profiles/ecs-winlogbeat.yml +43 -0
- sigma2sql/profiles/evtx.yml +71 -0
- sigma2sql/profiles/ocsf.yml +26 -0
- sigma2sql/profiles/sysmon.yml +26 -0
- sigma2sql/py.typed +0 -0
- sigma2sql/rule.py +260 -0
- sigma2sql/runner.py +82 -0
- sigma2sql/sql.py +325 -0
- sigma2sql-0.2.0.dist-info/METADATA +277 -0
- sigma2sql-0.2.0.dist-info/RECORD +25 -0
- sigma2sql-0.2.0.dist-info/WHEEL +4 -0
- sigma2sql-0.2.0.dist-info/entry_points.txt +2 -0
- sigma2sql-0.2.0.dist-info/licenses/LICENSE +21 -0
sigma2sql/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from sigma2sql.collection import Collection
|
|
2
|
+
from sigma2sql.correlation import correlation_query
|
|
3
|
+
from sigma2sql.errors import RuleError, UnsupportedRule
|
|
4
|
+
from sigma2sql.rule import Correlation, Rule, load_documents, load_file, load_rule, load_rule_file
|
|
5
|
+
from sigma2sql.sql import Target, to_query, to_where
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Collection",
|
|
9
|
+
"Correlation",
|
|
10
|
+
"Rule",
|
|
11
|
+
"RuleError",
|
|
12
|
+
"Target",
|
|
13
|
+
"UnsupportedRule",
|
|
14
|
+
"correlation_query",
|
|
15
|
+
"load_documents",
|
|
16
|
+
"load_file",
|
|
17
|
+
"load_rule",
|
|
18
|
+
"load_rule_file",
|
|
19
|
+
"to_query",
|
|
20
|
+
"to_where",
|
|
21
|
+
]
|
sigma2sql/__main__.py
ADDED
sigma2sql/cli.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Convert Sigma rules to SQL, test them against sample events and hunt through log files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from sigma2sql.collection import Collection, rule_files
|
|
17
|
+
from sigma2sql.correlation import correlation_query
|
|
18
|
+
from sigma2sql.errors import RuleError, UnsupportedRule
|
|
19
|
+
from sigma2sql.hunt import LEVELS, Hit, Report, hunt
|
|
20
|
+
from sigma2sql.profile import apply_profile, builtin_profiles, load_profile
|
|
21
|
+
from sigma2sql.rule import Correlation, Document
|
|
22
|
+
from sigma2sql.runner import matching_events, read_jsonl, rows
|
|
23
|
+
from sigma2sql.runner import target as runner_target
|
|
24
|
+
from sigma2sql.sql import DIALECTS, Target, to_query
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
28
|
+
parser = argparse.ArgumentParser(prog="sigma2sql", description=__doc__)
|
|
29
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version('sigma2sql')}")
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
convert = sub.add_parser("convert", help="print SQL for rules and correlations")
|
|
33
|
+
convert.add_argument("paths", nargs="+", type=Path, help="rule files or directories")
|
|
34
|
+
convert.add_argument("-d", "--dialect", choices=DIALECTS, default="sqlite")
|
|
35
|
+
convert.add_argument("-t", "--table", default="events")
|
|
36
|
+
convert.add_argument("--json-column", help="read fields from this JSON column")
|
|
37
|
+
convert.add_argument("--keyword-column", help="column searched by keyword rules")
|
|
38
|
+
_field_options(convert)
|
|
39
|
+
|
|
40
|
+
test = sub.add_parser("test", help="run a rule file against JSON Lines events")
|
|
41
|
+
test.add_argument("rule", type=Path)
|
|
42
|
+
test.add_argument("events", type=Path, help="one JSON object per line")
|
|
43
|
+
test.add_argument(
|
|
44
|
+
"--expect",
|
|
45
|
+
choices=("match", "none"),
|
|
46
|
+
help="exit 1 unless every rule in the file matches / nothing matches",
|
|
47
|
+
)
|
|
48
|
+
_field_options(test)
|
|
49
|
+
|
|
50
|
+
hunt_p = sub.add_parser("hunt", help="run rules over JSON, Parquet or CSV logs with DuckDB")
|
|
51
|
+
hunt_p.add_argument("rules", nargs="+", type=Path, help="rule files or directories")
|
|
52
|
+
hunt_p.add_argument(
|
|
53
|
+
"-l", "--logs", nargs="+", required=True, help="log files; globs such as 'logs/*.json'"
|
|
54
|
+
)
|
|
55
|
+
hunt_p.add_argument("-e", "--examples", type=int, default=0, help="matching events to show")
|
|
56
|
+
hunt_p.add_argument("--format", choices=("table", "json"), default="table")
|
|
57
|
+
hunt_p.add_argument(
|
|
58
|
+
"--fail-on", choices=LEVELS, help="exit 1 if a rule at this level or above matches"
|
|
59
|
+
)
|
|
60
|
+
_field_options(hunt_p)
|
|
61
|
+
|
|
62
|
+
check = sub.add_parser(
|
|
63
|
+
"check", help="test rules against their <rule>.match.jsonl / <rule>.nomatch.jsonl files"
|
|
64
|
+
)
|
|
65
|
+
check.add_argument("paths", nargs="+", type=Path, help="rule files or directories")
|
|
66
|
+
check.add_argument(
|
|
67
|
+
"--require-tests", action="store_true", help="fail when a rule has no test events"
|
|
68
|
+
)
|
|
69
|
+
_field_options(check)
|
|
70
|
+
|
|
71
|
+
sub.add_parser("profiles", help="list the built-in profiles")
|
|
72
|
+
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
# Rules contain non-ASCII (e.g. windash variants); don't depend on the console code page.
|
|
75
|
+
for stream in (sys.stdout, sys.stderr):
|
|
76
|
+
if hasattr(stream, "reconfigure"):
|
|
77
|
+
stream.reconfigure(encoding="utf-8")
|
|
78
|
+
if args.command == "profiles":
|
|
79
|
+
for name, description in builtin_profiles().items():
|
|
80
|
+
print(f"{name:<16}{description}")
|
|
81
|
+
return 0
|
|
82
|
+
try:
|
|
83
|
+
target = _target(args)
|
|
84
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
85
|
+
print(f"sigma2sql: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
if args.command == "convert":
|
|
88
|
+
return _convert(args.paths, target)
|
|
89
|
+
if args.command == "hunt":
|
|
90
|
+
return _hunt(args, target)
|
|
91
|
+
if args.command == "check":
|
|
92
|
+
return _check(args.paths, target, args.require_tests)
|
|
93
|
+
return _test(args.rule, args.events, target, args.expect)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _field_options(p: argparse.ArgumentParser) -> None:
|
|
97
|
+
p.add_argument(
|
|
98
|
+
"-p", "--profile", help="built-in profile name (see `sigma2sql profiles`) or YAML file"
|
|
99
|
+
)
|
|
100
|
+
p.add_argument("-m", "--mapping", type=Path, help="YAML map of Sigma field -> column/key")
|
|
101
|
+
p.add_argument("--nested", action="store_true", help="dotted names are nested JSON paths")
|
|
102
|
+
p.add_argument("--timestamp-field", help="event time field for correlations")
|
|
103
|
+
p.add_argument("--timestamp-format", choices=("iso", "epoch_s", "epoch_ms"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _target(args: argparse.Namespace) -> Target:
|
|
107
|
+
"""Defaults, then the profile, then explicit command-line options."""
|
|
108
|
+
try:
|
|
109
|
+
mapping = _load_mapping(args.mapping)
|
|
110
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
111
|
+
raise ValueError(f"mapping: {exc}") from exc
|
|
112
|
+
target = Target(
|
|
113
|
+
dialect=getattr(args, "dialect", "sqlite"),
|
|
114
|
+
table=getattr(args, "table", "events"),
|
|
115
|
+
mapping=mapping,
|
|
116
|
+
json_column=getattr(args, "json_column", None),
|
|
117
|
+
keyword_column=getattr(args, "keyword_column", None),
|
|
118
|
+
)
|
|
119
|
+
if args.profile:
|
|
120
|
+
target = apply_profile(target, load_profile(args.profile))
|
|
121
|
+
if args.nested:
|
|
122
|
+
target = replace(target, nested=True)
|
|
123
|
+
if args.timestamp_field:
|
|
124
|
+
target = replace(target, timestamp=args.timestamp_field)
|
|
125
|
+
if args.timestamp_format:
|
|
126
|
+
target = replace(target, timestamp_format=args.timestamp_format)
|
|
127
|
+
return target
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _report_errors(col: Collection) -> None:
|
|
131
|
+
for path, reason in col.errors:
|
|
132
|
+
print(f"skip {path}: {reason}", file=sys.stderr)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sql(item: Document, col: Collection, target: Target) -> str:
|
|
136
|
+
if isinstance(item, Correlation):
|
|
137
|
+
return correlation_query(item, col.resolve(item), target)
|
|
138
|
+
return to_query(item, target)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _convert(paths: list[Path], target: Target) -> int:
|
|
142
|
+
col = Collection.load(paths)
|
|
143
|
+
_report_errors(col)
|
|
144
|
+
items: list[Document] = [*col.standalone(), *col.correlations]
|
|
145
|
+
if not items and not col.errors:
|
|
146
|
+
print("sigma2sql: no .yml or .yaml files found", file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
failed = 0
|
|
149
|
+
for item in items:
|
|
150
|
+
try:
|
|
151
|
+
query = _sql(item, col, target)
|
|
152
|
+
except (RuleError, UnsupportedRule) as exc:
|
|
153
|
+
failed += 1
|
|
154
|
+
print(f"skip {item.path or item.title}: {exc}", file=sys.stderr)
|
|
155
|
+
continue
|
|
156
|
+
print(f"-- {item.title}" + (f" ({item.id})" if item.id else ""))
|
|
157
|
+
print(query + ";\n")
|
|
158
|
+
total = len(items) + len(col.errors)
|
|
159
|
+
if total > 1:
|
|
160
|
+
print(f"converted {len(items) - failed}/{total} rules", file=sys.stderr)
|
|
161
|
+
return 1 if failed or col.errors else 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _test(path: Path, events_path: Path, target: Target, expect: str | None) -> int:
|
|
165
|
+
col = Collection.load([path])
|
|
166
|
+
if col.errors:
|
|
167
|
+
_report_errors(col)
|
|
168
|
+
return 2
|
|
169
|
+
try:
|
|
170
|
+
events = read_jsonl(events_path.read_text(encoding="utf-8"))
|
|
171
|
+
except (OSError, ValueError) as exc:
|
|
172
|
+
print(f"sigma2sql: {exc}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
lines = [line for line in events_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
175
|
+
matched: list[bool] = []
|
|
176
|
+
items: list[Document] = [*col.standalone(), *col.correlations]
|
|
177
|
+
for item in items:
|
|
178
|
+
try:
|
|
179
|
+
if isinstance(item, Correlation) or item.aggregation is not None:
|
|
180
|
+
found: list[Any] = rows(_sql(item, col, runner_target(target)), events)
|
|
181
|
+
for row in found:
|
|
182
|
+
print(json.dumps(row, default=str))
|
|
183
|
+
what = "results"
|
|
184
|
+
else:
|
|
185
|
+
found = matching_events(item, events, target)
|
|
186
|
+
for i in found:
|
|
187
|
+
print(lines[i])
|
|
188
|
+
what = f"of {len(events)} events matched"
|
|
189
|
+
except (RuleError, UnsupportedRule) as exc:
|
|
190
|
+
print(f"sigma2sql: {item.title}: {exc}", file=sys.stderr)
|
|
191
|
+
return 2
|
|
192
|
+
print(f"{item.title}: {len(found)} {what}", file=sys.stderr)
|
|
193
|
+
matched.append(bool(found))
|
|
194
|
+
if expect == "match" and not all(matched):
|
|
195
|
+
return 1
|
|
196
|
+
if expect == "none" and any(matched):
|
|
197
|
+
return 1
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _check(paths: list[Path], target: Target, require_tests: bool) -> int:
|
|
202
|
+
passed = failed = untested = 0
|
|
203
|
+
for path in rule_files(paths):
|
|
204
|
+
cases = [
|
|
205
|
+
(f, f.name.endswith(".match.jsonl"))
|
|
206
|
+
for f in (path.with_suffix(".match.jsonl"), path.with_suffix(".nomatch.jsonl"))
|
|
207
|
+
if f.exists()
|
|
208
|
+
]
|
|
209
|
+
if not cases:
|
|
210
|
+
untested += 1
|
|
211
|
+
if require_tests:
|
|
212
|
+
failed += 1
|
|
213
|
+
print(f"FAIL {path}: no {path.stem}.match.jsonl or .nomatch.jsonl")
|
|
214
|
+
continue
|
|
215
|
+
problems = _check_rule(path, cases, target)
|
|
216
|
+
if problems:
|
|
217
|
+
failed += 1
|
|
218
|
+
for problem in problems:
|
|
219
|
+
print(f"FAIL {path}: {problem}")
|
|
220
|
+
else:
|
|
221
|
+
passed += 1
|
|
222
|
+
print(f"ok {path}")
|
|
223
|
+
print(f"{passed} passed, {failed} failed, {untested} without tests", file=sys.stderr)
|
|
224
|
+
return 1 if failed else 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _check_rule(path: Path, cases: list[tuple[Path, bool]], target: Target) -> list[str]:
|
|
228
|
+
col = Collection.load([path])
|
|
229
|
+
if col.errors:
|
|
230
|
+
return [reason for _, reason in col.errors]
|
|
231
|
+
problems: list[str] = []
|
|
232
|
+
items: list[Document] = [*col.standalone(), *col.correlations]
|
|
233
|
+
for events_path, should_match in cases:
|
|
234
|
+
try:
|
|
235
|
+
events = read_jsonl(events_path.read_text(encoding="utf-8"))
|
|
236
|
+
except (OSError, ValueError) as exc:
|
|
237
|
+
return [f"{events_path.name}: {exc}"]
|
|
238
|
+
for item in items:
|
|
239
|
+
try:
|
|
240
|
+
if isinstance(item, Correlation) or item.aggregation is not None:
|
|
241
|
+
found = rows(_sql(item, col, runner_target(target)), events)
|
|
242
|
+
if should_match and not found:
|
|
243
|
+
problems.append(f"{item.title}: no result for {events_path.name}")
|
|
244
|
+
elif not should_match and found:
|
|
245
|
+
problems.append(
|
|
246
|
+
f"{item.title}: {len(found)} results for {events_path.name}"
|
|
247
|
+
)
|
|
248
|
+
continue
|
|
249
|
+
hits = set(matching_events(item, events, target))
|
|
250
|
+
except (RuleError, UnsupportedRule) as exc:
|
|
251
|
+
problems.append(f"{item.title}: {exc}")
|
|
252
|
+
continue
|
|
253
|
+
wrong = sorted(set(range(len(events))) - hits if should_match else hits)
|
|
254
|
+
if wrong:
|
|
255
|
+
lines = ", ".join(str(i + 1) for i in wrong)
|
|
256
|
+
verb = "missed" if should_match else "matched"
|
|
257
|
+
problems.append(f"{item.title}: {verb} {events_path.name} event(s) {lines}")
|
|
258
|
+
return problems
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _hunt(args: argparse.Namespace, target: Target) -> int:
|
|
262
|
+
col = Collection.load(args.rules)
|
|
263
|
+
_report_errors(col)
|
|
264
|
+
try:
|
|
265
|
+
report = hunt(col, args.logs, target, args.examples)
|
|
266
|
+
except Exception as exc: # DuckDB raises its own error types for unreadable files
|
|
267
|
+
print(f"sigma2sql: {exc}", file=sys.stderr)
|
|
268
|
+
return 2
|
|
269
|
+
for item, reason in report.skipped:
|
|
270
|
+
print(f"skip {item.path or item.title}: {reason}", file=sys.stderr)
|
|
271
|
+
|
|
272
|
+
if args.format == "json":
|
|
273
|
+
print(json.dumps([_hit_json(h) for h in report.hits], indent=2))
|
|
274
|
+
else:
|
|
275
|
+
_print_table(report)
|
|
276
|
+
print(
|
|
277
|
+
f"{len(report.hits)} of {report.ran} rules matched {report.events} events"
|
|
278
|
+
f" ({len(report.skipped) + len(col.errors)} rules skipped)",
|
|
279
|
+
file=sys.stderr,
|
|
280
|
+
)
|
|
281
|
+
if args.fail_on:
|
|
282
|
+
limit = LEVELS.index(args.fail_on)
|
|
283
|
+
if any(h.rank <= limit for h in report.hits):
|
|
284
|
+
return 1
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _print_table(report: Report) -> None:
|
|
289
|
+
if not report.hits:
|
|
290
|
+
return
|
|
291
|
+
width = max(4, *(len(str(h.count)) for h in report.hits))
|
|
292
|
+
print(f"{'LEVEL':<14}{'HITS':>{width}} RULE")
|
|
293
|
+
for h in report.hits:
|
|
294
|
+
where = f" ({h.rule.path})" if h.rule.path else ""
|
|
295
|
+
print(f"{h.rule.level or '-':<14}{h.count:>{width}} {h.rule.title}{where}")
|
|
296
|
+
for event in h.examples:
|
|
297
|
+
print(f"{'':<14}{'':>{width}} {event}")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _hit_json(h: Hit) -> dict[str, object]:
|
|
301
|
+
return {
|
|
302
|
+
"title": h.rule.title,
|
|
303
|
+
"id": h.rule.id,
|
|
304
|
+
"level": h.rule.level,
|
|
305
|
+
"path": str(h.rule.path) if h.rule.path else None,
|
|
306
|
+
"correlation": isinstance(h.rule, Correlation) or h.rule.aggregation is not None,
|
|
307
|
+
"count": h.count,
|
|
308
|
+
"examples": [json.loads(e) for e in h.examples],
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _load_mapping(path: Path | None) -> dict[str, str]:
|
|
313
|
+
if path is None:
|
|
314
|
+
return {}
|
|
315
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
316
|
+
if not isinstance(data, dict) or not all(
|
|
317
|
+
isinstance(k, str) and isinstance(v, str) for k, v in data.items()
|
|
318
|
+
):
|
|
319
|
+
raise ValueError("expected a map of field name to column name")
|
|
320
|
+
return dict(data)
|
sigma2sql/collection.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Load many rule files and resolve the rules that correlations refer to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sigma2sql.errors import RuleError, UnsupportedRule
|
|
10
|
+
from sigma2sql.rule import Correlation, Rule, load_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Collection:
|
|
15
|
+
rules: list[Rule] = field(default_factory=list)
|
|
16
|
+
correlations: list[Correlation] = field(default_factory=list)
|
|
17
|
+
errors: list[tuple[Path, str]] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def load(cls, paths: Iterable[Path]) -> Collection:
|
|
21
|
+
col = cls()
|
|
22
|
+
for path in rule_files(paths):
|
|
23
|
+
try:
|
|
24
|
+
docs = load_file(path)
|
|
25
|
+
except (RuleError, UnsupportedRule, OSError) as exc:
|
|
26
|
+
col.errors.append((path, str(exc)))
|
|
27
|
+
continue
|
|
28
|
+
for doc in docs:
|
|
29
|
+
if isinstance(doc, Correlation):
|
|
30
|
+
col.correlations.append(doc)
|
|
31
|
+
else:
|
|
32
|
+
col.rules.append(doc)
|
|
33
|
+
return col
|
|
34
|
+
|
|
35
|
+
def resolve(self, corr: Correlation) -> list[Rule]:
|
|
36
|
+
"""The rules a correlation counts over, looked up by name or id."""
|
|
37
|
+
index: dict[str, Rule] = {}
|
|
38
|
+
for rule in self.rules:
|
|
39
|
+
for key in (rule.name, rule.id):
|
|
40
|
+
if key:
|
|
41
|
+
index.setdefault(key, rule)
|
|
42
|
+
missing = [r for r in corr.rules if r not in index]
|
|
43
|
+
if missing:
|
|
44
|
+
raise RuleError(f"correlation refers to unknown rules: {', '.join(missing)}")
|
|
45
|
+
return [index[r] for r in corr.rules]
|
|
46
|
+
|
|
47
|
+
def standalone(self) -> list[Rule]:
|
|
48
|
+
"""Rules that produce results of their own.
|
|
49
|
+
|
|
50
|
+
A rule used by a correlation only feeds that correlation unless the correlation
|
|
51
|
+
sets `generate: true`, as in the Sigma specification.
|
|
52
|
+
"""
|
|
53
|
+
hidden = {ref for c in self.correlations if not c.generate for ref in c.rules}
|
|
54
|
+
return [r for r in self.rules if not ({r.name, r.id} & hidden)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rule_files(paths: Iterable[Path]) -> Iterator[Path]:
|
|
58
|
+
for path in paths:
|
|
59
|
+
if path.is_dir():
|
|
60
|
+
yield from sorted(p for p in path.rglob("*") if p.suffix in (".yml", ".yaml"))
|
|
61
|
+
else:
|
|
62
|
+
yield path
|
sigma2sql/condition.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Parse `detection.condition` and resolve it into a single Expr.
|
|
2
|
+
|
|
3
|
+
Grammar (lowest precedence first):
|
|
4
|
+
|
|
5
|
+
expr := and ("or" and)*
|
|
6
|
+
and := not ("and" not)*
|
|
7
|
+
not := "not" not | primary
|
|
8
|
+
primary := "(" expr ")" | quant "of" target | IDENT
|
|
9
|
+
quant := "1" | "all"
|
|
10
|
+
target := "them" | IDENT (IDENT may contain * wildcards)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from sigma2sql.errors import RuleError, UnsupportedRule
|
|
19
|
+
from sigma2sql.expr import Expr, Not, all_of, any_of
|
|
20
|
+
|
|
21
|
+
TOKEN = re.compile(r"\s*(?:(\()|(\))|(\|)|([A-Za-z0-9_*.\-]+))")
|
|
22
|
+
KEYWORDS = {"and", "or", "not", "of", "them", "all"}
|
|
23
|
+
|
|
24
|
+
Resolve = Callable[[str], Expr]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compile_condition(condition: str, names: list[str], resolve: Resolve) -> Expr:
|
|
28
|
+
parser = _Parser(_tokenize(condition), names, resolve)
|
|
29
|
+
expr = parser.expr()
|
|
30
|
+
if parser.peek() is not None:
|
|
31
|
+
raise RuleError(f"unexpected {parser.peek()!r} in condition {condition!r}")
|
|
32
|
+
return expr
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _tokenize(condition: str) -> list[str]:
|
|
36
|
+
tokens: list[str] = []
|
|
37
|
+
pos = 0
|
|
38
|
+
text = condition.rstrip()
|
|
39
|
+
while pos < len(text):
|
|
40
|
+
m = TOKEN.match(text, pos)
|
|
41
|
+
if not m:
|
|
42
|
+
raise RuleError(f"cannot parse condition {condition!r} at {text[pos:]!r}")
|
|
43
|
+
if m.group(3):
|
|
44
|
+
raise UnsupportedRule("aggregations after '|' in the condition are not supported")
|
|
45
|
+
tokens.append(m.group(1) or m.group(2) or m.group(4))
|
|
46
|
+
pos = m.end()
|
|
47
|
+
if not tokens:
|
|
48
|
+
raise RuleError("empty condition")
|
|
49
|
+
return tokens
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Parser:
|
|
53
|
+
def __init__(self, tokens: list[str], names: list[str], resolve: Resolve) -> None:
|
|
54
|
+
self.tokens = tokens
|
|
55
|
+
self.pos = 0
|
|
56
|
+
self.names = names
|
|
57
|
+
self.resolve = resolve
|
|
58
|
+
|
|
59
|
+
def peek(self) -> str | None:
|
|
60
|
+
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
|
61
|
+
|
|
62
|
+
def _keyword(self, word: str) -> bool:
|
|
63
|
+
tok = self.peek()
|
|
64
|
+
if tok is not None and tok.lower() == word:
|
|
65
|
+
self.pos += 1
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def _take(self) -> str:
|
|
70
|
+
tok = self.peek()
|
|
71
|
+
if tok is None:
|
|
72
|
+
raise RuleError("condition ends too early")
|
|
73
|
+
self.pos += 1
|
|
74
|
+
return tok
|
|
75
|
+
|
|
76
|
+
def expr(self) -> Expr:
|
|
77
|
+
items = [self._and()]
|
|
78
|
+
while self._keyword("or"):
|
|
79
|
+
items.append(self._and())
|
|
80
|
+
return any_of(items)
|
|
81
|
+
|
|
82
|
+
def _and(self) -> Expr:
|
|
83
|
+
items = [self._not()]
|
|
84
|
+
while self._keyword("and"):
|
|
85
|
+
items.append(self._not())
|
|
86
|
+
return all_of(items)
|
|
87
|
+
|
|
88
|
+
def _not(self) -> Expr:
|
|
89
|
+
if self._keyword("not"):
|
|
90
|
+
return Not(self._not())
|
|
91
|
+
return self._primary()
|
|
92
|
+
|
|
93
|
+
def _primary(self) -> Expr:
|
|
94
|
+
tok = self._take()
|
|
95
|
+
if tok == "(":
|
|
96
|
+
inner = self.expr()
|
|
97
|
+
if self._take() != ")":
|
|
98
|
+
raise RuleError("missing ')' in condition")
|
|
99
|
+
return inner
|
|
100
|
+
if tok == "1" or tok.lower() == "all":
|
|
101
|
+
if not self._keyword("of"):
|
|
102
|
+
raise RuleError(f"expected 'of' after {tok!r}")
|
|
103
|
+
matched = [self.resolve(n) for n in self._targets(self._take())]
|
|
104
|
+
return any_of(matched) if tok == "1" else all_of(matched)
|
|
105
|
+
if tok == ")" or tok.lower() in KEYWORDS:
|
|
106
|
+
raise RuleError(f"unexpected {tok!r} in condition")
|
|
107
|
+
if tok not in self.names:
|
|
108
|
+
raise RuleError(f"condition refers to unknown search {tok!r}")
|
|
109
|
+
return self.resolve(tok)
|
|
110
|
+
|
|
111
|
+
def _targets(self, target: str) -> list[str]:
|
|
112
|
+
if target.lower() == "them":
|
|
113
|
+
found = [n for n in self.names if not n.startswith("_")]
|
|
114
|
+
else:
|
|
115
|
+
pattern = re.compile("^" + ".*".join(map(re.escape, target.split("*"))) + "$")
|
|
116
|
+
found = [n for n in self.names if pattern.match(n)]
|
|
117
|
+
if not found:
|
|
118
|
+
raise RuleError(f"'{target}' does not match any search")
|
|
119
|
+
return found
|
sigma2sql/correlation.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""SQL for correlation rules and legacy `| count()` aggregations.
|
|
2
|
+
|
|
3
|
+
Every referenced rule contributes rows to a `base` CTE (rule name, event time, group-by
|
|
4
|
+
values, the counted field). Without a timespan the result is a plain GROUP BY. With one,
|
|
5
|
+
each event closes a window `[t - timespan, t]` of its group; the window's events are found
|
|
6
|
+
with a self-join, which works the same on SQLite, PostgreSQL and DuckDB and allows
|
|
7
|
+
COUNT(DISTINCT ...), which window functions do not.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from sigma2sql.rule import OPS, Correlation, Rule
|
|
13
|
+
from sigma2sql.sql import Renderer, Target, literal, quote_ident, source
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def correlation_query(corr: Correlation, rules: list[Rule], target: Target) -> str:
|
|
17
|
+
r = Renderer(target)
|
|
18
|
+
groups = [quote_ident(f"g{i}") for i in range(len(corr.group_by))]
|
|
19
|
+
|
|
20
|
+
branches: list[str] = []
|
|
21
|
+
for rule in rules:
|
|
22
|
+
cols = [f"{literal(rule.ref)} AS sigma_rule", f"{r.epoch()} AS sigma_ts"]
|
|
23
|
+
for name, alias in zip(corr.group_by, groups, strict=True):
|
|
24
|
+
sigma_field = corr.aliases.get(name, {}).get(rule.ref, name)
|
|
25
|
+
cols.append(f"{r.column(sigma_field)} AS {alias}")
|
|
26
|
+
if corr.field:
|
|
27
|
+
cols.append(f"{r.column(corr.field)} AS sigma_value")
|
|
28
|
+
table, where = source(rule, target)
|
|
29
|
+
branches.append(f"SELECT {', '.join(cols)} FROM {table} WHERE {where}")
|
|
30
|
+
base = "\n UNION ALL\n ".join(branches)
|
|
31
|
+
|
|
32
|
+
def aggregate(b: str) -> str:
|
|
33
|
+
if corr.type == "event_count":
|
|
34
|
+
return "COUNT(*)"
|
|
35
|
+
if corr.type == "value_count":
|
|
36
|
+
return f"COUNT(DISTINCT {b}sigma_value)"
|
|
37
|
+
if corr.type == "temporal":
|
|
38
|
+
return f"COUNT(DISTINCT {b}sigma_rule)"
|
|
39
|
+
fn = "SUM" if corr.type == "value_sum" else "AVG"
|
|
40
|
+
return f"{fn}({r.cast_number(b + 'sigma_value')})"
|
|
41
|
+
|
|
42
|
+
def having(b: str) -> str:
|
|
43
|
+
agg = aggregate(b)
|
|
44
|
+
return " AND ".join(f"{agg} {OPS[op]} {_number(v)}" for op, v in corr.condition)
|
|
45
|
+
|
|
46
|
+
def named(prefix: str) -> list[str]:
|
|
47
|
+
pairs = zip(groups, corr.group_by, strict=True)
|
|
48
|
+
return [f"{prefix}{g} AS {quote_ident(name)}" for g, name in pairs]
|
|
49
|
+
|
|
50
|
+
if corr.timespan is None:
|
|
51
|
+
select = named("") + [
|
|
52
|
+
f"{r.from_epoch('MIN(sigma_ts)')} AS first_seen",
|
|
53
|
+
f"{r.from_epoch('MAX(sigma_ts)')} AS last_seen",
|
|
54
|
+
f"{aggregate('')} AS value",
|
|
55
|
+
]
|
|
56
|
+
group_by = f"\nGROUP BY {', '.join(groups)}" if groups else ""
|
|
57
|
+
return (
|
|
58
|
+
f"WITH base AS (\n {base}\n)\n"
|
|
59
|
+
f"SELECT {', '.join(select)}\nFROM base{group_by}\nHAVING {having('')}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
window_keys = [*groups, "sigma_ts"]
|
|
63
|
+
join = [r.same(f"b.{g}", f"w.{g}") for g in groups]
|
|
64
|
+
join.append(f"b.sigma_ts BETWEEN w.sigma_ts - {corr.timespan} AND w.sigma_ts")
|
|
65
|
+
select = named("w.") + [
|
|
66
|
+
f"{r.from_epoch('MIN(b.sigma_ts)')} AS window_start",
|
|
67
|
+
f"{r.from_epoch('w.sigma_ts')} AS window_end",
|
|
68
|
+
f"{aggregate('b.')} AS value",
|
|
69
|
+
]
|
|
70
|
+
return (
|
|
71
|
+
f"WITH base AS (\n {base}\n),\n"
|
|
72
|
+
f"windows AS (SELECT DISTINCT {', '.join(window_keys)} FROM base"
|
|
73
|
+
f" WHERE sigma_ts IS NOT NULL)\n"
|
|
74
|
+
f"SELECT {', '.join(select)}\n"
|
|
75
|
+
f"FROM windows w JOIN base b ON {' AND '.join(join)}\n"
|
|
76
|
+
f"GROUP BY {', '.join('w.' + k for k in window_keys)}\n"
|
|
77
|
+
f"HAVING {having('b.')}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _number(value: float) -> str:
|
|
82
|
+
return str(int(value)) if value.is_integer() else repr(value)
|