| #!/usr/bin/env python3 |
| # Copyright 2020 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """A linter for the Minijail seccomp policy file.""" |
| |
| import argparse |
| import re |
| import sys |
| from typing import List, NamedTuple, Optional, Set |
| |
| |
| # The syscalls we have determined are more dangerous and need justification |
| # for inclusion in a policy. |
| DANGEROUS_SYSCALLS = ( |
| "clone", |
| "mount", |
| "setns", |
| "kill", |
| "execve", |
| "execveat", |
| "getrandom", |
| "bpf", |
| "socket", |
| "ptrace", |
| "swapon", |
| "swapoff", |
| # TODO(b/193169195): Add argument granularity for the below syscalls. |
| "prctl", |
| "ioctl", |
| "mmap", |
| "mmap2", |
| "mprotect", |
| ) |
| |
| |
| # If a dangerous syscall uses these rules, then it's considered safe. |
| SYSCALL_SAFE_RULES = { |
| "getrandom": ("arg2 in ~GRND_RANDOM",), |
| "mmap": ( |
| "arg2 == PROT_READ || arg2 == PROT_NONE", |
| "arg2 in ~PROT_EXEC", |
| "arg2 in ~PROT_EXEC || arg2 in ~PROT_WRITE", |
| ), |
| "mmap2": ( |
| "arg2 == PROT_READ || arg2 == PROT_NONE", |
| "arg2 in ~PROT_EXEC", |
| "arg2 in ~PROT_EXEC || arg2 in ~PROT_WRITE", |
| ), |
| "mprotect": ( |
| "arg2 == PROT_READ || arg2 == PROT_NONE", |
| "arg2 in ~PROT_EXEC", |
| "arg2 in ~PROT_EXEC || arg2 in ~PROT_WRITE", |
| ), |
| } |
| |
| GLOBAL_SAFE_RULES = ( |
| "kill", |
| "kill-process", |
| "kill-thread", |
| "return 1", |
| ) |
| |
| |
| class CheckPolicyReturn(NamedTuple): |
| """Represents a return value from check_seccomp_policy |
| |
| Contains a message to print to the user and a list of errors that were |
| found in the file. |
| """ |
| |
| message: str |
| errors: List[str] |
| |
| |
| def parse_args(argv): |
| """Return the parsed CLI arguments for this tool.""" |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| "--denylist", |
| action="store_true", |
| help="Check as a denylist policy rather than the default allowlist.", |
| ) |
| parser.add_argument( |
| "--dangerous-syscalls", |
| action="store", |
| default=",".join(DANGEROUS_SYSCALLS), |
| help="Comma-separated list of dangerous sycalls (overrides default).", |
| ) |
| parser.add_argument( |
| "--assume-filename", |
| help="The filename when parsing stdin.", |
| ) |
| parser.add_argument( |
| "policy", |
| help="The seccomp policy.", |
| type=argparse.FileType("r", encoding="utf-8"), |
| ) |
| return parser.parse_args(argv), parser |
| |
| |
| def check_seccomp_policy( |
| check_file, dangerous_syscalls: Set[str], filename: Optional[str] = None |
| ): |
| """Fail if the seccomp policy file has dangerous, undocumented syscalls. |
| |
| Takes in a file object and a set of dangerous syscalls as arguments. |
| """ |
| |
| if filename is None: |
| filename = check_file.name |
| found_syscalls = set() |
| errors = [] |
| msg = "" |
| contains_dangerous_syscall = False |
| prev_line_comment = False |
| |
| for line_num, line in enumerate(check_file): |
| if re.match(r"^\s*#", line): |
| prev_line_comment = True |
| elif re.match(r"^\s*$", line): |
| # Empty lines shouldn't reset prev_line_comment. |
| continue |
| else: |
| match = re.match(r"^\s*(\w*)\s*:\s*(.*)\s*", line) |
| if match: |
| syscall = match.group(1) |
| rule = match.group(2) |
| err_prefix = f"{filename}:{line_num}:{syscall}:" |
| if syscall in found_syscalls: |
| errors.append(f"{err_prefix} duplicate entry found") |
| else: |
| found_syscalls.add(syscall) |
| if syscall in dangerous_syscalls: |
| contains_dangerous_syscall = True |
| if not prev_line_comment: |
| # Dangerous syscalls must be commented. |
| safe_rules = SYSCALL_SAFE_RULES.get(syscall, ()) |
| if rule in GLOBAL_SAFE_RULES or rule in safe_rules: |
| pass |
| elif safe_rules: |
| # Dangerous syscalls with known safe rules must |
| # use those rules. |
| errors.append( |
| f"{err_prefix} syscall is dangerous and " |
| f"should use one of the rules: {safe_rules}" |
| ) |
| else: |
| errors.append( |
| f"{err_prefix} syscall is dangerous and " |
| "requires a comment on the preceding line" |
| ) |
| prev_line_comment = False |
| else: |
| # This line is probably a continuation from the previous line. |
| # TODO(b/203216289): Support line breaks. |
| pass |
| |
| if contains_dangerous_syscall: |
| msg = ( |
| f"seccomp: {filename} contains dangerous syscalls, so" |
| " requires review from chromeos-security@" |
| ) |
| else: |
| msg = ( |
| f"seccomp: {filename} does not contain any dangerous" |
| " syscalls, so does not require review from" |
| " chromeos-security@" |
| ) |
| |
| if errors: |
| return CheckPolicyReturn(msg, errors) |
| |
| return CheckPolicyReturn(msg, errors) |
| |
| |
| def main(argv=None): |
| """Main entrypoint.""" |
| |
| if argv is None: |
| argv = sys.argv[1:] |
| |
| opts, _arg_parser = parse_args(argv) |
| |
| filename = opts.assume_filename if opts.assume_filename else opts.policy |
| check = check_seccomp_policy( |
| opts.policy, set(opts.dangerous_syscalls.split(",")), filename=filename |
| ) |
| |
| formatted_items = "" |
| if check.errors: |
| item_prefix = "\n * " |
| formatted_items = item_prefix + item_prefix.join(check.errors) |
| |
| print("* " + check.message + formatted_items) |
| |
| return 1 if check.errors else 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main(sys.argv[1:])) |