1"""`GenericChecker`."""
2
3import logging
4import warnings
5from functools import lru_cache
6from typing import Optional
7
8import idstools.rule
9import pytest
10
11from suricata_check.checkers.interface.checker import CheckerInterface
12from suricata_check.utils.checker_typing import ISSUES_TYPE, Issue
13from suricata_check.utils.regex import get_regex_provider
14
15_regex_provider = get_regex_provider()
16
17_CODE_STRUCTURE_REGEX = _regex_provider.compile(r"[A-Z]{1,}[0-9]{3}")
18
19
[docs]
20class GenericChecker:
21 """The GenericChecker class can be extended by tests to test classes implementing `CheckerInterface`."""
22
23 checker: CheckerInterface
24
25 @pytest.fixture(autouse=True)
26 def __run_around_tests(self: "GenericChecker") -> None:
27 logging.basicConfig(level=logging.DEBUG)
28
29 def _set_log_level(self: "GenericChecker", level: int) -> None:
30 logger = logging.getLogger()
31 logger.setLevel(level)
32 for handler in logger.handlers:
33 handler.setLevel(level)
34
35 @lru_cache(maxsize=1)
36 def _check_rule(
37 self: "GenericChecker",
38 rule: idstools.rule.Rule,
39 ) -> ISSUES_TYPE:
40 return self.checker.check_rule(rule)
41
42 def _test_issue(
43 self: "GenericChecker",
44 rule: Optional[idstools.rule.Rule],
45 code: str,
46 raised: bool,
47 fail: bool = True,
48 ) -> Optional[bool]:
49 """Checks whether a rule raises or does not raise an issue with a given code.
50
51 Raises a pytest failure, warning, or returns a boolean based on the provided arguments.
52 """
53 if rule is None:
54 pytest.fail("Rule is None")
55
56 correct, issue = self.check_issue(rule, code, raised)
57
58 if correct is not True:
59 msg = f"""\
60{'Unexpected' if not raised else 'Missing'} code {code}.
61{rule['raw']}
62{issue}\
63"""
64 if fail:
65 pytest.fail(msg)
66 else:
67 warnings.warn(RuntimeWarning(msg))
68
[docs]
69 def check_issue(
70 self: "GenericChecker",
71 rule: Optional[idstools.rule.Rule],
72 code: str,
73 raised: bool,
74 ) -> tuple[Optional[bool], Optional[Issue]]:
75 """Checks whether a rule raises an issue with a certain code and returns whether the expectation is met."""
76 if rule is None:
77 pytest.fail("Rule is None")
78
79 issues: ISSUES_TYPE = self._check_rule(rule)
80
81 self._test_no_undeclared_codes(issues)
82 self._test_issue_metadata(issues)
83
84 correct: Optional[bool] = None
85 issue: Optional[Issue] = None
86
87 if raised:
88 correct = False
89 for issue in issues:
90 if issue.code == code:
91 correct = True
92 break
93 issue = None
94 elif not raised:
95 correct = True
96 for issue in issues:
97 if issue.code == code:
98 correct = False
99 break
100
101 return correct, issue if not correct else None
102
103 def _test_no_undeclared_codes(self: "GenericChecker", issues: ISSUES_TYPE) -> None:
104 """Asserts the checker emits no undeclared codes."""
105 assert self.checker is not None
106
107 codes = set()
108 for issue in issues:
109 codes.add(issue.code)
110
111 for code in codes:
112 if code not in self.checker.codes:
113 pytest.fail(code)
114
[docs]
115 @pytest.hookimpl(trylast=True)
116 def test_code_structure(self: "GenericChecker") -> None:
117 """Asserts the checker only emits codes following the allowed structure."""
118 for code in self.checker.codes:
119 if _CODE_STRUCTURE_REGEX.match(code) is None:
120 pytest.fail(code)
121
122 def _test_issue_metadata(self: "GenericChecker", issues: ISSUES_TYPE) -> None:
123 """Asserts the checker adds required metadata to emitted issues."""
124 for issue in issues:
125 if not hasattr(issue, "checker"):
126 pytest.fail(
127 "Issue with code {} did not specify checker.".format(
128 str(issue.code)
129 )
130 )
131 if not hasattr(issue, "severity"):
132 pytest.fail(
133 "Issue with code {} did not specify severity.".format(
134 str(issue.code)
135 )
136 )
137 if issue.message.strip() != issue.message:
138 pytest.fail(
139 'Issue with code {} starts with or ends with whitespace in message: """{}"""'.format(
140 str(issue.code), str(issue.message)
141 )
142 )