#!/usr/bin/env python3 # Copyright 2021 The Chromium OS Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Script to make mass, CrOS-wide seccomp changes.""" import argparse import re import subprocess import sys import shutil from typing import Any, Iterable, Optional from dataclasses import dataclass, field # Pre-compiled regexes. AMD64_RE = re.compile(r'.*(amd|x86_)64.*\.policy') X86_RE = re.compile(r'.*x86.*\.policy') AARCH64_RE = re.compile(r'.*a(arch|rm)64.*\.policy') ARM_RE = re.compile(r'.*arm(v7)?.*\.policy') @dataclass(frozen=True) class Policies: """Dataclass to hold lists of policies which match certain types.""" arm: list[str] = field(default_factory=list) x86_64: list[str] = field(default_factory=list) x86: list[str] = field(default_factory=list) arm64: list[str] = field(default_factory=list) none: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, list[str]]: """Convert this class to a dictionary.""" return {**self.__dict__} def main(): """Run the program from cmd line""" args = parse_args() if all(x is None for x in [args.all, args.b64, args.b32, args.none]): print('Require at least one of {--all, --b64, --b32, --none}', file=sys.stderr) sys.exit(1) matches, success = find_potential_policy_files(args.packages) separated = Policies() for m in matches: if AMD64_RE.match(m): separated.x86_64.append(m) continue if X86_RE.match(m): separated.x86.append(m) continue if AARCH64_RE.match(m): separated.arm64.append(m) continue if ARM_RE.match(m): separated.arm.append(m) continue separated.none.append(m) syscall_lookup_table = _make_syscall_lookup_table(args) for (type_, val) in separated.to_dict().items(): for fp in val: syscalls = syscall_lookup_table[type_] missing = check_missing_syscalls(syscalls, fp) if missing is None: print(f'E ({type_}) {fp}') elif len(missing) == 0: print(f'_ ({type_}) {fp}') else: missing_str = ','.join(missing) print(f'M ({type_}) {fp} :: {missing_str}') if not args.edit: sys.exit(0 if success else 2) for (type_, val) in separated.to_dict().items(): for fp in val: syscalls = syscall_lookup_table[type_] if args.force: _confirm_add(fp, syscalls, args.yes) continue missing = check_missing_syscalls(syscalls, fp) if missing is None or len(missing) == 0: print(f'Already good for {fp} ({type_})') else: _confirm_add(fp, missing, args.yes) sys.exit(0 if success else 2) def _make_syscall_lookup_table(args: Any) -> dict[str, list[str]]: """Make lookup table, segmented by all/b32/b64/none policies. Args: args: Direct output from parse_args. Returns: dict of syscalls we want to search for in each policy file, where the key is the policy file arch, and the value is a list of syscalls as strings. """ syscall_lookup_table = Policies().to_dict() if args.all: split_syscalls = [x.strip() for x in args.all.split(',')] for v in syscall_lookup_table.values(): v.extend(split_syscalls) if args.b32: split_syscalls = [x.strip() for x in args.b32.split(',')] syscall_lookup_table['x86'].extend(split_syscalls) syscall_lookup_table['arm'].extend(split_syscalls) if args.b64: split_syscalls = [x.strip() for x in args.b64.split(',')] syscall_lookup_table['x86_64'].extend(split_syscalls) syscall_lookup_table['arm64'].extend(split_syscalls) if args.none: split_syscalls = [x.strip() for x in args.none.split(',')] syscall_lookup_table['none'].extend(split_syscalls) return syscall_lookup_table def _confirm_add(fp: str, syscalls: Iterable[str], noninteractive=None): """Interactive confirmation check you wish to add a syscall. Args: fp: filepath of the file to edit. syscalls: list-like of syscalls to add to append to the files. noninteractive: Just add the syscalls without asking. """ if noninteractive: _update_seccomp(fp, list(syscalls)) return syscalls_str = ','.join(syscalls) user_input = input(f'Add {syscalls_str} for {fp}? [y/N]> ') if user_input.lower().startswith('y'): _update_seccomp(fp, list(syscalls)) print('Edited!') else: print(f'Skipping {fp}') def check_missing_syscalls(syscalls: list[str], fp: str) -> Optional[set[str]]: """Return which specified syscalls are missing in the given file.""" missing_syscalls = set(syscalls) with open(fp) as f: try: lines = f.readlines() for syscall in syscalls: for line in lines: if re.match(syscall + r':\s*1', line): missing_syscalls.remove(syscall) except UnicodeDecodeError: return None return missing_syscalls def _update_seccomp(fp: str, missing_syscalls: list[str]): """Update the seccomp of the file based on the seccomp change type.""" with open(fp, 'a') as f: sorted_syscalls = sorted(missing_syscalls) for to_write in sorted_syscalls: f.write(to_write + ': 1\n') def _search_cmd(query: str, use_fd=True) -> list[str]: if use_fd and shutil.which('fdfind') is not None: return [ 'fdfind', '-t', 'f', '--full-path', f'^.*{query}.*\\.policy$', ] return [ 'find', '.', '-regex', f'^.*{query}.*\\.policy$', '-type', 'f', ] def find_potential_policy_files(packages: list[str]) -> tuple[list[str], bool]: """Find potentially related policy files to the given packages. Returns: (policy_files, successful): A list of policy file paths, and a boolean indicating whether all queries were successful in finding at least one related policy file. """ all_queries_succeeded = True matches = [] for p in packages: # It's quite common that hyphens are translated to underscores # and similarly common that underscores are translated to hyphens. # We make them agnostic here. hyphen_agnostic = re.sub(r'[-_]', '[-_]', p) cmd = subprocess.run( _search_cmd(hyphen_agnostic), stdout=subprocess.PIPE, check=True, ) new_matches = [a for a in cmd.stdout.decode('utf-8').split('\n') if a] if not new_matches: print(f'WARNING: No matches found for {p}', file=sys.stderr) all_queries_succeeded = False else: matches.extend(new_matches) return matches, all_queries_succeeded def parse_args() -> Any: """Handle command line arguments.""" parser = argparse.ArgumentParser( description='Check for missing syscalls in' ' seccomp policy files, or make' ' mass seccomp changes.\n\n' 'The format of this output follows the template:\n' ' status (arch) local/policy/filepath :: syscall,syscall,syscall\n' 'Where the status can be "_" for present, "M" for missing,' ' or "E" for Error\n\n' 'Example:\n' ' mass_seccomp_editor.py --all fstatfs --b32 fstatfs64' ' modemmanager\n\n' 'Exit Codes:\n' " '0' for successfully found specific policy files\n" " '1' for python-related error.\n" " '2' for no matched policy files for a given query.", formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument('packages', nargs='+') parser.add_argument( '--all', type=str, metavar='syscalls', help='comma separated syscalls to check in all policy files') parser.add_argument( '--b64', type=str, metavar='syscalls', help='Comma separated syscalls to check in 64bit architectures') parser.add_argument( '--b32', type=str, metavar='syscalls', help='Comma separated syscalls to check in 32bit architectures') parser.add_argument( '--none', type=str, metavar='syscalls', help='Comma separated syscalls to check in unknown architectures') parser.add_argument('--edit', action='store_true', help='Make changes to the listed files,' ' rather than just printing out what is missing') parser.add_argument('-y', '--yes', action='store_true', help='Say "Y" to all interactive checks') parser.add_argument('--force', action='store_true', help='Edit all files, regardless of missing status.' ' Does nothing without --edit.') return parser.parse_args() if __name__ == '__main__': main()