1#!/usr/bin/env python3 2# Copyright 2020 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""A linter for the Minijail seccomp policy file.""" 7 8import argparse 9import re 10import sys 11from typing import List, NamedTuple, Optional, Set 12 13 14# The syscalls we have determined are more dangerous and need justification 15# for inclusion in a policy. 16DANGEROUS_SYSCALLS = ( 17 "clone", 18 "mount", 19 "setns", 20 "kill", 21 "execve", 22 "execveat", 23 "getrandom", 24 "bpf", 25 "socket", 26 "ptrace", 27 "swapon", 28 "swapoff", 29 # TODO(b/193169195): Add argument granularity for the below syscalls. 30 "prctl", 31 "ioctl", 32 "mmap", 33 "mmap2", 34 "mprotect", 35) 36 37 38# If a dangerous syscall uses these rules, then it's considered safe. 39SYSCALL_SAFE_RULES = { 40 "getrandom": ("arg2 in ~GRND_RANDOM",), 41 "mmap": ( 42 "arg2 == PROT_READ || arg2 == PROT_NONE", 43 "arg2 in ~PROT_EXEC", 44 "arg2 in ~PROT_EXEC || arg2 in ~PROT_WRITE", 45 ), 46 "mmap2": ( 47 "arg2 == PROT_READ || arg2 == PROT_NONE", 48 "arg2 in ~PROT_EXEC", 49 "arg2 in ~PROT_EXEC || arg2 in ~PROT_WRITE", 50 ), 51 "mprotect": ( 52 "arg2 == PROT_READ || arg2 == PROT_NONE", 53 "arg2 in ~PROT_EXEC", 54 "arg2 in ~PROT_EXEC || arg2 in ~PROT_WRITE", 55 ), 56} 57 58GLOBAL_SAFE_RULES = ( 59 "kill", 60 "kill-process", 61 "kill-thread", 62 "return 1", 63) 64 65 66class CheckPolicyReturn(NamedTuple): 67 """Represents a return value from check_seccomp_policy 68 69 Contains a message to print to the user and a list of errors that were 70 found in the file. 71 """ 72 73 message: str 74 errors: List[str] 75 76 77def parse_args(argv): 78 """Return the parsed CLI arguments for this tool.""" 79 parser = argparse.ArgumentParser(description=__doc__) 80 parser.add_argument( 81 "--denylist", 82 action="store_true", 83 help="Check as a denylist policy rather than the default allowlist.", 84 ) 85 parser.add_argument( 86 "--dangerous-syscalls", 87 action="store", 88 default=",".join(DANGEROUS_SYSCALLS), 89 help="Comma-separated list of dangerous sycalls (overrides default).", 90 ) 91 parser.add_argument( 92 "--assume-filename", 93 help="The filename when parsing stdin.", 94 ) 95 parser.add_argument( 96 "policy", 97 help="The seccomp policy.", 98 type=argparse.FileType("r", encoding="utf-8"), 99 ) 100 return parser.parse_args(argv), parser 101 102 103def check_seccomp_policy( 104 check_file, dangerous_syscalls: Set[str], filename: Optional[str] = None 105): 106 """Fail if the seccomp policy file has dangerous, undocumented syscalls. 107 108 Takes in a file object and a set of dangerous syscalls as arguments. 109 """ 110 111 if filename is None: 112 filename = check_file.name 113 found_syscalls = set() 114 errors = [] 115 msg = "" 116 contains_dangerous_syscall = False 117 prev_line_comment = False 118 119 for line_num, line in enumerate(check_file): 120 if re.match(r"^\s*#", line): 121 prev_line_comment = True 122 elif re.match(r"^\s*$", line): 123 # Empty lines shouldn't reset prev_line_comment. 124 continue 125 else: 126 match = re.match(r"^\s*(\w*)\s*:\s*(.*)\s*", line) 127 if match: 128 syscall = match.group(1) 129 rule = match.group(2) 130 err_prefix = f"{filename}:{line_num}:{syscall}:" 131 if syscall in found_syscalls: 132 errors.append(f"{err_prefix} duplicate entry found") 133 else: 134 found_syscalls.add(syscall) 135 if syscall in dangerous_syscalls: 136 contains_dangerous_syscall = True 137 if not prev_line_comment: 138 # Dangerous syscalls must be commented. 139 safe_rules = SYSCALL_SAFE_RULES.get(syscall, ()) 140 if rule in GLOBAL_SAFE_RULES or rule in safe_rules: 141 pass 142 elif safe_rules: 143 # Dangerous syscalls with known safe rules must 144 # use those rules. 145 errors.append( 146 f"{err_prefix} syscall is dangerous and " 147 f"should use one of the rules: {safe_rules}" 148 ) 149 else: 150 errors.append( 151 f"{err_prefix} syscall is dangerous and " 152 "requires a comment on the preceding line" 153 ) 154 prev_line_comment = False 155 else: 156 # This line is probably a continuation from the previous line. 157 # TODO(b/203216289): Support line breaks. 158 pass 159 160 if contains_dangerous_syscall: 161 msg = ( 162 f"seccomp: {filename} contains dangerous syscalls, so" 163 " requires review from chromeos-security@" 164 ) 165 else: 166 msg = ( 167 f"seccomp: {filename} does not contain any dangerous" 168 " syscalls, so does not require review from" 169 " chromeos-security@" 170 ) 171 172 if errors: 173 return CheckPolicyReturn(msg, errors) 174 175 return CheckPolicyReturn(msg, errors) 176 177 178def main(argv=None): 179 """Main entrypoint.""" 180 181 if argv is None: 182 argv = sys.argv[1:] 183 184 opts, _arg_parser = parse_args(argv) 185 186 filename = opts.assume_filename if opts.assume_filename else opts.policy 187 check = check_seccomp_policy( 188 opts.policy, set(opts.dangerous_syscalls.split(",")), filename=filename 189 ) 190 191 formatted_items = "" 192 if check.errors: 193 item_prefix = "\n * " 194 formatted_items = item_prefix + item_prefix.join(check.errors) 195 196 print("* " + check.message + formatted_items) 197 198 return 1 if check.errors else 0 199 200 201if __name__ == "__main__": 202 sys.exit(main(sys.argv[1:])) 203