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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Suricata/Snort rule parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Iterator, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SuricataRule:
|
|
13
|
+
action: str
|
|
14
|
+
protocol: str
|
|
15
|
+
src_addr: str
|
|
16
|
+
src_port: str
|
|
17
|
+
direction: str
|
|
18
|
+
dst_addr: str
|
|
19
|
+
dst_port: str
|
|
20
|
+
options: Dict[str, str] = field(default_factory=dict)
|
|
21
|
+
raw: str = ""
|
|
22
|
+
source_file: Optional[Path] = None
|
|
23
|
+
line_number: int = 0
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def sid(self) -> Optional[str]:
|
|
27
|
+
return self.options.get("sid")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def msg(self) -> Optional[str]:
|
|
31
|
+
return self.options.get("msg", "").strip('"')
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def classtype(self) -> Optional[str]:
|
|
35
|
+
return self.options.get("classtype")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SuricataParseError(Exception):
|
|
39
|
+
def __init__(self, message: str, file: Optional[Path] = None, line: int = 0):
|
|
40
|
+
self.file = file
|
|
41
|
+
self.line = line
|
|
42
|
+
super().__init__(f"{file}:{line}: {message}" if file else message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Matches the rule header: action proto src_addr src_port direction dst_addr dst_port
|
|
46
|
+
_HEADER = re.compile(
|
|
47
|
+
r"^(alert|drop|pass|reject|rejectsrc|rejectdst|rejectboth)\s+"
|
|
48
|
+
r"(\w+)\s+" # protocol
|
|
49
|
+
r"([\w\[\]!,./]+)\s+" # src_addr
|
|
50
|
+
r"([\w\[\]!,]+)\s+" # src_port
|
|
51
|
+
r"(<>|->)\s+" # direction
|
|
52
|
+
r"([\w\[\]!,./]+)\s+" # dst_addr
|
|
53
|
+
r"([\w\[\]!,]+)\s*" # dst_port
|
|
54
|
+
r"\((.+)\)\s*$", # options body
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Tokenize options: key:value; or key; pairs
|
|
59
|
+
_OPTION_TOKEN = re.compile(r'(\w+)\s*(?::\s*("(?:[^"\\]|\\.)*"|[^;]*))?;')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SuricataParser:
|
|
63
|
+
"""Parse Suricata/Snort rules."""
|
|
64
|
+
|
|
65
|
+
def parse_string(
|
|
66
|
+
self, line: str, source_file: Optional[Path] = None, line_number: int = 0
|
|
67
|
+
) -> Optional[SuricataRule]:
|
|
68
|
+
"""Parse a single rule line. Returns None for comments/blank lines."""
|
|
69
|
+
stripped = line.strip()
|
|
70
|
+
if not stripped or stripped.startswith("#"):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
m = _HEADER.match(stripped)
|
|
74
|
+
if not m:
|
|
75
|
+
raise SuricataParseError("Invalid rule syntax", file=source_file, line=line_number)
|
|
76
|
+
|
|
77
|
+
opts: Dict[str, str] = {}
|
|
78
|
+
for om in _OPTION_TOKEN.finditer(m.group(8)):
|
|
79
|
+
opts[om.group(1)] = om.group(2) or ""
|
|
80
|
+
|
|
81
|
+
return SuricataRule(
|
|
82
|
+
action=m.group(1).lower(),
|
|
83
|
+
protocol=m.group(2).lower(),
|
|
84
|
+
src_addr=m.group(3),
|
|
85
|
+
src_port=m.group(4),
|
|
86
|
+
direction=m.group(5),
|
|
87
|
+
dst_addr=m.group(6),
|
|
88
|
+
dst_port=m.group(7),
|
|
89
|
+
options=opts,
|
|
90
|
+
raw=stripped,
|
|
91
|
+
source_file=source_file,
|
|
92
|
+
line_number=line_number,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def parse_file(self, path: Path | str) -> List[SuricataRule]:
|
|
96
|
+
"""Parse all rules from a .rules file."""
|
|
97
|
+
path = Path(path)
|
|
98
|
+
try:
|
|
99
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
100
|
+
except OSError as e:
|
|
101
|
+
raise SuricataParseError(f"Cannot read file: {e}", file=path)
|
|
102
|
+
|
|
103
|
+
rules = []
|
|
104
|
+
for i, line in enumerate(lines, start=1):
|
|
105
|
+
rule = self.parse_string(line, source_file=path, line_number=i)
|
|
106
|
+
if rule is not None:
|
|
107
|
+
rules.append(rule)
|
|
108
|
+
return rules
|
|
109
|
+
|
|
110
|
+
def parse_directory(
|
|
111
|
+
self, directory: Path | str, recursive: bool = True
|
|
112
|
+
) -> Iterator[SuricataRule]:
|
|
113
|
+
"""Yield rules from .rules files in a directory."""
|
|
114
|
+
directory = Path(directory).resolve()
|
|
115
|
+
if not directory.is_dir():
|
|
116
|
+
raise SuricataParseError(f"Not a directory: {directory}")
|
|
117
|
+
|
|
118
|
+
glob = "**/*.rules" if recursive else "*.rules"
|
|
119
|
+
for path in sorted(directory.glob(glob)):
|
|
120
|
+
if not path.resolve().is_relative_to(directory):
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
yield from self.parse_file(path)
|
|
124
|
+
except SuricataParseError:
|
|
125
|
+
pass
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""YARA rule parser — tokenizes and validates YARA rule syntax."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterator, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class YaraString:
|
|
13
|
+
name: str
|
|
14
|
+
type: str # "text", "hex", "regex"
|
|
15
|
+
value: str
|
|
16
|
+
modifiers: List[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class YaraRule:
|
|
21
|
+
name: str
|
|
22
|
+
tags: List[str] = field(default_factory=list)
|
|
23
|
+
meta: dict = field(default_factory=dict)
|
|
24
|
+
strings: List[YaraString] = field(default_factory=list)
|
|
25
|
+
condition: str = ""
|
|
26
|
+
source_file: Optional[Path] = None
|
|
27
|
+
line_number: int = 0
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def identifier(self) -> str:
|
|
31
|
+
return self.name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_RULE_PATTERN = re.compile(
|
|
35
|
+
r"(?:private\s+|global\s+)*rule\s+(\w+)"
|
|
36
|
+
r"(?:\s*:\s*([\w\s]+?))?\s*\{(.*?)\}",
|
|
37
|
+
re.DOTALL,
|
|
38
|
+
)
|
|
39
|
+
_META_ITEM = re.compile(r'(\w+)\s*=\s*(?:"([^"]*?)"|(\d+)|true|false)', re.MULTILINE)
|
|
40
|
+
_STRING_ITEM = re.compile(
|
|
41
|
+
r'(\$\w*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|(\{[^}]+\})|(/(?:[^/\\]|\\.)+/\w*))',
|
|
42
|
+
re.MULTILINE,
|
|
43
|
+
)
|
|
44
|
+
_CONDITION_BLOCK = re.compile(r"condition\s*:(.*?)(?=\}|\Z)", re.DOTALL)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class YaraParseError(Exception):
|
|
48
|
+
def __init__(self, message: str, file: Optional[Path] = None, line: int = 0):
|
|
49
|
+
self.file = file
|
|
50
|
+
self.line = line
|
|
51
|
+
super().__init__(f"{file}:{line}: {message}" if file else message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class YaraParser:
|
|
55
|
+
"""Parse YARA rules from files, directories, or raw strings."""
|
|
56
|
+
|
|
57
|
+
def parse_string(self, content: str, source_file: Optional[Path] = None) -> List[YaraRule]:
|
|
58
|
+
"""Parse all YARA rules from a string."""
|
|
59
|
+
rules: List[YaraRule] = []
|
|
60
|
+
for match in _RULE_PATTERN.finditer(content):
|
|
61
|
+
rule = self._parse_rule_match(match, content, source_file)
|
|
62
|
+
rules.append(rule)
|
|
63
|
+
return rules
|
|
64
|
+
|
|
65
|
+
def parse_file(self, path: Path | str) -> List[YaraRule]:
|
|
66
|
+
"""Parse YARA rules from a single file."""
|
|
67
|
+
path = Path(path)
|
|
68
|
+
try:
|
|
69
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
70
|
+
except OSError as e:
|
|
71
|
+
raise YaraParseError(f"Cannot read file: {e}", file=path)
|
|
72
|
+
rules = self.parse_string(content, source_file=path)
|
|
73
|
+
if not rules:
|
|
74
|
+
raise YaraParseError("No YARA rules found in file", file=path)
|
|
75
|
+
return rules
|
|
76
|
+
|
|
77
|
+
def parse_directory(
|
|
78
|
+
self,
|
|
79
|
+
directory: Path | str,
|
|
80
|
+
recursive: bool = True,
|
|
81
|
+
extensions: tuple[str, ...] = (".yar", ".yara"),
|
|
82
|
+
) -> Iterator[YaraRule]:
|
|
83
|
+
"""Yield YARA rules from all matching files in a directory."""
|
|
84
|
+
directory = Path(directory).resolve()
|
|
85
|
+
if not directory.is_dir():
|
|
86
|
+
raise YaraParseError(f"Not a directory: {directory}")
|
|
87
|
+
|
|
88
|
+
glob = "**/*" if recursive else "*"
|
|
89
|
+
for ext in extensions:
|
|
90
|
+
for path in sorted(directory.glob(f"{glob}{ext}")):
|
|
91
|
+
# Prevent symlink traversal outside the base directory
|
|
92
|
+
if not path.resolve().is_relative_to(directory):
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
yield from self.parse_file(path)
|
|
96
|
+
except YaraParseError:
|
|
97
|
+
pass # Individual file errors collected by validator
|
|
98
|
+
|
|
99
|
+
def _parse_rule_match(
|
|
100
|
+
self, match: re.Match, full_text: str, source_file: Optional[Path]
|
|
101
|
+
) -> YaraRule:
|
|
102
|
+
name = match.group(1)
|
|
103
|
+
tags_raw = match.group(2) or ""
|
|
104
|
+
body = match.group(3)
|
|
105
|
+
line_number = full_text[: match.start()].count("\n") + 1
|
|
106
|
+
|
|
107
|
+
rule = YaraRule(
|
|
108
|
+
name=name,
|
|
109
|
+
tags=[t.strip() for t in tags_raw.split() if t.strip()],
|
|
110
|
+
source_file=source_file,
|
|
111
|
+
line_number=line_number,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Extract meta section
|
|
115
|
+
meta_match = re.search(r"meta\s*:(.*?)(?=strings:|condition:|$)", body, re.DOTALL)
|
|
116
|
+
if meta_match:
|
|
117
|
+
for m in _META_ITEM.finditer(meta_match.group(1)):
|
|
118
|
+
rule.meta[m.group(1)] = m.group(2) or m.group(3) or ""
|
|
119
|
+
|
|
120
|
+
# Extract strings section
|
|
121
|
+
strings_match = re.search(r"strings\s*:(.*?)(?=condition:|$)", body, re.DOTALL)
|
|
122
|
+
if strings_match:
|
|
123
|
+
for s in _STRING_ITEM.finditer(strings_match.group(1)):
|
|
124
|
+
name_s = s.group(1)
|
|
125
|
+
if s.group(2) is not None:
|
|
126
|
+
ytype, value = "text", s.group(2)
|
|
127
|
+
elif s.group(3) is not None:
|
|
128
|
+
ytype, value = "hex", s.group(3)
|
|
129
|
+
else:
|
|
130
|
+
ytype, value = "regex", s.group(4)
|
|
131
|
+
rule.strings.append(YaraString(name=name_s, type=ytype, value=value))
|
|
132
|
+
|
|
133
|
+
# Extract condition
|
|
134
|
+
cond = _CONDITION_BLOCK.search(body)
|
|
135
|
+
if cond:
|
|
136
|
+
rule.condition = cond.group(1).strip()
|
|
137
|
+
|
|
138
|
+
return rule
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""HTML audit report reporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from secrulekit.validators.rule_validator import ValidationResult, Severity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HtmlReporter:
|
|
13
|
+
"""Generate an HTML audit report from validation results."""
|
|
14
|
+
|
|
15
|
+
def generate(self, result: ValidationResult, output: Optional[Path | str] = None) -> str:
|
|
16
|
+
rows = ""
|
|
17
|
+
for f in result.findings:
|
|
18
|
+
color = {
|
|
19
|
+
Severity.ERROR: "#dc3545",
|
|
20
|
+
Severity.WARNING: "#fd7e14",
|
|
21
|
+
Severity.INFO: "#0d6efd",
|
|
22
|
+
}.get(f.severity, "#6c757d")
|
|
23
|
+
rows += (
|
|
24
|
+
f"<tr>"
|
|
25
|
+
f"<td><span style='color:{color};font-weight:600'>{html.escape(f.severity.value.upper())}</span></td>"
|
|
26
|
+
f"<td><code>{html.escape(f.code)}</code></td>"
|
|
27
|
+
f"<td>{html.escape(f.rule_name or '')}</td>"
|
|
28
|
+
f"<td>{html.escape(f.message)}</td>"
|
|
29
|
+
f"<td style='font-size:0.85em'>{html.escape(str(f.file) if f.file else '')}:{f.line if f.line else ''}</td>"
|
|
30
|
+
f"</tr>\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
passed_color = "#198754" if result.passed else "#dc3545"
|
|
34
|
+
passed_text = "PASSED" if result.passed else "FAILED"
|
|
35
|
+
|
|
36
|
+
page = f"""<!DOCTYPE html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="UTF-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
41
|
+
<title>SecRuleKit Validation Report</title>
|
|
42
|
+
<style>
|
|
43
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 2rem; background: #f8f9fa; }}
|
|
44
|
+
h1 {{ color: #212529; }}
|
|
45
|
+
.badge {{ display:inline-block; padding:.3em .6em; border-radius:.4em; color:#fff; font-size:.9em; font-weight:600; }}
|
|
46
|
+
.summary {{ display:flex; gap:1.5rem; margin:1.5rem 0; flex-wrap:wrap; }}
|
|
47
|
+
.card {{ background:#fff; border-radius:.5rem; padding:1rem 1.5rem; box-shadow:0 1px 3px rgba(0,0,0,.1); min-width:120px; }}
|
|
48
|
+
.card .num {{ font-size:2rem; font-weight:700; }}
|
|
49
|
+
table {{ width:100%; border-collapse:collapse; background:#fff; border-radius:.5rem; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,.1); }}
|
|
50
|
+
th {{ background:#343a40; color:#fff; padding:.75rem 1rem; text-align:left; }}
|
|
51
|
+
td {{ padding:.65rem 1rem; border-bottom:1px solid #dee2e6; font-size:.9em; }}
|
|
52
|
+
tr:last-child td {{ border-bottom:none; }}
|
|
53
|
+
code {{ background:#e9ecef; padding:.1em .3em; border-radius:.2em; font-size:.9em; }}
|
|
54
|
+
</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<h1>SecRuleKit Validation Report</h1>
|
|
58
|
+
<p>Result: <span class="badge" style="background:{passed_color}">{passed_text}</span></p>
|
|
59
|
+
|
|
60
|
+
<div class="summary">
|
|
61
|
+
<div class="card">
|
|
62
|
+
<div class="num" style="color:#198754">{result.valid_count}</div>
|
|
63
|
+
<div>Valid rules</div>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="card">
|
|
66
|
+
<div class="num" style="color:#dc3545">{result.error_count}</div>
|
|
67
|
+
<div>Errors</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="card">
|
|
70
|
+
<div class="num" style="color:#fd7e14">{result.warning_count}</div>
|
|
71
|
+
<div>Warnings</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<table>
|
|
76
|
+
<thead>
|
|
77
|
+
<tr><th>Severity</th><th>Code</th><th>Rule</th><th>Message</th><th>Location</th></tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody>
|
|
80
|
+
{rows if rows else '<tr><td colspan="5" style="text-align:center;color:#6c757d">No findings</td></tr>'}
|
|
81
|
+
</tbody>
|
|
82
|
+
</table>
|
|
83
|
+
|
|
84
|
+
<p style="margin-top:2rem;color:#6c757d;font-size:.85em">
|
|
85
|
+
Generated by <a href="https://github.com/lkop32788/SecRuleKit">SecRuleKit</a>
|
|
86
|
+
</p>
|
|
87
|
+
</body>
|
|
88
|
+
</html>"""
|
|
89
|
+
|
|
90
|
+
if output:
|
|
91
|
+
Path(output).write_text(page, encoding="utf-8")
|
|
92
|
+
return page
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""JSON audit report reporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from secrulekit.validators.rule_validator import ValidationResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JsonReporter:
|
|
13
|
+
"""Write validation results as a JSON report."""
|
|
14
|
+
|
|
15
|
+
def generate(self, result: ValidationResult, output: Optional[Path | str] = None) -> str:
|
|
16
|
+
data = {
|
|
17
|
+
"summary": {
|
|
18
|
+
"valid": result.valid_count,
|
|
19
|
+
"errors": result.error_count,
|
|
20
|
+
"warnings": result.warning_count,
|
|
21
|
+
"passed": result.passed,
|
|
22
|
+
},
|
|
23
|
+
"findings": [
|
|
24
|
+
{
|
|
25
|
+
"severity": f.severity.value,
|
|
26
|
+
"code": f.code,
|
|
27
|
+
"message": f.message,
|
|
28
|
+
"rule": f.rule_name,
|
|
29
|
+
"file": str(f.file) if f.file else None,
|
|
30
|
+
"line": f.line,
|
|
31
|
+
}
|
|
32
|
+
for f in result.findings
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
text = json.dumps(data, indent=2, ensure_ascii=False)
|
|
36
|
+
if output:
|
|
37
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
38
|
+
return text
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Utility functions: fingerprinting, deduplication, ATT&CK mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from typing import Dict, Iterable, Iterator, List, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from secrulekit.parsers.yara_parser import YaraRule
|
|
10
|
+
from secrulekit.parsers.sigma_parser import SigmaRule
|
|
11
|
+
from secrulekit.parsers.suricata_parser import SuricataRule
|
|
12
|
+
|
|
13
|
+
AnyRule = Union[YaraRule, SigmaRule, SuricataRule]
|
|
14
|
+
|
|
15
|
+
# Minimal ATT&CK technique ID → name mapping (subset for offline use)
|
|
16
|
+
_ATTACK_MAP: Dict[str, str] = {
|
|
17
|
+
"T1059": "Command and Scripting Interpreter",
|
|
18
|
+
"T1059.001": "PowerShell",
|
|
19
|
+
"T1059.003": "Windows Command Shell",
|
|
20
|
+
"T1059.004": "Unix Shell",
|
|
21
|
+
"T1055": "Process Injection",
|
|
22
|
+
"T1055.001": "Dynamic-link Library Injection",
|
|
23
|
+
"T1055.012": "Process Hollowing",
|
|
24
|
+
"T1036": "Masquerading",
|
|
25
|
+
"T1027": "Obfuscated Files or Information",
|
|
26
|
+
"T1003": "OS Credential Dumping",
|
|
27
|
+
"T1003.001": "LSASS Memory",
|
|
28
|
+
"T1082": "System Information Discovery",
|
|
29
|
+
"T1083": "File and Directory Discovery",
|
|
30
|
+
"T1071": "Application Layer Protocol",
|
|
31
|
+
"T1071.001": "Web Protocols",
|
|
32
|
+
"T1566": "Phishing",
|
|
33
|
+
"T1566.001": "Spearphishing Attachment",
|
|
34
|
+
"T1190": "Exploit Public-Facing Application",
|
|
35
|
+
"T1210": "Exploitation of Remote Services",
|
|
36
|
+
"T1021": "Remote Services",
|
|
37
|
+
"T1021.001": "Remote Desktop Protocol",
|
|
38
|
+
"T1078": "Valid Accounts",
|
|
39
|
+
"T1110": "Brute Force",
|
|
40
|
+
"T1562": "Impair Defenses",
|
|
41
|
+
"T1562.001": "Disable or Modify Tools",
|
|
42
|
+
"T1112": "Modify Registry",
|
|
43
|
+
"T1547": "Boot or Logon Autostart Execution",
|
|
44
|
+
"T1547.001": "Registry Run Keys / Startup Folder",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fingerprint_rule(rule: AnyRule) -> str:
|
|
49
|
+
"""Return a stable SHA-256 fingerprint of the rule's semantic content."""
|
|
50
|
+
if isinstance(rule, YaraRule):
|
|
51
|
+
payload = "|".join([
|
|
52
|
+
rule.name,
|
|
53
|
+
"|".join(sorted(s.value for s in rule.strings)),
|
|
54
|
+
re.sub(r"\s+", " ", rule.condition),
|
|
55
|
+
])
|
|
56
|
+
elif isinstance(rule, SigmaRule):
|
|
57
|
+
payload = "|".join([
|
|
58
|
+
rule.title,
|
|
59
|
+
str(sorted(rule.detection.items())),
|
|
60
|
+
str(rule.logsource),
|
|
61
|
+
])
|
|
62
|
+
else: # Suricata
|
|
63
|
+
payload = "|".join([
|
|
64
|
+
rule.protocol,
|
|
65
|
+
rule.src_addr, rule.src_port,
|
|
66
|
+
rule.dst_addr, rule.dst_port,
|
|
67
|
+
rule.options.get("content", ""),
|
|
68
|
+
rule.options.get("sid", ""),
|
|
69
|
+
])
|
|
70
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def deduplicate_rules(rules: Iterable[AnyRule]) -> Tuple[List[AnyRule], List[AnyRule]]:
|
|
74
|
+
"""
|
|
75
|
+
Split rules into unique and duplicate lists.
|
|
76
|
+
|
|
77
|
+
Returns (unique_rules, duplicate_rules).
|
|
78
|
+
"""
|
|
79
|
+
seen: Dict[str, AnyRule] = {}
|
|
80
|
+
duplicates: List[AnyRule] = []
|
|
81
|
+
|
|
82
|
+
for rule in rules:
|
|
83
|
+
fp = fingerprint_rule(rule)
|
|
84
|
+
if fp in seen:
|
|
85
|
+
duplicates.append(rule)
|
|
86
|
+
else:
|
|
87
|
+
seen[fp] = rule
|
|
88
|
+
|
|
89
|
+
return list(seen.values()), duplicates
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def lookup_attack_technique(technique_id: str) -> str:
|
|
93
|
+
"""Return the ATT&CK technique name for an ID, or empty string if unknown."""
|
|
94
|
+
tid = technique_id.upper().strip()
|
|
95
|
+
return _ATTACK_MAP.get(tid, "")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def iter_attack_tags(rule: SigmaRule) -> Iterator[Tuple[str, str]]:
|
|
99
|
+
"""Yield (technique_id, technique_name) pairs from a Sigma rule's tags."""
|
|
100
|
+
for tag in rule.tags:
|
|
101
|
+
if not tag.lower().startswith("attack.t"):
|
|
102
|
+
continue
|
|
103
|
+
tid = tag.split("attack.")[-1].upper()
|
|
104
|
+
yield tid, lookup_attack_technique(tid)
|