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