blob: 957227b85197418499301956f8e80d3c33a52635 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2021 The ChromiumOS 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()