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