Skip to content

Commit 7e60dda

Browse files
authored
feat: add RailException support and improve error handling (#1178)
* feat: add RailException support and improve error handling - Add TypedDict for structured return values - implement RailException for injection detection (a must have for checks) - improve error handling for malformed YARA rules * improve test coverage
1 parent 002a520 commit 7e60dda

File tree

4 files changed

+363
-49
lines changed

4 files changed

+363
-49
lines changed

nemoguardrails/library/injection_detection/actions.py

Lines changed: 90 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import re
3333
from functools import lru_cache
3434
from pathlib import Path
35-
from typing import Dict, Optional, Tuple, Union
35+
from typing import Dict, List, Optional, Tuple, TypedDict, Union
3636

3737
yara = None
3838
try:
@@ -49,6 +49,12 @@
4949
log = logging.getLogger(__name__)
5050

5151

52+
class InjectionDetectionResult(TypedDict):
53+
is_injection: bool
54+
text: str
55+
detections: List[str]
56+
57+
5258
def _check_yara_available():
5359
if yara is None:
5460
raise ImportError(
@@ -197,13 +203,13 @@ def _load_rules(
197203
}
198204
rules = yara.compile(filepaths=rules_to_load)
199205
except yara.SyntaxError as e:
200-
msg = f"Encountered SyntaxError: {e}"
206+
msg = f"Failed to initialize injection detection due to configuration or YARA rule error: YARA compilation failed: {e}"
201207
log.error(msg)
202-
raise e
208+
return None
203209
return rules
204210

205211

206-
def _omit_injection(text: str, matches: list["yara.Match"]) -> str:
212+
def _omit_injection(text: str, matches: list["yara.Match"]) -> Tuple[bool, str]:
207213
"""
208214
Attempts to strip the offending injection attempts from the provided text.
209215
@@ -216,14 +222,18 @@ def _omit_injection(text: str, matches: list["yara.Match"]) -> str:
216222
matches (list['yara.Match']): A list of YARA rule matches.
217223
218224
Returns:
219-
str: The text with the detected injections stripped out.
225+
Tuple[bool, str]: A tuple containing:
226+
- bool: True if injection was detected and modified,
227+
False if the text is safe (i.e., not modified).
228+
- str: The text, with detected injections stripped out if modified.
220229
221230
Raises:
222231
ImportError: If the yara module is not installed.
223232
"""
224233

225-
# Copy the text to a placeholder variable
234+
original_text = text
226235
modified_text = text
236+
is_injection = False
227237
for match in matches:
228238
if match.strings:
229239
for match_string in match.strings:
@@ -234,10 +244,16 @@ def _omit_injection(text: str, matches: list["yara.Match"]) -> str:
234244
modified_text = modified_text.replace(plaintext, "")
235245
except (AttributeError, UnicodeDecodeError) as e:
236246
log.warning(f"Error processing match: {e}")
237-
return modified_text
247+
248+
if modified_text != original_text:
249+
is_injection = True
250+
return is_injection, modified_text
251+
else:
252+
is_injection = False
253+
return is_injection, original_text
238254

239255

240-
def _sanitize_injection(text: str, matches: list["yara.Match"]) -> str:
256+
def _sanitize_injection(text: str, matches: list["yara.Match"]) -> Tuple[bool, str]:
241257
"""
242258
Attempts to sanitize the offending injection attempts in the provided text.
243259
This is done by 'de-fanging' the offending content, transforming it into a state that will not execute
@@ -253,19 +269,27 @@ def _sanitize_injection(text: str, matches: list["yara.Match"]) -> str:
253269
matches (list['yara.Match']): A list of YARA rule matches.
254270
255271
Returns:
256-
str: The text with the detected injections sanitized.
272+
Tuple[bool, str]: A tuple containing:
273+
- bool: True if injection was detected, False otherwise.
274+
- str: The sanitized text, or original text depending on sanitization outcome.
275+
Currently, this function will always raise NotImplementedError.
257276
258277
Raises:
259278
NotImplementedError: If the sanitization logic is not implemented.
260279
ImportError: If the yara module is not installed.
261280
"""
262-
263281
raise NotImplementedError(
264282
"Injection sanitization is not yet implemented. Please use 'reject' or 'omit'"
265283
)
284+
# Hypothetical logic if implemented, to match existing behavior in injection_detection:
285+
# sanitized_text_attempt = "..." # result of sanitization
286+
# if sanitized_text_attempt != text:
287+
# return True, text # Original text returned, marked as injection detected
288+
# else:
289+
# return False, sanitized_text_attempt
266290

267291

268-
def _reject_injection(text: str, rules: "yara.Rules") -> Tuple[bool, str]:
292+
def _reject_injection(text: str, rules: "yara.Rules") -> Tuple[bool, List[str]]:
269293
"""
270294
Detects whether the provided text contains potential injection attempts.
271295
@@ -277,8 +301,9 @@ def _reject_injection(text: str, rules: "yara.Rules") -> Tuple[bool, str]:
277301
rules ('yara.Rules'): The loaded YARA rules.
278302
279303
Returns:
280-
bool: True if attempted exploitation is detected, False otherwise.
281-
str: list of matches as a string
304+
Tuple[bool, List[str]]: A tuple containing:
305+
- bool: True if attempted exploitation is detected, False otherwise.
306+
- List[str]: List of matched rule names.
282307
283308
Raises:
284309
ValueError: If the `action` parameter in the configuration is invalid.
@@ -289,18 +314,20 @@ def _reject_injection(text: str, rules: "yara.Rules") -> Tuple[bool, str]:
289314
log.warning(
290315
"reject_injection guardrail was invoked but no rules were specified in the InjectionDetection config."
291316
)
292-
return False, ""
317+
return False, []
293318
matches = rules.match(data=text)
294319
if matches:
295-
matches_string = ", ".join([match_name.rule for match_name in matches])
296-
log.info(f"Input matched on rule {matches_string}.")
297-
return True, matches_string
320+
matched_rules = [match_name.rule for match_name in matches]
321+
log.info(f"Input matched on rule {', '.join(matched_rules)}.")
322+
return True, matched_rules
298323
else:
299-
return False, ""
324+
return False, []
300325

301326

302327
@action()
303-
async def injection_detection(text: str, config: RailsConfig) -> str:
328+
async def injection_detection(
329+
text: str, config: RailsConfig
330+
) -> InjectionDetectionResult:
304331
"""
305332
Detects and mitigates potential injection attempts in the provided text.
306333
@@ -310,45 +337,68 @@ async def injection_detection(text: str, config: RailsConfig) -> str:
310337
311338
Args:
312339
text (str): The text to check for command injection.
340+
313341
config (RailsConfig): The Rails configuration object containing injection detection settings.
314342
315343
Returns:
316-
str: The sanitized or original text, depending on the action specified in the configuration.
344+
InjectionDetectionResult: A TypedDict containing:
345+
- is_injection (bool): Whether an injection was detected. True if any injection is detected,
346+
False if no injection is detected.
347+
- text (str): The sanitized or original text
348+
- detections (List[str]): List of matched rule names if any injection is detected
317349
318350
Raises:
319351
ValueError: If the `action` parameter in the configuration is invalid.
320352
NotImplementedError: If an unsupported action is encountered.
353+
ImportError: If the yara module is not installed.
321354
"""
322355
_check_yara_available()
323356

324357
_validate_injection_config(config)
358+
325359
action_option, yara_path, rule_names, yara_rules = _extract_injection_config(config)
326360

327361
rules = _load_rules(yara_path, rule_names, yara_rules)
328362

329-
if action_option == "reject":
330-
verdict, detections = _reject_injection(text, rules)
331-
if verdict:
332-
return f"I'm sorry, the desired output triggered rule(s) designed to mitigate exploitation of {detections}."
333-
else:
334-
return text
335363
if rules is None:
336364
log.warning(
337365
"injection detection guardrail was invoked but no rules were specified in the InjectionDetection config."
338366
)
339-
return text
340-
matches = rules.match(data=text)
341-
if matches:
342-
matches_string = ", ".join([match_name.rule for match_name in matches])
343-
log.info(f"Input matched on rule {matches_string}.")
344-
if action_option == "omit":
345-
return _omit_injection(text, matches)
346-
elif action_option == "sanitize":
347-
return _sanitize_injection(text, matches)
367+
return InjectionDetectionResult(is_injection=False, text=text, detections=[])
368+
369+
if action_option == "reject":
370+
is_injection, detected_rules = _reject_injection(text, rules)
371+
return InjectionDetectionResult(
372+
is_injection=is_injection, text=text, detections=detected_rules
373+
)
374+
else:
375+
matches = rules.match(data=text)
376+
if matches:
377+
detected_rules_list = [match_name.rule for match_name in matches]
378+
log.info(f"Input matched on rule {', '.join(detected_rules_list)}.")
379+
380+
if action_option == "omit":
381+
is_injection, result_text = _omit_injection(text, matches)
382+
return InjectionDetectionResult(
383+
is_injection=is_injection,
384+
text=result_text,
385+
detections=detected_rules_list,
386+
)
387+
elif action_option == "sanitize":
388+
# _sanitize_injection will raise NotImplementedError before returning a tuple.
389+
# the assignment below is for structural consistency if it were implemented.
390+
is_injection, result_text = _sanitize_injection(text, matches)
391+
return InjectionDetectionResult(
392+
is_injection=is_injection,
393+
text=result_text,
394+
detections=detected_rules_list,
395+
)
396+
else:
397+
raise NotImplementedError(
398+
f"Expected `action` parameter to be 'reject', 'omit', or 'sanitize' but got {action_option} instead."
399+
)
400+
# no matches found
348401
else:
349-
# We should never ever hit this since we inspect the action option above, but putting an error here anyway.
350-
raise NotImplementedError(
351-
f"Expected `action` parameter to be 'omit' or 'sanitize' but got {action_option} instead."
402+
return InjectionDetectionResult(
403+
is_injection=False, text=text, detections=[]
352404
)
353-
else:
354-
return text
Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
# OUTPUT RAILS
2-
31
flow injection detection
42
"""
53
Reject, omit, or sanitize injection attempts from the bot.
4+
This rail operates on the $bot_message.
65
"""
7-
$bot_message = await InjectionDetectionAction(text=$bot_message)
6+
response = await InjectionDetectionAction(text=$bot_message)
7+
join_separator = ", "
8+
injection_detection_action = $config.rails.config.injection_detection.action
9+
10+
if response["is_injection"]
11+
if $config.enable_rails_exceptions
12+
send InjectionDetectionRailException(message="Output not allowed. The output was blocked by the 'injection detection' flow.")
13+
else if injection_detection_action == "reject"
14+
bot "I'm sorry, the desired output triggered rule(s) designed to mitigate exploitation of {{ response.detections | join(join_separator) }}."
15+
abort
16+
else if injection_detection_action == "omit" or injection_detection_action == "sanitize"
17+
$bot_message = response["text"]
18+
else
19+
$bot_message = response["text"]
Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
1-
define subflow injection detection
1+
2+
define flow injection detection
23
"""
34
Reject, omit, or sanitize injection attempts from the bot.
45
"""
5-
$bot_message = execute injection_detection(text=$bot_message)
6+
$response = execute injection_detection(text=$bot_message)
7+
$join_separator = ", "
8+
$injection_detection_action = $config.rails.config.injection_detection.action
9+
if $response["is_injection"]
10+
if $config.enable_rails_exceptions
11+
create event InjectionDetectionRailException(message="Output not allowed. The output was blocked by the 'injection detection' flow.")
12+
stop
13+
else if $config.rails.config.injection_detection.action == "reject"
14+
bot say "I'm sorry, the desired output triggered rule(s) designed to mitigate exploitation of {{ response.detections | join(join_separator) }}."
15+
stop
16+
else if $injection_detection_action == "omit" or $injection_detection_action == "sanitize"
17+
$bot_message = $response["text"]
18+
else
19+
$bot_message = $response["text"]

0 commit comments

Comments
 (0)