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