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.
@@ -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,4 @@
1
+ from secrulekit.reporters.json_reporter import JsonReporter
2
+ from secrulekit.reporters.html_reporter import HtmlReporter
3
+
4
+ __all__ = ["JsonReporter", "HtmlReporter"]
@@ -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,3 @@
1
+ from secrulekit.utils.helpers import fingerprint_rule, deduplicate_rules
2
+
3
+ __all__ = ["fingerprint_rule", "deduplicate_rules"]
@@ -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)
@@ -0,0 +1,3 @@
1
+ from secrulekit.validators.rule_validator import RuleValidator, ValidationResult, Finding
2
+
3
+ __all__ = ["RuleValidator", "ValidationResult", "Finding"]