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