Fix mypy typing warnings in __init__.py
[kconfig-hardened-check.git] / kernel_hardening_checker / __init__.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 performs input/output.
9 """
10
11 # pylint: disable=missing-function-docstring,line-too-long,invalid-name,too-many-branches,too-many-statements
12
13 import gzip
14 import sys
15 from argparse import ArgumentParser
16 from collections import OrderedDict
17 from typing import List, Tuple, OrderedDict, TextIO
18 import re
19 import json
20 from .__about__ import __version__
21 from .checks import add_kconfig_checks, add_cmdline_checks, normalize_cmdline_options, add_sysctl_checks
22 from .engine import populate_with_data, perform_checks, override_expected_value
23
24
25 def _open(file: str, *args, **kwargs) -> TextIO:
26     if file.endswith('.gz'):
27         return gzip.open(file, *args, **kwargs)
28     return open(file, *args, **kwargs)
29
30
31 def detect_arch(fname: str, archs: List[str]) -> Tuple[str | None, str]:
32     with _open(fname, 'rt', encoding='utf-8') as f:
33         arch_pattern = re.compile(r"CONFIG_[a-zA-Z0-9_]+=y$")
34         arch = None
35         for line in f.readlines():
36             if arch_pattern.match(line):
37                 option, _ = line[7:].split('=', 1)
38                 if option in archs:
39                     if arch is None:
40                         arch = option
41                     else:
42                         return None, 'detected more than one microarchitecture'
43         if arch is None:
44             return None, 'failed to detect microarchitecture'
45         return arch, 'OK'
46
47
48 def detect_kernel_version(fname: str) -> Tuple[Tuple | None, str]:
49     with _open(fname, 'rt', encoding='utf-8') as f:
50         ver_pattern = re.compile(r"^# Linux/.+ Kernel Configuration$|^Linux version .+")
51         for line in f.readlines():
52             if ver_pattern.match(line):
53                 line = line.strip()
54                 parts = line.split()
55                 ver_str = parts[2].split('-', 1)[0]
56                 ver_numbers = ver_str.split('.')
57                 if len(ver_numbers) >= 3:
58                     if all(map(lambda x: x.isdigit(), ver_numbers)):
59                         return tuple(map(int, ver_numbers)), 'OK'
60                 msg = f'failed to parse the version "{parts[2]}"'
61                 return None, msg
62         return None, 'no kernel version detected'
63
64
65 def detect_compiler(fname: str) -> Tuple[str | None, str]:
66     gcc_version = None
67     clang_version = None
68     with _open(fname, 'rt', encoding='utf-8') as f:
69         for line in f.readlines():
70             if line.startswith('CONFIG_GCC_VERSION='):
71                 gcc_version = line[19:-1]
72             if line.startswith('CONFIG_CLANG_VERSION='):
73                 clang_version = line[21:-1]
74     if gcc_version is None or clang_version is None:
75         return None, 'no CONFIG_GCC_VERSION or CONFIG_CLANG_VERSION'
76     if gcc_version == '0' and clang_version != '0':
77         return f'CLANG {clang_version}', 'OK'
78     if gcc_version != '0' and clang_version == '0':
79         return f'GCC {gcc_version}', 'OK'
80     sys.exit(f'[!] ERROR: invalid GCC_VERSION and CLANG_VERSION: {gcc_version} {clang_version}')
81
82
83 def print_unknown_options(checklist: List, parsed_options: OrderedDict, opt_type: str) -> None:
84     known_options = []
85
86     for o1 in checklist:
87         if o1.opt_type != 'complex':
88             known_options.append(o1.name)
89             continue
90         for o2 in o1.opts:
91             if o2.opt_type != 'complex':
92                 if hasattr(o2, 'name'):
93                     known_options.append(o2.name)
94                 continue
95             for o3 in o2.opts:
96                 assert(o3.opt_type != 'complex'), \
97                        f'unexpected ComplexOptCheck inside {o2.name}'
98                 if hasattr(o3, 'name'):
99                     known_options.append(o3.name)
100
101     for option, value in parsed_options.items():
102         if option not in known_options:
103             print(f'[?] No check for {opt_type} option {option} ({value})')
104
105
106 def print_checklist(mode: str | None, checklist: List, with_results: bool) -> None:
107     if mode == 'json':
108         output = []
109         for opt in checklist:
110             output.append(opt.json_dump(with_results))
111         print(json.dumps(output))
112         return
113
114     # table header
115     sep_line_len = 91
116     if with_results:
117         sep_line_len += 30
118     print('=' * sep_line_len)
119     print(f'{"option_name":^40}|{"type":^7}|{"desired_val":^12}|{"decision":^10}|{"reason":^18}', end='')
120     if with_results:
121         print('| check_result', end='')
122     print()
123     print('=' * sep_line_len)
124
125     # table contents
126     for opt in checklist:
127         if with_results:
128             if mode == 'show_ok':
129                 if not opt.result.startswith('OK'):
130                     continue
131             if mode == 'show_fail':
132                 if not opt.result.startswith('FAIL'):
133                     continue
134         opt.table_print(mode, with_results)
135         print()
136         if mode == 'verbose':
137             print('-' * sep_line_len)
138     print()
139
140     # final score
141     if with_results:
142         fail_count = len(list(filter(lambda opt: opt.result.startswith('FAIL'), checklist)))
143         fail_suppressed = ''
144         ok_count = len(list(filter(lambda opt: opt.result.startswith('OK'), checklist)))
145         ok_suppressed = ''
146         if mode == 'show_ok':
147             fail_suppressed = ' (suppressed in output)'
148         if mode == 'show_fail':
149             ok_suppressed = ' (suppressed in output)'
150         print(f'[+] Config check is finished: \'OK\' - {ok_count}{ok_suppressed} / \'FAIL\' - {fail_count}{fail_suppressed}')
151
152
153 def parse_kconfig_file(_mode: str | None, parsed_options: OrderedDict, fname: str) -> None:
154     with _open(fname, 'rt', encoding='utf-8') as f:
155         opt_is_on = re.compile(r"CONFIG_[a-zA-Z0-9_]+=.+$")
156         opt_is_off = re.compile(r"# CONFIG_[a-zA-Z0-9_]+ is not set$")
157
158         for line in f.readlines():
159             line = line.strip()
160             option = None
161             value = None
162
163             if opt_is_on.match(line):
164                 option, value = line.split('=', 1)
165                 if value == 'is not set':
166                     sys.exit(f'[!] ERROR: bad enabled Kconfig option "{line}"')
167             elif opt_is_off.match(line):
168                 option, value = line[2:].split(' ', 1)
169                 assert(value == 'is not set'), \
170                        f'unexpected value of disabled Kconfig option "{line}"'
171             elif line != '' and not line.startswith('#'):
172                 sys.exit(f'[!] ERROR: unexpected line in Kconfig file: "{line}"')
173
174             if option in parsed_options:
175                 sys.exit(f'[!] ERROR: Kconfig option "{line}" is found multiple times')
176
177             if option:
178                 parsed_options[option] = value
179
180
181 def parse_cmdline_file(mode: str | None, parsed_options: OrderedDict, fname: str) -> None:
182     with open(fname, 'r', encoding='utf-8') as f:
183         line = f.readline()
184         opts = line.split()
185
186         line = f.readline()
187         if line:
188             sys.exit(f'[!] ERROR: more than one line in "{fname}"')
189
190         for opt in opts:
191             if '=' in opt:
192                 name, value = opt.split('=', 1)
193             else:
194                 name = opt
195                 value = '' # '' is not None
196             if name in parsed_options and mode != 'json':
197                 print(f'[!] WARNING: cmdline option "{name}" is found multiple times')
198             value = normalize_cmdline_options(name, value)
199             parsed_options[name] = value
200
201
202 def parse_sysctl_file(mode: str | None, parsed_options: OrderedDict, fname: str) -> None:
203     with open(fname, 'r', encoding='utf-8') as f:
204         sysctl_pattern = re.compile(r"[a-zA-Z0-9/\._-]+ =.*$")
205         for line in f.readlines():
206             line = line.strip()
207             if not sysctl_pattern.match(line):
208                 sys.exit(f'[!] ERROR: unexpected line in sysctl file: "{line}"')
209             option, value = line.split('=', 1)
210             option = option.strip()
211             value = value.strip()
212             # sysctl options may be found multiple times, let's save the last value:
213             parsed_options[option] = value
214
215     # let's check the presence of some ancient sysctl option
216     # to ensure that we are parsing the output of `sudo sysctl -a > file`
217     if 'kernel.printk' not in parsed_options:
218         sys.exit(f'[!] ERROR: {fname} doesn\'t look like a sysctl output file, please try `sudo sysctl -a > {fname}`')
219
220     # let's check the presence of a sysctl option available for root
221     if 'kernel.cad_pid' not in parsed_options and mode != 'json':
222         print(f'[!] WARNING: sysctl option "kernel.cad_pid" available for root is not found in {fname}, please try `sudo sysctl -a > {fname}`')
223
224
225 def main() -> None:
226     # Report modes:
227     #   * verbose mode for
228     #     - reporting about unknown kernel options in the Kconfig
229     #     - verbose printing of ComplexOptCheck items
230     #   * json mode for printing the results in JSON format
231     report_modes = ['verbose', 'json', 'show_ok', 'show_fail']
232     supported_archs = ['X86_64', 'X86_32', 'ARM64', 'ARM']
233     parser = ArgumentParser(prog='kernel-hardening-checker',
234                             description='A tool for checking the security hardening options of the Linux kernel')
235     parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
236     parser.add_argument('-m', '--mode', choices=report_modes,
237                         help='choose the report mode')
238     parser.add_argument('-c', '--config',
239                         help='check the security hardening options in the kernel Kconfig file (also supports *.gz files)')
240     parser.add_argument('-l', '--cmdline',
241                         help='check the security hardening options in the kernel cmdline file (contents of /proc/cmdline)')
242     parser.add_argument('-s', '--sysctl',
243                         help='check the security hardening options in the sysctl output file (`sudo sysctl -a > file`)')
244     parser.add_argument('-v', '--kernel-version',
245                         help='extract the version from the kernel version file (contents of /proc/version)')
246     parser.add_argument('-p', '--print', choices=supported_archs,
247                         help='print the security hardening recommendations for the selected microarchitecture')
248     parser.add_argument('-g', '--generate', choices=supported_archs,
249                         help='generate a Kconfig fragment with the security hardening options for the selected microarchitecture')
250     args = parser.parse_args()
251
252     mode = None
253     if args.mode:
254         mode = args.mode
255         if mode != 'json':
256             print(f'[+] Special report mode: {mode}')
257
258     config_checklist = [] # type: List
259
260     if args.config:
261         if args.print:
262             sys.exit('[!] ERROR: --config and --print can\'t be used together')
263         if args.generate:
264             sys.exit('[!] ERROR: --config and --generate can\'t be used together')
265
266         if mode != 'json':
267             print(f'[+] Kconfig file to check: {args.config}')
268             if args.cmdline:
269                 print(f'[+] Kernel cmdline file to check: {args.cmdline}')
270             if args.sysctl:
271                 print(f'[+] Sysctl output file to check: {args.sysctl}')
272
273         arch, msg = detect_arch(args.config, supported_archs)
274         if arch is None:
275             sys.exit(f'[!] ERROR: {msg}')
276         if mode != 'json':
277             print(f'[+] Detected microarchitecture: {arch}')
278
279         if args.kernel_version:
280             kernel_version, msg = detect_kernel_version(args.kernel_version)
281         else:
282             kernel_version, msg = detect_kernel_version(args.config)
283         if kernel_version is None:
284             if args.kernel_version is None:
285                 print('[!] Hint: provide the kernel version file through --kernel-version option')
286             sys.exit(f'[!] ERROR: {msg}')
287         if mode != 'json':
288             print(f'[+] Detected kernel version: {kernel_version}')
289
290         compiler, msg = detect_compiler(args.config)
291         if mode != 'json':
292             if compiler:
293                 print(f'[+] Detected compiler: {compiler}')
294             else:
295                 print(f'[-] Can\'t detect the compiler: {msg}')
296
297         # add relevant Kconfig checks to the checklist
298         add_kconfig_checks(config_checklist, arch)
299
300         if args.cmdline:
301             # add relevant cmdline checks to the checklist
302             add_cmdline_checks(config_checklist, arch)
303
304         if args.sysctl:
305             # add relevant sysctl checks to the checklist
306             add_sysctl_checks(config_checklist, arch)
307
308         # populate the checklist with the parsed Kconfig data
309         parsed_kconfig_options = OrderedDict() # type: OrderedDict
310         parse_kconfig_file(mode, parsed_kconfig_options, args.config)
311         populate_with_data(config_checklist, parsed_kconfig_options, 'kconfig')
312
313         # populate the checklist with the kernel version data
314         populate_with_data(config_checklist, kernel_version, 'version')
315
316         if args.cmdline:
317             # populate the checklist with the parsed cmdline data
318             parsed_cmdline_options = OrderedDict() # type: OrderedDict
319             parse_cmdline_file(mode, parsed_cmdline_options, args.cmdline)
320             populate_with_data(config_checklist, parsed_cmdline_options, 'cmdline')
321
322         if args.sysctl:
323             # populate the checklist with the parsed sysctl data
324             parsed_sysctl_options = OrderedDict() # type: OrderedDict
325             parse_sysctl_file(mode, parsed_sysctl_options, args.sysctl)
326             populate_with_data(config_checklist, parsed_sysctl_options, 'sysctl')
327
328         # hackish refinement of the CONFIG_ARCH_MMAP_RND_BITS check
329         mmap_rnd_bits_max = parsed_kconfig_options.get('CONFIG_ARCH_MMAP_RND_BITS_MAX', None)
330         if mmap_rnd_bits_max:
331             override_expected_value(config_checklist, 'CONFIG_ARCH_MMAP_RND_BITS', mmap_rnd_bits_max)
332         else:
333             # remove the CONFIG_ARCH_MMAP_RND_BITS check to avoid false results
334             if mode != 'json':
335                 print('[-] Can\'t check CONFIG_ARCH_MMAP_RND_BITS without CONFIG_ARCH_MMAP_RND_BITS_MAX')
336             config_checklist[:] = [o for o in config_checklist if o.name != 'CONFIG_ARCH_MMAP_RND_BITS']
337
338         # now everything is ready, perform the checks
339         perform_checks(config_checklist)
340
341         if mode == 'verbose':
342             # print the parsed options without the checks (for debugging)
343             print_unknown_options(config_checklist, parsed_kconfig_options, 'kconfig')
344             if args.cmdline:
345                 print_unknown_options(config_checklist, parsed_cmdline_options, 'cmdline')
346             if args.sysctl:
347                 print_unknown_options(config_checklist, parsed_sysctl_options, 'sysctl')
348
349         # finally print the results
350         print_checklist(mode, config_checklist, True)
351         sys.exit(0)
352     elif args.cmdline:
353         sys.exit('[!] ERROR: checking cmdline depends on checking Kconfig')
354     elif args.sysctl:
355         # separate sysctl checking (without kconfig)
356         assert(args.config is None and args.cmdline is None), 'unexpected args'
357         if args.print:
358             sys.exit('[!] ERROR: --sysctl and --print can\'t be used together')
359         if args.generate:
360             sys.exit('[!] ERROR: --sysctl and --generate can\'t be used together')
361
362         if mode != 'json':
363             print(f'[+] Sysctl output file to check: {args.sysctl}')
364
365         # add relevant sysctl checks to the checklist
366         add_sysctl_checks(config_checklist, None)
367
368         # populate the checklist with the parsed sysctl data
369         parsed_sysctl_options = OrderedDict()
370         parse_sysctl_file(mode, parsed_sysctl_options, args.sysctl)
371         populate_with_data(config_checklist, parsed_sysctl_options, 'sysctl')
372
373         # now everything is ready, perform the checks
374         perform_checks(config_checklist)
375
376         if mode == 'verbose':
377             # print the parsed options without the checks (for debugging)
378             print_unknown_options(config_checklist, parsed_sysctl_options, 'sysctl')
379
380         # finally print the results
381         print_checklist(mode, config_checklist, True)
382         sys.exit(0)
383
384     if args.print:
385         assert(args.config is None and args.cmdline is None and args.sysctl is None), 'unexpected args'
386         if args.generate:
387             sys.exit('[!] ERROR: --print and --generate can\'t be used together')
388         if mode and mode not in ('verbose', 'json'):
389             sys.exit(f'[!] ERROR: wrong mode "{mode}" for --print')
390         arch = args.print
391         assert arch, 'unexpected empty arch from ArgumentParser'
392         add_kconfig_checks(config_checklist, arch)
393         add_cmdline_checks(config_checklist, arch)
394         add_sysctl_checks(config_checklist, arch)
395         if mode != 'json':
396             print(f'[+] Printing kernel security hardening options for {arch}...')
397         print_checklist(mode, config_checklist, False)
398         sys.exit(0)
399
400     if args.generate:
401         assert(args.config is None and args.cmdline is None and args.sysctl is None and args.print is None), 'unexpected args'
402         if mode:
403             sys.exit(f'[!] ERROR: wrong mode "{mode}" for --generate')
404         arch = args.generate
405         assert arch, 'unexpected empty arch from ArgumentParser'
406         add_kconfig_checks(config_checklist, arch)
407         print(f'CONFIG_{arch}=y') # the Kconfig fragment should describe the microarchitecture
408         for opt in config_checklist:
409             if opt.name == 'CONFIG_ARCH_MMAP_RND_BITS':
410                 continue # don't add CONFIG_ARCH_MMAP_RND_BITS because its value needs refinement
411             if opt.expected == 'is not off':
412                 continue # don't add Kconfig options without explicitly recommended values
413             if opt.expected == 'is not set':
414                 print(f'# {opt.name} is not set')
415             else:
416                 print(f'{opt.name}={opt.expected}')
417         sys.exit(0)
418
419     parser.print_help()
420     sys.exit(0)