2
0
mirror of https://gitlab.com/apparmor/apparmor synced 2025-08-22 01:57:43 +00:00
John Johansen c0fcd1698b utils: add support for priority rule prefix
Add basic support for the priority rules prefix. This patch does not
allow the utils to set or suggest priorities. It allows parsing and
retaining of the priority prefix if it already exists on rules and
checking if it's in the supported range.

Signed-off-by: Georgia Garcia <georgia.garcia@canonical.com>
Signed-off-by: John Johansen <john.johansen@canonical.com>
2025-05-05 14:54:22 -03:00

648 lines
26 KiB
Python

# ----------------------------------------------------------------------
# Copyright (C) 2016 Christian Boltz <apparmor@cboltz.de>
#
# 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.
#
# ----------------------------------------------------------------------
from apparmor.aare import AARE
from apparmor.common import AppArmorBug, AppArmorException
from apparmor.regex import RE_PROFILE_FILE_ENTRY, strip_quotes
from apparmor.rule import (
BaseRule, BaseRuleset, check_and_split_list, logprof_value_or_all,
parse_modifiers, quote_if_needed)
from apparmor.translations import init_translation
_ = init_translation()
allow_exec_transitions = ('ix', 'ux', 'Ux', 'px', 'Px', 'cx', 'Cx') # 2 chars - len relevant for split_perms()
allow_exec_fallback_transitions = ('pix', 'Pix', 'cix', 'Cix', 'pux', 'PUx', 'cux', 'CUx') # 3 chars - len relevant for split_perms()
deny_exec_transitions = ('x')
file_permissions = ('m', 'r', 'w', 'a', 'l', 'k', 'link', 'subset') # also defines the write order
implicit_all_permissions = ('m', 'r', 'w', 'l', 'k')
class FileRule(BaseRule):
"""Class to handle and store a single file rule"""
# Nothing external should reference this class, all external users
# should reference the class field FileRule.ALL
class __FileAll:
pass
class __FileAnyExec:
pass
ALL = __FileAll
ANY_EXEC = __FileAnyExec
rule_name = 'file'
_match_re = RE_PROFILE_FILE_ENTRY
def __init__(self, path, perms, exec_perms, target, owner, file_keyword=False, leading_perms=False,
audit=False, deny=False, allow_keyword=False, comment='', log_event=None, priority=None):
"""Initialize object
Parameters:
- path: string, AARE or FileRule.ALL
- perms: string, set of chars or FileRule.ALL (must not contain exec mode)
- exec_perms: None or string
- target: string, AARE or FileRule.ALL
- owner: bool
- file_keyword: bool
- leading_perms: bool
"""
super().__init__(audit=audit, deny=deny, allow_keyword=allow_keyword,
comment=comment, log_event=log_event, priority=priority)
# rulepart partperms is_path log_event
self.path, self.all_paths = self._aare_or_all(path, 'path', True, log_event) # noqa: E221
self.target, self.all_targets = self._aare_or_all(target, 'target', False, log_event)
self.can_glob = not self.all_paths
self.can_glob_ext = not self.all_paths
self.can_edit = not self.all_paths
if isinstance(perms, str):
perms, tmp_exec_perms = split_perms(perms, deny)
if tmp_exec_perms:
raise AppArmorBug('perms must not contain exec perms')
elif perms is None:
perms = set()
if perms == {'subset'}:
raise AppArmorBug('subset without link permissions given')
elif perms in ({'link'}, {'link', 'subset'}):
self.perms = perms
self.all_perms = False
else:
self.perms, self.all_perms, unknown_items = check_and_split_list(
perms, file_permissions, self.ALL, type(self).__name__, 'permissions', allow_empty_list=True)
if unknown_items:
raise AppArmorBug('Passed unknown perms to %s: %s' % (type(self).__name__, str(unknown_items)))
if self.perms and 'a' in self.perms and 'w' in self.perms:
raise AppArmorException("Conflicting permissions found: 'a' and 'w'")
self.original_perms = None # might be set by aa-logprof / aa.py propose_file_rules()
if exec_perms is None:
self.exec_perms = None
elif 'link' in self.perms:
raise AppArmorBug("link rules can't have execute permissions")
elif exec_perms == self.ANY_EXEC:
self.exec_perms = exec_perms
elif isinstance(exec_perms, str):
if deny:
if exec_perms != 'x':
raise AppArmorException(_("file deny rules only allow to use 'x' as execute mode, but not %s" % exec_perms))
else:
if exec_perms == 'x':
raise AppArmorException(_("Execute flag ('x') in file rule must specify the exec mode (ix, Px, Cx etc.)"))
elif exec_perms not in allow_exec_transitions and exec_perms not in allow_exec_fallback_transitions:
raise AppArmorBug('Unknown execute mode specified in file rule: %s' % exec_perms)
self.exec_perms = exec_perms
else:
raise AppArmorBug('Passed unknown perms object to %s: %s' % (type(self).__name__, str(perms)))
if not isinstance(owner, bool):
raise AppArmorBug('non-boolean value passed to owner flag')
self.owner = owner
self.can_owner = owner # offer '(O)wner permissions on/off' buttons only if the rule has the owner flag
if not isinstance(file_keyword, bool):
raise AppArmorBug('non-boolean value passed to file keyword flag')
self.file_keyword = file_keyword
if not isinstance(leading_perms, bool):
raise AppArmorBug('non-boolean value passed to leading permissions flag')
self.leading_perms = leading_perms
# XXX subset
# check for invalid combinations (bare 'file,' vs. path rule)
# if (self.all_paths and not self.all_perms) or (not self.all_paths and self.all_perms):
# raise AppArmorBug('all_paths and all_perms must be equal')
# elif
if self.all_paths and (self.exec_perms or self.target):
raise AppArmorBug('exec perms or target specified for bare file rule')
@classmethod
def _create_instance(cls, raw_rule, matches):
"""parse raw_rule and return instance of this class"""
priority, audit, deny, allow_keyword, comment = parse_modifiers(matches)
owner = bool(matches.group('owner'))
leading_perms = False
if matches.group('path'):
path = strip_quotes(matches.group('path'))
elif matches.group('path2'):
path = strip_quotes(matches.group('path2'))
leading_perms = True
elif matches.group('link_path'):
path = strip_quotes(matches.group('link_path'))
leading_perms = True
else:
path = cls.ALL
if matches.group('perms'):
perms = matches.group('perms')
perms, exec_perms = split_perms(perms, deny)
elif matches.group('perms2'):
perms = matches.group('perms2')
perms, exec_perms = split_perms(perms, deny)
leading_perms = True
elif matches.group('link_keyword'):
if matches.group('subset_keyword'):
perms = {'link', 'subset'}
else:
perms = {'link'}
exec_perms = None
leading_perms = True
else:
perms = cls.ALL
exec_perms = None
if matches.group('target'):
target = strip_quotes(matches.group('target'))
elif matches.group('link_target'):
target = strip_quotes(matches.group('link_target'))
else:
target = cls.ALL
file_keyword = bool(matches.group('file_keyword'))
return cls(path, perms, exec_perms, target, owner, file_keyword, leading_perms,
audit=audit, deny=deny, allow_keyword=allow_keyword, comment=comment, priority=priority)
def get_clean(self, depth=0):
"""return rule (in clean/default formatting)"""
space = ' ' * depth
if self.all_paths:
path = ''
elif self.path:
path = quote_if_needed(self.path.regex)
else:
raise AppArmorBug('Empty path in file rule')
if self.all_perms:
perms = ''
else:
perms = self._joint_perms()
if not perms:
raise AppArmorBug('Empty permissions in file rule')
if self.leading_perms:
path_and_perms = '%s %s' % (perms, path)
else:
path_and_perms = '%s %s' % (path, perms)
if self.all_targets:
target = ''
elif self.target:
target = ' -> %s' % quote_if_needed(self.target.regex)
else:
raise AppArmorBug('Empty exec target in file rule')
if self.owner:
owner = 'owner '
else:
owner = ''
if self.file_keyword:
file_keyword = 'file '
else:
file_keyword = ''
if self.all_paths and self.all_perms and not path and not perms and not target:
return ('%s%s%sfile,%s' % (space, self.modifiers_str(), owner, self.comment)) # plain 'file,' rule
elif not self.all_paths and not self.all_perms and path and perms:
return ('%s%s%s%s%s%s,%s' % (space, self.modifiers_str(), owner, file_keyword, path_and_perms, target, self.comment))
else:
raise AppArmorBug('Invalid combination of path and perms in file rule - either specify path and perms, or none of them')
def _joint_perms(self):
"""return the permissions as string (using self.perms and self.exec_perms)"""
return self._join_given_perms(self.perms, self.exec_perms)
def _join_given_perms(self, perms, exec_perms):
"""return the permissions as string (using the perms and exec_perms given as parameter)"""
perm_string = ''
for perm in file_permissions:
if perm in perms:
if perm == 'subset':
perm = ' subset' # add leading space
perm_string = perm_string + perm
if exec_perms == self.ANY_EXEC:
raise AppArmorBug(type(self).__name__ + ".ANY_EXEC can't be used for actual rules")
if exec_perms:
perm_string = perm_string + exec_perms
return perm_string
def _is_covered_localvars(self, other_rule):
"""check if other_rule is covered by this rule object"""
if not self._is_covered_aare(self.path, self.all_paths, other_rule.path, other_rule.all_paths, 'path'):
return False
if self.perms and 'subset' in self.perms and other_rule.perms and 'subset' not in other_rule.perms:
return False # subset is a restriction (also, if subset is included, this means this instance is a link rule, so other file permissions can't be covered)
elif self.perms and 'link' in self.perms and other_rule.perms and 'link' in other_rule.perms:
pass # skip _is_covered_list() because it would interpret 'subset' as additional permissions, not as restriction
elif not self._is_covered_list(perms_with_a(self.perms), self.all_perms, perms_with_a(other_rule.perms), other_rule.all_perms, 'perms', sanity_check=False):
# perms can be empty if only exec_perms are specified, therefore disable the sanity check in _is_covered_list()...
# 'w' covers 'a', therefore use perms_with_a() to temporarily add 'a' if 'w' is present
return False
# TODO: check link / link subset vs. 'l'?
# ... and do our own sanity check
if not other_rule.perms and not other_rule.all_perms and not other_rule.exec_perms:
raise AppArmorBug('No permission or exec permission specified in other file rule')
if not self.exec_perms and other_rule.exec_perms:
return False
# TODO: handle fallback modes?
if other_rule.exec_perms == self.ANY_EXEC and self.exec_perms:
pass # other_rule has ANY_EXEC and self has an exec rule set -> covered, so avoid hitting the 'elif' branch
elif other_rule.exec_perms and self.exec_perms != other_rule.exec_perms:
return False
# check exec_mode and target only if other_rule contains exec_perms (except ANY_EXEC) or link permissions
# (for mrwk permissions, the target is ignored anyway)
if ((other_rule.exec_perms and other_rule.exec_perms != self.ANY_EXEC)
or (other_rule.perms and 'l' in other_rule.perms)
or (other_rule.perms and 'link' in other_rule.perms)):
if not self._is_covered_aare(self.target, self.all_targets, other_rule.target, other_rule.all_targets, 'target'):
return False
# a different target means running with a different profile, therefore we have to be more strict than _is_covered_aare()
# XXX should we enforce an exact match for a) exec and/or b) link target?
if self.all_targets != other_rule.all_targets:
return False
if self.owner and not other_rule.owner:
return False
# no check for file_keyword and leading_perms - they are not relevant for is_covered()
# still here? -> then it is covered
return True
def _is_equal_localvars(self, rule_obj, strict):
"""compare if rule-specific variables are equal"""
if self.owner != rule_obj.owner:
return False
if not self._is_equal_aare(self.path, self.all_paths, rule_obj.path, rule_obj.all_paths, 'path'):
return False
if self.perms != rule_obj.perms:
return False
if self.all_perms != rule_obj.all_perms:
return False
if self.exec_perms != rule_obj.exec_perms:
return False
if not self._is_equal_aare(self.target, self.all_targets, rule_obj.target, rule_obj.all_targets, 'target'):
return False
if strict: # file_keyword and leading_perms are only cosmetics, but still a difference
if self.file_keyword != rule_obj.file_keyword:
return False
if self.leading_perms != rule_obj.leading_perms:
return False
return True
def severity(self, sev_db):
if self.all_paths:
severity = sev_db.rank_path('/**', 'mrwlkix')
else:
severity = -1
# TODO: special handling for link / link subset?
sev = sev_db.rank_path(self.path.regex, self._joint_perms())
if isinstance(sev, int): # type check avoids breakage caused by 'unknown'
severity = max(severity, sev)
if severity == -1:
severity = sev # effectively 'unknown'
return severity
def _logprof_header_localvars(self):
headers = []
path = logprof_value_or_all(self.path, self.all_paths)
headers.extend((_('Path'), path))
old_mode = ''
if self.original_perms:
original_perms_set = {}
for who in ['all', 'owner']:
original_perms_set[who] = {}
original_perms_set[who]['perms'] = self.original_perms['allow'][who]
original_perms_set[who]['exec_perms'] = None
if self.original_perms['allow'][who] == FileRule.ALL:
original_perms_set[who]['perms'] = set(implicit_all_permissions)
original_perms_set[who]['exec_perms'] = 'ix'
original_perms_all = self._join_given_perms(original_perms_set['all']['perms'],
original_perms_set['all']['exec_perms'])
original_perms_owner = self._join_given_perms(
original_perms_set['owner']['perms'] - original_perms_set['all']['perms'], # only list owner perms that are not covered by other perms
original_perms_set['owner']['exec_perms'])
if original_perms_all and original_perms_owner:
old_mode = '%s + owner %s' % (original_perms_all, original_perms_owner)
elif original_perms_all:
old_mode = original_perms_all
elif original_perms_owner:
old_mode = 'owner %s' % original_perms_owner
else:
old_mode = ''
if old_mode:
headers.extend((_('Old Mode'), old_mode))
perms = logprof_value_or_all(self.perms, self.all_perms)
if self.perms or self.exec_perms:
perms = self._joint_perms()
if self.owner:
perms = 'owner %s' % perms
if not self.all_targets:
perms = "%s -> %s" % (perms, self.target.regex)
headers.extend((_('New Mode'), perms))
# TODO: different output for link rules?
# file_keyword and leading_perms are not really relevant
return headers
def glob(self):
"""Change path to next possible glob"""
if self.all_paths:
return
self.path = self.path.glob_path()
self.raw_rule = None
def glob_ext(self):
"""Change path to next possible glob with extension"""
if self.all_paths:
return
self.path = self.path.glob_path_withext()
self.raw_rule = None
def edit_header(self):
if self.all_paths:
raise AppArmorBug('Attemp to edit bare file rule')
return (_('Enter new path: '), self.path.regex)
def validate_edit(self, newpath):
if self.all_paths:
raise AppArmorBug('Attemp to edit bare file rule')
newpath = AARE(newpath, True) # might raise AppArmorException if the new path doesn't start with / or a variable
return newpath.match(self.path)
def store_edit(self, newpath):
if self.all_paths:
raise AppArmorBug('Attemp to edit bare file rule')
self.path = AARE(newpath, True) # might raise AppArmorException if the new path doesn't start with / or a variable
self.raw_rule = None
@staticmethod
def hashlog_from_event(hl, e):
# FileRule can be of two types, "file" or "exec"
if e['operation'] == 'exec':
if not e['name']:
raise AppArmorException('exec without executed binary')
if not e['name2']:
e['name2'] = '' # exec events in enforce mode don't have target=...
hl[e['name']][e['name2']] = True
return
# Map c (create) and d (delete) to w (logging is more detailed than the profile language)
dmask = e['denied_mask']
dmask = dmask.replace('c', 'w')
dmask = dmask.replace('d', 'w')
owner = False
if '::' in dmask:
# old log styles used :: to indicate if permissions are meant for owner or other
(owner_d, other_d) = dmask.split('::')
if owner_d and other_d:
raise AppArmorException(
'Found log event with both owner and other permissions. Please open a bugreport!')
if owner_d:
dmask = owner_d
owner = True
else:
dmask = other_d
if e.get('ouid') is not None and e['fsuid'] == e['ouid']:
# in current log style, owner permissions are indicated by a match of fsuid and ouid
owner = True
if 'x' in dmask and dmask != 'x':
dmask = dmask.replace('x', '') # if dmask contains x and another mode, drop x here - we should see a separate exec event
for perm in dmask:
if perm in 'mrwalk': # intentionally not allowing 'x' here
hl[e['name']][owner][perm] = True
else:
raise AppArmorException(_('Log contains unknown mode %s') % dmask)
@classmethod
def from_hashlog(cls, hl):
for h1, h2 in BaseRule.generate_rules_from_hashlog(hl, 2):
# FileRule can be either a 'normal' or an 'exec' file rule. These rules are encoded in hashlog differently.
if hl[h1][h2] is True: # Exec Rule
name = h1
yield FileRule(name, None, FileRule.ANY_EXEC, FileRule.ALL, owner=False, log_event=True)
else:
path = h1
owner = h2
mode = set(hl[path][owner].keys())
# logparser sums up multiple log events, so both 'a' and 'w' can be present
if 'a' in mode and 'w' in mode:
mode.remove('a')
yield cls(path, mode, None, FileRule.ALL, owner=owner, log_event=True)
# TODO: check for existing rules with this path, and merge them into one rule
class FileRuleset(BaseRuleset):
"""Class to handle and store a collection of file rules"""
def get_rules_for_path(self, path, audit=False, deny=False):
"""Get all rules matching the given path
path can be str or AARE
If audit is True, only return rules with the audit flag set.
If deny is True, only return matching deny rules"""
matching_rules = type(self)()
for rule in self.rules:
if (rule.all_paths or rule.path.match(path)) and ((not deny) or rule.deny) and ((not audit) or rule.audit):
matching_rules.add(rule)
return matching_rules
def get_perms_for_path(self, path, audit=False, deny=False):
"""Get the summarized permissions of all rules matching the given path, and the list of paths involved in the calculation
path can be str or AARE
If audit is True, only analyze rules with the audit flag set.
If deny is True, only analyze matching deny rules
Returns {'allow': {'owner': set_of_perms, 'all': set_of_perms},
'deny': {'owner': set_of_perms, 'all': set_of_perms},
'path': involved_paths}
Note: exec rules and exec/link target are not honored!
"""
# XXX do we need to honor the link target?
perms = {
'allow': {'owner': set(), 'all': set()},
'deny': {'owner': set(), 'all': set()},
}
all_perms = {
'allow': {'owner': False, 'all': False},
'deny': {'owner': False, 'all': False},
}
paths = set()
matching_rules = self.get_rules_for_path(path, audit, deny)
for rule in matching_rules.rules:
allow_or_deny = 'allow'
if rule.deny:
allow_or_deny = 'deny'
owner_or_all = 'all'
if rule.owner:
owner_or_all = 'owner'
if rule.all_perms:
all_perms[allow_or_deny][owner_or_all] = True
elif rule.perms:
perms[allow_or_deny][owner_or_all] = perms[allow_or_deny][owner_or_all].union(rule.perms)
paths.add(rule.path.regex)
allow = {}
deny = {}
for who in ('all', 'owner'):
if all_perms['allow'][who]:
allow[who] = FileRule.ALL
else:
allow[who] = perms['allow'][who]
if all_perms['deny'][who]:
deny[who] = FileRule.ALL
else:
deny[who] = perms['deny'][who]
return {'allow': allow, 'deny': deny, 'paths': paths}
def get_exec_rules_for_path(self, path, only_exact_matches=True):
"""Get all rules matching the given path that contain exec permissions
path can be str or AARE"""
matches = type(self)()
for rule in self.get_rules_for_path(path).rules:
if rule.exec_perms:
if rule.path.is_equal(path):
matches.add(rule)
elif not only_exact_matches:
matches.add(rule)
return matches
def get_exec_conflict_rules(self, oldrule):
"""check if one of the exec rules conflict with oldrule. If yes, return the conflicting rules."""
conflictingrules = type(self)()
if oldrule.exec_perms:
execrules = self.get_exec_rules_for_path(oldrule.path)
for mergerule in execrules.rules:
if mergerule.exec_perms != oldrule.exec_perms or mergerule.target != oldrule.target:
conflictingrules.add(mergerule)
return conflictingrules
def split_perms(perm_string, deny):
"""parse permission string
- perm_string: the permission string to parse
- deny: True if this is a deny rule
"""
perms = set()
exec_mode = None
while perm_string:
if perm_string[0] in file_permissions:
perms.add(perm_string[0])
perm_string = perm_string[1:]
elif perm_string.startswith('x'):
if not deny:
raise AppArmorException(_("'x' must be preceded by an exec qualifier (i, P, C or U)"))
exec_mode = 'x'
perm_string = perm_string[1:]
elif perm_string.startswith(allow_exec_transitions):
if exec_mode and exec_mode != perm_string[0:2]:
raise AppArmorException(_('conflicting execute permissions found: %s and %s' % (exec_mode, perm_string[0:2])))
exec_mode = perm_string[0:2]
perm_string = perm_string[2:]
elif perm_string.startswith(allow_exec_fallback_transitions):
if exec_mode and exec_mode != perm_string[0:3]:
raise AppArmorException(_('conflicting execute permissions found: %s and %s' % (exec_mode, perm_string[0:3])))
exec_mode = perm_string[0:3]
perm_string = perm_string[3:]
else:
raise AppArmorException(_('permission contains unknown character(s) %s' % perm_string))
return perms, exec_mode
def perms_with_a(perms):
"""if perms includes 'w', add 'a' perms
- perms: the original permissions
"""
if not perms or 'w' not in perms:
return perms # no need to change anything
perms_with_a = set(perms)
perms_with_a.add('a')
return perms_with_a