secrulekit 1.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.
- secrulekit/__init__.py +19 -0
- secrulekit/cli.py +244 -0
- secrulekit/converters/__init__.py +3 -0
- secrulekit/converters/converter.py +208 -0
- secrulekit/parsers/__init__.py +5 -0
- secrulekit/parsers/sigma_parser.py +109 -0
- secrulekit/parsers/suricata_parser.py +125 -0
- secrulekit/parsers/yara_parser.py +138 -0
- secrulekit/reporters/__init__.py +4 -0
- secrulekit/reporters/html_reporter.py +92 -0
- secrulekit/reporters/json_reporter.py +38 -0
- secrulekit/utils/__init__.py +3 -0
- secrulekit/utils/helpers.py +104 -0
- secrulekit/validators/__init__.py +3 -0
- secrulekit/validators/rule_validator.py +276 -0
- secrulekit-1.2.0.dist-info/METADATA +241 -0
- secrulekit-1.2.0.dist-info/RECORD +21 -0
- secrulekit-1.2.0.dist-info/WHEEL +5 -0
- secrulekit-1.2.0.dist-info/entry_points.txt +2 -0
- secrulekit-1.2.0.dist-info/licenses/LICENSE +21 -0
- secrulekit-1.2.0.dist-info/top_level.txt +1 -0
secrulekit/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""SecRuleKit — Security detection rule parser, validator, and converter."""
|
|
2
|
+
|
|
3
|
+
from secrulekit.parsers.yara_parser import YaraParser
|
|
4
|
+
from secrulekit.parsers.sigma_parser import SigmaParser
|
|
5
|
+
from secrulekit.parsers.suricata_parser import SuricataParser
|
|
6
|
+
from secrulekit.validators.rule_validator import RuleValidator, ValidationResult
|
|
7
|
+
from secrulekit.converters.converter import RuleConverter
|
|
8
|
+
|
|
9
|
+
__version__ = "1.2.0"
|
|
10
|
+
__author__ = "Cherno.x"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"YaraParser",
|
|
14
|
+
"SigmaParser",
|
|
15
|
+
"SuricataParser",
|
|
16
|
+
"RuleValidator",
|
|
17
|
+
"ValidationResult",
|
|
18
|
+
"RuleConverter",
|
|
19
|
+
]
|
secrulekit/cli.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""SecRuleKit CLI — entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich import box
|
|
12
|
+
|
|
13
|
+
from secrulekit import __version__
|
|
14
|
+
from secrulekit.validators.rule_validator import RuleValidator, Severity
|
|
15
|
+
from secrulekit.converters.converter import RuleConverter, SUPPORTED_TARGETS, ConversionError
|
|
16
|
+
from secrulekit.parsers.sigma_parser import SigmaParser, SigmaParseError
|
|
17
|
+
from secrulekit.reporters.json_reporter import JsonReporter
|
|
18
|
+
from secrulekit.reporters.html_reporter import HtmlReporter
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
err_console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group()
|
|
25
|
+
@click.version_option(__version__, prog_name="secrulekit")
|
|
26
|
+
def main() -> None:
|
|
27
|
+
"""SecRuleKit — security detection rule parser, validator, and converter."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------ #
|
|
31
|
+
# validate
|
|
32
|
+
# ------------------------------------------------------------------ #
|
|
33
|
+
|
|
34
|
+
@main.command()
|
|
35
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
36
|
+
@click.option("-f", "--format", "fmt",
|
|
37
|
+
type=click.Choice(["yara", "sigma", "suricata", "snort", "auto"], case_sensitive=False),
|
|
38
|
+
default="auto", show_default=True, help="Rule format")
|
|
39
|
+
@click.option("-r", "--recursive", is_flag=True, default=True, show_default=True,
|
|
40
|
+
help="Recurse into subdirectories")
|
|
41
|
+
@click.option("--strict", is_flag=True, help="Treat warnings as errors")
|
|
42
|
+
@click.option("--report", "report_file", type=click.Path(), default=None,
|
|
43
|
+
help="Write report to FILE (.json or .html)")
|
|
44
|
+
@click.option("--no-color", is_flag=True, help="Disable colored output")
|
|
45
|
+
def validate(path: str, fmt: str, recursive: bool, strict: bool, report_file: str | None, no_color: bool) -> None:
|
|
46
|
+
"""Validate rule files for syntax and semantic errors."""
|
|
47
|
+
if no_color:
|
|
48
|
+
console._force_terminal = False # type: ignore[attr-defined]
|
|
49
|
+
|
|
50
|
+
validator = RuleValidator(strict=strict)
|
|
51
|
+
result = validator.validate_path(Path(path), fmt=fmt)
|
|
52
|
+
|
|
53
|
+
# Print findings table
|
|
54
|
+
table = Table(box=box.SIMPLE_HEAD, show_edge=False)
|
|
55
|
+
table.add_column("Severity", style="bold", width=10)
|
|
56
|
+
table.add_column("Code", width=10)
|
|
57
|
+
table.add_column("Rule", width=30)
|
|
58
|
+
table.add_column("Message")
|
|
59
|
+
table.add_column("Location", style="dim")
|
|
60
|
+
|
|
61
|
+
severity_style = {
|
|
62
|
+
Severity.ERROR: "red",
|
|
63
|
+
Severity.WARNING: "yellow",
|
|
64
|
+
Severity.INFO: "blue",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for f in result.findings:
|
|
68
|
+
table.add_row(
|
|
69
|
+
f"[{severity_style[f.severity]}]{f.severity.value.upper()}[/]",
|
|
70
|
+
f.code,
|
|
71
|
+
f.rule_name or "",
|
|
72
|
+
f.message,
|
|
73
|
+
f"{f.file}:{f.line}" if f.file else "",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
console.print(table)
|
|
77
|
+
console.print(f"\n{result.summary()}")
|
|
78
|
+
|
|
79
|
+
if report_file:
|
|
80
|
+
rpath = Path(report_file)
|
|
81
|
+
if rpath.suffix.lower() == ".html":
|
|
82
|
+
HtmlReporter().generate(result, output=rpath)
|
|
83
|
+
else:
|
|
84
|
+
JsonReporter().generate(result, output=rpath)
|
|
85
|
+
console.print(f"[dim]Report written to {rpath}[/]")
|
|
86
|
+
|
|
87
|
+
sys.exit(0 if result.passed else 1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
# convert
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
|
|
94
|
+
@main.command()
|
|
95
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
96
|
+
@click.option("--to", "target",
|
|
97
|
+
type=click.Choice(list(SUPPORTED_TARGETS), case_sensitive=False),
|
|
98
|
+
required=True, help="Target format")
|
|
99
|
+
@click.option("-o", "--output", "output_file", type=click.Path(), default=None,
|
|
100
|
+
help="Write output to FILE (default: stdout)")
|
|
101
|
+
@click.option("--batch", is_flag=True, help="Convert all .yml files in a directory")
|
|
102
|
+
def convert(path: str, target: str, output_file: str | None, batch: bool) -> None:
|
|
103
|
+
"""Convert Sigma rules to another format."""
|
|
104
|
+
converter = RuleConverter()
|
|
105
|
+
parser = SigmaParser()
|
|
106
|
+
results = []
|
|
107
|
+
|
|
108
|
+
if batch or Path(path).is_dir():
|
|
109
|
+
rules = list(parser.parse_directory(Path(path)))
|
|
110
|
+
else:
|
|
111
|
+
try:
|
|
112
|
+
rules = [parser.parse_file(Path(path))]
|
|
113
|
+
except SigmaParseError as e:
|
|
114
|
+
err_console.print(f"[red]Parse error:[/] {e}")
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
for rule in rules:
|
|
118
|
+
try:
|
|
119
|
+
output = converter.convert(rule, target)
|
|
120
|
+
results.append(output)
|
|
121
|
+
except ConversionError as e:
|
|
122
|
+
err_console.print(f"[yellow]Skipped '{rule.title}':[/] {e}")
|
|
123
|
+
|
|
124
|
+
combined = "\n\n".join(results)
|
|
125
|
+
if output_file:
|
|
126
|
+
Path(output_file).write_text(combined, encoding="utf-8")
|
|
127
|
+
console.print(f"[green]Wrote {len(results)} rule(s) to {output_file}[/]")
|
|
128
|
+
else:
|
|
129
|
+
console.print(combined)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------ #
|
|
133
|
+
# info
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
|
|
136
|
+
@main.command()
|
|
137
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
138
|
+
def info(path: str) -> None:
|
|
139
|
+
"""Display rule metadata and ATT&CK mapping."""
|
|
140
|
+
from secrulekit.utils.helpers import iter_attack_tags
|
|
141
|
+
|
|
142
|
+
parser = SigmaParser()
|
|
143
|
+
try:
|
|
144
|
+
rule = parser.parse_file(Path(path))
|
|
145
|
+
except SigmaParseError as e:
|
|
146
|
+
err_console.print(f"[red]Parse error:[/] {e}")
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
console.print(f"[bold]{rule.title}[/]")
|
|
150
|
+
if rule.id:
|
|
151
|
+
console.print(f" ID: {rule.id}")
|
|
152
|
+
if rule.status:
|
|
153
|
+
console.print(f" Status: {rule.status}")
|
|
154
|
+
if rule.level:
|
|
155
|
+
console.print(f" Severity: {rule.level}")
|
|
156
|
+
if rule.author:
|
|
157
|
+
console.print(f" Author: {rule.author}")
|
|
158
|
+
if rule.description:
|
|
159
|
+
console.print(f" Description: {rule.description}")
|
|
160
|
+
|
|
161
|
+
tactics = rule.attack_tactics
|
|
162
|
+
if tactics:
|
|
163
|
+
console.print(f" Tactics: {', '.join(tactics)}")
|
|
164
|
+
|
|
165
|
+
techniques = list(iter_attack_tags(rule))
|
|
166
|
+
if techniques:
|
|
167
|
+
console.print(" Techniques:")
|
|
168
|
+
for tid, tname in techniques:
|
|
169
|
+
console.print(f" {tid} {tname or '(unknown)'}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------ #
|
|
173
|
+
# stats
|
|
174
|
+
# ------------------------------------------------------------------ #
|
|
175
|
+
|
|
176
|
+
@main.command()
|
|
177
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
178
|
+
@click.option("-f", "--format", "fmt",
|
|
179
|
+
type=click.Choice(["sigma", "yara", "suricata", "auto"], case_sensitive=False),
|
|
180
|
+
default="auto")
|
|
181
|
+
def stats(path: str, fmt: str) -> None:
|
|
182
|
+
"""Show statistics for a rule collection."""
|
|
183
|
+
from secrulekit.parsers.yara_parser import YaraParser
|
|
184
|
+
from secrulekit.parsers.suricata_parser import SuricataParser
|
|
185
|
+
from collections import Counter
|
|
186
|
+
|
|
187
|
+
p = Path(path)
|
|
188
|
+
|
|
189
|
+
if fmt in ("sigma", "auto") and (p.is_dir() or p.suffix in (".yml", ".yaml")):
|
|
190
|
+
rules = list(SigmaParser().parse_directory(p) if p.is_dir() else [SigmaParser().parse_file(p)])
|
|
191
|
+
levels = Counter(r.level or "unknown" for r in rules)
|
|
192
|
+
tactics = Counter(t for r in rules for t in r.attack_tactics)
|
|
193
|
+
console.print(f"[bold]Sigma rules:[/] {len(rules)}")
|
|
194
|
+
console.print("Severity breakdown:")
|
|
195
|
+
for lvl, cnt in levels.most_common():
|
|
196
|
+
console.print(f" {lvl:15s} {cnt}")
|
|
197
|
+
if tactics:
|
|
198
|
+
console.print("Top ATT&CK tactics:")
|
|
199
|
+
for tactic, cnt in tactics.most_common(5):
|
|
200
|
+
console.print(f" {tactic:30s} {cnt}")
|
|
201
|
+
elif fmt in ("yara", "auto"):
|
|
202
|
+
rules = list(YaraParser().parse_directory(p) if p.is_dir() else YaraParser().parse_file(p))
|
|
203
|
+
console.print(f"[bold]YARA rules:[/] {len(rules)}")
|
|
204
|
+
with_meta = sum(1 for r in rules if r.meta)
|
|
205
|
+
console.print(f" With metadata: {with_meta}")
|
|
206
|
+
console.print(f" Without meta: {len(rules) - with_meta}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ #
|
|
210
|
+
# dedup
|
|
211
|
+
# ------------------------------------------------------------------ #
|
|
212
|
+
|
|
213
|
+
@main.command()
|
|
214
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
215
|
+
@click.option("-f", "--format", "fmt",
|
|
216
|
+
type=click.Choice(["yara", "sigma", "suricata", "auto"], case_sensitive=False),
|
|
217
|
+
default="auto")
|
|
218
|
+
@click.option("-o", "--output", "output_dir", type=click.Path(), default=None,
|
|
219
|
+
help="Write deduplicated rules to OUTPUT_DIR")
|
|
220
|
+
def dedup(path: str, fmt: str, output_dir: str | None) -> None:
|
|
221
|
+
"""Find and remove duplicate rules from a collection."""
|
|
222
|
+
from secrulekit.utils.helpers import deduplicate_rules
|
|
223
|
+
from secrulekit.parsers.yara_parser import YaraParser
|
|
224
|
+
from secrulekit.parsers.suricata_parser import SuricataParser
|
|
225
|
+
|
|
226
|
+
p = Path(path)
|
|
227
|
+
|
|
228
|
+
if fmt in ("sigma", "auto") and (p.is_dir() or p.suffix in (".yml", ".yaml")):
|
|
229
|
+
rules = list(SigmaParser().parse_directory(p) if p.is_dir() else [SigmaParser().parse_file(p)])
|
|
230
|
+
else:
|
|
231
|
+
rules = list(YaraParser().parse_directory(p) if p.is_dir() else YaraParser().parse_file(p))
|
|
232
|
+
|
|
233
|
+
unique, dupes = deduplicate_rules(rules)
|
|
234
|
+
console.print(f"Total: {len(rules)} Unique: {len(unique)} Duplicates: {len(dupes)}")
|
|
235
|
+
|
|
236
|
+
if dupes:
|
|
237
|
+
console.print("\n[yellow]Duplicate rules:[/]")
|
|
238
|
+
for r in dupes:
|
|
239
|
+
name = r.title if isinstance(r, SigmaParser.__class__) else getattr(r, "name", str(r))
|
|
240
|
+
console.print(f" {getattr(r, 'source_file', '')} {getattr(r, 'title', getattr(r, 'name', ''))}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
main()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Sigma rule conversion engine — converts to Splunk SPL, ElasticSearch, YARA-L, QRadar AQL, Suricata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from secrulekit.parsers.sigma_parser import SigmaRule
|
|
9
|
+
|
|
10
|
+
SUPPORTED_TARGETS = ("splunk", "elastic", "yaral", "qradar", "suricata")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConversionError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RuleConverter:
|
|
18
|
+
"""Convert Sigma rules to various SIEM/detection platform query languages."""
|
|
19
|
+
|
|
20
|
+
def convert(self, rule: SigmaRule, target: str) -> str:
|
|
21
|
+
target = target.lower()
|
|
22
|
+
if target not in SUPPORTED_TARGETS:
|
|
23
|
+
raise ConversionError(
|
|
24
|
+
f"Unsupported target '{target}'. Supported: {', '.join(SUPPORTED_TARGETS)}"
|
|
25
|
+
)
|
|
26
|
+
method = getattr(self, f"to_{target}")
|
|
27
|
+
return method(rule)
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------ #
|
|
30
|
+
# Splunk SPL
|
|
31
|
+
# ------------------------------------------------------------------ #
|
|
32
|
+
|
|
33
|
+
def to_splunk(self, rule: SigmaRule) -> str:
|
|
34
|
+
"""Convert Sigma rule to Splunk SPL search."""
|
|
35
|
+
parts: List[str] = []
|
|
36
|
+
detection = rule.detection
|
|
37
|
+
|
|
38
|
+
for key, value in detection.items():
|
|
39
|
+
if key == "condition":
|
|
40
|
+
continue
|
|
41
|
+
parts.extend(self._sigma_selection_to_splunk(key, value))
|
|
42
|
+
|
|
43
|
+
condition = detection.get("condition", "selection")
|
|
44
|
+
source_clause = self._logsource_to_splunk_index(rule.logsource)
|
|
45
|
+
|
|
46
|
+
where_clauses = " AND ".join(parts) if parts else "*"
|
|
47
|
+
comment = f"| `comment(\"{rule.title}\")`" if rule.title else ""
|
|
48
|
+
return f"index={source_clause} {where_clauses}{comment}"
|
|
49
|
+
|
|
50
|
+
def _sigma_selection_to_splunk(self, key: str, value: Any) -> List[str]:
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
clauses = []
|
|
53
|
+
for field_name, field_val in value.items():
|
|
54
|
+
field_name = field_name.replace("|contains", "").replace("|startswith", "").replace("|endswith", "")
|
|
55
|
+
if isinstance(field_val, list):
|
|
56
|
+
or_parts = " OR ".join(f'{field_name}="{v}"' for v in field_val)
|
|
57
|
+
clauses.append(f"({or_parts})")
|
|
58
|
+
else:
|
|
59
|
+
clauses.append(f'{field_name}="{field_val}"')
|
|
60
|
+
return clauses
|
|
61
|
+
if isinstance(value, list):
|
|
62
|
+
return [f'"{v}"' for v in value]
|
|
63
|
+
return [f'"{value}"']
|
|
64
|
+
|
|
65
|
+
def _logsource_to_splunk_index(self, logsource: Dict[str, Any]) -> str:
|
|
66
|
+
category = logsource.get("category", "")
|
|
67
|
+
product = logsource.get("product", "")
|
|
68
|
+
if "windows" in product.lower():
|
|
69
|
+
return "wineventlog"
|
|
70
|
+
if "linux" in product.lower():
|
|
71
|
+
return "syslog"
|
|
72
|
+
if "network" in category.lower():
|
|
73
|
+
return "network"
|
|
74
|
+
return "*"
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ #
|
|
77
|
+
# ElasticSearch Query DSL
|
|
78
|
+
# ------------------------------------------------------------------ #
|
|
79
|
+
|
|
80
|
+
def to_elastic(self, rule: SigmaRule) -> str:
|
|
81
|
+
"""Convert Sigma rule to ElasticSearch Query DSL (JSON)."""
|
|
82
|
+
must_clauses: List[Dict] = []
|
|
83
|
+
detection = rule.detection
|
|
84
|
+
|
|
85
|
+
for key, value in detection.items():
|
|
86
|
+
if key == "condition":
|
|
87
|
+
continue
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
for field_name, field_val in value.items():
|
|
90
|
+
field_name = field_name.split("|")[0]
|
|
91
|
+
if isinstance(field_val, list):
|
|
92
|
+
must_clauses.append(
|
|
93
|
+
{"bool": {"should": [{"match": {field_name: v}} for v in field_val]}}
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
must_clauses.append({"match": {field_name: field_val}})
|
|
97
|
+
|
|
98
|
+
query = {
|
|
99
|
+
"query": {
|
|
100
|
+
"bool": {
|
|
101
|
+
"must": must_clauses if must_clauses else [{"match_all": {}}]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return json.dumps(query, indent=2)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
# YARA-L (Google SecOps / Chronicle)
|
|
109
|
+
# ------------------------------------------------------------------ #
|
|
110
|
+
|
|
111
|
+
def to_yaral(self, rule: SigmaRule) -> str:
|
|
112
|
+
"""Convert Sigma rule to YARA-L 2.0 skeleton."""
|
|
113
|
+
safe_name = rule.title.lower().replace(" ", "_").replace("-", "_")[:64]
|
|
114
|
+
meta_lines = [f' title = "{rule.title}"']
|
|
115
|
+
if rule.description:
|
|
116
|
+
meta_lines.append(f' description = "{rule.description}"')
|
|
117
|
+
if rule.id:
|
|
118
|
+
meta_lines.append(f' id = "{rule.id}"')
|
|
119
|
+
if rule.level:
|
|
120
|
+
meta_lines.append(f' severity = "{rule.level.upper()}"')
|
|
121
|
+
for tag in rule.attack_techniques:
|
|
122
|
+
meta_lines.append(f' mitre_attack_technique = "{tag}"')
|
|
123
|
+
|
|
124
|
+
events_lines = [" $e.metadata.event_type = \"GENERIC_EVENT\""]
|
|
125
|
+
for key, value in rule.detection.items():
|
|
126
|
+
if key == "condition":
|
|
127
|
+
continue
|
|
128
|
+
if isinstance(value, dict):
|
|
129
|
+
for field_name, field_val in value.items():
|
|
130
|
+
field_name = field_name.split("|")[0]
|
|
131
|
+
if isinstance(field_val, list):
|
|
132
|
+
or_expr = " or ".join(f'$e.target.process.command_line = "{v}"' for v in field_val)
|
|
133
|
+
events_lines.append(f" ({or_expr})")
|
|
134
|
+
else:
|
|
135
|
+
events_lines.append(f' $e.target.process.command_line = "{field_val}"')
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
f"rule {safe_name} {{\n"
|
|
139
|
+
f" meta:\n"
|
|
140
|
+
+ "\n".join(f" {l}" for l in meta_lines) + "\n"
|
|
141
|
+
f" events:\n"
|
|
142
|
+
+ "\n".join(f" {l}" for l in events_lines) + "\n"
|
|
143
|
+
f" condition:\n"
|
|
144
|
+
f" $e\n"
|
|
145
|
+
f"}}\n"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
# QRadar AQL
|
|
150
|
+
# ------------------------------------------------------------------ #
|
|
151
|
+
|
|
152
|
+
def to_qradar(self, rule: SigmaRule) -> str:
|
|
153
|
+
"""Convert Sigma rule to QRadar AQL query."""
|
|
154
|
+
where_parts: List[str] = []
|
|
155
|
+
|
|
156
|
+
for key, value in rule.detection.items():
|
|
157
|
+
if key == "condition":
|
|
158
|
+
continue
|
|
159
|
+
if isinstance(value, dict):
|
|
160
|
+
for field_name, field_val in value.items():
|
|
161
|
+
field_name = field_name.split("|")[0]
|
|
162
|
+
if isinstance(field_val, list):
|
|
163
|
+
or_parts = " OR ".join(f"LOGSOURCETYPENAME(devicetype) ILIKE '%{v}%'" for v in field_val)
|
|
164
|
+
where_parts.append(f"({or_parts})")
|
|
165
|
+
else:
|
|
166
|
+
where_parts.append(f'"{field_name}" ILIKE \'%{field_val}%\'')
|
|
167
|
+
|
|
168
|
+
where_clause = " AND ".join(where_parts) if where_parts else "1=1"
|
|
169
|
+
return (
|
|
170
|
+
f"SELECT UTF8(payload) as 'Payload', sourceip, destinationip, "
|
|
171
|
+
f"LOGSOURCETYPENAME(devicetype) as 'Log Source Type', "
|
|
172
|
+
f"CATEGORYNAME(category) as 'Category'\n"
|
|
173
|
+
f"FROM events\n"
|
|
174
|
+
f"WHERE {where_clause}\n"
|
|
175
|
+
f"LAST 24 HOURS"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------ #
|
|
179
|
+
# Suricata
|
|
180
|
+
# ------------------------------------------------------------------ #
|
|
181
|
+
|
|
182
|
+
def to_suricata(self, rule: SigmaRule) -> str:
|
|
183
|
+
"""Generate a Suricata rule skeleton from a Sigma rule."""
|
|
184
|
+
safe_msg = rule.title.replace('"', '\\"')
|
|
185
|
+
sid = rule.id[:8].replace("-", "") if rule.id else "9000000"
|
|
186
|
+
try:
|
|
187
|
+
sid_int = int(sid, 16)
|
|
188
|
+
except ValueError:
|
|
189
|
+
sid_int = 9000000
|
|
190
|
+
|
|
191
|
+
content_options: List[str] = []
|
|
192
|
+
for key, value in rule.detection.items():
|
|
193
|
+
if key == "condition":
|
|
194
|
+
continue
|
|
195
|
+
if isinstance(value, dict):
|
|
196
|
+
for _, field_val in value.items():
|
|
197
|
+
vals = field_val if isinstance(field_val, list) else [field_val]
|
|
198
|
+
for v in vals:
|
|
199
|
+
if isinstance(v, str) and len(v) < 128:
|
|
200
|
+
content_options.append(f'content:"{v}"; nocase;')
|
|
201
|
+
|
|
202
|
+
options_str = " ".join(content_options) or 'content:"";'
|
|
203
|
+
return (
|
|
204
|
+
f'alert tcp any any -> any any '
|
|
205
|
+
f'(msg:"{safe_msg}"; {options_str} '
|
|
206
|
+
f'sid:{sid_int}; rev:1; '
|
|
207
|
+
f'classtype:attempted-admin;)'
|
|
208
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Sigma rule parser — parses YAML-based Sigma detection rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SigmaRule:
|
|
14
|
+
title: str
|
|
15
|
+
id: Optional[str] = None
|
|
16
|
+
status: Optional[str] = None
|
|
17
|
+
description: Optional[str] = None
|
|
18
|
+
author: Optional[str] = None
|
|
19
|
+
date: Optional[str] = None
|
|
20
|
+
modified: Optional[str] = None
|
|
21
|
+
tags: List[str] = field(default_factory=list)
|
|
22
|
+
logsource: Dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
detection: Dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
falsepositives: List[str] = field(default_factory=list)
|
|
25
|
+
level: Optional[str] = None
|
|
26
|
+
source_file: Optional[Path] = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def attack_techniques(self) -> List[str]:
|
|
30
|
+
return [t.replace("attack.t", "T").replace("attack.T", "T") for t in self.tags if "attack.t" in t.lower()]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def attack_tactics(self) -> List[str]:
|
|
34
|
+
tactics = {
|
|
35
|
+
"initial_access", "execution", "persistence", "privilege_escalation",
|
|
36
|
+
"defense_evasion", "credential_access", "discovery", "lateral_movement",
|
|
37
|
+
"collection", "command_and_control", "exfiltration", "impact",
|
|
38
|
+
}
|
|
39
|
+
return [t.replace("attack.", "") for t in self.tags if t.replace("attack.", "") in tactics]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SigmaParseError(Exception):
|
|
43
|
+
def __init__(self, message: str, file: Optional[Path] = None):
|
|
44
|
+
self.file = file
|
|
45
|
+
super().__init__(f"{file}: {message}" if file else message)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SigmaParser:
|
|
49
|
+
"""Parse Sigma rules from YAML files or directories."""
|
|
50
|
+
|
|
51
|
+
def parse_string(self, content: str, source_file: Optional[Path] = None) -> SigmaRule:
|
|
52
|
+
"""Parse a single Sigma rule from a YAML string."""
|
|
53
|
+
try:
|
|
54
|
+
data = yaml.safe_load(content)
|
|
55
|
+
except yaml.YAMLError as e:
|
|
56
|
+
raise SigmaParseError(f"YAML parse error: {e}", file=source_file)
|
|
57
|
+
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
raise SigmaParseError("Rule must be a YAML mapping", file=source_file)
|
|
60
|
+
|
|
61
|
+
if "title" not in data:
|
|
62
|
+
raise SigmaParseError("Missing required field: title", file=source_file)
|
|
63
|
+
if "detection" not in data:
|
|
64
|
+
raise SigmaParseError("Missing required field: detection", file=source_file)
|
|
65
|
+
|
|
66
|
+
return SigmaRule(
|
|
67
|
+
title=data["title"],
|
|
68
|
+
id=data.get("id"),
|
|
69
|
+
status=data.get("status"),
|
|
70
|
+
description=data.get("description"),
|
|
71
|
+
author=data.get("author"),
|
|
72
|
+
date=str(data.get("date", "")),
|
|
73
|
+
modified=str(data.get("modified", "")),
|
|
74
|
+
tags=data.get("tags", []) or [],
|
|
75
|
+
logsource=data.get("logsource", {}),
|
|
76
|
+
detection=data["detection"],
|
|
77
|
+
falsepositives=data.get("falsepositives", []) or [],
|
|
78
|
+
level=data.get("level"),
|
|
79
|
+
source_file=source_file,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def parse_file(self, path: Path | str) -> SigmaRule:
|
|
83
|
+
"""Parse a Sigma rule from a YAML file."""
|
|
84
|
+
path = Path(path)
|
|
85
|
+
try:
|
|
86
|
+
content = path.read_text(encoding="utf-8")
|
|
87
|
+
except OSError as e:
|
|
88
|
+
raise SigmaParseError(f"Cannot read file: {e}", file=path)
|
|
89
|
+
return self.parse_string(content, source_file=path)
|
|
90
|
+
|
|
91
|
+
def parse_directory(
|
|
92
|
+
self,
|
|
93
|
+
directory: Path | str,
|
|
94
|
+
recursive: bool = True,
|
|
95
|
+
) -> Iterator[SigmaRule]:
|
|
96
|
+
"""Yield Sigma rules from all .yml/.yaml files in a directory."""
|
|
97
|
+
directory = Path(directory).resolve()
|
|
98
|
+
if not directory.is_dir():
|
|
99
|
+
raise SigmaParseError(f"Not a directory: {directory}")
|
|
100
|
+
|
|
101
|
+
glob = "**/*" if recursive else "*"
|
|
102
|
+
for ext in ("*.yml", "*.yaml"):
|
|
103
|
+
for path in sorted(directory.glob(f"{glob[:-1]}{ext[1:]}" if glob != "*" else ext)):
|
|
104
|
+
if not path.resolve().is_relative_to(directory):
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
yield self.parse_file(path)
|
|
108
|
+
except SigmaParseError:
|
|
109
|
+
pass
|