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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
description: Windows event logs exported to JSON by evtx_dump or python-evtx (Event.System / Event.EventData)
|
|
2
|
+
nested: true
|
|
3
|
+
timestamp: "Event.System.TimeCreated.#attributes.SystemTime"
|
|
4
|
+
# Some providers (e.g. Defender) name fields with spaces: "New Value" for NewValue.
|
|
5
|
+
default_field: ["Event.EventData.{field}", "Event.EventData.{field_words}"]
|
|
6
|
+
fields:
|
|
7
|
+
# EventID carries Qualifiers on some logs and then becomes {"#attributes": ..., "#text": N}.
|
|
8
|
+
EventID: [Event.System.EventID.#text, Event.System.EventID]
|
|
9
|
+
Channel: Event.System.Channel
|
|
10
|
+
Computer: Event.System.Computer
|
|
11
|
+
Provider_Name: Event.System.Provider.#attributes.Name
|
|
12
|
+
Level: Event.System.Level
|
|
13
|
+
Keywords: Event.System.Keywords
|
|
14
|
+
# WMI-Activity events keep their data under UserData, one element per event type.
|
|
15
|
+
Operation:
|
|
16
|
+
- Event.EventData.Operation
|
|
17
|
+
- Event.UserData.Operation_ClientFailure.Operation
|
|
18
|
+
- Event.UserData.Operation_StartedOperational.Operation
|
|
19
|
+
ClientMachine: [Event.EventData.ClientMachine, Event.UserData.Operation_ClientFailure.ClientMachine]
|
|
20
|
+
PossibleCause: [Event.EventData.PossibleCause, Event.UserData.Operation_ClientFailure.PossibleCause]
|
|
21
|
+
Consumer: [Event.EventData.Consumer, Event.UserData.Operation_ESStoConsumerBinding.CONSUMER]
|
|
22
|
+
Query: [Event.EventData.Query, Event.UserData.Operation_TemporaryEssStarted.Query]
|
|
23
|
+
logsources:
|
|
24
|
+
# Sysmon categories: the Sysmon channel plus the category's event ID.
|
|
25
|
+
- {match: {product: windows, category: process_creation}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 1}}
|
|
26
|
+
- {match: {product: windows, category: file_change}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 2}}
|
|
27
|
+
- {match: {product: windows, category: network_connection}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 3}}
|
|
28
|
+
- {match: {product: windows, category: process_termination}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 5}}
|
|
29
|
+
- {match: {product: windows, category: driver_load}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 6}}
|
|
30
|
+
- {match: {product: windows, category: image_load}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 7}}
|
|
31
|
+
- {match: {product: windows, category: create_remote_thread}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 8}}
|
|
32
|
+
- {match: {product: windows, category: raw_access_thread}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 9}}
|
|
33
|
+
- {match: {product: windows, category: process_access}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 10}}
|
|
34
|
+
- {match: {product: windows, category: file_event}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 11}}
|
|
35
|
+
- {match: {product: windows, category: registry_add}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 12}}
|
|
36
|
+
- {match: {product: windows, category: registry_delete}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 12}}
|
|
37
|
+
- {match: {product: windows, category: registry_set}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 13}}
|
|
38
|
+
- {match: {product: windows, category: registry_rename}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 14}}
|
|
39
|
+
- {match: {product: windows, category: registry_event}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: [12, 13, 14]}}
|
|
40
|
+
- {match: {product: windows, category: create_stream_hash}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 15}}
|
|
41
|
+
- {match: {product: windows, category: pipe_created}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: [17, 18]}}
|
|
42
|
+
- {match: {product: windows, category: wmi_event}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: [19, 20, 21]}}
|
|
43
|
+
- {match: {product: windows, category: dns_query}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 22}}
|
|
44
|
+
- {match: {product: windows, category: file_delete}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: [23, 26]}}
|
|
45
|
+
- {match: {product: windows, category: clipboard_capture}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 24}}
|
|
46
|
+
- {match: {product: windows, category: process_tampering}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 25}}
|
|
47
|
+
- {match: {product: windows, category: file_executable_detected}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational, Event.System.EventID: 29}}
|
|
48
|
+
# PowerShell categories.
|
|
49
|
+
- {match: {product: windows, category: ps_script}, where: {Event.System.Channel: Microsoft-Windows-PowerShell/Operational, Event.System.EventID: 4104}}
|
|
50
|
+
- {match: {product: windows, category: ps_module}, where: {Event.System.Channel: Microsoft-Windows-PowerShell/Operational, Event.System.EventID: 4103}}
|
|
51
|
+
- {match: {product: windows, category: ps_classic_start}, where: {Event.System.Channel: Windows PowerShell, Event.System.EventID: 400}}
|
|
52
|
+
- {match: {product: windows, category: ps_classic_provider_start}, where: {Event.System.Channel: Windows PowerShell, Event.System.EventID: 600}}
|
|
53
|
+
# Services map to their channel.
|
|
54
|
+
- {match: {product: windows, service: security}, where: {Event.System.Channel: Security}}
|
|
55
|
+
- {match: {product: windows, service: system}, where: {Event.System.Channel: System}}
|
|
56
|
+
- {match: {product: windows, service: application}, where: {Event.System.Channel: Application}}
|
|
57
|
+
- {match: {product: windows, service: sysmon}, where: {Event.System.Channel: Microsoft-Windows-Sysmon/Operational}}
|
|
58
|
+
- {match: {product: windows, service: powershell}, where: {Event.System.Channel: Microsoft-Windows-PowerShell/Operational}}
|
|
59
|
+
- {match: {product: windows, service: powershell-classic}, where: {Event.System.Channel: Windows PowerShell}}
|
|
60
|
+
- {match: {product: windows, service: taskscheduler}, where: {Event.System.Channel: Microsoft-Windows-TaskScheduler/Operational}}
|
|
61
|
+
- {match: {product: windows, service: wmi}, where: {Event.System.Channel: Microsoft-Windows-WMI-Activity/Operational}}
|
|
62
|
+
- {match: {product: windows, service: windefend}, where: {Event.System.Channel: Microsoft-Windows-Windows Defender/Operational}}
|
|
63
|
+
- {match: {product: windows, service: bits-client}, where: {Event.System.Channel: Microsoft-Windows-Bits-Client/Operational}}
|
|
64
|
+
- {match: {product: windows, service: firewall-as}, where: {Event.System.Channel: Microsoft-Windows-Windows Firewall With Advanced Security/Firewall}}
|
|
65
|
+
- {match: {product: windows, service: appxdeployment-server}, where: {Event.System.Channel: Microsoft-Windows-AppXDeploymentServer/Operational}}
|
|
66
|
+
- {match: {product: windows, service: codeintegrity-operational}, where: {Event.System.Channel: Microsoft-Windows-CodeIntegrity/Operational}}
|
|
67
|
+
- {match: {product: windows, service: dns-client}, where: {Event.System.Channel: Microsoft-Windows-DNS Client Events/Operational}}
|
|
68
|
+
- {match: {product: windows, service: driver-framework}, where: {Event.System.Channel: Microsoft-Windows-DriverFrameworks-UserMode/Operational}}
|
|
69
|
+
- {match: {product: windows, service: ntlm}, where: {Event.System.Channel: Microsoft-Windows-NTLM/Operational}}
|
|
70
|
+
- {match: {product: windows, service: terminalservices-localsessionmanager}, where: {Event.System.Channel: Microsoft-Windows-TerminalServices-LocalSessionManager/Operational}}
|
|
71
|
+
- {match: {product: windows, service: openssh}, where: {Event.System.Channel: OpenSSH/Operational}}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
description: OCSF 1.x events (nested JSON, time in epoch milliseconds), routed by class_uid
|
|
2
|
+
nested: true
|
|
3
|
+
timestamp: time
|
|
4
|
+
timestamp_format: epoch_ms
|
|
5
|
+
fields:
|
|
6
|
+
Image: process.file.path
|
|
7
|
+
CommandLine: process.cmd_line
|
|
8
|
+
ProcessId: process.pid
|
|
9
|
+
CurrentDirectory: process.file.parent_folder
|
|
10
|
+
ParentImage: actor.process.file.path
|
|
11
|
+
ParentCommandLine: actor.process.cmd_line
|
|
12
|
+
ParentProcessId: actor.process.pid
|
|
13
|
+
User: actor.user.name
|
|
14
|
+
Computer: device.hostname
|
|
15
|
+
TargetFilename: file.path
|
|
16
|
+
DestinationIp: dst_endpoint.ip
|
|
17
|
+
DestinationPort: dst_endpoint.port
|
|
18
|
+
DestinationHostname: dst_endpoint.hostname
|
|
19
|
+
SourceIp: src_endpoint.ip
|
|
20
|
+
SourcePort: src_endpoint.port
|
|
21
|
+
QueryName: query.hostname
|
|
22
|
+
logsources:
|
|
23
|
+
- {match: {category: process_creation}, where: {class_uid: 1007, activity_id: 1}}
|
|
24
|
+
- {match: {category: file_event}, where: {class_uid: 1001}}
|
|
25
|
+
- {match: {category: network_connection}, where: {class_uid: 4001}}
|
|
26
|
+
- {match: {category: dns_query}, where: {class_uid: 4003}}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
description: Sysmon events with their original field names; each Sigma category is limited to its Sysmon event ID
|
|
2
|
+
timestamp: UtcTime
|
|
3
|
+
logsources:
|
|
4
|
+
- {match: {product: windows, category: process_creation}, where: {EventID: 1}}
|
|
5
|
+
- {match: {product: windows, category: file_change}, where: {EventID: 2}}
|
|
6
|
+
- {match: {product: windows, category: network_connection}, where: {EventID: 3}}
|
|
7
|
+
- {match: {product: windows, category: process_termination}, where: {EventID: 5}}
|
|
8
|
+
- {match: {product: windows, category: driver_load}, where: {EventID: 6}}
|
|
9
|
+
- {match: {product: windows, category: image_load}, where: {EventID: 7}}
|
|
10
|
+
- {match: {product: windows, category: create_remote_thread}, where: {EventID: 8}}
|
|
11
|
+
- {match: {product: windows, category: raw_access_thread}, where: {EventID: 9}}
|
|
12
|
+
- {match: {product: windows, category: process_access}, where: {EventID: 10}}
|
|
13
|
+
- {match: {product: windows, category: file_event}, where: {EventID: 11}}
|
|
14
|
+
- {match: {product: windows, category: registry_add}, where: {EventID: 12}}
|
|
15
|
+
- {match: {product: windows, category: registry_delete}, where: {EventID: 12}}
|
|
16
|
+
- {match: {product: windows, category: registry_set}, where: {EventID: 13}}
|
|
17
|
+
- {match: {product: windows, category: registry_rename}, where: {EventID: 14}}
|
|
18
|
+
- {match: {product: windows, category: registry_event}, where: {EventID: [12, 13, 14]}}
|
|
19
|
+
- {match: {product: windows, category: create_stream_hash}, where: {EventID: 15}}
|
|
20
|
+
- {match: {product: windows, category: pipe_created}, where: {EventID: [17, 18]}}
|
|
21
|
+
- {match: {product: windows, category: wmi_event}, where: {EventID: [19, 20, 21]}}
|
|
22
|
+
- {match: {product: windows, category: dns_query}, where: {EventID: 22}}
|
|
23
|
+
- {match: {product: windows, category: file_delete}, where: {EventID: [23, 26]}}
|
|
24
|
+
- {match: {product: windows, category: clipboard_capture}, where: {EventID: 24}}
|
|
25
|
+
- {match: {product: windows, category: process_tampering}, where: {EventID: 25}}
|
|
26
|
+
- {match: {product: windows, category: file_executable_detected}, where: {EventID: 29}}
|
sigma2sql/py.typed
ADDED
|
File without changes
|
sigma2sql/rule.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from sigma2sql.condition import compile_condition
|
|
13
|
+
from sigma2sql.detection import compile_search
|
|
14
|
+
from sigma2sql.errors import RuleError, UnsupportedRule
|
|
15
|
+
from sigma2sql.expr import Expr, any_of
|
|
16
|
+
|
|
17
|
+
CorrelationType = Literal["event_count", "value_count", "value_sum", "value_avg", "temporal"]
|
|
18
|
+
CORRELATION_TYPES: tuple[CorrelationType, ...] = (
|
|
19
|
+
"event_count",
|
|
20
|
+
"value_count",
|
|
21
|
+
"value_sum",
|
|
22
|
+
"value_avg",
|
|
23
|
+
"temporal",
|
|
24
|
+
)
|
|
25
|
+
OPS = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<=", "eq": "="}
|
|
26
|
+
LEGACY_OPS = {">": "gt", ">=": "gte", "<": "lt", "<=": "lte", "=": "eq", "==": "eq"}
|
|
27
|
+
TIMESPAN = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
|
|
28
|
+
UNIT = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
29
|
+
# `selection | count(field) by a, b > 5`, the pre-correlation aggregation syntax.
|
|
30
|
+
AGGREGATION = re.compile(
|
|
31
|
+
r"^count\(\s*(?P<field>[\w.-]*)\s*\)"
|
|
32
|
+
r"(?:\s+by\s+(?P<by>[\w.-]+(?:\s*,\s*[\w.-]+)*))?"
|
|
33
|
+
r"\s*(?P<op>>=|<=|==|=|>|<)\s*(?P<value>\d+)$"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
Condition = tuple[tuple[str, float], ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Rule:
|
|
41
|
+
title: str
|
|
42
|
+
id: str | None
|
|
43
|
+
level: str | None
|
|
44
|
+
searches: dict[str, Any]
|
|
45
|
+
conditions: tuple[str, ...]
|
|
46
|
+
logsource: Mapping[str, str] = dataclasses.field(default_factory=dict)
|
|
47
|
+
name: str | None = None
|
|
48
|
+
# Set for rules written with the legacy `| count() ...` condition syntax.
|
|
49
|
+
aggregation: Correlation | None = None
|
|
50
|
+
path: Path | None = dataclasses.field(default=None, compare=False)
|
|
51
|
+
|
|
52
|
+
def compile(self) -> Expr:
|
|
53
|
+
"""Build the match tree. Searches the condition never uses are not compiled."""
|
|
54
|
+
cache: dict[str, Expr] = {}
|
|
55
|
+
|
|
56
|
+
def resolve(name: str) -> Expr:
|
|
57
|
+
if name not in cache:
|
|
58
|
+
cache[name] = compile_search(name, self.searches[name])
|
|
59
|
+
return cache[name]
|
|
60
|
+
|
|
61
|
+
names = list(self.searches)
|
|
62
|
+
return any_of([compile_condition(c, names, resolve) for c in self.conditions])
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def ref(self) -> str:
|
|
66
|
+
"""The name correlations use for this rule."""
|
|
67
|
+
return self.name or self.id or self.title
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Correlation:
|
|
72
|
+
"""A Sigma correlation rule: a count or sequence over the matches of other rules."""
|
|
73
|
+
|
|
74
|
+
title: str
|
|
75
|
+
id: str | None
|
|
76
|
+
level: str | None
|
|
77
|
+
type: CorrelationType
|
|
78
|
+
rules: tuple[str, ...]
|
|
79
|
+
group_by: tuple[str, ...] = ()
|
|
80
|
+
timespan: int | None = None # seconds
|
|
81
|
+
condition: Condition = ()
|
|
82
|
+
field: str | None = None
|
|
83
|
+
aliases: Mapping[str, Mapping[str, str]] = dataclasses.field(default_factory=dict)
|
|
84
|
+
generate: bool = False
|
|
85
|
+
name: str | None = None
|
|
86
|
+
path: Path | None = dataclasses.field(default=None, compare=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
Document = Rule | Correlation
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_documents(text: str, path: Path | None = None) -> list[Document]:
|
|
93
|
+
try:
|
|
94
|
+
docs = [d for d in yaml.safe_load_all(text) if d is not None]
|
|
95
|
+
except yaml.YAMLError as exc:
|
|
96
|
+
raise RuleError(f"invalid YAML: {exc}") from exc
|
|
97
|
+
if not docs:
|
|
98
|
+
raise RuleError("empty rule file")
|
|
99
|
+
out: list[Document] = []
|
|
100
|
+
for doc in docs:
|
|
101
|
+
if not isinstance(doc, dict):
|
|
102
|
+
raise RuleError("a rule must be a YAML map")
|
|
103
|
+
if "action" in doc:
|
|
104
|
+
raise UnsupportedRule("legacy rule collections ('action: global') are not supported")
|
|
105
|
+
if "correlation" in doc:
|
|
106
|
+
out.append(_correlation(doc, path))
|
|
107
|
+
elif "filter" in doc:
|
|
108
|
+
raise UnsupportedRule("filter rules are not supported")
|
|
109
|
+
else:
|
|
110
|
+
out.append(_rule(doc, path))
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_file(path: Path) -> list[Document]:
|
|
115
|
+
return load_documents(path.read_text(encoding="utf-8"), path)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_rule(text: str, path: Path | None = None) -> Rule:
|
|
119
|
+
"""Load a file that holds exactly one detection rule."""
|
|
120
|
+
docs = load_documents(text, path)
|
|
121
|
+
if len(docs) != 1 or not isinstance(docs[0], Rule):
|
|
122
|
+
raise UnsupportedRule("expected a single detection rule")
|
|
123
|
+
return docs[0]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_rule_file(path: Path) -> Rule:
|
|
127
|
+
return load_rule(path.read_text(encoding="utf-8"), path)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _meta(doc: dict[str, Any], path: Path | None) -> dict[str, Any]:
|
|
131
|
+
def opt(key: str) -> str | None:
|
|
132
|
+
value = doc.get(key)
|
|
133
|
+
return str(value) if value is not None else None
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"title": str(doc.get("title") or (path.stem if path else "untitled")),
|
|
137
|
+
"id": opt("id"),
|
|
138
|
+
"level": opt("level"),
|
|
139
|
+
"name": opt("name"),
|
|
140
|
+
"path": path,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _rule(doc: dict[str, Any], path: Path | None) -> Rule:
|
|
145
|
+
detection = doc.get("detection")
|
|
146
|
+
if not isinstance(detection, dict):
|
|
147
|
+
raise RuleError("missing 'detection' section")
|
|
148
|
+
|
|
149
|
+
condition = detection.get("condition")
|
|
150
|
+
if isinstance(condition, str):
|
|
151
|
+
conditions: tuple[str, ...] = (condition,)
|
|
152
|
+
elif isinstance(condition, list) and condition and all(isinstance(c, str) for c in condition):
|
|
153
|
+
conditions = tuple(condition)
|
|
154
|
+
else:
|
|
155
|
+
raise RuleError("missing or invalid 'detection.condition'")
|
|
156
|
+
|
|
157
|
+
searches = {str(k): v for k, v in detection.items() if k not in ("condition", "timeframe")}
|
|
158
|
+
if not searches:
|
|
159
|
+
raise RuleError("detection has no searches")
|
|
160
|
+
|
|
161
|
+
logsource = doc.get("logsource") or {}
|
|
162
|
+
if not isinstance(logsource, dict):
|
|
163
|
+
raise RuleError("'logsource' must be a map")
|
|
164
|
+
|
|
165
|
+
rule = Rule(
|
|
166
|
+
searches=searches,
|
|
167
|
+
conditions=conditions,
|
|
168
|
+
logsource={str(k): str(v) for k, v in logsource.items() if v is not None},
|
|
169
|
+
**_meta(doc, path),
|
|
170
|
+
)
|
|
171
|
+
if not any("|" in c for c in conditions):
|
|
172
|
+
return rule
|
|
173
|
+
if len(conditions) != 1:
|
|
174
|
+
raise UnsupportedRule("aggregations need a single condition")
|
|
175
|
+
base, aggregation = (part.strip() for part in conditions[0].split("|", 1))
|
|
176
|
+
timeframe = detection.get("timeframe")
|
|
177
|
+
rule = replace(rule, conditions=(base,))
|
|
178
|
+
return replace(rule, aggregation=_aggregation(rule, aggregation, timeframe))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _aggregation(rule: Rule, text: str, timeframe: Any) -> Correlation:
|
|
182
|
+
m = AGGREGATION.match(text)
|
|
183
|
+
if not m:
|
|
184
|
+
raise UnsupportedRule(f"aggregation {text!r} is not supported (only count)")
|
|
185
|
+
by = tuple(g.strip() for g in m["by"].split(",")) if m["by"] else ()
|
|
186
|
+
return Correlation(
|
|
187
|
+
title=rule.title,
|
|
188
|
+
id=rule.id,
|
|
189
|
+
level=rule.level,
|
|
190
|
+
type="value_count" if m["field"] else "event_count",
|
|
191
|
+
rules=(rule.ref,),
|
|
192
|
+
group_by=by,
|
|
193
|
+
timespan=_timespan(timeframe) if timeframe is not None else None,
|
|
194
|
+
condition=((LEGACY_OPS[m["op"]], float(m["value"])),),
|
|
195
|
+
field=m["field"] or None,
|
|
196
|
+
path=rule.path,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _correlation(doc: dict[str, Any], path: Path | None) -> Correlation:
|
|
201
|
+
spec = doc["correlation"]
|
|
202
|
+
if not isinstance(spec, dict):
|
|
203
|
+
raise RuleError("'correlation' must be a map")
|
|
204
|
+
kind = spec.get("type")
|
|
205
|
+
if kind == "temporal_ordered":
|
|
206
|
+
raise UnsupportedRule("temporal_ordered correlations are not supported")
|
|
207
|
+
if kind not in CORRELATION_TYPES:
|
|
208
|
+
raise UnsupportedRule(f"correlation type {kind!r} is not supported")
|
|
209
|
+
|
|
210
|
+
rules = spec.get("rules")
|
|
211
|
+
if isinstance(rules, str):
|
|
212
|
+
rules = [rules]
|
|
213
|
+
if not isinstance(rules, list) or not rules:
|
|
214
|
+
raise RuleError("correlation needs a list of 'rules'")
|
|
215
|
+
|
|
216
|
+
group_by = spec.get("group-by") or []
|
|
217
|
+
if isinstance(group_by, str):
|
|
218
|
+
group_by = [group_by]
|
|
219
|
+
|
|
220
|
+
raw_condition = spec.get("condition") or {}
|
|
221
|
+
if not isinstance(raw_condition, dict):
|
|
222
|
+
raise RuleError("correlation 'condition' must be a map")
|
|
223
|
+
value_field = raw_condition.get("field", spec.get("field"))
|
|
224
|
+
condition: list[tuple[str, float]] = []
|
|
225
|
+
for op, value in raw_condition.items():
|
|
226
|
+
if op == "field":
|
|
227
|
+
continue
|
|
228
|
+
if op not in OPS or isinstance(value, bool) or not isinstance(value, int | float):
|
|
229
|
+
raise RuleError(f"invalid correlation condition {op}: {value!r}")
|
|
230
|
+
condition.append((op, float(value)))
|
|
231
|
+
if kind == "temporal" and not condition:
|
|
232
|
+
condition = [("gte", float(len(rules)))]
|
|
233
|
+
if not condition:
|
|
234
|
+
raise RuleError("correlation needs a condition")
|
|
235
|
+
if kind in ("value_count", "value_sum", "value_avg") and not value_field:
|
|
236
|
+
raise RuleError(f"{kind} correlations need a condition field")
|
|
237
|
+
|
|
238
|
+
aliases = spec.get("aliases") or {}
|
|
239
|
+
if not isinstance(aliases, dict) or not all(isinstance(v, dict) for v in aliases.values()):
|
|
240
|
+
raise RuleError("correlation 'aliases' must map names to {rule: field}")
|
|
241
|
+
|
|
242
|
+
timespan = spec.get("timespan")
|
|
243
|
+
return Correlation(
|
|
244
|
+
type=kind,
|
|
245
|
+
rules=tuple(str(r) for r in rules),
|
|
246
|
+
group_by=tuple(str(g) for g in group_by),
|
|
247
|
+
timespan=_timespan(timespan) if timespan is not None else None,
|
|
248
|
+
condition=tuple(condition),
|
|
249
|
+
field=str(value_field) if value_field else None,
|
|
250
|
+
aliases={str(k): {str(r): str(f) for r, f in v.items()} for k, v in aliases.items()},
|
|
251
|
+
generate=bool(spec.get("generate", False)),
|
|
252
|
+
**_meta(doc, path),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _timespan(value: Any) -> int:
|
|
257
|
+
m = TIMESPAN.match(str(value))
|
|
258
|
+
if not m:
|
|
259
|
+
raise RuleError(f"invalid timespan {value!r}; use e.g. 30s, 5m, 1h, 1d")
|
|
260
|
+
return int(m[1]) * UNIT[m[2]]
|
sigma2sql/runner.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Run rules against sample events in an in-memory SQLite database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
from contextlib import closing
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sigma2sql.rule import Rule
|
|
14
|
+
from sigma2sql.sql import Target, to_query
|
|
15
|
+
|
|
16
|
+
TABLE = "events"
|
|
17
|
+
COLUMN = "data"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def connect() -> sqlite3.Connection:
|
|
21
|
+
"""SQLite connection with the REGEXP and cidr_match functions the sqlite dialect uses."""
|
|
22
|
+
conn = sqlite3.connect(":memory:")
|
|
23
|
+
conn.create_function("regexp", 2, _regexp, deterministic=True)
|
|
24
|
+
conn.create_function("cidr_match", 2, _cidr_match, deterministic=True)
|
|
25
|
+
return conn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def target(base: Target | None = None) -> Target:
|
|
29
|
+
"""`base` (mapping, timestamp settings) pointed at the runner's events table."""
|
|
30
|
+
base = base or Target()
|
|
31
|
+
routes = tuple(replace(r, table=None) for r in base.routes)
|
|
32
|
+
return replace(base, dialect="sqlite", table=TABLE, json_column=COLUMN, routes=routes)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load(events: list[dict[str, Any]]) -> sqlite3.Connection:
|
|
36
|
+
conn = connect()
|
|
37
|
+
conn.execute(f"CREATE TABLE {TABLE} ({COLUMN} TEXT NOT NULL)")
|
|
38
|
+
conn.executemany(
|
|
39
|
+
f"INSERT INTO {TABLE} (rowid, {COLUMN}) VALUES (?, ?)",
|
|
40
|
+
[(i + 1, json.dumps(e)) for i, e in enumerate(events)],
|
|
41
|
+
)
|
|
42
|
+
return conn
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def matching_events(
|
|
46
|
+
rule: Rule, events: list[dict[str, Any]], base: Target | None = None
|
|
47
|
+
) -> list[int]:
|
|
48
|
+
"""Return the indexes of the events the rule matches."""
|
|
49
|
+
query = to_query(rule, target(base), columns="rowid")
|
|
50
|
+
with closing(load(events)) as conn:
|
|
51
|
+
return [row[0] - 1 for row in conn.execute(query + " ORDER BY rowid")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rows(query: str, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
55
|
+
"""Run a query (e.g. a correlation) over the events and return named rows."""
|
|
56
|
+
with closing(load(events)) as conn:
|
|
57
|
+
cur = conn.execute(query)
|
|
58
|
+
names = [d[0] for d in cur.description]
|
|
59
|
+
return [dict(zip(names, row, strict=True)) for row in cur.fetchall()]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_jsonl(text: str) -> list[dict[str, Any]]:
|
|
63
|
+
events: list[dict[str, Any]] = []
|
|
64
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
65
|
+
if not line.strip():
|
|
66
|
+
continue
|
|
67
|
+
event = json.loads(line)
|
|
68
|
+
if not isinstance(event, dict):
|
|
69
|
+
raise ValueError(f"line {n}: each event must be a JSON object")
|
|
70
|
+
events.append(event)
|
|
71
|
+
return events
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _regexp(pattern: str, value: Any) -> bool:
|
|
75
|
+
return value is not None and re.search(pattern, str(value)) is not None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cidr_match(value: Any, network: str) -> bool:
|
|
79
|
+
try:
|
|
80
|
+
return ipaddress.ip_address(str(value)) in ipaddress.ip_network(network)
|
|
81
|
+
except ValueError:
|
|
82
|
+
return False
|