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