Fix mypy warning in json_dump()
[kconfig-hardened-check.git] / kernel_hardening_checker / engine.py
1 #!/usr/bin/env python3
2
3 """
4 This tool is for checking the security hardening options of the Linux kernel.
5
6 Author: Alexander Popov <alex.popov@linux.com>
7
8 This module is the engine of checks.
9 """
10
11 # pylint: disable=missing-class-docstring,missing-function-docstring
12 # pylint: disable=line-too-long,invalid-name,too-many-branches
13
14 from typing import Dict, Tuple
15 import sys
16
17 GREEN_COLOR = '\x1b[32m'
18 RED_COLOR = '\x1b[31m'
19 COLOR_END = '\x1b[0m'
20
21 def colorize_result(input_text):
22     if input_text is None or not sys.stdout.isatty():
23         return input_text
24     if input_text.startswith('OK'):
25         color = GREEN_COLOR
26     else:
27         assert(input_text.startswith('FAIL:')), f'unexpected result "{input_text}"'
28         color = RED_COLOR
29     return f'{color}{input_text}{COLOR_END}'
30
31
32 class OptCheck:
33     def __init__(self, reason: str, decision: str, name: str, expected: str):
34         assert(name and name == name.strip() and len(name.split()) == 1), \
35                f'invalid name "{name}" for {self.__class__.__name__}'
36         self.name = name
37
38         assert(decision and decision == decision.strip() and len(decision.split()) == 1), \
39                f'invalid decision "{decision}" for "{name}" check'
40         self.decision = decision
41
42         assert(reason and reason == reason.strip() and len(reason.split()) == 1), \
43                f'invalid reason "{reason}" for "{name}" check'
44         self.reason = reason
45
46         assert(expected and expected == expected.strip()), \
47                f'invalid expected value "{expected}" for "{name}" check (1)'
48         val_len = len(expected.split())
49         if val_len == 3:
50             assert(expected in ('is not set', 'is not off')), \
51                    f'invalid expected value "{expected}" for "{name}" check (2)'
52         elif val_len == 2:
53             assert(expected == 'is present'), \
54                    f'invalid expected value "{expected}" for "{name}" check (3)'
55         else:
56             assert(val_len == 1), \
57                    f'invalid expected value "{expected}" for "{name}" check (4)'
58         self.expected = expected
59
60         self.state = None
61         self.result = None
62
63     @property
64     def opt_type(self):
65         return None
66
67     def set_state(self, data):
68         assert(data is None or isinstance(data, str)), \
69                f'invalid state "{data}" for "{self.name}" check'
70         self.state = data
71
72     def check(self):
73         # handle the 'is present' check
74         if self.expected == 'is present':
75             if self.state is None:
76                 self.result = 'FAIL: is not present'
77             else:
78                 self.result = 'OK: is present'
79             return
80
81         # handle the 'is not off' option check
82         if self.expected == 'is not off':
83             if self.state == 'off':
84                 self.result = 'FAIL: is off'
85             elif self.state == '0':
86                 self.result = 'FAIL: is off, "0"'
87             elif self.state is None:
88                 self.result = 'FAIL: is off, not found'
89             else:
90                 self.result = f'OK: is not off, "{self.state}"'
91             return
92
93         # handle the option value check
94         if self.expected == self.state:
95             self.result = 'OK'
96         elif self.state is None:
97             if self.expected == 'is not set':
98                 self.result = 'OK: is not found'
99             else:
100                 self.result = 'FAIL: is not found'
101         else:
102             self.result = f'FAIL: "{self.state}"'
103
104     def table_print(self, _mode, with_results: bool):
105         print(f'{self.name:<40}|{self.opt_type:^7}|{self.expected:^12}|{self.decision:^10}|{self.reason:^18}', end='')
106         if with_results:
107             print(f'| {colorize_result(self.result)}', end='')
108
109     def json_dump(self, with_results: bool) -> Dict:
110         dump = {
111             "option_name": self.name,
112             "type": self.opt_type,
113             "desired_val": self.expected,
114             "decision": self.decision,
115             "reason": self.reason,
116         }
117         if with_results:
118             assert self.result, f'unexpected empty result in {self.name}'
119             dump["check_result"] = self.result
120             dump["check_result_bool"] = self.result.startswith('OK')
121         return dump
122
123
124 class KconfigCheck(OptCheck):
125     def __init__(self, *args, **kwargs):
126         super().__init__(*args, **kwargs)
127         self.name = f'CONFIG_{self.name}'
128
129     @property
130     def opt_type(self):
131         return 'kconfig'
132
133
134 class CmdlineCheck(OptCheck):
135     @property
136     def opt_type(self):
137         return 'cmdline'
138
139
140 class SysctlCheck(OptCheck):
141     @property
142     def opt_type(self):
143         return 'sysctl'
144
145
146 class VersionCheck:
147     def __init__(self, ver_expected: Tuple):
148         assert(ver_expected and isinstance(ver_expected, tuple) and len(ver_expected) == 3), \
149                f'invalid expected version "{ver_expected}" for VersionCheck (1)'
150         assert(all(map(lambda x: isinstance(x, int), ver_expected))), \
151                f'invalid expected version "{ver_expected}" for VersionCheck (2)'
152         self.ver_expected = ver_expected
153         self.ver = ()
154         self.result = None
155
156     @property
157     def opt_type(self):
158         return 'version'
159
160     def set_state(self, data: Tuple):
161         assert(data and isinstance(data, tuple) and len(data) >= 3), \
162                f'invalid version "{data}" for VersionCheck'
163         self.ver = data[:3]
164
165     def check(self):
166         if self.ver[0] > self.ver_expected[0]:
167             self.result = f'OK: version >= {self.ver_expected}'
168             return
169         if self.ver[0] < self.ver_expected[0]:
170             self.result = f'FAIL: version < {self.ver_expected}'
171             return
172         # self.ver[0] and self.ver_expected[0] are equal
173         if self.ver[1] > self.ver_expected[1]:
174             self.result = f'OK: version >= {self.ver_expected}'
175             return
176         if self.ver[1] < self.ver_expected[1]:
177             self.result = f'FAIL: version < {self.ver_expected}'
178             return
179         # self.ver[1] and self.ver_expected[1] are equal too
180         if self.ver[2] >= self.ver_expected[2]:
181             self.result = f'OK: version >= {self.ver_expected}'
182             return
183         self.result = f'FAIL: version < {self.ver_expected}'
184
185     def table_print(self, _mode, with_results: bool):
186         ver_req = f'kernel version >= {self.ver_expected}'
187         print(f'{ver_req:<91}', end='')
188         if with_results:
189             print(f'| {colorize_result(self.result)}', end='')
190
191
192 class ComplexOptCheck:
193     def __init__(self, *opts):
194         self.opts = opts
195         assert(self.opts), \
196                f'empty {self.__class__.__name__} check'
197         assert(len(self.opts) != 1), \
198                f'useless {self.__class__.__name__} check: {opts}'
199         assert(isinstance(opts[0], (KconfigCheck, CmdlineCheck, SysctlCheck))), \
200                f'invalid {self.__class__.__name__} check: {opts}'
201         self.result = None
202
203     @property
204     def opt_type(self):
205         return 'complex'
206
207     @property
208     def name(self):
209         return self.opts[0].name
210
211     @property
212     def expected(self):
213         return self.opts[0].expected
214
215     def table_print(self, mode: str, with_results: bool):
216         if mode == 'verbose':
217             class_name = f'<<< {self.__class__.__name__} >>>'
218             print(f'    {class_name:87}', end='')
219             if with_results:
220                 print(f'| {colorize_result(self.result)}', end='')
221             for o in self.opts:
222                 print()
223                 o.table_print(mode, with_results)
224         else:
225             o = self.opts[0]
226             o.table_print(mode, False)
227             if with_results:
228                 print(f'| {colorize_result(self.result)}', end='')
229
230     def json_dump(self, with_results: bool) -> Dict:
231         dump = self.opts[0].json_dump(False)
232         if with_results:
233             # Add the 'check_result' and 'check_result_bool' keys to the dictionary
234             assert self.result, f'unexpected empty result in {self.name}'
235             dump["check_result"] = self.result
236             dump["check_result_bool"] = self.result.startswith('OK')
237         return dump
238
239
240 class OR(ComplexOptCheck):
241     # self.opts[0] is the option that this OR-check is about.
242     # Use cases:
243     #     OR(<X_is_hardened>, <X_is_disabled>)
244     #     OR(<X_is_hardened>, <old_X_is_hardened>)
245     def check(self):
246         for i, opt in enumerate(self.opts):
247             opt.check()
248             if opt.result.startswith('OK'):
249                 self.result = opt.result
250                 # Add more info for additional checks:
251                 if i != 0:
252                     if opt.result == 'OK':
253                         self.result = f'OK: {opt.name} is "{opt.expected}"'
254                     elif opt.result == 'OK: is not found':
255                         self.result = f'OK: {opt.name} is not found'
256                     elif opt.result == 'OK: is present':
257                         self.result = f'OK: {opt.name} is present'
258                     elif opt.result.startswith('OK: is not off'):
259                         self.result = f'OK: {opt.name} is not off'
260                     else:
261                         # VersionCheck provides enough info
262                         assert(opt.result.startswith('OK: version')), \
263                                f'unexpected OK description "{opt.result}"'
264                 return
265         self.result = self.opts[0].result
266
267
268 class AND(ComplexOptCheck):
269     # self.opts[0] is the option that this AND-check is about.
270     # Use cases:
271     #     AND(<suboption>, <main_option>)
272     #       Suboption is not checked if checking of the main_option is failed.
273     #     AND(<X_is_disabled>, <old_X_is_disabled>)
274     def check(self):
275         for i, opt in reversed(list(enumerate(self.opts))):
276             opt.check()
277             if i == 0:
278                 self.result = opt.result
279                 return
280             if not opt.result.startswith('OK'):
281                 # This FAIL is caused by additional checks,
282                 # and not by the main option that this AND-check is about.
283                 # Describe the reason of the FAIL.
284                 if opt.result.startswith('FAIL: \"') or opt.result == 'FAIL: is not found':
285                     self.result = f'FAIL: {opt.name} is not "{opt.expected}"'
286                 elif opt.result == 'FAIL: is not present':
287                     self.result = f'FAIL: {opt.name} is not present'
288                 elif opt.result in ('FAIL: is off', 'FAIL: is off, "0"'):
289                     self.result = f'FAIL: {opt.name} is off'
290                 elif opt.result == 'FAIL: is off, not found':
291                     self.result = f'FAIL: {opt.name} is off, not found'
292                 else:
293                     # VersionCheck provides enough info
294                     self.result = opt.result
295                     assert(opt.result.startswith('FAIL: version')), \
296                            f'unexpected FAIL description "{opt.result}"'
297                 return
298
299
300 SIMPLE_OPTION_TYPES = ('kconfig', 'cmdline', 'sysctl', 'version')
301
302
303 def populate_simple_opt_with_data(opt, data, data_type: str):
304     assert(opt.opt_type != 'complex'), \
305            f'unexpected ComplexOptCheck "{opt.name}"'
306     assert(opt.opt_type in SIMPLE_OPTION_TYPES), \
307            f'invalid opt_type "{opt.opt_type}"'
308     assert(data_type in SIMPLE_OPTION_TYPES), \
309            f'invalid data_type "{data_type}"'
310     assert(data), \
311            'empty data'
312
313     if data_type != opt.opt_type:
314         return
315
316     if data_type in ('kconfig', 'cmdline', 'sysctl'):
317         opt.set_state(data.get(opt.name, None))
318     else:
319         assert(data_type == 'version'), \
320                f'unexpected data_type "{data_type}"'
321         opt.set_state(data)
322
323
324 def populate_opt_with_data(opt, data, data_type):
325     assert(opt.opt_type != 'version'), 'a single VersionCheck is useless'
326     if opt.opt_type != 'complex':
327         populate_simple_opt_with_data(opt, data, data_type)
328     else:
329         for o in opt.opts:
330             if o.opt_type != 'complex':
331                 populate_simple_opt_with_data(o, data, data_type)
332             else:
333                 # Recursion for nested ComplexOptCheck objects
334                 populate_opt_with_data(o, data, data_type)
335
336
337 def populate_with_data(checklist, data, data_type):
338     for opt in checklist:
339         populate_opt_with_data(opt, data, data_type)
340
341
342 def override_expected_value(checklist, name, new_val):
343     for opt in checklist:
344         if opt.name == name:
345             assert(opt.opt_type in ('kconfig', 'cmdline', 'sysctl')), \
346                    f'overriding an expected value for "{opt.opt_type}" checks is not supported yet'
347             opt.expected = new_val
348
349
350 def perform_checks(checklist):
351     for opt in checklist:
352         opt.check()