Merge branch 'master' into typing
[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             dump["check_result"] = self.result
119             dump["check_result_bool"] = self.result.startswith('OK')
120         return dump
121
122
123 class KconfigCheck(OptCheck):
124     def __init__(self, *args, **kwargs):
125         super().__init__(*args, **kwargs)
126         self.name = f'CONFIG_{self.name}'
127
128     @property
129     def opt_type(self):
130         return 'kconfig'
131
132
133 class CmdlineCheck(OptCheck):
134     @property
135     def opt_type(self):
136         return 'cmdline'
137
138
139 class SysctlCheck(OptCheck):
140     @property
141     def opt_type(self):
142         return 'sysctl'
143
144
145 class VersionCheck:
146     def __init__(self, ver_expected: Tuple):
147         assert(ver_expected and isinstance(ver_expected, tuple) and len(ver_expected) == 3), \
148                f'invalid expected version "{ver_expected}" for VersionCheck (1)'
149         assert(all(map(lambda x: isinstance(x, int), ver_expected))), \
150                f'invalid expected version "{ver_expected}" for VersionCheck (2)'
151         self.ver_expected = ver_expected
152         self.ver = ()
153         self.result = None
154
155     @property
156     def opt_type(self):
157         return 'version'
158
159     def set_state(self, data: Tuple):
160         assert(data and isinstance(data, tuple) and len(data) >= 3), \
161                f'invalid version "{data}" for VersionCheck'
162         self.ver = data[:3]
163
164     def check(self):
165         if self.ver[0] > self.ver_expected[0]:
166             self.result = f'OK: version >= {self.ver_expected}'
167             return
168         if self.ver[0] < self.ver_expected[0]:
169             self.result = f'FAIL: version < {self.ver_expected}'
170             return
171         # self.ver[0] and self.ver_expected[0] are equal
172         if self.ver[1] > self.ver_expected[1]:
173             self.result = f'OK: version >= {self.ver_expected}'
174             return
175         if self.ver[1] < self.ver_expected[1]:
176             self.result = f'FAIL: version < {self.ver_expected}'
177             return
178         # self.ver[1] and self.ver_expected[1] are equal too
179         if self.ver[2] >= self.ver_expected[2]:
180             self.result = f'OK: version >= {self.ver_expected}'
181             return
182         self.result = f'FAIL: version < {self.ver_expected}'
183
184     def table_print(self, _mode, with_results: bool):
185         ver_req = f'kernel version >= {self.ver_expected}'
186         print(f'{ver_req:<91}', end='')
187         if with_results:
188             print(f'| {colorize_result(self.result)}', end='')
189
190
191 class ComplexOptCheck:
192     def __init__(self, *opts):
193         self.opts = opts
194         assert(self.opts), \
195                f'empty {self.__class__.__name__} check'
196         assert(len(self.opts) != 1), \
197                f'useless {self.__class__.__name__} check: {opts}'
198         assert(isinstance(opts[0], (KconfigCheck, CmdlineCheck, SysctlCheck))), \
199                f'invalid {self.__class__.__name__} check: {opts}'
200         self.result = None
201
202     @property
203     def opt_type(self):
204         return 'complex'
205
206     @property
207     def name(self):
208         return self.opts[0].name
209
210     @property
211     def expected(self):
212         return self.opts[0].expected
213
214     def table_print(self, mode: str, with_results: bool):
215         if mode == 'verbose':
216             class_name = f'<<< {self.__class__.__name__} >>>'
217             print(f'    {class_name:87}', end='')
218             if with_results:
219                 print(f'| {colorize_result(self.result)}', end='')
220             for o in self.opts:
221                 print()
222                 o.table_print(mode, with_results)
223         else:
224             o = self.opts[0]
225             o.table_print(mode, False)
226             if with_results:
227                 print(f'| {colorize_result(self.result)}', end='')
228
229     def json_dump(self, with_results: bool) -> Dict:
230         dump = self.opts[0].json_dump(False)
231         if with_results:
232             # Add the 'check_result' and 'check_result_bool' keys to the dictionary
233             dump["check_result"] = self.result
234             dump["check_result_bool"] = self.result.startswith('OK')
235         return dump
236
237
238 class OR(ComplexOptCheck):
239     # self.opts[0] is the option that this OR-check is about.
240     # Use cases:
241     #     OR(<X_is_hardened>, <X_is_disabled>)
242     #     OR(<X_is_hardened>, <old_X_is_hardened>)
243     def check(self):
244         for i, opt in enumerate(self.opts):
245             opt.check()
246             if opt.result.startswith('OK'):
247                 self.result = opt.result
248                 # Add more info for additional checks:
249                 if i != 0:
250                     if opt.result == 'OK':
251                         self.result = f'OK: {opt.name} is "{opt.expected}"'
252                     elif opt.result == 'OK: is not found':
253                         self.result = f'OK: {opt.name} is not found'
254                     elif opt.result == 'OK: is present':
255                         self.result = f'OK: {opt.name} is present'
256                     elif opt.result.startswith('OK: is not off'):
257                         self.result = f'OK: {opt.name} is not off'
258                     else:
259                         # VersionCheck provides enough info
260                         assert(opt.result.startswith('OK: version')), \
261                                f'unexpected OK description "{opt.result}"'
262                 return
263         self.result = self.opts[0].result
264
265
266 class AND(ComplexOptCheck):
267     # self.opts[0] is the option that this AND-check is about.
268     # Use cases:
269     #     AND(<suboption>, <main_option>)
270     #       Suboption is not checked if checking of the main_option is failed.
271     #     AND(<X_is_disabled>, <old_X_is_disabled>)
272     def check(self):
273         for i, opt in reversed(list(enumerate(self.opts))):
274             opt.check()
275             if i == 0:
276                 self.result = opt.result
277                 return
278             if not opt.result.startswith('OK'):
279                 # This FAIL is caused by additional checks,
280                 # and not by the main option that this AND-check is about.
281                 # Describe the reason of the FAIL.
282                 if opt.result.startswith('FAIL: \"') or opt.result == 'FAIL: is not found':
283                     self.result = f'FAIL: {opt.name} is not "{opt.expected}"'
284                 elif opt.result == 'FAIL: is not present':
285                     self.result = f'FAIL: {opt.name} is not present'
286                 elif opt.result in ('FAIL: is off', 'FAIL: is off, "0"'):
287                     self.result = f'FAIL: {opt.name} is off'
288                 elif opt.result == 'FAIL: is off, not found':
289                     self.result = f'FAIL: {opt.name} is off, not found'
290                 else:
291                     # VersionCheck provides enough info
292                     self.result = opt.result
293                     assert(opt.result.startswith('FAIL: version')), \
294                            f'unexpected FAIL description "{opt.result}"'
295                 return
296
297
298 SIMPLE_OPTION_TYPES = ('kconfig', 'cmdline', 'sysctl', 'version')
299
300
301 def populate_simple_opt_with_data(opt, data, data_type: str):
302     assert(opt.opt_type != 'complex'), \
303            f'unexpected ComplexOptCheck "{opt.name}"'
304     assert(opt.opt_type in SIMPLE_OPTION_TYPES), \
305            f'invalid opt_type "{opt.opt_type}"'
306     assert(data_type in SIMPLE_OPTION_TYPES), \
307            f'invalid data_type "{data_type}"'
308     assert(data), \
309            'empty data'
310
311     if data_type != opt.opt_type:
312         return
313
314     if data_type in ('kconfig', 'cmdline', 'sysctl'):
315         opt.set_state(data.get(opt.name, None))
316     else:
317         assert(data_type == 'version'), \
318                f'unexpected data_type "{data_type}"'
319         opt.set_state(data)
320
321
322 def populate_opt_with_data(opt, data, data_type):
323     assert(opt.opt_type != 'version'), 'a single VersionCheck is useless'
324     if opt.opt_type != 'complex':
325         populate_simple_opt_with_data(opt, data, data_type)
326     else:
327         for o in opt.opts:
328             if o.opt_type != 'complex':
329                 populate_simple_opt_with_data(o, data, data_type)
330             else:
331                 # Recursion for nested ComplexOptCheck objects
332                 populate_opt_with_data(o, data, data_type)
333
334
335 def populate_with_data(checklist, data, data_type):
336     for opt in checklist:
337         populate_opt_with_data(opt, data, data_type)
338
339
340 def override_expected_value(checklist, name, new_val):
341     for opt in checklist:
342         if opt.name == name:
343             assert(opt.opt_type in ('kconfig', 'cmdline', 'sysctl')), \
344                    f'overriding an expected value for "{opt.opt_type}" checks is not supported yet'
345             opt.expected = new_val
346
347
348 def perform_checks(checklist):
349     for opt in checklist:
350         opt.check()