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 +30 -0
- kodon/cli.py +133 -0
- kodon/compile.py +115 -0
- kodon/contracts.py +100 -0
- kodon/coverage/attack_scope.yml +30 -0
- kodon/coverage.py +70 -0
- kodon/duckdb_backend.py +80 -0
- kodon/kusto_backend.py +42 -0
- kodon/loader.py +197 -0
- kodon/pipelines/kql_tables.yml +72 -0
- kodon/pipelines/synthetic_canonical.yml +71 -0
- kodon/replay.py +115 -0
- kodon/rules/email/xmailer_cli_mailer_anomaly.yml +52 -0
- kodon/rules/identity/inbox_rule_external_forward.yml +49 -0
- kodon/rules/identity/mfa_success_multi_country_window.yml +62 -0
- kodon/rules/identity/oauth_consent_mail_files_scopes_by_user.yml +54 -0
- kodon/rules/network/beacon_fixed_destination_high_count.yml +73 -0
- kodon/rules/network/reverse_shell_conn_shape.yml +58 -0
- kodon/rules/windows/kerberos_rc4_tgs_volume_single_host.yml +62 -0
- kodon/rules/windows/msbuild_masquerade_network_egress.yml +53 -0
- kodon/rules/windows/nltest_domain_trust_enum_workstation.yml +53 -0
- kodon/rules/windows/wmi_remote_process_creation.yml +61 -0
- kodon/synth.py +360 -0
- kodon-0.0.1.dist-info/METADATA +291 -0
- kodon-0.0.1.dist-info/RECORD +28 -0
- kodon-0.0.1.dist-info/WHEEL +4 -0
- kodon-0.0.1.dist-info/entry_points.txt +2 -0
- kodon-0.0.1.dist-info/licenses/LICENSE +201 -0
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)
|
kodon/duckdb_backend.py
ADDED
|
@@ -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)
|