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 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,3 @@
1
+ from secrulekit.converters.converter import RuleConverter
2
+
3
+ __all__ = ["RuleConverter"]
@@ -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,5 @@
1
+ from secrulekit.parsers.yara_parser import YaraParser
2
+ from secrulekit.parsers.sigma_parser import SigmaParser
3
+ from secrulekit.parsers.suricata_parser import SuricataParser
4
+
5
+ __all__ = ["YaraParser", "SigmaParser", "SuricataParser"]
@@ -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