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