mirror of
https://gitlab.com/apparmor/apparmor
synced 2025-08-22 18:17:09 +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:
parent
9f4dfdd57e
commit
b850f19622
@ -22,7 +22,7 @@ include $(COMMONDIR)/Make.rules
|
|||||||
|
|
||||||
PYTOOLS = aa-easyprof aa-genprof aa-logprof aa-cleanprof aa-mergeprof \
|
PYTOOLS = aa-easyprof aa-genprof aa-logprof aa-cleanprof aa-mergeprof \
|
||||||
aa-autodep aa-audit aa-complain aa-enforce aa-disable \
|
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
|
TOOLS = ${PYTOOLS} aa-decode aa-remove-unknown
|
||||||
PYSETUP = python-tools-setup.py
|
PYSETUP = python-tools-setup.py
|
||||||
PYMODULES = $(wildcard apparmor/*.py apparmor/rule/*.py)
|
PYMODULES = $(wildcard apparmor/*.py apparmor/rule/*.py)
|
||||||
|
144
utils/aa-show-usage
Executable file
144
utils/aa-show-usage
Executable 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()
|
@ -14,6 +14,7 @@
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import itertools
|
||||||
|
|
||||||
from apparmor.common import AppArmorBug, AppArmorException
|
from apparmor.common import AppArmorBug, AppArmorException
|
||||||
from apparmor.translations import init_translation
|
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_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_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 = '(?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_SAFE_OR_UNSAFE = '(?P<execmode>(safe|unsafe))'
|
||||||
RE_XATTRS = r'(\s+xattrs\s*=\s*\((?P<xattrs>([^)=]+(=[^)=]+)?\s?)+)\)\s*)?'
|
RE_XATTRS = r'(\s+xattrs\s*=\s*\((?P<xattrs>([^)=]+(=[^)=]+)?\s?)+)\)\s*)?'
|
||||||
RE_FLAGS = r'(\s+(flags\s*=\s*)?\((?P<flags>[^)]+)\))?'
|
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_END = re.compile(r'^\s*\}' + RE_EOL)
|
||||||
RE_PROFILE_ALL = re.compile(RE_PRIORITY_AUDIT_DENY + r'all' + RE_COMMA_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)
|
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]
|
return data[1:-1]
|
||||||
else:
|
else:
|
||||||
return data
|
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user