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