yaralyzer 1.0.11__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,164 @@
1
+ """
2
+ Builds bare bones YARA rules to match strings and regex patterns.
3
+
4
+ Example rule string:
5
+
6
+ ```
7
+ rule Just_A_Piano_Man {
8
+ meta:
9
+ author = "Tim"
10
+ strings:
11
+ $hilton_producer = /Scott.*Storch/
12
+ condition:
13
+ $hilton_producer
14
+ }
15
+ ```
16
+ """
17
+ import re
18
+ from typing import Literal, Optional
19
+
20
+ import yara
21
+
22
+ from yaralyzer.config import YARALYZE
23
+ from yaralyzer.util.logging import log
24
+
25
+ PatternType = Literal['hex', 'regex']
26
+ YaraModifierType = Literal['ascii', 'fullword', 'nocase', 'wide']
27
+
28
+ HEX = 'hex'
29
+ PATTERN = 'pattern'
30
+ REGEX = 'regex'
31
+ RULE = 'rule'
32
+ UNDERSCORE = '_'
33
+ YARA_REGEX_MODIFIERS = ['nocase', 'ascii', 'wide', 'fullword']
34
+
35
+ SAFE_LABEL_REPLACEMENTS = {
36
+ '/': 'frontslash',
37
+ '\\': 'backslash',
38
+ "'": 'singlequote',
39
+ '"': 'doublequote',
40
+ '`': 'backtick',
41
+ '-': UNDERSCORE,
42
+ ' ': UNDERSCORE,
43
+ }
44
+
45
+ RULE_TEMPLATE = """
46
+ rule {rule_name} {{
47
+ meta:
48
+ author = "The Yaralyzer"
49
+ strings:
50
+ ${pattern_label} = {pattern}
51
+ condition:
52
+ ${pattern_label}
53
+ }}
54
+ """
55
+
56
+ BYTES_RULE_TEMPLATE = """
57
+ rule {rule_name} {{
58
+ meta:
59
+ author = "The Yaralyzer"
60
+ strings:
61
+ ${pattern_label} = {{ {bytes_pattern} }}
62
+ condition:
63
+ ${pattern_label}
64
+ }}
65
+ """
66
+
67
+
68
+ def yara_rule_string(
69
+ pattern: str,
70
+ pattern_type: PatternType = REGEX,
71
+ rule_name: str = YARALYZE,
72
+ pattern_label: Optional[str] = PATTERN,
73
+ modifier: Optional[YaraModifierType] = None
74
+ ) -> str:
75
+ """
76
+ Build a YARA rule string for a given `pattern`.
77
+
78
+ Args:
79
+ pattern (str): The string or regex pattern to match.
80
+ pattern_type (str): Either `"regex"` or `"hex"`. Default is `"regex"`.
81
+ rule_name (str): The name of the YARA rule. Default is `"YARALYZE"`.
82
+ pattern_label (Optional[str]): The label for the pattern in the YARA rule. Default is `"pattern"`.
83
+ modifier (Optional[str]): Optional regex modifier (e.g. 'nocase', 'ascii', 'wide', 'fullword').
84
+ Only valid if `pattern_type` is `"regex"`.
85
+
86
+ Returns:
87
+ str: The constructed YARA rule as a string.
88
+ """
89
+ if not (modifier is None or modifier in YARA_REGEX_MODIFIERS):
90
+ raise TypeError(f"Modifier '{modifier}' is not one of {YARA_REGEX_MODIFIERS}")
91
+
92
+ if pattern_type == REGEX:
93
+ pattern = f"/{pattern}/"
94
+ elif pattern_type == HEX:
95
+ pattern = f"{{{pattern}}}"
96
+ else:
97
+ raise ValueError(f"pattern_type must be either '{REGEX}' or '{HEX}'")
98
+
99
+ if modifier:
100
+ pattern += f" {modifier}"
101
+
102
+ rule = RULE_TEMPLATE.format(
103
+ rule_name=rule_name,
104
+ pattern_label=pattern_label,
105
+ pattern=pattern,
106
+ modifier='' if modifier is None else f" {modifier}"
107
+ )
108
+
109
+ log.debug(f"Built YARA rule: \n{rule}")
110
+ return rule
111
+
112
+
113
+ def build_yara_rule(
114
+ pattern: str,
115
+ pattern_type: PatternType = REGEX,
116
+ rule_name: str = YARALYZE,
117
+ pattern_label: Optional[str] = PATTERN,
118
+ modifier: Optional[YaraModifierType] = None
119
+ ) -> yara.Rule:
120
+ """
121
+ Build a compiled `yara.Rule` object.
122
+
123
+ Args:
124
+ pattern (str): The string or regex pattern to match.
125
+ pattern_type (str): Either `"regex"` or `"hex"`. Default is `"regex"`.
126
+ rule_name (str): The name of the YARA rule. Default is `"YARALYZE"`.
127
+ pattern_label (Optional[str]): The label for the pattern in the YARA rule. Default is `"pattern"`.
128
+ modifier (Optional[str]): Optional regex modifier (e.g. 'nocase', 'ascii', 'wide', 'fullword').
129
+ Only valid if `pattern_type` is `"regex"`.
130
+
131
+ Returns:
132
+ yara.Rule: Compiled YARA rule object.
133
+ """
134
+ rule_string = yara_rule_string(pattern, pattern_type, rule_name, pattern_label, modifier)
135
+ return yara.compile(source=rule_string)
136
+
137
+
138
+ def safe_label(_label: str) -> str:
139
+ """
140
+ YARA rule and pattern names can only contain alphanumeric chars.
141
+
142
+ Args:
143
+ _label (str): The label to sanitize.
144
+
145
+ Returns:
146
+ str: A sanitized label safe for use in YARA rules.
147
+ """
148
+ label = _label
149
+
150
+ for char, replacement in SAFE_LABEL_REPLACEMENTS.items():
151
+ if replacement != UNDERSCORE:
152
+ label = label.replace(char, f"__{replacement.upper()}__")
153
+ else:
154
+ label = label.replace(char, replacement)
155
+
156
+ if re.match('^\\d', label):
157
+ label = '_' + label
158
+
159
+ if not re.match('\\w+', label):
160
+ msg = f"'{label}' is invalid: YARA labels must be alphanumeric/underscore and cannot start with a number"
161
+ raise ValueError(msg)
162
+
163
+ log.debug(f"Built safe label {label} from {_label}")
164
+ return label
yaralyzer/yaralyzer.py ADDED
@@ -0,0 +1,304 @@
1
+ """Main Yaralyzer class and alternate constructors."""
2
+ from os import path
3
+ from typing import Callable, Iterator, List, Optional, Tuple, Union
4
+
5
+ import yara
6
+ from rich.console import Console, ConsoleOptions, RenderResult
7
+ from rich.padding import Padding
8
+ from rich.style import Style
9
+ from rich.text import Text
10
+
11
+ from yaralyzer.bytes_match import BytesMatch
12
+ from yaralyzer.config import YARALYZE, YaralyzerConfig
13
+ from yaralyzer.decoding.bytes_decoder import BytesDecoder
14
+ from yaralyzer.helpers.file_helper import files_in_dir, load_binary_data
15
+ from yaralyzer.helpers.rich_text_helper import dim_if, reverse_color, print_fatal_error_and_exit
16
+ from yaralyzer.helpers.string_helper import comma_join, newline_join
17
+ from yaralyzer.output.file_hashes_table import bytes_hashes_table
18
+ from yaralyzer.output.regex_match_metrics import RegexMatchMetrics
19
+ from yaralyzer.output.rich_console import YARALYZER_THEME, console
20
+ from yaralyzer.util.logging import log
21
+ from yaralyzer.yara.yara_match import YaraMatch
22
+ from yaralyzer.yara.yara_rule_builder import yara_rule_string
23
+
24
+ YARA_FILE_DOES_NOT_EXIST_ERROR_MSG = "is not a valid yara rules file (it doesn't exist)"
25
+
26
+
27
+ # TODO: might be worth introducing a Scannable namedtuple or similar
28
+ class Yaralyzer:
29
+ """
30
+ Central class that handles setting up / compiling YARA rules and reading binary data from files as needed.
31
+
32
+ Alternate constructors are provided depending on whether:
33
+
34
+ * YARA rules are already compiled
35
+
36
+ * YARA rules should be compiled from a string
37
+
38
+ * YARA rules should be read from a file
39
+
40
+ * YARA rules should be read from a directory of .yara files
41
+
42
+ The real action happens in the `__rich__console__()` dunder method.
43
+
44
+ Attributes:
45
+ bytes (bytes): The binary data to scan.
46
+ bytes_length (int): The length of the binary data.
47
+ scannable_label (str): A label for the binary data, typically the filename or a user-provided label.
48
+ rules (yara.Rules): The compiled YARA rules to use for scanning.
49
+ rules_label (str): A label for the ruleset, typically derived from filenames or user input.
50
+ highlight_style (str): The style to use for highlighting matches in the output.
51
+ non_matches (List[dict]): A list of YARA rules that did not match the binary data.
52
+ matches (List[YaraMatch]): A list of YaraMatch objects representing the matches found.
53
+ extraction_stats (RegexMatchMetrics): Metrics related to decoding attempts on matched data
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ rules: Union[str, yara.Rules],
59
+ rules_label: str,
60
+ scannable: Union[bytes, str],
61
+ scannable_label: Optional[str] = None,
62
+ highlight_style: str = YaralyzerConfig.HIGHLIGHT_STYLE
63
+ ) -> None:
64
+ """
65
+ Initialize a `Yaralyzer` instance for scanning binary data with YARA rules.
66
+
67
+ Args:
68
+ rules (Union[str, yara.Rules]): YARA rules to use for scanning. Can be a string or a pre-compiled
69
+ `yara.Rules` object (strings will be compiled to an instance of `yara.Rules`).
70
+ rules_label (str): Label to identify the ruleset in output and logs.
71
+ scannable (Union[bytes, str]): The data to scan. If it's `bytes` type then that data is scanned;
72
+ if it's a string it is treated as a file path to load bytes from.
73
+ scannable_label (Optional[str], optional): Label for the `scannable` arg data.
74
+ Required if `scannable` is `bytes`.
75
+ If `scannable` is a file path `scannable_label` will default to the file's basename.
76
+ highlight_style (str, optional): Style to use for highlighting matches in output.
77
+ Defaults to `YaralyzerConfig.HIGHLIGHT_STYLE`.
78
+
79
+ Raises:
80
+ TypeError: If `scannable` is `bytes` and `scannable_label` is not provided.
81
+ """
82
+ if 'args' not in vars(YaralyzerConfig):
83
+ YaralyzerConfig.set_default_args()
84
+
85
+ yara.set_config(
86
+ stack_size=YaralyzerConfig.args.yara_stack_size,
87
+ max_match_data=YaralyzerConfig.args.max_match_length
88
+ )
89
+
90
+ if isinstance(scannable, bytes):
91
+ if scannable_label is None:
92
+ raise TypeError("Must provide 'scannable_label' arg when yaralyzing raw bytes")
93
+
94
+ self.bytes: bytes = scannable
95
+ self.scannable_label: str = scannable_label
96
+ else:
97
+ self.bytes: bytes = load_binary_data(scannable)
98
+ self.scannable_label: str = scannable_label or path.basename(scannable)
99
+
100
+ if isinstance(rules, yara.Rules):
101
+ self.rules: yara.Rules = rules
102
+ else:
103
+ log.info(f"Compiling YARA rules from provided string:\n{rules}")
104
+ self.rules: yara.Rules = yara.compile(source=rules)
105
+
106
+ self.bytes_length: int = len(self.bytes)
107
+ self.rules_label: str = rules_label
108
+ self.highlight_style: str = highlight_style
109
+ # Outcome tracking variables
110
+ self.non_matches: List[dict] = []
111
+ self.matches: List[YaraMatch] = []
112
+ self.extraction_stats = RegexMatchMetrics()
113
+
114
+ @classmethod
115
+ def for_rules_files(
116
+ cls,
117
+ yara_rules_files: List[str],
118
+ scannable: Union[bytes, str],
119
+ scannable_label: Optional[str] = None
120
+ ) -> 'Yaralyzer':
121
+ """
122
+ Alternate constructor to load YARA rules from files and label rules with the filenames.
123
+
124
+ Args:
125
+ yara_rules_files (List[str]): List of file paths to YARA rules files.
126
+ scannable (Union[bytes, str]): The data to scan. If `bytes`, raw data is scanned;
127
+ if `str`, it is treated as a file path to load bytes from.
128
+ scannable_label (Optional[str], optional): Label for the `scannable` data.
129
+ Required if `scannable` is `bytes`. If scannable is a file path, defaults to the file's basename.
130
+
131
+ Raises:
132
+ FileNotFoundError: If any file in `yara_rules_files` does not exist.
133
+ TypeError: If `yara_rules_files` is not a list.
134
+ """
135
+ if not isinstance(yara_rules_files, list):
136
+ raise TypeError(f"{yara_rules_files} is not a list")
137
+
138
+ for file in yara_rules_files:
139
+ if not path.exists(file):
140
+ raise FileNotFoundError(f"'{file}' {YARA_FILE_DOES_NOT_EXIST_ERROR_MSG}")
141
+
142
+ filepaths_arg = {path.basename(file): file for file in yara_rules_files}
143
+
144
+ try:
145
+ yara_rules = yara.compile(filepaths=filepaths_arg)
146
+ except yara.SyntaxError as e:
147
+ print_fatal_error_and_exit(f"Failed to parse YARA rules file(s): {e}")
148
+
149
+ yara_rules_label = comma_join(yara_rules_files, func=path.basename)
150
+ return cls(yara_rules, yara_rules_label, scannable, scannable_label)
151
+
152
+ @classmethod
153
+ def for_rules_dirs(
154
+ cls,
155
+ dirs: List[str],
156
+ scannable: Union[bytes, str],
157
+ scannable_label: Optional[str] = None
158
+ ) -> 'Yaralyzer':
159
+ """
160
+ Alternate constructor that will load all `.yara` files in `yara_rules_dir`.
161
+
162
+ Args:
163
+ dirs (List[str]): List of directories to search for `.yara` files.
164
+ scannable (Union[bytes, str]): The data to scan. If `bytes`, raw data is scanned;
165
+ if `str`, it is treated as a file path to load bytes from.
166
+ scannable_label (Optional[str], optional): Label for the `scannable` data.
167
+ Required if `scannable` is `bytes`. If scannable is a file path, defaults to the file's basename.
168
+
169
+ Raises:
170
+ FileNotFoundError: If `dirs` is not a list of valid directories.
171
+ """
172
+ if not (isinstance(dirs, list) and all(path.isdir(dir) for dir in dirs)):
173
+ raise FileNotFoundError(f"'{dirs}' is not a list of valid directories")
174
+
175
+ rules_files = [path.join(dir, f) for dir in dirs for f in files_in_dir(dir)]
176
+ return cls.for_rules_files(rules_files, scannable, scannable_label)
177
+
178
+ @classmethod
179
+ def for_patterns(
180
+ cls,
181
+ patterns: List[str],
182
+ patterns_type: str,
183
+ scannable: Union[bytes, str],
184
+ scannable_label: Optional[str] = None,
185
+ rules_label: Optional[str] = None,
186
+ pattern_label: Optional[str] = None,
187
+ regex_modifier: Optional[str] = None,
188
+ ) -> 'Yaralyzer':
189
+ """
190
+ Alternate constructor taking regex pattern strings. Rules label defaults to the patterns joined by comma.
191
+
192
+ Args:
193
+ patterns (List[str]): List of regex or hex patterns to build rules from.
194
+ patterns_type (str): Either `"regex"` or `"hex"` to indicate the type of patterns provided.
195
+ scannable (Union[bytes, str]): The data to scan. If `bytes`, raw data is scanned;
196
+ if `str`, it is treated as a file path to load bytes from.
197
+ scannable_label (Optional[str], optional): Label for the `scannable` data.
198
+ Required if `scannable` is `bytes`.
199
+ If scannable is a file path, defaults to the file's basename.
200
+ rules_label (Optional[str], optional): Label for the ruleset. Defaults to the patterns joined by comma.
201
+ pattern_label (Optional[str], optional): Label for each pattern in the YARA rules. Defaults to "pattern".
202
+ regex_modifier (Optional[str], optional): Optional regex modifier (e.g. "nocase", "ascii", "wide", etc).
203
+ Only valid if `patterns_type` is `"regex"`.
204
+ """
205
+ rule_strings = []
206
+
207
+ for i, pattern in enumerate(patterns):
208
+ suffix = f"_{i + 1}" if len(patterns) > 1 else ''
209
+
210
+ rule_strings.append(yara_rule_string(
211
+ pattern=pattern,
212
+ pattern_type=patterns_type,
213
+ rule_name=f"{rules_label or YARALYZE}{suffix}",
214
+ pattern_label=f"{pattern_label}{suffix}" if pattern_label else None,
215
+ modifier=regex_modifier
216
+ ))
217
+
218
+ rules_string = newline_join(rule_strings)
219
+ rules_label = comma_join(patterns)
220
+ return cls(rules_string, rules_label, scannable, scannable_label)
221
+
222
+ def yaralyze(self) -> None:
223
+ """Use YARA to find matches and then force decode them."""
224
+ console.print(self)
225
+
226
+ def match_iterator(self) -> Iterator[Tuple[BytesMatch, BytesDecoder]]:
227
+ """
228
+ Iterator version of `yaralyze()`.
229
+
230
+ Yields:
231
+ Tuple[BytesMatch, BytesDecoder]: Match and decode data tuple.
232
+ """
233
+ self.rules.match(data=self.bytes, callback=self._yara_callback)
234
+
235
+ for yara_match in self.matches:
236
+ console.print(yara_match)
237
+ console.line()
238
+
239
+ for match in BytesMatch.from_yara_match(self.bytes, yara_match.match, self.highlight_style):
240
+ decoder = BytesDecoder(match, yara_match.rule_name)
241
+ self.extraction_stats.tally_match(decoder)
242
+ yield match, decoder
243
+
244
+ self._print_non_matches()
245
+
246
+ def _yara_callback(self, data: dict) -> Callable:
247
+ """
248
+ Callback invoked by `yara-python` to handle matches and non-matches as they are discovered.
249
+
250
+ Args:
251
+ data (dict): Data provided when `yara-python` invokes the callback.
252
+
253
+ Returns:
254
+ Callable: Always returns `yara.CALLBACK_CONTINUE` to signal `yara-python` should continue processing.
255
+ """
256
+ if data['matches']:
257
+ self.matches.append(YaraMatch(data, self._panel_text()))
258
+ else:
259
+ self.non_matches.append(data)
260
+
261
+ return yara.CALLBACK_CONTINUE
262
+
263
+ def _print_non_matches(self) -> None:
264
+ """Print info about the YARA rules that didn't match the bytes."""
265
+ if len(self.non_matches) == 0:
266
+ return
267
+
268
+ non_matches_text = sorted([Text(nm['rule'], 'grey') for nm in self.non_matches], key=str)
269
+
270
+ # Only show the non matches if there were valid ones, otherwise just show the number
271
+ if len(self.matches) == 0:
272
+ non_match_desc = f" did not match any of the {len(self.non_matches)} yara rules"
273
+ console.print(dim_if(self.__text__() + Text(non_match_desc, style='grey'), True))
274
+ return
275
+
276
+ non_match_desc = f" did not match the other {len(self.non_matches)} yara rules"
277
+ console.print(self.__text__() + Text(non_match_desc, style='grey') + Text(': '), style='dim')
278
+ console.print(Padding(Text(', ', 'white').join(non_matches_text), (0, 0, 1, 4)))
279
+
280
+ def _panel_text(self) -> Text:
281
+ """Inverted colors for the panel at the top of the match section of the output."""
282
+ styles = [reverse_color(YARALYZER_THEME.styles[f"yara.{s}"]) for s in ('scanned', 'rules')]
283
+ return self.__text__(*styles)
284
+
285
+ def _filename_string(self) -> str:
286
+ """The string to use when exporting this yaralyzer to SVG/HTML/etc."""
287
+ return str(self).replace('>', '').replace('<', '').replace(' ', '_')
288
+
289
+ def __text__(self, byte_style: Style | str = 'yara.scanned', rule_style: Style | str = 'yara.rules') -> Text:
290
+ """Text representation of this YARA scan (__text__() was taken)."""
291
+ txt = Text('').append(self.scannable_label, style=byte_style or 'yara.scanned')
292
+ return txt.append(' scanned with <').append(self.rules_label, style=rule_style or 'yara.rules').append('>')
293
+
294
+ def __rich_console__(self, _console: Console, options: ConsoleOptions) -> RenderResult:
295
+ """Does the stuff. TODO: not the best place to put the core logic."""
296
+ yield bytes_hashes_table(self.bytes, self.scannable_label)
297
+
298
+ for _bytes_match, bytes_decoder in self.match_iterator():
299
+ for attempt in bytes_decoder.__rich_console__(_console, options):
300
+ yield attempt
301
+
302
+ def __str__(self) -> str:
303
+ """Plain text (no rich colors) representation of the scan for display."""
304
+ return self.__text__().plain