c466b3618ab26b47f1dbd0ebc52abdceee56d93f
[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, 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_unknown_options(checklist: List, parsed_options: OrderedDict[str, str], 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: StrOrNone, 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: StrOrNone, parsed_options: OrderedDict[str, str], 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                 assert(value), f'unexpected empty value for {option}'
179                 parsed_options[option] = value
180
181
182 def parse_cmdline_file(mode: StrOrNone, parsed_options: OrderedDict[str, str], fname: str) -> None:
183     with open(fname, 'r', encoding='utf-8') as f:
184         line = f.readline()
185         opts = line.split()
186
187         line = f.readline()
188         if line:
189             sys.exit(f'[!] ERROR: more than one line in "{fname}"')
190
191         for opt in opts:
192             if '=' in opt:
193                 name, value = opt.split('=', 1)
194             else:
195                 name = opt
196                 value = '' # '' is not None
197             if name in parsed_options and mode != 'json':
198                 print(f'[!] WARNING: cmdline option "{name}" is found multiple times')
199             value = normalize_cmdline_options(name, value)
200             assert(value is not None), f'unexpected None value for {name}'
201             parsed_options[name] = value
202
203
204 def parse_sysctl_file(mode: StrOrNone, parsed_options: OrderedDict[str, str], fname: str) -> None:
205     with open(fname, 'r', encoding='utf-8') as f:
206         sysctl_pattern = re.compile(r"[a-zA-Z0-9/\._-]+ =.*$")
207         for line in f.readlines():
208             line = line.strip()
209             if not sysctl_pattern.match(line):
210                 sys.exit(f'[!] ERROR: unexpected line in sysctl file: "{line}"')
211             option, value = line.split('=', 1)
212             option = option.strip()
213             value = value.strip()
214             # sysctl options may be found multiple times, let's save the last value:
215             parsed_options[option] = value
216
217     # let's check the presence of some ancient sysctl option
218     # to ensure that we are parsing the output of `sudo sysctl -a > file`
219     if 'kernel.printk' not in parsed_options:
220         sys.exit(f'[!] ERROR: {fname} doesn\'t look like a sysctl output file, please try `sudo sysctl -a > {fname}`')
221
222     # let's check the presence of a sysctl option available for root
223     if 'kernel.cad_pid' not in parsed_options and mode != 'json':
224         print(f'[!] WARNING: sysctl option "kernel.cad_pid" available for root is not found in {fname}, please try `sudo sysctl -a > {fname}`')
225
226
227 def main() -> None:
228     # Report modes:
229     #   * verbose mode for
230     #     - reporting about unknown kernel options in the Kconfig
231     #     - verbose printing of ComplexOptCheck items
232     #   * json mode for printing the results in JSON format
233     report_modes = ['verbose', 'json', 'show_ok', 'show_fail']
234     supported_archs = ['X86_64', 'X86_32', 'ARM64', 'ARM']
235     parser = ArgumentParser(prog='kernel-hardening-checker',
236                             description='A tool for checking the security hardening options of the Linux kernel')
237     parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
238     parser.add_argument('-m', '--mode', choices=report_modes,
239                         help='choose the report mode')
240     parser.add_argument('-c', '--config',
241                         help='check the security hardening options in the kernel Kconfig file (also supports *.gz files)')
242     parser.add_argument('-l', '--cmdline',
243                         help='check the security hardening options in the kernel cmdline file (contents of /proc/cmdline)')
244     parser.add_argument('-s', '--sysctl',
245                         help='check the security hardening options in the sysctl output file (`sudo sysctl -a > file`)')
246     parser.add_argument('-v', '--kernel-version',
247                         help='extract the version from the kernel version file (contents of /proc/version)')
248     parser.add_argument('-p', '--print', choices=supported_archs,
249                         help='print the security hardening recommendations for the selected microarchitecture')
250     parser.add_argument('-g', '--generate', choices=supported_archs,
251                         help='generate a Kconfig fragment with the security hardening options for the selected microarchitecture')
252     args = parser.parse_args()
253
254     mode = None
255     if args.mode:
256         mode = args.mode
257         if mode != 'json':
258             print(f'[+] Special report mode: {mode}')
259
260     config_checklist = [] # type: List
261
262     if args.config:
263         if args.print:
264             sys.exit('[!] ERROR: --config and --print can\'t be used together')
265         if args.generate:
266             sys.exit('[!] ERROR: --config and --generate can\'t be used together')
267
268         if mode != 'json':
269             print(f'[+] Kconfig file to check: {args.config}')
270             if args.cmdline:
271                 print(f'[+] Kernel cmdline file to check: {args.cmdline}')
272             if args.sysctl:
273                 print(f'[+] Sysctl output file to check: {args.sysctl}')
274
275         arch, msg = detect_arch(args.config, supported_archs)
276         if arch is None:
277             sys.exit(f'[!] ERROR: {msg}')
278         if mode != 'json':
279             print(f'[+] Detected microarchitecture: {arch}')
280
281         if args.kernel_version:
282             kernel_version, msg = detect_kernel_version(args.kernel_version)
283         else:
284             kernel_version, msg = detect_kernel_version(args.config)
285         if kernel_version is None:
286             if args.kernel_version is None:
287                 print('[!] Hint: provide the kernel version file through --kernel-version option')
288             sys.exit(f'[!] ERROR: {msg}')
289         if mode != 'json':
290             print(f'[+] Detected kernel version: {kernel_version}')
291
292         compiler, msg = detect_compiler(args.config)
293         if mode != 'json':
294             if compiler:
295                 print(f'[+] Detected compiler: {compiler}')
296             else:
297                 print(f'[-] Can\'t detect the compiler: {msg}')
298
299         # add relevant Kconfig checks to the checklist
300         add_kconfig_checks(config_checklist, arch)
301
302         if args.cmdline:
303             # add relevant cmdline checks to the checklist
304             add_cmdline_checks(config_checklist, arch)
305
306         if args.sysctl:
307             # add relevant sysctl checks to the checklist
308             add_sysctl_checks(config_checklist, arch)
309
310         # populate the checklist with the parsed Kconfig data
311         parsed_kconfig_options = OrderedDict() # type: OrderedDict[str, str]
312         parse_kconfig_file(mode, parsed_kconfig_options, args.config)
313         populate_with_data(config_checklist, parsed_kconfig_options, 'kconfig')
314
315         # populate the checklist with the kernel version data
316         populate_with_data(config_checklist, kernel_version, 'version')
317
318         if args.cmdline:
319             # populate the checklist with the parsed cmdline data
320             parsed_cmdline_options = OrderedDict() # type: OrderedDict[str, str]
321             parse_cmdline_file(mode, parsed_cmdline_options, args.cmdline)
322             populate_with_data(config_checklist, parsed_cmdline_options, 'cmdline')
323
324         if args.sysctl:
325             # populate the checklist with the parsed sysctl data
326             parsed_sysctl_options = OrderedDict() # type: OrderedDict[str, str]
327             parse_sysctl_file(mode, parsed_sysctl_options, args.sysctl)
328             populate_with_data(config_checklist, parsed_sysctl_options, 'sysctl')
329
330         # hackish refinement of the CONFIG_ARCH_MMAP_RND_BITS check
331         mmap_rnd_bits_max = parsed_kconfig_options.get('CONFIG_ARCH_MMAP_RND_BITS_MAX', None)
332         if mmap_rnd_bits_max:
333             override_expected_value(config_checklist, 'CONFIG_ARCH_MMAP_RND_BITS', mmap_rnd_bits_max)
334         else:
335             # remove the CONFIG_ARCH_MMAP_RND_BITS check to avoid false results
336             if mode != 'json':
337                 print('[-] Can\'t check CONFIG_ARCH_MMAP_RND_BITS without CONFIG_ARCH_MMAP_RND_BITS_MAX')
338             config_checklist[:] = [o for o in config_checklist if o.name != 'CONFIG_ARCH_MMAP_RND_BITS']
339
340         # now everything is ready, perform the checks
341         perform_checks(config_checklist)
342
343         if mode == 'verbose':
344             # print the parsed options without the checks (for debugging)
345             print_unknown_options(config_checklist, parsed_kconfig_options, 'kconfig')
346             if args.cmdline:
347                 print_unknown_options(config_checklist, parsed_cmdline_options, 'cmdline')
348             if args.sysctl:
349                 print_unknown_options(config_checklist, parsed_sysctl_options, 'sysctl')
350
351         # finally print the results
352         print_checklist(mode, config_checklist, True)
353         sys.exit(0)
354     elif args.cmdline:
355         sys.exit('[!] ERROR: checking cmdline depends on checking Kconfig')
356     elif args.sysctl:
357         # separate sysctl checking (without kconfig)
358         assert(args.config is None and args.cmdline is None), 'unexpected args'
359         if args.print:
360             sys.exit('[!] ERROR: --sysctl and --print can\'t be used together')
361         if args.generate:
362             sys.exit('[!] ERROR: --sysctl and --generate can\'t be used together')
363
364         if mode != 'json':
365             print(f'[+] Sysctl output file to check: {args.sysctl}')
366
367         # add relevant sysctl checks to the checklist
368         add_sysctl_checks(config_checklist, None)
369
370         # populate the checklist with the parsed sysctl data
371         parsed_sysctl_options = OrderedDict()
372         parse_sysctl_file(mode, parsed_sysctl_options, args.sysctl)
373         populate_with_data(config_checklist, parsed_sysctl_options, 'sysctl')
374
375         # now everything is ready, perform the checks
376         perform_checks(config_checklist)
377
378         if mode == 'verbose':
379             # print the parsed options without the checks (for debugging)
380             print_unknown_options(config_checklist, parsed_sysctl_options, 'sysctl')
381
382         # finally print the results
383         print_checklist(mode, config_checklist, True)
384         sys.exit(0)
385
386     if args.print:
387         assert(args.config is None and args.cmdline is None and args.sysctl is None), 'unexpected args'
388         if args.generate:
389             sys.exit('[!] ERROR: --print and --generate can\'t be used together')
390         if mode and mode not in ('verbose', 'json'):
391             sys.exit(f'[!] ERROR: wrong mode "{mode}" for --print')
392         arch = args.print
393         assert arch, 'unexpected empty arch from ArgumentParser'
394         add_kconfig_checks(config_checklist, arch)
395         add_cmdline_checks(config_checklist, arch)
396         add_sysctl_checks(config_checklist, arch)
397         if mode != 'json':
398             print(f'[+] Printing kernel security hardening options for {arch}...')
399         print_checklist(mode, config_checklist, False)
400         sys.exit(0)
401
402     if args.generate:
403         assert(args.config is None and args.cmdline is None and args.sysctl is None and args.print is None), 'unexpected args'
404         if mode:
405             sys.exit(f'[!] ERROR: wrong mode "{mode}" for --generate')
406         arch = args.generate
407         assert arch, 'unexpected empty arch from ArgumentParser'
408         add_kconfig_checks(config_checklist, arch)
409         print(f'CONFIG_{arch}=y') # the Kconfig fragment should describe the microarchitecture
410         for opt in config_checklist:
411             if opt.name == 'CONFIG_ARCH_MMAP_RND_BITS':
412                 continue # don't add CONFIG_ARCH_MMAP_RND_BITS because its value needs refinement
413             if opt.expected == 'is not off':
414                 continue # don't add Kconfig options without explicitly recommended values
415             if opt.expected == 'is not set':
416                 print(f'# {opt.name} is not set')
417             else:
418                 print(f'{opt.name}={opt.expected}')
419         sys.exit(0)
420
421     parser.print_help()
422     sys.exit(0)