From ff1baf3851d7b62a2f7b420e070125cc8ecd5a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20B=C3=A9lair?= Date: Tue, 13 Aug 2024 16:58:25 +0000 Subject: [PATCH] aa-notify: Enhanced Graphical User Interfaces --- .gitlab-ci.yml | 3 +- README.md | 3 + utils/Makefile | 2 +- utils/aa-notify | 231 +++++++++++++++++++++- utils/apparmor/aa.py | 8 + utils/apparmor/gui.py | 233 +++++++++++++++++++++++ utils/apparmor/update_profile.py | 80 ++++++++ utils/com.ubuntu.pkexec.aa-notify.policy | 30 +++ utils/default_unconfined.template | 14 ++ utils/notify.conf | 9 + utils/python-tools-setup.py | 18 ++ utils/test/test-aa-notify.py | 9 +- 12 files changed, 626 insertions(+), 14 deletions(-) create mode 100644 utils/apparmor/gui.py create mode 100755 utils/apparmor/update_profile.py create mode 100644 utils/com.ubuntu.pkexec.aa-notify.policy create mode 100644 utils/default_unconfined.template diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 12814ca6f..613c40b15 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -77,7 +77,8 @@ test-utils: extends: - .ubuntu-before_script script: - - apt-get install --no-install-recommends -y libc6-dev libjs-jquery libjs-jquery-throttle-debounce libjs-jquery-isonscreen libjs-jquery-tablesorter flake8 python3-coverage python3-notify2 python3-psutil python3-setuptools + - apt-get install --no-install-recommends -y libc6-dev libjs-jquery libjs-jquery-throttle-debounce libjs-jquery-isonscreen libjs-jquery-tablesorter flake8 python3-coverage python3-notify2 python3-psutil python3-setuptools python3-tk python3-ttkthemes python3-gi + # See apparmor/apparmor#221 - make -C parser/tst gen_dbus - make -C parser/tst gen_xtrans diff --git a/README.md b/README.md index e86c6383d..3612ecdb3 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,9 @@ The aa-notify tool's Python dependencies can be satisfied by installing the following packages (Debian package names, other distros may vary): * python3-notify2 * python3-psutil +* python3-tk +* python3-ttkthemes +* python3-gi Perl is no longer needed since none of the utilities shipped to end users depend on it anymore. diff --git a/utils/Makefile b/utils/Makefile index bc6e5ba3b..74dde2ae3 100644 --- a/utils/Makefile +++ b/utils/Makefile @@ -51,7 +51,7 @@ po/${NAME}.pot: ${TOOLS} ${PYMODULES} .PHONY: install install: ${MANPAGES} ${HTMLMANPAGES} install -d ${CONFDIR} - install -m 644 logprof.conf severity.db notify.conf ${CONFDIR} + install -m 644 logprof.conf severity.db notify.conf default_unconfined.template ${CONFDIR} install -d ${BINDIR} # aa-easyprof is installed by python-tools-setup.py install -m 755 $(filter-out aa-easyprof, ${TOOLS}) ${BINDIR} diff --git a/utils/aa-notify b/utils/aa-notify index 31cad560b..9e29a2e12 100755 --- a/utils/aa-notify +++ b/utils/aa-notify @@ -39,17 +39,29 @@ import re import sys import time +import subprocess + import notify2 import psutil import apparmor.aa as aa import apparmor.ui as aaui import apparmor.config as aaconfig +import apparmor.update_profile as update_profile import LibAppArmor # C-library to parse one log line -from apparmor.common import DebugLogger, open_file_read +from apparmor.common import DebugLogger, open_file_read, AppArmorException from apparmor.fail import enable_aa_exception_handler from apparmor.notify import get_last_login_timestamp from apparmor.translations import init_translation +from apparmor.logparser import ReadLog +from apparmor.gui import UsernsGUI, AddProfileGUI, ErrorGUI, ShowMoreGUI, set_interface_theme + +from dbus import DBusException +import gi +from gi.repository import GLib +import threading + +gi.require_version('GLib', '2.0') def get_user_login(): @@ -124,11 +136,23 @@ def notify_about_new_entries(logfile, filters, wait=0): # Follow the logfile and stream notifications # Rate limit to not show too many notifications try: + # Before use, notify2 must be initialized and the DBUS channel + # should be opened using the non-root user. This step needs to + # be executed after the drop_privileges(). + notify2.init('aa-notify', mainloop='glib') + except DBusException: + sys.exit(_('Cannot initialize notify2. Please check that your terminal can use a graphical interface')) + + try: + thread = threading.Thread(target=start_glib_loop) + thread.daemon = True + thread.start() + for event in follow_apparmor_events(logfile, wait): if not is_event_in_filter(event, filters): continue debug_logger.info(format_event(event, logfile)) - yield (format_event(event, logfile)) + yield event, format_event(event, logfile) except PermissionError: sys.exit(_("ERROR: Cannot read {}. Please check permissions.").format(logfile)) @@ -396,6 +420,151 @@ def compile_filter_regex(filters): return filters +def is_special_profile_userns(ev, special_profiles): + if not special_profiles or ev['profile'] not in special_profiles: + return False # We don't use special profiles or there is already a profile defined: we don't ask to add userns + + if 'execpath' not in ev or not ev['execpath']: + ev['execpath'] = aa.find_executable(ev['comm']) + + return True + + +def ask_for_user_ns_denied(path, name, profile_path): + gui = UsernsGUI(name, path) + ans = gui.show() + if ans in {'allow', 'deny'}: + update_profile_path = update_profile.__file__ + local_template_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'default_unconfined.template') + if os.path.exists(local_template_path): # We are using local aa-notify -> we use local template + template_path = local_template_path + else: + template_path = aa.CONFDIR + '/default_unconfined.template' + + command = ['pkexec', '--keep-cwd', update_profile_path, 'create_userns', template_path, name, path, profile_path, ans] + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError as e: + if e.returncode != 126: # return code 126 means the user cancelled the request + UsernsGUI.show_error_cannot_reload_profile(profile_path, e.returncode) + else: + debug_logger.debug('No action from the user for {}'.format(path)) + + +def prompt_userns(ev, special_profiles): + """If the user namespace creation denial was generated by an unconfined binary, displays a graphical notification. + Creates a new profile to allow userns if the user wants it. Returns whether a notification was displayed to the user + """ + if not is_special_profile_userns(ev, special_profiles): + return False + + if ev['execpath'] is None: + UsernsGUI.show_error_cannot_find_execpath(ev['comm'], os.path.dirname(os.path.abspath(__file__)) + '/default_unconfined.template') + return True + + aa.update_profiles() + + if aa.get_profile_filename_from_profile_name(ev['comm']): + # There is already a profile with this name: we show an error to the user. + # We could use the full path as profile name like for the old profiles if we want to handle this case + # but if execpath is not supported by the kernel it could also mean that we inferred a bad path + # So we do nothing beyond showing this error. + ErrorGUI( + _('Application {0} tried to create an user namespace, but a profile already exists with this name.\n' + 'This is likely because there is several binaries named {0} thus the path inferred by AppArmor ({1}) is not correct.\n' + 'You should review your profiles (in {2}).').format(ev['comm'], ev['execpath'], aa.profile_dir), + False).show() + return True + + profile_path = aa.get_profile_filename_from_profile_name(ev['comm'], True) + if not profile_path: + return False + + ask_for_user_ns_denied(ev['execpath'], ev['comm'], profile_path) + + return True + + +# TODO reuse more code from aa-logprof in callbacks +def cb_more_info(notification, action, _args): + (raw_ev, rl) = _args + notification.close() + + parsed_event = rl.parse_record(raw_ev) + out = _('Operation denied by AppArmor\n\n') + + for key, value in parsed_event.items(): + if value: + out += '\t{} = {}\n'.format(_(key), value) + + out += _('\nThe software that declined this operation is {}\n').format(parsed_event['profile']) + + rule = rl.create_rule_from_ev(parsed_event) + + if rule: + aa.update_profiles() + profile_path = aa.get_profile_filename_from_profile_name(parsed_event['profile']) + if profile_path: + out += _('If you want to allow this operation you can add the line below in profile {}\n').format(profile_path) + else: + out += _('However {0} is not in {1}\nIt is likely that the profile was not stored in {1} or was removed.\n').format(parsed_event['profile'], aa.profile_dir) + out += rule.get_clean() + else: # Should not happen + out += _('ERROR: Could not create rule from event.') + return + + ShowMoreGUI(profile_path, out, profile_path is not None).show() + + +def cb_add_to_profile(notification, action, _args): + (raw_ev, rl, special_profiles) = _args + notification.close() + parsed_event = rl.parse_record(raw_ev) + + rule = rl.create_rule_from_ev(parsed_event) + + if not rule: + ErrorGUI.show(_('ERROR: Could not create rule from event.')) + return + + aa.update_profiles() + + if customized_message['userns']['cond'](parsed_event, special_profiles): + if parsed_event['execpath'] is None: + UsernsGUI.show_error_cannot_find_execpath(parsed_event['comm'], os.path.dirname(os.path.abspath(__file__)) + '/default_unconfined.template') + return + profile_path = aa.get_profile_filename_from_profile_name(parsed_event['comm'], True) + if not profile_path: + ErrorGUI(_('Cannot get profile path for {}.').format(parsed_event['comm']), False).show() + return + ask_for_user_ns_denied(parsed_event['execpath'], parsed_event['comm'], profile_path) + else: + try: + AddProfileGUI(rule.get_clean(), parsed_event['profile']).show() + except AppArmorException: + AddProfileGUI.show_error_cannot_find_profile(parsed_event['profile']) + + +customized_message = { + 'userns': { + 'cond': lambda ev, special_profiles: (ev['operation'] == 'userns_create' or ev['operation'] == 'capable') and is_special_profile_userns(ev, special_profiles), + 'msg': 'Application {0} wants to create an user namespace which could be used to compromise your system\nDo you want to allow it next time {0} is run?' + } +} + + +def customize_notification_message(ev, msg, special_profiles): + if customized_message['userns']['cond'](ev, special_profiles): + msg = _(customized_message['userns']['msg']).format(ev['comm']) + + return msg + + +def start_glib_loop(): + loop = GLib.MainLoop() + loop.run() + + def main(): """Run aa-notify. @@ -432,6 +601,7 @@ def main(): parser.add_argument('-v', '--verbose', action='store_true', help=_('show messages with stats')) parser.add_argument('-u', '--user', type=str, help=_('user to drop privileges to when not using sudo')) parser.add_argument('-w', '--wait', type=int, metavar=('NUM'), help=_('wait NUM seconds before displaying notifications (with -p)')) + parser.add_argument('--prompt-filter', type=str, metavar=('PF'), help=_('kind of operations which display a popup prompt')) parser.add_argument('--debug', action='store_true', help=_('debug mode')) parser.add_argument('--configdir', type=str, help=argparse.SUPPRESS) @@ -476,6 +646,15 @@ def main(): else: # fallback to env variable (or None if not set) confdir = os.getenv('__AA_CONFDIR') + # Todo: add more kinds of notifications + supported_prompt_filter = {'userns'} + + if args.prompt_filter: + args.prompt_filter = set(args.prompt_filter.strip().split(',')) + unsupported = args.prompt_filter - supported_prompt_filter + if unsupported: + sys.exit(_('ERROR: using an unsupported prompt filter: {}\nSupported values: {}').format(', '.join(unsupported), ', '.join(supported_prompt_filter))) + aa.init_aa(confdir=confdir) # Initialize aa.logfile @@ -511,6 +690,9 @@ def main(): - message_body - message_footer - use_group + - userns_special_profiles + - ignore_denied_capability + - interface_theme - filter.profile, - filter.operation, - filter.name, @@ -524,6 +706,9 @@ def main(): # Warn about unknown keys in the config allowed_config_keys = [ 'use_group', + 'userns_special_profiles', + 'ignore_denied_capability', + 'interface_theme', 'show_notifications', 'message_body', 'message_footer', @@ -598,6 +783,21 @@ def main(): # @TODO: Extend UI lib to have warning and error functions that # can be used in an uniform way with both text and JSON output. + if 'userns_special_profiles' in config['']: + userns_special_profiles = config['']['userns_special_profiles'].strip().split(',') + else: + userns_special_profiles = ['unconfined'] # By default, unconfined is the only special profile + + if 'ignore_denied_capability' in config['']: + ignore_denied_capability = config['']['ignore_denied_capability'].strip().split(',') + else: + ignore_denied_capability = ['sudo', 'su'] + + if 'interface_theme' in config['']: + set_interface_theme(config['']['interface_theme'].strip()) + else: + set_interface_theme('ubuntu') + if args.file: logfile = args.file elif os.path.isfile('/var/run/auditd.pid') and os.path.isfile('/var/log/audit/audit.log'): @@ -628,12 +828,26 @@ def main(): if not args.user and os.getuid() == 0 and 'SUDO_USER' not in os.environ.keys(): sys.exit("ERROR: Cannot be started a real root user. Use --user to define what user to use.") + # Required to parse_record. + rl = ReadLog('', '', '') + # At this point this script needs to be able to read 'logfile' but once # the for loop starts, privileges can be dropped since the file descriptor # has been opened and access granted. Further reads of the file will not # trigger any new permission checks. # @TODO Plan to catch PermissionError here or..? - for message in notify_about_new_entries(logfile, filters, args.wait): + for (event, message) in notify_about_new_entries(logfile, filters, args.wait): + ev = rl.parse_record(event) + + # @TODO redo special behaviours with a more regular function + # We ignore capability denials for binaries in ignore_denied_capability + if ev['operation'] == 'capable' and ev['comm'] in ignore_denied_capability: + continue + + # Special behaivor for userns: + if args.prompt_filter and 'userns' in args.prompt_filter and customized_message['userns']['cond'](ev, userns_special_profiles): + if prompt_userns(ev, userns_special_profiles): + continue # Notification already displayed for this event, we go to the next one. # Notifications should not be run as root, since root probably is # the wrong desktop user and not the one getting the notifications. @@ -643,16 +857,17 @@ def main(): if 'DBUS_SESSION_BUS_ADDRESS' not in os.environ: os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=/run/user/{}/bus'.format(os.geteuid()) - # Before use, notify2 must be initialized and the DBUS channel - # should be opened using the non-root user. This step needs to - # be executed after the drop_privileges(). - notify2.init('AppArmor') + message = customize_notification_message(ev, message, userns_special_profiles) n = notify2.Notification( - _('AppArmor notification'), + _('AppArmor security notice'), message, 'gtk-dialog-warning' ) + + n.add_action('clicked', 'Allow', cb_add_to_profile, (event, rl, userns_special_profiles)) + n.add_action('more_clicked', 'Show More', cb_more_info, (event, rl)) + n.show() # When notification is sent, raise privileged back to root if the diff --git a/utils/apparmor/aa.py b/utils/apparmor/aa.py index 0fd31cc74..cf2340007 100644 --- a/utils/apparmor/aa.py +++ b/utils/apparmor/aa.py @@ -1607,6 +1607,14 @@ def collapse_log(hashlog, ignore_null_profiles=True): return log_dict +def update_profiles(ui_msg=False, skip_profiles=()): + reset_aa() + try: + read_profiles(ui_msg, skip_profiles) + except AppArmorException as e: + print(_("Error while loading profiles: {}").format(e)) + + def read_profiles(ui_msg=False, skip_profiles=()): # we'll read all profiles from disk, so reset the storage first (autodep() might have created/stored # a profile already, which would cause a 'Conflicting profile' error in attach_profile_data()) diff --git a/utils/apparmor/gui.py b/utils/apparmor/gui.py new file mode 100644 index 000000000..659c1b1e2 --- /dev/null +++ b/utils/apparmor/gui.py @@ -0,0 +1,233 @@ +import os +import tkinter as tk +import tkinter.ttk as ttk +import tkinter.messagebox as messagebox +import tkinter.font as font +import subprocess +import ttkthemes + +import apparmor.aa as aa +import apparmor.update_profile as update_profile +from apparmor.common import AppArmorException + +from apparmor.translations import init_translation + +_ = init_translation() + +notification_custom_msg = { + 'userns': _('Application {0} wants to create an user namespace which could be used to compromise your system\nDo you want to allow it next time {0} is run?\nApplication path is {1}') +} + +global interface_theme + + +class GUI: + def __init__(self): + try: + self.master = tk.Tk() + except tk.TclError: + print(_('ERROR: Cannot initialize Tkinter. Please check that your terminal can use a graphical interface')) + os._exit(1) + + style = ttkthemes.ThemedStyle(self.master) + style.theme_use(interface_theme) + self.bg_color = style.lookup('TLabel', 'background') + self.master.configure(background=self.bg_color) + + self.label_frame = ttk.Frame(self.master, padding=(20, 10)) + self.label_frame.pack() + + self.button_frame = ttk.Frame(self.master, padding=(0, 10)) + self.button_frame.pack() + + +class AddProfileGUI(GUI): + def __init__(self, rule, profile_name, dbg=None): + self.dbg = dbg + self.rule = rule + self.profile_name = profile_name + self.profile_path = aa.get_profile_filename_from_profile_name(profile_name) + + if not self.profile_path: + raise AppArmorException('Cannot find profile for {}'.format(self.profile_name)) + super().__init__() + + self.master.title(_('AppArmor - Add rule to profile')) + + self.profile_label = ttk.Label(self.label_frame, text=_('Profile for: {}').format(self.profile_name)) + self.profile_label.pack() + + self.entry_frame = ttk.Frame(self.master) + self.entry_frame.pack() + + self.rule = tk.StringVar(value=self.rule) + self.rule_entry = ttk.Entry(self.entry_frame, font=font.nametofont("TkDefaultFont"), width=50, textvariable=self.rule) + self.rule_entry.pack(side=tk.LEFT) + + self.button_frame = ttk.Frame(self.master, padding=(10, 10)) + self.button_frame.pack() + + self.add_to_profile_button = ttk.Button(self.button_frame, text=_('Add to Profile'), command=self.add_to_profile) + self.add_to_profile_button.pack(side=tk.LEFT) + + self.show_profile_button = ttk.Button(self.button_frame, text=_('Show Current Profile'), command=lambda: open_with_default_editor(self.profile_path)) + self.show_profile_button.pack(side=tk.LEFT) + + self.cancel_button = ttk.Button(self.button_frame, text=_('Cancel'), command=self.master.destroy) + self.cancel_button.pack(side=tk.LEFT) + + def add_to_profile(self): + if self.dbg: + self.dbg.debug('Adding rule \'{}\' to profile at: {}'.format(self.rule.get(), self.profile_name)) + + # We get update_profile.py through this import so that it works in all cases + update_profile_path = update_profile.__file__ + command = ['pkexec', '--keep-cwd', update_profile_path, 'add_rule', self.rule.get(), self.profile_name] + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError as e: + if e.returncode != 126: # return code 126 means the user cancelled the request + ErrorGUI(_('Failed to add rule {} to {}\n Error code = {}').format(self.rule.get(), self.profile_name, e.returncode), False).show() + + self.master.destroy() + + def show(self): + self.master.mainloop() + + @staticmethod + def show_error_cannot_find_profile(profile_name): + ErrorGUI( + _( + 'Cannot find profile for {}\n\n' + 'It is likely that the profile was not stored in {} or was removed.' + ).format(profile_name, aa.profile_dir), + False + ).show() + + +class ShowMoreGUI(GUI): + def __init__(self, profile_path, msg, profile_found=True): + self.profile_path = profile_path + self.msg = msg + self.profile_found = profile_found + + super().__init__() + + self.master.title(_('AppArmor - More info')) + + self.label = tk.Label(self.label_frame, background=self.bg_color, text=self.msg, anchor='w', justify=tk.LEFT, wraplength=460) + self.label.pack() + + if self.profile_found: + self.show_profile_button = ttk.Button(self.button_frame, text=_('Show Current Profile'), command=lambda: open_with_default_editor(self.profile_path)) + self.show_profile_button.pack() + + def show(self): + self.master.mainloop() + + +class UsernsGUI(GUI): + def __init__(self, name, path): + self.name = name + self.path = path + self.result = None + + super().__init__() + + self.master.title(_('AppArmor - User namespace creation restricted')) + + label_text = notification_custom_msg['userns'].format(name, path) + self.label = ttk.Label(self.label_frame, text=label_text, wraplength=460) + self.label.pack() + link = ttk.Label(self.master, text=_('More information'), foreground='blue', cursor='hand2') + link.pack() + link.bind('', self.more_info) + + self.add_policy_button = ttk.Button(self.master, text=_('Allow'), command=lambda: self.set_result("allow")) + self.add_policy_button.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + + self.never_ask_button = ttk.Button(self.master, text=_('Deny'), command=lambda: self.set_result("deny")) + self.never_ask_button.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + + self.do_nothing_button = ttk.Button(self.master, text=_('Do nothing'), command=self.master.destroy) + self.do_nothing_button.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + + @staticmethod + def more_info(ev): + more_info_text = _(""" +In Linux, user namespaces enable non-root users to perform certain privileged operations. This feature can be useful for several legitimate use cases. + +However, this feature also introduces security risks, (e.g. privilege escalation exploits). + +This dialog allows you to choose whether you want to enable user namespaces for this application.""") + messagebox.showinfo( + _('AppArmor: More info'), + more_info_text, + ) + + def set_result(self, result): + self.result = result + self.master.destroy() + + def show(self): + self.master.mainloop() + return self.result + + @staticmethod + def show_error_cannot_reload_profile(profile_path, error): + ErrorGUI(_('Failed to create or load profile {}\n Error code = {}').format(profile_path, error), False).show() + + @staticmethod + def show_error_cannot_find_execpath(name, template_path): + ErrorGUI( + _( + 'Application {0} wants to create an user namespace which could be used to compromise your system\n\n' + 'However, apparmor cannot find {0}. If you want to allow it, please create a profile for it.\n\n' + 'A profile template is in {1}\n Profiles are in {2}' + ).format(name, template_path, aa.profile_dir), + False + ).show() + + +class ErrorGUI(GUI): + def __init__(self, msg, is_fatal): + self.msg = msg + self.is_fatal = is_fatal + + super().__init__() + + self.master.title('AppArmor Error') + + # Create label to display the error message + self.label = ttk.Label(self.label_frame, background=self.bg_color, text=self.msg, wraplength=460) + self.label.pack() + + # Create a button to close the dialog + self.button = ttk.Button(self.button_frame, text="OK", command=self.destroy) + self.button.pack() + + def destroy(self): + self.master.destroy() + + if self.is_fatal: + os._exit(1) + + def show(self): + self.master.mainloop() + if self.is_fatal: + os._exit(1) + + +def set_interface_theme(theme): + global interface_theme + interface_theme = theme + + +def open_with_default_editor(profile_path): + try: + default_app = subprocess.run(['xdg-mime', 'query', 'default', 'text/plain'], capture_output=True, text=True, check=True).stdout.strip() + subprocess.run(['gtk-launch', default_app, profile_path], check=True) + except subprocess.CalledProcessError: + ErrorGUI(_('Failed to launch default editor'), False).show() + except FileNotFoundError as e: + ErrorGUI(_('Failed to open file: {}').format(e), False).show() diff --git a/utils/apparmor/update_profile.py b/utils/apparmor/update_profile.py new file mode 100755 index 000000000..56072c2cb --- /dev/null +++ b/utils/apparmor/update_profile.py @@ -0,0 +1,80 @@ +#!/usr/bin/python3 + +import subprocess +import sys + +# TODO: transform this script to a package to use local imports so that if called with ./aa-notify, we use ./apparmor.* +from apparmor import aa +from apparmor.logparser import ReadLog + +from apparmor.translations import init_translation +_ = init_translation() + + +def create_userns(template_path, name, bin_path, profile_path, decision): + with open(template_path, 'r') as f: + profile_template = f.read() + + rule = 'userns' if decision == 'allow' else 'audit deny userns' + profile = profile_template.format(rule=rule, name=name, path=bin_path) + + with open(profile_path, 'w') as file: + file.write(profile) + + try: + subprocess.run(['apparmor_parser', '-r', profile_path], check=True) + except subprocess.CalledProcessError: + exit(_('Cannot reload updated profile')) + + +def add_to_profile(rule, profile_name): + aa.init_aa() + aa.read_profiles() + + rule_type, rule_class = ReadLog('', '', '').get_rule_type(rule) + + rule_obj = rule_class.create_instance(rule) + + if profile_name not in aa.aa or profile_name not in aa.aa[profile_name]: + exit(_('Cannot find {} in profiles').format(profile_name)) + aa.aa[profile_name][profile_name][rule_type].add(rule_obj, cleanup=True) + + # Save changes + aa.write_profile_ui_feedback(profile_name) + aa.reload_base(profile_name) + + +def usage(is_help): + print('This tool is a low level tool - do not use it directly') + print('{} create_userns '.format(sys.argv[0])) + print('{} add_rule '.format(sys.argv[0])) + + if is_help: + exit(0) + else: + exit(1) + + +def main(): + if len(sys.argv) > 1: + command = sys.argv[1] + else: + command = None # Handle the case where no command is provided + + match command: + case 'create_userns': + if not len(sys.argv) == 7: + usage(False) + create_userns(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6]) + case 'add_rule': + if not len(sys.argv) == 4: + usage(False) + add_to_profile(sys.argv[2], sys.argv[3]) + case 'help': + usage(True) + case _: + usage(False) + + +if __name__ == '__main__': + main() diff --git a/utils/com.ubuntu.pkexec.aa-notify.policy b/utils/com.ubuntu.pkexec.aa-notify.policy new file mode 100644 index 000000000..1b0d19501 --- /dev/null +++ b/utils/com.ubuntu.pkexec.aa-notify.policy @@ -0,0 +1,30 @@ + + + + + + AppArmor: modifying security profile + To modify an AppArmor security profile, you need to authenticate. + + auth_admin + auth_admin + auth_admin + + {LIB_PATH}apparmor/update_profile.py + add_rule + + + AppArmor: adding userns profile + To allow a program to use unprivileged user namespaces, you need to authenticate. + + auth_admin + auth_admin + auth_admin + + {LIB_PATH}apparmor/update_profile.py + create_userns + + + diff --git a/utils/default_unconfined.template b/utils/default_unconfined.template new file mode 100644 index 000000000..43779041e --- /dev/null +++ b/utils/default_unconfined.template @@ -0,0 +1,14 @@ +# This profile was auto-generated by AppArmor +# This profile allows everything and only exists to give the +# application the possibility to use unprivileged user namespaces + +abi , +include + + +profile {name} {path} flags=(unconfined) {{ + {rule}, + + # Site-specific additions and overrides. See local/README for details. + include if exists +}} diff --git a/utils/notify.conf b/utils/notify.conf index e44d9eec1..fefb4ad76 100644 --- a/utils/notify.conf +++ b/utils/notify.conf @@ -11,6 +11,15 @@ # Set to 'no' to disable AppArmor notifications globally show_notifications="yes" +# Special profiles used to remove privileges for unconfined binaries using user namespaces. If unsure, leave as is. +userns_special_profiles="unconfined,unprivileged_userns" +# Theme to use for aa-notify GUI themes. See https://ttkthemes.readthedocs.io/en/latest/themes.html for available themes. +interface_theme="ubuntu" + +# Binaries for which we ignore userns-related capability denials +ignore_denied_capability="sudo,su" + + # OPTIONAL - restrict using aa-notify to users in the given group # (if not set, everybody who has permissions to read the logfile can use it) # use_group="admin" diff --git a/utils/python-tools-setup.py b/utils/python-tools-setup.py index 82c4baf3c..e84375898 100644 --- a/utils/python-tools-setup.py +++ b/utils/python-tools-setup.py @@ -55,6 +55,24 @@ class Install(_install): for d in data: self.copy_tree(d, os.path.join(prefix + "/usr/share/apparmor/easyprof", os.path.basename(d))) + # Make update_profile.py executable + update_profile_path = os.path.join(self.install_lib, 'apparmor/update_profile.py') + print('changing mode of {} to 755'.format(update_profile_path)) + os.chmod(update_profile_path, 0o755) + + pkexec_action_name = 'com.ubuntu.pkexec.aa-notify.policy' + print('Installing {} to /usr/share/polkit-1/actions/ mode 644'.format(pkexec_action_name)) + with open(pkexec_action_name, 'r') as f: + polkit_template = f.read() + + polkit = polkit_template.format(LIB_PATH=self.install_lib) + + if not os.path.exists(prefix + '/usr/share/polkit-1/actions/'): + os.mkdir(prefix + '/usr/share/polkit-1/actions/') + with open(prefix + '/usr/share/polkit-1/actions/' + pkexec_action_name, 'w') as f: + f.write(polkit) + os.chmod(prefix + '/usr/share/polkit-1/actions/' + pkexec_action_name, 0o644) + if os.path.exists('staging'): shutil.rmtree('staging') diff --git a/utils/test/test-aa-notify.py b/utils/test/test-aa-notify.py index 9712505bb..d706edf2e 100644 --- a/utils/test/test-aa-notify.py +++ b/utils/test/test-aa-notify.py @@ -184,10 +184,10 @@ class AANotifyTest(AANotifyBase): expected_return_code = 0 expected_output_1 = \ '''usage: aa-notify [-h] [-p] [--display DISPLAY] [-f FILE] [-l] [-s NUM] [-v] - [-u USER] [-w NUM] [--debug] [--filter.profile PROFILE] - [--filter.operation OPERATION] [--filter.name NAME] - [--filter.denied DENIED] [--filter.family FAMILY] - [--filter.socket SOCKET] + [-u USER] [-w NUM] [--prompt-filter PF] [--debug] + [--filter.profile PROFILE] [--filter.operation OPERATION] + [--filter.name NAME] [--filter.denied DENIED] + [--filter.family FAMILY] [--filter.socket SOCKET] Display AppArmor notifications or messages for DENIED entries. ''' # noqa: E128 @@ -207,6 +207,7 @@ Display AppArmor notifications or messages for DENIED entries. -u USER, --user USER user to drop privileges to when not using sudo -w NUM, --wait NUM wait NUM seconds before displaying notifications (with -p) + --prompt-filter PF kind of operations which display a popup prompt --debug debug mode Filtering options: