@@ -138,6 +138,156 @@ def extract_params_location(location_header_value, original_parsed_url):
138138 yield "GET" , parsed_url , p , p_value , "location_header" , _exclude_key (flat_params , p )
139139
140140
141+ _yara_identifier_regex = re .compile (r"[A-Za-z_]\w*" )
142+ _yara_rule_modifiers = ("private" , "global" )
143+
144+
145+ def _skip_yara_noncode (source , i ):
146+ """
147+ If a comment, string literal, or regex literal starts at index i, return the index just past it.
148+
149+ Returns None if index i is ordinary code. YARA uses a backslash for division, so a bare
150+ "/" is unambiguously either a comment or a regex literal.
151+ """
152+ c = source [i ]
153+ if c == "/" :
154+ following = source [i + 1 : i + 2 ]
155+ if following == "/" :
156+ newline = source .find ("\n " , i )
157+ return len (source ) if newline == - 1 else newline + 1
158+ if following == "*" :
159+ end = source .find ("*/" , i + 2 )
160+ if end == - 1 :
161+ raise ExcavateError ("unterminated block comment" )
162+ return end + 2
163+ j = i + 1
164+ in_character_class = False
165+ while j < len (source ):
166+ char = source [j ]
167+ if char == "\\ " :
168+ j += 2
169+ continue
170+ if char == "\n " :
171+ break
172+ if in_character_class :
173+ if char == "]" :
174+ in_character_class = False
175+ elif char == "[" :
176+ in_character_class = True
177+ elif char == "/" :
178+ # consume any trailing regex modifiers
179+ j += 1
180+ while j < len (source ) and source [j ] in "is" :
181+ j += 1
182+ return j
183+ j += 1
184+ raise ExcavateError ("unterminated regular expression" )
185+ if c == '"' :
186+ j = i + 1
187+ while j < len (source ):
188+ char = source [j ]
189+ if char == "\\ " :
190+ j += 2
191+ continue
192+ if char == '"' :
193+ return j + 1
194+ j += 1
195+ raise ExcavateError ("unterminated string literal" )
196+ return None
197+
198+
199+ def split_yara_rules (source ):
200+ """
201+ Split YARA source into its individual rules.
202+
203+ Returns (imports, rules), where imports is a list of import statements and rules is a
204+ list of (rule_name, rule_text) tuples in source order.
205+
206+ Rule boundaries are found by brace depth, skipping over comments, string literals, and
207+ regex literals so that a brace inside any of them cannot end a rule early. Hex strings
208+ are brace-balanced, so depth counting covers them for free. Raises ExcavateError on
209+ anything it cannot parse, rather than silently dropping a rule.
210+ """
211+ imports = []
212+ rules = []
213+ i = 0
214+ length = len (source )
215+ # start offset of any private/global modifiers preceding the "rule" keyword
216+ modifier_start = None
217+
218+ while i < length :
219+ skipped = _skip_yara_noncode (source , i )
220+ if skipped is not None :
221+ i = skipped
222+ continue
223+ if source [i ].isspace ():
224+ i += 1
225+ continue
226+
227+ keyword_match = _yara_identifier_regex .match (source , i )
228+ if not keyword_match :
229+ raise ExcavateError (f"unexpected character [{ source [i ]} ] outside of any rule" )
230+ keyword = keyword_match .group ()
231+
232+ if keyword == "include" :
233+ raise ExcavateError ("include statements are not supported; inline the included rules instead" )
234+ if keyword == "import" :
235+ quote = source .find ('"' , keyword_match .end ())
236+ if quote == - 1 or source [keyword_match .end () : quote ].strip ():
237+ raise ExcavateError ("malformed import statement" )
238+ end_of_import = _skip_yara_noncode (source , quote )
239+ imports .append (source [i :end_of_import ])
240+ i = end_of_import
241+ modifier_start = None
242+ continue
243+ if keyword in _yara_rule_modifiers :
244+ if modifier_start is None :
245+ modifier_start = i
246+ i = keyword_match .end ()
247+ continue
248+ if keyword != "rule" :
249+ raise ExcavateError (f"expected 'rule' but found [{ keyword } ]" )
250+
251+ rule_start = i if modifier_start is None else modifier_start
252+ modifier_start = None
253+
254+ name_start = keyword_match .end ()
255+ while name_start < length and source [name_start ].isspace ():
256+ name_start += 1
257+ name_match = _yara_identifier_regex .match (source , name_start )
258+ if not name_match or name_start == keyword_match .end ():
259+ raise ExcavateError ("could not find rule name" )
260+ rule_name = name_match .group ()
261+
262+ # walk to the closing brace that matches the rule's opening brace
263+ j = name_match .end ()
264+ depth = 0
265+ rule_end = None
266+ while j < length :
267+ skipped = _skip_yara_noncode (source , j )
268+ if skipped is not None :
269+ j = skipped
270+ continue
271+ char = source [j ]
272+ if char == "{" :
273+ depth += 1
274+ elif char == "}" :
275+ if depth == 0 :
276+ raise ExcavateError (f"unexpected closing brace in rule [{ rule_name } ]" )
277+ depth -= 1
278+ if depth == 0 :
279+ rule_end = j + 1
280+ break
281+ j += 1
282+ if rule_end is None :
283+ raise ExcavateError (f"unterminated rule [{ rule_name } ]" )
284+
285+ rules .append ((rule_name , source [rule_start :rule_end ]))
286+ i = rule_end
287+
288+ return imports , rules
289+
290+
141291class YaraRuleSettings :
142292 def __init__ (self , description , tags , emit_match , severity , confidence ):
143293 self .description = description
@@ -346,9 +496,8 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte
346496 )
347497 if yara_rule_settings .emit_match :
348498 event_data ["description" ] += f" and extracted [{ result } ]"
349- event_data ["severity" ] = yara_rule_settings .get ("severity" , "LOW" )
350- event_data ["confidence" ] = yara_rule_settings .get ("confidence" , "UNKNOWN" )
351499
500+ # severity and confidence are filled in from the rule's meta by report()
352501 await self .report (event_data , event , yara_rule_settings , discovery_context )
353502
354503
@@ -391,9 +540,6 @@ class Config(BaseModuleConfig):
391540
392541 _module_threads = 6
393542
394- yara_rule_name_regex = re .compile (r"rule\s(\w+)\s{" )
395- yara_rule_regex = re .compile (r"(?s)((?:rule\s+\w+\s*{[^{}]*(?:{[^{}]*}[^{}]*)*[^{}]*(?:/\S*?}[^/]*?/)*)*})" )
396-
397543 def in_bl (self , value ):
398544 # Check if the value is in the blacklist or starts with a blacklisted prefix.
399545 lower_value = value .lower ()
@@ -1190,10 +1336,6 @@ def add_yara_rule(self, rule_name, rule_content, rule_instance):
11901336 self .yara_rules_dict [rule_name ] = rule_content
11911337 self .yara_preprocess_dict [rule_name ] = rule_instance .preprocess
11921338
1193- async def extract_yara_rules (self , rules_content ):
1194- for r in await self .helpers .re .findall (self .yara_rule_regex , rules_content ):
1195- yield r
1196-
11971339 async def emit_web_parameter (
11981340 self , host , param_type , name , original_value , url , description , additional_params , event , context
11991341 ):
@@ -1228,6 +1370,7 @@ async def emit_custom_parameters(self, event, config_key, param_type, descriptio
12281370 async def setup (self ):
12291371 self .yara_rules_dict = {}
12301372 self .yara_preprocess_dict = {}
1373+ self .custom_yara_imports = []
12311374
12321375 modules_WEB_PARAMETER = [
12331376 module_name
@@ -1268,7 +1411,6 @@ async def setup(self):
12681411
12691412 self .custom_yara_rules = self .config .get ("custom_yara_rules" , "" )
12701413 if self .custom_yara_rules :
1271- custom_rules_count = 0
12721414 if Path (self .custom_yara_rules ).is_file ():
12731415 with open (self .custom_yara_rules ) as f :
12741416 rules_content = f .read ()
@@ -1278,28 +1420,28 @@ async def setup(self):
12781420 rules_content = self .custom_yara_rules
12791421
12801422 self .debug (f"Final combined yara rule contents: { rules_content } " )
1281- custom_yara_rule_processed = self .extract_yara_rules (rules_content )
1282- async for rule_content in custom_yara_rule_processed :
1423+ try :
1424+ self .custom_yara_imports , custom_rules = split_yara_rules (rules_content )
1425+ except ExcavateError as e :
1426+ return False , f"Custom Yara rules are formatted incorrectly: { e } "
1427+ if not custom_rules :
1428+ return False , "Custom Yara rules contain no rules"
1429+
1430+ import_prefix = "" .join (f"{ i } \n " for i in self .custom_yara_imports )
1431+ for rule_name , rule_content in custom_rules :
1432+ if rule_name in self .yara_rules_dict :
1433+ return False , f"Custom Yara rule [{ rule_name } ] collides with the name of an existing rule"
12831434 try :
1284- yara .compile (source = rule_content )
1435+ yara .compile (source = f" { import_prefix } { rule_content } " )
12851436 except yara .SyntaxError as e :
1286- return False , f"Custom Yara rule failed to compile: { e } "
1287-
1288- rule_match = await self .helpers .re .search (self .yara_rule_name_regex , rule_content )
1289- if not rule_match :
1290- return False , "Custom Yara formatted incorrectly: could not find rule name"
1291-
1292- rule_name = rule_match .groups (1 )[0 ]
1293- c = CustomExtractor (self )
1294- self .add_yara_rule (rule_name , rule_content , c )
1295- custom_rules_count += 1
1296- if custom_rules_count > 0 :
1297- self .hugeinfo (f"Successfully added { str (custom_rules_count )} custom Yara rule(s)" )
1437+ return False , f"Custom Yara rule [{ rule_name } ] failed to compile: { e } "
1438+ self .add_yara_rule (rule_name , rule_content , CustomExtractor (self ))
1439+ self .hugeinfo (f"Successfully added { len (custom_rules ):,} custom Yara rule(s)" )
12981440
12991441 yara_max_match_data = self .config .get ("yara_max_match_data" , 2000 )
13001442
13011443 yara .set_config (max_match_data = yara_max_match_data )
1302- yara_rules_combined = "\n " .join (self .yara_rules_dict .values ())
1444+ yara_rules_combined = "\n " .join ([ * self .custom_yara_imports , * self . yara_rules_dict .values ()] )
13031445 try :
13041446 start = time .time ()
13051447 self .verbose (f"Compiling { len (self .yara_rules_dict ):,} YARA rules" )
0 commit comments