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 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
@@ -0,0 +1,3 @@
1
+ from sigma2sql.cli import main
2
+
3
+ raise SystemExit(main())
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)
@@ -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
@@ -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)