diff --git a/utils/Makefile b/utils/Makefile index f406e08e3..1e3c36b81 100644 --- a/utils/Makefile +++ b/utils/Makefile @@ -22,7 +22,7 @@ include $(COMMONDIR)/Make.rules PYTOOLS = aa-easyprof aa-genprof aa-logprof aa-cleanprof aa-mergeprof \ aa-autodep aa-audit aa-complain aa-enforce aa-disable \ - aa-notify aa-unconfined + aa-notify aa-unconfined aa-show-usage TOOLS = ${PYTOOLS} aa-decode aa-remove-unknown PYSETUP = python-tools-setup.py PYMODULES = $(wildcard apparmor/*.py apparmor/rule/*.py) diff --git a/utils/aa-show-usage b/utils/aa-show-usage new file mode 100755 index 000000000..56116dfa7 --- /dev/null +++ b/utils/aa-show-usage @@ -0,0 +1,144 @@ +#! /usr/bin/python3 +# ---------------------------------------------------------------------- +# Copyright (C) 2025 Maxime Bélair +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of version 2 of the GNU General Public +# License as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# ---------------------------------------------------------------------- +# + +import argparse +import glob +import re +import json +import sys +import os + +from apparmor import aa +from apparmor.translations import init_translation +from apparmor.regex import expand_braces, resolve_variables + +_ = init_translation() + +MAX_RECURSION = 10 + + +def has_matching_file(pattern): + for pat in expand_braces(pattern): + if any(glob.iglob(pat, recursive=True)): + return True + return False + + +def display_profile_text(used, unused): + if used: + print(_('Used profiles:')) + for (name, attach, path) in used: + print(_(' Profile {} for {} ({})').format(name, attach, path)) + if unused: + print(_('Unused profiles:')) + for (name, attach, path) in unused: + print(_(' Profile {} for {} ({})').format(name, attach, path)) + + +def profiles_to_json(profiles): + return [{'name': profile_name, 'attach': attach, 'path': path} for profile_name, attach, path in profiles] + + +def display_profile_json(used, unused): + profiles = {} + profiles['version'] = 1 # JSON format version - increase if you change the json structure + if used: + profiles['used'] = profiles_to_json(used) + if unused: + profiles['unused'] = profiles_to_json(unused) + + print(json.dumps(profiles, indent=2)) + + +def filter_profile(path, profile_name, attach, prof_filter): + if prof_filter['flags'] and not prof_filter['flags'].match(aa.active_profiles.profiles[profile_name].data['flags'] or ''): + return False + if prof_filter['name'] and not prof_filter['name'].match(profile_name or ''): + return False + if prof_filter['attach'] and not prof_filter['attach'].match(attach or ''): + return False + if prof_filter['path'] and not prof_filter['path'].match(path or ''): + return False + + return True + + +def get_used_profiles(args, prof_filter): + aa.init_aa(confdir=args.configdir or os.getenv('__AA_CONFDIR'), profiledir=args.dir) + aa.read_profiles() + used = [] + unused = [] + + for a, v in aa.active_profiles.attachments.items(): + filename = v['f'] + profile_name = v['p'] + if not filter_profile(filename, profile_name, a, prof_filter): + continue + + var_dict = aa.active_profiles.get_all_merged_variables(filename, aa.include_list_recursive(aa.active_profiles.files[filename], True)) + resolved = resolve_variables(a, var_dict) + found = False + for entry in resolved: + if has_matching_file(entry): + found = True + + if found and args.show_type != 'unused': + used.append((profile_name, a, filename)) + if not found and args.show_type != 'used': + unused.append((profile_name, a, filename)) + + return used, unused + + +def main(): + parser = argparse.ArgumentParser(description=_('Check which profiles are used')) + parser.add_argument('-s', '--show-type', type=str, default='all', choices=['all', 'used', 'unused'], help=_('Type of profiles to show')) + parser.add_argument('-j', '--json', action='store_true', help=_('Output in JSON')) + parser.add_argument('-d', '--dir', type=str, help=_('Path to profiles')) + parser.add_argument('--configdir', type=str, help=argparse.SUPPRESS) + + filter_group = parser.add_argument_group(_('Filtering options'), + description=(_('Filters are used to reduce the output of information to only ' + 'those entries that will match the filter. Filters use Python\'s regular ' + 'expression syntax.'))) + filter_group.add_argument('--filter.flags', dest='filter_flags', metavar='FLAGS', help=_('Filter by flags')) + filter_group.add_argument('--filter.profile_name', dest='filter_name', metavar='PROFILE_NAME', help=_('Filter by profile name')) + filter_group.add_argument('--filter.profile_attach', dest='filter_attach', metavar='PROFILE_ATTACH', help=_('Filter by profile attachment')) + filter_group.add_argument('--filter.profile_path', dest='filter_path', metavar='PROFILE_PATH', help=_('Filter by profile path')) + + # If not a TTY then assume running in test mode and fix output width + if not sys.stdout.isatty(): + parser.formatter_class = lambda prog: argparse.HelpFormatter(prog, width=80) + + args = parser.parse_args() + + prof_filter = { + 'flags': re.compile(args.filter_flags) if args.filter_flags else None, + 'name': re.compile(args.filter_name) if args.filter_name else None, + 'attach': re.compile(args.filter_attach) if args.filter_attach else None, + 'path': re.compile(args.filter_path) if args.filter_path else None, + } + + used, unused = get_used_profiles(args, prof_filter) + + if args.json: + display_profile_json(used, unused) + else: + display_profile_text(used, unused) + + +if __name__ == '__main__': + main() diff --git a/utils/apparmor/regex.py b/utils/apparmor/regex.py index 535918b17..121310d11 100644 --- a/utils/apparmor/regex.py +++ b/utils/apparmor/regex.py @@ -14,6 +14,7 @@ # ---------------------------------------------------------------------- import re +import itertools from apparmor.common import AppArmorBug, AppArmorException from apparmor.translations import init_translation @@ -27,12 +28,15 @@ RE_COMMA_EOL = r'\s*,' + RE_EOL # optional whitespace, comma + RE_EOL RE_PROFILE_NAME = r'(?P<%s>(\S+|"[^"]+"))' # string without spaces, or quoted string. %s is the match group name RE_PATH = r'/\S*|"/[^"]*"' # filename (starting with '/') without spaces, or quoted filename. +RE_VAR = r'@{[^}\s]+}' RE_PROFILE_PATH = '(?P<%s>(' + RE_PATH + '))' # quoted or unquoted filename. %s is the match group name -RE_PROFILE_PATH_OR_VAR = '(?P<%s>(' + RE_PATH + r'|@{\S+}\S*|"@{\S+}[^"]*"))' # quoted or unquoted filename or variable. %s is the match group name +RE_PROFILE_PATH_OR_VAR = '(?P<%s>(' + RE_PATH + '|' + RE_VAR + r'\S*|"' + RE_VAR + '[^"]*"))' # quoted or unquoted filename or variable. %s is the match group name RE_SAFE_OR_UNSAFE = '(?P(safe|unsafe))' RE_XATTRS = r'(\s+xattrs\s*=\s*\((?P([^)=]+(=[^)=]+)?\s?)+)\)\s*)?' RE_FLAGS = r'(\s+(flags\s*=\s*)?\((?P[^)]+)\))?' +RE_VARIABLE = re.compile(RE_VAR) + RE_PROFILE_END = re.compile(r'^\s*\}' + RE_EOL) RE_PROFILE_ALL = re.compile(RE_PRIORITY_AUDIT_DENY + r'all' + RE_COMMA_EOL) RE_PROFILE_CAP = re.compile(RE_PRIORITY_AUDIT_DENY + r'capability(?P(\s+\S+)+)?' + RE_COMMA_EOL) @@ -251,3 +255,93 @@ def strip_quotes(data): return data[1:-1] else: return data + + +def expand_var(var, var_dict, seen_vars): + if var in seen_vars: + raise AppArmorException(_('Circular dependency detected for variable {}').format(var)) + + if var not in var_dict: + raise AppArmorException(_('Trying to reference non-existing variable {}').format(var)) + + resolved = [] + for val in var_dict[var]: + resolved.extend(expand_string(val, var_dict, seen_vars | {var})) + return resolved + + +def expand_string(s, var_dict, seen_vars): + + matches = list(RE_VARIABLE.finditer(s)) + if not matches: + return [s] + + parts = [] + last_idx = 0 + for match in matches: + start, end = match.span() + if start > last_idx: + parts.append([s[last_idx:start]]) + + var_name = match.group(0) + parts.append(expand_var(var_name, var_dict, seen_vars)) + last_idx = end + + if last_idx < len(s): + parts.append([s[last_idx:]]) + + return [''.join(p) for p in itertools.product(*parts)] + + +def resolve_variables(s, var_dict): + return expand_string(s, var_dict, set()) + + +# This function could be replaced by braceexpand.braceexpand +# It exists to avoid relying on an external python package. +def expand_braces(s): + i = s.find('{') + if i == -1: + if '}' in s: + raise AppArmorException('Unbalanced braces in pattern {}'.format(s)) + return [s] + + level = 0 + for j in range(i, len(s)): + if s[j] == '{': + level += 1 + elif s[j] == '}': + level -= 1 + if level == 0: + break + else: + raise AppArmorException('Unbalanced braces in pattern {}'.format(s)) + + prefix = s[:i] + group = s[i + 1:j] + suffix = s[j + 1:] + + # Split group on commas at the top level (i.e. not inside nested braces) + alts = [] + curr = '' + nested = 0 + for char in group: + if char == ',' and nested == 0: + alts.append(curr) + curr = "" + else: + if char == '{': + nested += 1 + elif char == '}': + nested -= 1 + curr += char + alts.append(curr) + + # Recursively combine prefix, each alternative, and suffix + results = [] + for alt in alts: + for expansion in expand_braces(prefix + alt + suffix): + results.append(expansion) + if len(results) <= 1: + raise AppArmorException('Braces should provide at least two alternatives, found {}: {}'.format(len(results), s)) + return results