2
0
mirror of https://gitlab.com/apparmor/apparmor synced 2025-08-22 10:07:12 +00:00

utils: add aa-show-usage for profile usage analysis

Introduce aa-show-usage, a new helper allowing to determine which
profiles on the system are used and which are not. A profile is marked as
used when at least one file installed in the machine matches the attach point
specified in the profile.

This tool supports filtering options, allowing users to, for example,
display only unconfined profiles that are currently in use. This can
notably help sysadmins to evaluate the security of their systems.

Signed-off-by: Maxime Bélair <maxime.belair@canonical.com>
This commit is contained in:
Maxime Bélair 2025-04-04 16:05:30 +02:00 committed by Christian Boltz
parent 9f4dfdd57e
commit b850f19622
3 changed files with 240 additions and 2 deletions

View File

@ -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)

144
utils/aa-show-usage Executable file
View File

@ -0,0 +1,144 @@
#! /usr/bin/python3
# ----------------------------------------------------------------------
# Copyright (C) 2025 Maxime Bélair <maxime.belair@canonical.com>
#
# 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()

View File

@ -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<execmode>(safe|unsafe))'
RE_XATTRS = r'(\s+xattrs\s*=\s*\((?P<xattrs>([^)=]+(=[^)=]+)?\s?)+)\)\s*)?'
RE_FLAGS = r'(\s+(flags\s*=\s*)?\((?P<flags>[^)]+)\))?'
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<capability>(\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