kodon 0.0.1__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.
kodon/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """kodon: detection-as-code. Sigma rules with tests, compiled to Splunk SPL,
2
+ Sentinel KQL and DuckDB SQL, replay-tested on synthetic telemetry.
3
+
4
+ Public consumption contract (what a downstream pipeline imports):
5
+
6
+ from kodon import load_rules, compile_rule, replay, coverage_report
7
+ from kodon.contracts import RuleMeta, DetectionHit, CoverageReport
8
+ """
9
+
10
+ from kodon.compile import TARGETS, compile_all, compile_rule
11
+ from kodon.contracts import CoverageReport, DetectionHit, RuleMeta
12
+ from kodon.coverage import coverage_report
13
+ from kodon.loader import LoadedRule, load_rules
14
+ from kodon.replay import replay
15
+
16
+ __version__ = "0.0.1"
17
+
18
+ __all__ = [
19
+ "TARGETS",
20
+ "CoverageReport",
21
+ "DetectionHit",
22
+ "LoadedRule",
23
+ "RuleMeta",
24
+ "__version__",
25
+ "compile_all",
26
+ "compile_rule",
27
+ "coverage_report",
28
+ "load_rules",
29
+ "replay",
30
+ ]
kodon/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ """kodon validate | compile --target | test | coverage | demo"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from kodon.compile import TARGETS, compile_all, compile_rule
11
+ from kodon.coverage import coverage_report, render_matrix
12
+ from kodon.loader import LoadedRule, RuleContractError, load_rules
13
+ from kodon.replay import replay_samples
14
+ from kodon.synth import write_samples
15
+
16
+
17
+ def _samples_dir() -> Path:
18
+ from kodon.loader import repo_root
19
+
20
+ root = repo_root()
21
+ if root is not None and (root / "tests" / "samples").is_dir():
22
+ return root / "tests" / "samples"
23
+ raise FileNotFoundError("tests/samples not found; run `kodon demo` or scripts/gen_samples.py")
24
+
25
+
26
+ def _load() -> list[LoadedRule]:
27
+ try:
28
+ return load_rules()
29
+ except RuleContractError as exc:
30
+ print(f"CONTRACT VIOLATION: {exc}", file=sys.stderr)
31
+ sys.exit(2)
32
+
33
+
34
+ def cmd_validate(args: argparse.Namespace) -> int:
35
+ rules = _load()
36
+ print(f"{'rule_id':<42} {'kind':<12} {'level':<8} techniques")
37
+ for lr in rules:
38
+ m = lr.meta
39
+ print(f"{m.rule_id:<42} {m.kind:<12} {m.level:<8} {', '.join(m.techniques)}")
40
+ print(f"\n{len(rules)} rules valid; metadata contract satisfied")
41
+ return 0
42
+
43
+
44
+ def cmd_compile(args: argparse.Namespace) -> int:
45
+ rules = _load()
46
+ if args.rule:
47
+ rules = [lr for lr in rules if lr.rule_id == args.rule]
48
+ if not rules:
49
+ print(f"no rule named {args.rule}", file=sys.stderr)
50
+ return 2
51
+ for lr in rules:
52
+ print(f"# {lr.rule_id} [{args.target}]")
53
+ print(compile_rule(lr, args.target))
54
+ print()
55
+ return 0
56
+
57
+
58
+ def _replay_table(rules: list[LoadedRule], samples: Path) -> bool:
59
+ ok = True
60
+ print(f"{'rule_id':<42} {'hit rows':>8} {'matched':>8} {'miss rows':>9} {'matched':>8} result")
61
+ for lr in rules:
62
+ r = replay_samples(lr, samples)
63
+ ok = ok and r.passed
64
+ print(
65
+ f"{r.rule_id:<42} {r.hit_rows:>8} {r.hit_matches:>8} {r.miss_rows:>9} {r.miss_matches:>8} "
66
+ f"{'PASS' if r.passed else 'FAIL'}"
67
+ )
68
+ return ok
69
+
70
+
71
+ def cmd_test(args: argparse.Namespace) -> int:
72
+ rules = _load()
73
+ ok = _replay_table(rules, Path(args.samples) if args.samples else _samples_dir())
74
+ print("\nreplay: PASS" if ok else "\nreplay: FAIL")
75
+ return 0 if ok else 1
76
+
77
+
78
+ def cmd_coverage(args: argparse.Namespace) -> int:
79
+ rules = _load()
80
+ report = coverage_report([lr.meta for lr in rules])
81
+ if args.json:
82
+ print(report.model_dump_json(indent=2))
83
+ else:
84
+ print(render_matrix(report))
85
+ return 0
86
+
87
+
88
+ def cmd_demo(args: argparse.Namespace) -> int:
89
+ print("kodon demo: generate -> validate -> compile -> replay -> coverage (no model, no network)\n")
90
+ rules = _load()
91
+ with tempfile.TemporaryDirectory(prefix="kodon-samples-") as tmp:
92
+ samples = Path(tmp)
93
+ counts = write_samples(samples)
94
+ print(f"[1/5] generated seeded samples for {len(counts)} rules "
95
+ f"({sum(h for h, _ in counts.values())} must-hit rows, {sum(m for _, m in counts.values())} must-miss rows)")
96
+ print(f"[2/5] validated {len(rules)} rules against the metadata contract")
97
+ for target in TARGETS:
98
+ compile_all(rules, target)
99
+ print(f"[3/5] compiled {len(rules)} rules to {', '.join(TARGETS)}")
100
+ example = next(lr for lr in rules if lr.correlation is not None)
101
+ print(f"\n example, {example.rule_id}:")
102
+ for target in TARGETS:
103
+ body = compile_rule(example, target).strip().replace("\n\n", "\n")
104
+ print(f" --- {target}\n" + "\n".join(" " + line for line in body.splitlines()))
105
+ print("\n[4/5] replay: compiled DuckDB SQL over the samples")
106
+ ok = _replay_table(rules, samples)
107
+ print("\n[5/5] coverage")
108
+ print(render_matrix(coverage_report([lr.meta for lr in rules])))
109
+ print("demo: PASS" if ok else "demo: FAIL")
110
+ return 0 if ok else 1
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ parser = argparse.ArgumentParser(prog="kodon", description=__doc__)
115
+ sub = parser.add_subparsers(dest="command", required=True)
116
+ sub.add_parser("validate", help="load every rule and enforce the metadata contract").set_defaults(fn=cmd_validate)
117
+ c = sub.add_parser("compile", help="print compiled queries")
118
+ c.add_argument("--target", choices=TARGETS, required=True)
119
+ c.add_argument("--rule", help="only this rule id")
120
+ c.set_defaults(fn=cmd_compile)
121
+ t = sub.add_parser("test", help="replay every rule's hit/miss samples")
122
+ t.add_argument("--samples", help="samples directory (default: tests/samples)")
123
+ t.set_defaults(fn=cmd_test)
124
+ cv = sub.add_parser("coverage", help="ATT&CK coverage matrix and gaps")
125
+ cv.add_argument("--json", action="store_true")
126
+ cv.set_defaults(fn=cmd_coverage)
127
+ sub.add_parser("demo", help="generate, validate, compile, replay, coverage").set_defaults(fn=cmd_demo)
128
+ args = parser.parse_args(argv)
129
+ return args.fn(args)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())
kodon/compile.py ADDED
@@ -0,0 +1,115 @@
1
+ """compile(rule, target) -> query string.
2
+
3
+ Targets:
4
+ splunk pySigma Splunk backend, no field mapping (Sigma taxonomy field names)
5
+ kql pySigma Kusto backend + pipelines/kql_tables.yml (table per logsource)
6
+ duckdb kodon's DuckDB backend + pipelines/synthetic_canonical.yml
7
+
8
+ Field names in the splunk and kql output are the Sigma taxonomy names. Mapping
9
+ them onto a deployment's actual columns is a processing pipeline the deployment
10
+ owns and passes in as `pipeline=`; that is the seam a downstream consumer fills.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ from functools import cache
17
+
18
+ from sigma.backends.splunk import SplunkBackend
19
+ from sigma.collection import SigmaCollection
20
+ from sigma.conversion.base import Backend
21
+ from sigma.correlations import SigmaCorrelationRule
22
+ from sigma.processing.pipeline import ProcessingPipeline
23
+ from sigma.rule import SigmaRule
24
+
25
+ from kodon.duckdb_backend import DuckDBBackend
26
+ from kodon.kusto_backend import KodonKustoBackend
27
+ from kodon.loader import LoadedRule, resource_dir
28
+
29
+ TARGETS = ("splunk", "kql", "duckdb")
30
+ DEFAULT_PIPELINE_FILE = {"kql": "kql_tables.yml", "duckdb": "synthetic_canonical.yml"}
31
+ KQL_TIMESTAMP = "TimeGenerated"
32
+ KQL_UNITS = {"s": "s", "m": "m", "h": "h", "d": "d"}
33
+ KQL_OPS = {"LT": "<", "LTE": "<=", "GT": ">", "GTE": ">=", "EQ": "==", "NEQ": "!="}
34
+
35
+
36
+ @cache
37
+ def _pipeline_from_file(path: str) -> ProcessingPipeline:
38
+ with open(path, encoding="utf-8") as fh:
39
+ return ProcessingPipeline.from_yaml(fh.read())
40
+
41
+
42
+ def default_pipeline(target: str) -> ProcessingPipeline | None:
43
+ name = DEFAULT_PIPELINE_FILE.get(target)
44
+ if name is None:
45
+ return None
46
+ return _pipeline_from_file(str(resource_dir("pipelines") / name))
47
+
48
+
49
+ def _backend(target: str, pipeline: ProcessingPipeline | None) -> Backend:
50
+ if target == "splunk":
51
+ return SplunkBackend(pipeline)
52
+ if target == "kql":
53
+ return KodonKustoBackend(pipeline)
54
+ if target == "duckdb":
55
+ return DuckDBBackend(pipeline)
56
+ raise ValueError(f"unknown target {target!r}; expected one of {TARGETS}")
57
+
58
+
59
+ def _fresh(loaded: LoadedRule) -> tuple[SigmaCollection, SigmaRule, SigmaCorrelationRule | None]:
60
+ """pySigma pipelines rewrite field names on the rule object itself, so
61
+ each compilation works on its own deep copy of the parsed collection."""
62
+ collection = copy.deepcopy(loaded.collection)
63
+ rule = next(r for r in collection.rules if isinstance(r, SigmaRule))
64
+ corr = next((r for r in collection.rules if isinstance(r, SigmaCorrelationRule)), None)
65
+ return collection, rule, corr
66
+
67
+
68
+ def _kql(backend: KodonKustoBackend, rule: SigmaRule, corr: SigmaCorrelationRule | None) -> str:
69
+ """The Kusto backend does not emit correlation rules (pysigma-backend-kusto
70
+ 1.0), so kodon appends the summarize clause itself: the same fixed-window
71
+ aggregation the Splunk and DuckDB outputs use."""
72
+ rule._output = True # a rule referenced by a correlation is marked non-output
73
+ body = backend.convert_rule(rule)[0]
74
+ pipeline = backend.last_processing_pipeline
75
+ table = pipeline.state.get("query_table")
76
+ query = f"{table}\n| where {body}" if table else f"search {body}"
77
+ if corr is None:
78
+ return query
79
+ pipeline.apply(corr) # maps the group-by and value-count fields like the rule's own
80
+ span = corr.timespan
81
+ kql_span = f"{span.count * 7}d" if span.unit == "w" else f"{span.count}{KQL_UNITS[span.unit]}"
82
+ fields = ", ".join(backend.escape_and_quote_field(f) for f in corr.group_by or [])
83
+ if corr.type.name.lower() == "event_count":
84
+ agg, alias = "event_count = count()", "event_count"
85
+ else:
86
+ field = backend.escape_and_quote_field(str(corr.condition.fieldref))
87
+ agg, alias = f"value_count = dcount({field})", "value_count"
88
+ op = KQL_OPS[corr.condition.op.name]
89
+ return (
90
+ f"{query}\n| summarize {agg} by bin({KQL_TIMESTAMP}, {kql_span}), {fields}"
91
+ f"\n| where {alias} {op} {corr.condition.count}"
92
+ )
93
+
94
+
95
+ def compile_rule(
96
+ loaded: LoadedRule, target: str, pipeline: ProcessingPipeline | None = None
97
+ ) -> str:
98
+ """Compile one loaded rule (its correlation, if it has one) for a target."""
99
+ if target not in TARGETS:
100
+ raise ValueError(f"unknown target {target!r}; expected one of {TARGETS}")
101
+ pipeline = pipeline if pipeline is not None else default_pipeline(target)
102
+ backend = _backend(target, pipeline)
103
+ collection, rule, corr = _fresh(loaded)
104
+ if target == "kql":
105
+ return _kql(backend, rule, corr) # type: ignore[arg-type]
106
+ queries = backend.convert(collection)
107
+ if len(queries) != 1:
108
+ raise RuntimeError(f"{loaded.rule_id}: expected one query for {target}, got {len(queries)}")
109
+ return queries[0]
110
+
111
+
112
+ def compile_all(
113
+ rules: list[LoadedRule], target: str, pipeline: ProcessingPipeline | None = None
114
+ ) -> dict[str, str]:
115
+ return {lr.rule_id: compile_rule(lr, target, pipeline) for lr in rules}
kodon/contracts.py ADDED
@@ -0,0 +1,100 @@
1
+ """The public consumption contract: what a downstream consumer receives.
2
+
3
+ Everything a consumer needs is a plain pydantic model. pySigma objects never
4
+ cross this boundary, so a consumer does not need to know pySigma to use the
5
+ hits, and the models can be serialised straight into a finding store.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from typing import Any, Literal
12
+
13
+ from pydantic import BaseModel, Field, field_validator
14
+
15
+ Status = Literal["experimental", "test", "stable", "deprecated", "unsupported"]
16
+ Level = Literal["informational", "low", "medium", "high", "critical"]
17
+ RuleKind = Literal["event", "correlation"]
18
+
19
+
20
+ class DetectionHealth(BaseModel):
21
+ """When the rule was added, when a human last looked at it, and who owns it."""
22
+
23
+ added: date
24
+ last_reviewed: date
25
+ owner: str
26
+
27
+ @field_validator("last_reviewed")
28
+ @classmethod
29
+ def _reviewed_after_added(cls, v: date, info: Any) -> date:
30
+ added = info.data.get("added")
31
+ if added is not None and v < added:
32
+ raise ValueError("last_reviewed precedes added")
33
+ return v
34
+
35
+
36
+ class RuleMeta(BaseModel):
37
+ """Metadata contract enforced by the loader for every rule file."""
38
+
39
+ rule_id: str = Field(description="File stem; stable, human-readable identifier")
40
+ sigma_id: str = Field(description="Sigma id: uuid5 of the rule_id, 32 hex chars")
41
+ title: str
42
+ status: Status
43
+ level: Level
44
+ kind: RuleKind
45
+ logsource: dict[str, str]
46
+ techniques: list[str] = Field(description="ATT&CK technique ids, e.g. T1127.001")
47
+ tactics: list[str] = Field(description="ATT&CK tactic slugs, e.g. defense-evasion")
48
+ data_sources: list[str]
49
+ false_positives: list[str]
50
+ do_not_alert_when: str = Field(
51
+ description="The judgment call: when this rule should stay a hunt, not an alert"
52
+ )
53
+ detection_health: DetectionHealth
54
+ references: list[str] = Field(default_factory=list)
55
+ path: str
56
+
57
+
58
+ class DetectionHit(BaseModel):
59
+ """One row that a compiled rule matched. For an event rule this is the
60
+ event; for a correlation rule it is one aggregated group (bucket,
61
+ group-by values, count)."""
62
+
63
+ rule_id: str
64
+ sigma_id: str
65
+ title: str
66
+ level: Level
67
+ kind: RuleKind
68
+ techniques: list[str]
69
+ tactics: list[str]
70
+ matched: dict[str, Any]
71
+
72
+
73
+ class TechniqueRef(BaseModel):
74
+ technique: str
75
+ name: str
76
+ tactics: list[str]
77
+
78
+
79
+ class CoverageReport(BaseModel):
80
+ """ATT&CK coverage of the rule set against a declared scope.
81
+
82
+ Gaps are computed against the scope, not against all of ATT&CK: a matrix
83
+ that is red everywhere says nothing about what the rule set was meant to
84
+ cover.
85
+ """
86
+
87
+ scope: list[TechniqueRef]
88
+ by_technique: dict[str, list[str]] = Field(description="technique id -> rule ids")
89
+ by_tactic: dict[str, list[str]] = Field(description="tactic -> rule ids")
90
+ out_of_scope: dict[str, list[str]] = Field(
91
+ description="techniques tagged by a rule but absent from the scope"
92
+ )
93
+
94
+ @property
95
+ def gaps(self) -> list[TechniqueRef]:
96
+ return [t for t in self.scope if not self.by_technique.get(t.technique)]
97
+
98
+ @property
99
+ def covered(self) -> list[TechniqueRef]:
100
+ return [t for t in self.scope if self.by_technique.get(t.technique)]
@@ -0,0 +1,30 @@
1
+ # The ATT&CK techniques this rule set is meant to cover. Coverage and gaps are
2
+ # computed against this list, not against the whole matrix: a matrix that is
3
+ # red everywhere says nothing about what the rule set was meant to cover.
4
+ # Technique names are from MITRE ATT&CK (https://attack.mitre.org), used under
5
+ # its terms; tactics listed are the ones this rule set watches for.
6
+ techniques:
7
+ - {technique: T1078.004, name: "Valid Accounts: Cloud Accounts", tactics: [initial-access]}
8
+ - {technique: T1621, name: "Multi-Factor Authentication Request Generation", tactics: [credential-access]}
9
+ - {technique: T1110.003, name: "Brute Force: Password Spraying", tactics: [credential-access]}
10
+ - {technique: T1566.001, name: "Phishing: Spearphishing Attachment", tactics: [initial-access]}
11
+ - {technique: T1204.002, name: "User Execution: Malicious File", tactics: [execution]}
12
+ - {technique: T1059.001, name: "Command and Scripting Interpreter: PowerShell", tactics: [execution]}
13
+ - {technique: T1047, name: "Windows Management Instrumentation", tactics: [execution]}
14
+ - {technique: T1053.005, name: "Scheduled Task/Job: Scheduled Task", tactics: [persistence]}
15
+ - {technique: T1098, name: "Account Manipulation", tactics: [persistence]}
16
+ - {technique: T1136.003, name: "Create Account: Cloud Account", tactics: [persistence]}
17
+ - {technique: T1127.001, name: "Trusted Developer Utilities Proxy Execution: MSBuild", tactics: [defense-evasion]}
18
+ - {technique: T1036.005, name: "Masquerading: Match Legitimate Name or Location", tactics: [defense-evasion]}
19
+ - {technique: T1070.001, name: "Indicator Removal: Clear Windows Event Logs", tactics: [defense-evasion]}
20
+ - {technique: T1003.001, name: "OS Credential Dumping: LSASS Memory", tactics: [credential-access]}
21
+ - {technique: T1558.003, name: "Steal or Forge Kerberos Tickets: Kerberoasting", tactics: [credential-access]}
22
+ - {technique: T1528, name: "Steal Application Access Token", tactics: [credential-access]}
23
+ - {technique: T1482, name: "Domain Trust Discovery", tactics: [discovery]}
24
+ - {technique: T1021.006, name: "Remote Services: Windows Remote Management", tactics: [lateral-movement]}
25
+ - {technique: T1114.003, name: "Email Collection: Email Forwarding Rule", tactics: [collection]}
26
+ - {technique: T1071.001, name: "Application Layer Protocol: Web Protocols", tactics: [command-and-control]}
27
+ - {technique: T1571, name: "Non-Standard Port", tactics: [command-and-control]}
28
+ - {technique: T1105, name: "Ingress Tool Transfer", tactics: [command-and-control]}
29
+ - {technique: T1048.003, name: "Exfiltration Over Unencrypted Non-C2 Protocol", tactics: [exfiltration]}
30
+ - {technique: T1567.002, name: "Exfiltration to Cloud Storage", tactics: [exfiltration]}
kodon/coverage.py ADDED
@@ -0,0 +1,70 @@
1
+ """ATT&CK coverage matrix and gaps, computed against a declared scope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from kodon.contracts import CoverageReport, RuleMeta, TechniqueRef
11
+ from kodon.loader import resource_dir
12
+
13
+ SCOPE_FILE = "attack_scope.yml"
14
+
15
+
16
+ def load_scope(path: Path | None = None) -> list[TechniqueRef]:
17
+ p = path or resource_dir("coverage") / SCOPE_FILE
18
+ data = yaml.safe_load(p.read_text(encoding="utf-8"))
19
+ return [TechniqueRef.model_validate(t) for t in data["techniques"]]
20
+
21
+
22
+ def coverage_report(metas: list[RuleMeta], scope: list[TechniqueRef] | None = None) -> CoverageReport:
23
+ scope = scope if scope is not None else load_scope()
24
+ in_scope = {t.technique for t in scope}
25
+ by_technique: dict[str, list[str]] = defaultdict(list)
26
+ by_tactic: dict[str, list[str]] = defaultdict(list)
27
+ out_of_scope: dict[str, list[str]] = defaultdict(list)
28
+ for m in metas:
29
+ for t in m.techniques:
30
+ by_technique[t].append(m.rule_id)
31
+ if t not in in_scope:
32
+ out_of_scope[t].append(m.rule_id)
33
+ for tac in m.tactics:
34
+ by_tactic[tac].append(m.rule_id)
35
+ return CoverageReport(
36
+ scope=scope,
37
+ by_technique=dict(by_technique),
38
+ by_tactic=dict(by_tactic),
39
+ out_of_scope=dict(out_of_scope),
40
+ )
41
+
42
+
43
+ def render_matrix(report: CoverageReport) -> str:
44
+ """Tactic-major text matrix: one line per in-scope technique."""
45
+ tactics: list[str] = []
46
+ for t in report.scope:
47
+ for tac in t.tactics:
48
+ if tac not in tactics:
49
+ tactics.append(tac)
50
+ lines = [
51
+ f"ATT&CK coverage: {len(report.covered)}/{len(report.scope)} in-scope techniques "
52
+ f"have at least one rule; {len(report.gaps)} gaps",
53
+ "",
54
+ ]
55
+ for tac in tactics:
56
+ lines.append(f"[{tac}]")
57
+ for t in report.scope:
58
+ if tac not in t.tactics:
59
+ continue
60
+ rules = report.by_technique.get(t.technique, [])
61
+ mark = "#" if rules else "."
62
+ who = ", ".join(rules) if rules else "GAP"
63
+ lines.append(f" {mark} {t.technique:<10} {t.name:<44} {who}")
64
+ lines.append("")
65
+ if report.out_of_scope:
66
+ lines.append("tagged but outside the declared scope (add to coverage/attack_scope.yml):")
67
+ for t, rules in sorted(report.out_of_scope.items()):
68
+ lines.append(f" ? {t:<10} {', '.join(rules)}")
69
+ lines.append("")
70
+ return "\n".join(lines)
@@ -0,0 +1,80 @@
1
+ """A thin DuckDB backend built on the maintained pySigma SQLite backend.
2
+
3
+ Three things differ from SQLite and matter for correctness:
4
+
5
+ * Sigma string matching is case-insensitive. SQLite's LIKE is; DuckDB's is
6
+ not, so every string match is emitted as ILIKE.
7
+ * DuckDB has no REGEXP operator; regular expressions use regexp_matches().
8
+ * The SQLite backend ignores a correlation's timespan. Here event_count and
9
+ value_count are bucketed with time_bucket() over the timestamp column, the
10
+ same fixed-window approximation Splunk's `bin _time span=` uses.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, ClassVar
16
+
17
+ from sigma.backends.sqlite import sqliteBackend
18
+ from sigma.correlations import SigmaCorrelationRule, SigmaCorrelationTypeLiteral
19
+ from sigma.exceptions import SigmaConversionError
20
+
21
+ DEFAULT_TABLE = "events"
22
+ DEFAULT_TIMESTAMP = "ts"
23
+
24
+
25
+ class DuckDBBackend(sqliteBackend):
26
+ name: ClassVar[str] = "DuckDB backend (kodon)"
27
+ formats: ClassVar[dict[str, str]] = {"default": "DuckDB SQL"}
28
+
29
+ eq_expression: ClassVar[str] = "{field} ILIKE {value} ESCAPE '\\'"
30
+ startswith_expression: ClassVar[str] = "{field} ILIKE '{value}%' ESCAPE '\\'"
31
+ endswith_expression: ClassVar[str] = "{field} ILIKE '%{value}' ESCAPE '\\'"
32
+ contains_expression: ClassVar[str] = "{field} ILIKE '%{value}%' ESCAPE '\\'"
33
+ wildcard_match_expression: ClassVar[str] = "{field} ILIKE '{value}' ESCAPE '\\'"
34
+ wildcard_match_str_expression: ClassVar[str] = "{field} ILIKE '{value}' ESCAPE '\\'"
35
+ re_expression: ClassVar[str] = "regexp_matches({field}, '{regex}')"
36
+ field_quote: ClassVar[str] = '"'
37
+ table = DEFAULT_TABLE
38
+ timestamp_field = DEFAULT_TIMESTAMP
39
+
40
+ def convert_condition_field_eq_val_str(self, cond: Any, state: Any) -> Any:
41
+ # SQLite's backend falls through to `field = value` for plain strings,
42
+ # which is case-sensitive in DuckDB. Route plain equality through ILIKE.
43
+ value = cond.value
44
+ if not value.contains_special():
45
+ return self.eq_expression.format(
46
+ field=self.escape_and_quote_field(cond.field),
47
+ value=self.convert_value_str(value, state),
48
+ )
49
+ return super().convert_condition_field_eq_val_str(cond, state)
50
+
51
+ def convert_correlation_rule_from_template(
52
+ self,
53
+ rule: SigmaCorrelationRule,
54
+ correlation_type: SigmaCorrelationTypeLiteral,
55
+ method: str,
56
+ ) -> list[str]:
57
+ if correlation_type not in ("event_count", "value_count"):
58
+ raise SigmaConversionError(
59
+ rule, rule.source, f"correlation type {correlation_type!r} not supported by kodon"
60
+ )
61
+ state = self.last_processing_pipeline.state
62
+ table = state.get("table", self.table)
63
+ ts = state.get("timestamp_field", self.timestamp_field)
64
+ search = self.convert_correlation_search(rule).replace("FROM logs", f"FROM {table}")
65
+ group_fields = [self.escape_and_quote_field(f) for f in rule.group_by or []]
66
+ seconds = self.convert_timespan(rule.timespan, method)
67
+ bucket = f"time_bucket(INTERVAL '{seconds} seconds', {self.escape_and_quote_field(ts)}) AS bucket"
68
+ if correlation_type == "event_count":
69
+ aggregate = "COUNT(*)"
70
+ alias = "event_count"
71
+ else:
72
+ aggregate = f"COUNT(DISTINCT {self.escape_and_quote_field(str(rule.condition.fieldref))})"
73
+ alias = "value_count"
74
+ op = self.correlation_condition_mapping[rule.condition.op]
75
+ select = ", ".join([bucket, *group_fields, f"{aggregate} AS {alias}"])
76
+ group = ", ".join(["1", *group_fields])
77
+ return [
78
+ f"SELECT {select} FROM ({search}) AS subquery "
79
+ f"GROUP BY {group} HAVING {aggregate} {op} {rule.condition.count}"
80
+ ]
kodon/kusto_backend.py ADDED
@@ -0,0 +1,42 @@
1
+ """Two small corrections on top of the pySigma Kusto backend.
2
+
3
+ * A field name that is not a plain identifier (Sigma's dotted names such as
4
+ id.orig_h) is emitted in KQL bracket form, ['id.orig_h'].
5
+ * A CIDR match quotes its field the same way (pySigma formats it raw).
6
+ * `in~` is only used for string lists. KQL's in~ compares strings, so a
7
+ numeric list (ports, event ids) is emitted as an OR of == comparisons.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import re
14
+ from typing import Any, ClassVar
15
+
16
+ from sigma.backends.kusto import KustoBackend
17
+ from sigma.conditions import ConditionFieldEqualsValueExpression
18
+ from sigma.types import SigmaString
19
+
20
+ IDENTIFIER: re.Pattern[str] = re.compile(r"^\w+$")
21
+
22
+
23
+ class KodonKustoBackend(KustoBackend):
24
+ name: ClassVar[str] = "Kusto backend (kodon)"
25
+
26
+ def escape_and_quote_field(self, field_name: str) -> str:
27
+ if IDENTIFIER.match(field_name) or field_name.startswith("['"):
28
+ return field_name # plain identifier, or already bracketed by an earlier pass
29
+ return "['" + field_name.replace("'", "\\'") + "']"
30
+
31
+ def decide_convert_condition_as_in_expression(self, cond: Any, state: Any) -> bool:
32
+ if not super().decide_convert_condition_as_in_expression(cond, state):
33
+ return False
34
+ return all(
35
+ isinstance(arg, ConditionFieldEqualsValueExpression) and isinstance(arg.value, SigmaString)
36
+ for arg in cond.args
37
+ )
38
+
39
+ def convert_condition_field_eq_val_cidr(self, cond: Any, state: Any) -> Any:
40
+ quoted = copy.copy(cond)
41
+ quoted.field = self.escape_and_quote_field(cond.field)
42
+ return super().convert_condition_field_eq_val_cidr(quoted, state)