1"""`MsgChecker`."""
2
3import logging
4from types import MappingProxyType
5
6from suricata_check.checkers.interface import CheckerInterface
7from suricata_check.utils.checker import (
8 get_rule_option,
9 is_rule_option_equal_to_regex,
10 is_rule_option_set,
11 is_rule_suboption_set,
12)
13from suricata_check.utils.checker_typing import ISSUES_TYPE, Issue
14from suricata_check.utils.regex_provider import get_regex_provider
15from suricata_check.utils.rule import Rule
16
17_regex_provider = get_regex_provider()
18
19_S400_REGEX = _regex_provider.compile(r"""^"[A-Z0-9 ]+ [A-Z0-9_]+ .*"$""")
20_MALWARE_REGEX = _regex_provider.compile(r"^.*(malware).*$", _regex_provider.IGNORECASE)
21_S401_REGEX = _regex_provider.compile(r"""^".* [a-zA-Z0-9]+/[a-zA-Z0-9]+ .*"$""")
22_VAGUE_KEYWORDS = ("possible", "unknown")
23_S402_REGEX = _regex_provider.compile(
24 r"^.*({}).*$".format("|".join(_VAGUE_KEYWORDS)),
25 _regex_provider.IGNORECASE,
26)
27_UNDESIRABLE_DATE_REGEXES = (
28 _regex_provider.compile(r"^.*(\d{4}/\d{2}/\d{2}).*$", _regex_provider.IGNORECASE),
29 _regex_provider.compile(r"^.*(\d{4}-[2-9]\d-\d{2}).*$", _regex_provider.IGNORECASE),
30) # Desirable format is ISO (YYYY-MM-DD)
31_S404_REGEX = _regex_provider.compile(
32 r"^.*(C2|C&C|Command and Control|Command & Control).*$",
33 _regex_provider.IGNORECASE,
34)
35_S405_REGEX = _regex_provider.compile(
36 r"^.*(Go|MSIL|ELF64|MSIL|JS|Win32|DOS|Amiga|C64|Plan9).*$",
37 _regex_provider.IGNORECASE,
38)
39_S406_REGEX = _regex_provider.compile(
40 r"^.*((\w+\.)+[a-z]{2,}).*$",
41 _regex_provider.IGNORECASE,
42)
43_S407_REGEX = _regex_provider.compile(
44 r"^.*((\w+\[\.\])+[a-z]{2,}).*$",
45 _regex_provider.IGNORECASE,
46)
47_S408_REGEX = _regex_provider.compile(
48 r"^.*((\w+\. )+[a-z]{2,}).*$",
49 _regex_provider.IGNORECASE,
50)
51
52_logger = logging.getLogger(__name__)
53
54
[docs]
55class MsgChecker(CheckerInterface):
56 """The `MsgChecker` contains several checks based for the Msg option in Suricata rules.
57
58 Codes S400-S410 report on non-standard `msg` fields.
59 """
60
61 codes = MappingProxyType(
62 {
63 "S400": {"severity": logging.INFO},
64 "S401": {"severity": logging.INFO},
65 "S402": {"severity": logging.INFO},
66 "S403": {"severity": logging.INFO},
67 "S404": {"severity": logging.INFO},
68 "S405": {"severity": logging.INFO},
69 "S406": {"severity": logging.WARNING},
70 "S407": {"severity": logging.INFO},
71 "S408": {"severity": logging.INFO},
72 "S409": {"severity": logging.INFO},
73 },
74 )
75
76 def _check_rule( # noqa: C901
77 self: "MsgChecker",
78 rule: Rule,
79 ) -> ISSUES_TYPE:
80 issues: ISSUES_TYPE = []
81
82 if is_rule_option_set(rule, "msg") and not is_rule_option_equal_to_regex(
83 rule,
84 "msg",
85 _S400_REGEX,
86 ):
87 issues.append(
88 Issue(
89 code="S400",
90 message="""\
91The rule has a non-standard format for the msg field.
92Consider changing the msg field to `RULESET CATEGORY Description`.\
93""",
94 ),
95 )
96
97 if (
98 is_rule_option_set(rule, "msg")
99 and self.__desribes_malware(rule)
100 and not is_rule_option_equal_to_regex(rule, "msg", _S401_REGEX)
101 ):
102 issues.append(
103 Issue(
104 code="S401",
105 message="""\
106The rule describes malware but does not specify the paltform or malware family in the msg field.
107Consider changing the msg field to include `Platform/malfamily`.\
108""",
109 ),
110 )
111
112 if not (
113 is_rule_option_set(rule, "noalert")
114 or is_rule_suboption_set(rule, "flowbits", "noalert")
115 ) and is_rule_option_equal_to_regex(rule, "msg", _S402_REGEX):
116 issues.append(
117 Issue(
118 code="S402",
119 message="""\
120The rule uses vague keywords such as possible or unknown in the msg field.
121Consider rephrasing to provide a more clear message for interpreting generated alerts.\
122""",
123 ),
124 )
125
126 for regex in _UNDESIRABLE_DATE_REGEXES:
127 if is_rule_option_equal_to_regex(rule, "msg", regex):
128 issues.append(
129 Issue(
130 code="S403",
131 message="""\
132The rule uses a non-ISO date in the msg field.
133Consider reformatting the date to ISO format (YYYY-MM-DD).\
134""",
135 ),
136 )
137 break
138
139 if is_rule_option_equal_to_regex(rule, "msg", _S404_REGEX):
140 issues.append(
141 Issue(
142 code="S404",
143 message="""\
144The rule uses a different way of writing CnC (Command & Control) in the msg field.
145Consider writing CnC instead.\
146""",
147 ),
148 )
149
150 if self.__desribes_malware(rule) and not is_rule_option_equal_to_regex(
151 rule,
152 "msg",
153 _S405_REGEX,
154 ):
155 issues.append(
156 Issue(
157 code="S405",
158 message="""\
159The rule likely detects malware but does not specify the file type in the msg field.
160Consider specifying a file type such as `DOS` or `ELF64`.\
161""",
162 ),
163 )
164
165 if is_rule_option_equal_to_regex(rule, "msg", _S406_REGEX):
166 issues.append(
167 Issue(
168 code="S406",
169 message="""\
170The rule specifies a domain name without escaping the label seperators.
171Consider escaping the domain names by putting a space before the dot like `foo .bar` to prevent information leaks.\
172""",
173 ),
174 )
175
176 if is_rule_option_equal_to_regex(rule, "msg", _S407_REGEX):
177 issues.append(
178 Issue(
179 code="S407",
180 message="""\
181The rule specifies a domain name and escapes it in a non-standard way in the msg field.
182Consider escaping the domain names by putting a space before the dot like `foo .bar`.\
183""",
184 ),
185 )
186
187 if is_rule_option_equal_to_regex(rule, "msg", _S408_REGEX):
188 issues.append(
189 Issue(
190 code="S408",
191 message="""\
192The rule specifies a domain name and escapes it in a non-standard way in the msg field.
193Consider escaping the domain names by putting a space before the dot like `foo .bar`.\
194""",
195 ),
196 )
197
198 # Note that all characters under 128 are ASCII
199 if is_rule_option_set(rule, "msg") and any(
200 ord(c) > 128 # noqa: PLR2004
201 for c in get_rule_option(rule, "msg") # type: ignore reportOptionalIterable
202 ):
203 issues.append(
204 Issue(
205 code="S409",
206 message="""\
207The rule uses non-ASCII characters in the msg field.
208Consider removing non-ASCII characters.\
209""",
210 ),
211 )
212
213 return issues
214
215 @staticmethod
216 def __desribes_malware(rule: Rule) -> bool:
217 if is_rule_suboption_set(rule, "metadata", "malware_family"):
218 return True
219
220 if is_rule_option_equal_to_regex(rule, "msg", _MALWARE_REGEX):
221 return True
222
223 _logger.debug("Rule does not describe malware: %s", rule.raw)
224
225 return False