From 4df5ac780d5c81cdefce708f110987a78ce263dd Mon Sep 17 00:00:00 2001 From: Christian Boltz Date: Sun, 3 May 2020 13:41:19 +0200 Subject: [PATCH] Add IncludeRule and IncludeRuleset including tests These classes are meant to handle 'include' and 'include if exists' rules. Due to restrictions in re_match_include_parse(), some cases in is_covered_localvars() and is_equal_localvars() can't be reached in the unittests. Also, IncludeRule isn't used in aa-logprof (yet?), which means logprof_header_localvars() result format isn't decided yet, and therefore not tested. This means test coverage for the new classes isn't 100% this time ;-) --- utils/apparmor/rule/include.py | 130 ++++++++++ utils/test/test-include.py | 426 +++++++++++++++++++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 utils/apparmor/rule/include.py create mode 100644 utils/test/test-include.py diff --git a/utils/apparmor/rule/include.py b/utils/apparmor/rule/include.py new file mode 100644 index 000000000..d93d675b3 --- /dev/null +++ b/utils/apparmor/rule/include.py @@ -0,0 +1,130 @@ +# ---------------------------------------------------------------------- +# Copyright (C) 2020 Christian Boltz +# +# 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.regex import RE_INCLUDE, re_match_include_parse +from apparmor.common import AppArmorBug, AppArmorException, type_is_str +from apparmor.rule import BaseRule, BaseRuleset, parse_comment + +# setup module translations +from apparmor.translations import init_translation +_ = init_translation() + + +class IncludeRule(BaseRule): + '''Class to handle and store a single include rule''' + + rule_name = 'include' + + def __init__(self, path, ifexists, ismagic, audit=False, deny=False, allow_keyword=False, + comment='', log_event=None): + + super(IncludeRule, self).__init__(audit=audit, deny=deny, + allow_keyword=allow_keyword, + comment=comment, + log_event=log_event) + + # include doesn't support audit or deny + if audit: + raise AppArmorBug('Attempt to initialize IncludeRule with audit flag') + if deny: + raise AppArmorBug('Attempt to initialize IncludeRule with deny flag') + + if type(ifexists) is not bool: + raise AppArmorBug('Passed unknown type for ifexists to IncludeRule: %s' % ifexists) + if type(ismagic) is not bool: + raise AppArmorBug('Passed unknown type for ismagic to IncludeRule: %s' % ismagic) + if not type_is_str(path): + raise AppArmorBug('Passed unknown type for path to IncludeRule: %s' % path) + if not path: + raise AppArmorBug('Passed empty path to IncludeRule: %s' % path) + + self.path = path + self.ifexists = ifexists + self.ismagic = ismagic + + @classmethod + def _match(cls, raw_rule): + return RE_INCLUDE.search(raw_rule) + + @classmethod + def _parse(cls, raw_rule): + '''parse raw_rule and return IncludeRule''' + + matches = cls._match(raw_rule) + if not matches: + raise AppArmorException(_("Invalid include rule '%s'") % raw_rule) + + comment = parse_comment(matches) + + # TODO: move re_match_include_parse() from regex.py to this class after converting all code to use IncludeRule + path, ifexists, ismagic = re_match_include_parse(raw_rule) + + return IncludeRule(path, ifexists, ismagic, + audit=False, deny=False, allow_keyword=False, comment=comment) + + def get_clean(self, depth=0): + '''return rule (in clean/default formatting)''' + + space = ' ' * depth + + ifexists_txt = '' + if self.ifexists: + ifexists_txt = ' if exists' + + if self.ismagic: + return('%sinclude%s <%s>%s' % (space, ifexists_txt, self.path, self.comment)) + else: + return('%sinclude%s "%s"%s' % (space, ifexists_txt, self.path, self.comment)) + + def is_covered_localvars(self, other_rule): + '''check if other_rule is covered by this rule object''' + + if (self.path != other_rule.path): + return False + + if (self.ifexists != other_rule.ifexists) and (self.ifexists == True): # "if exists" is allowed to differ + return False + + if (self.ismagic != other_rule.ismagic): + return False + + # still here? -> then it is covered + return True + + def is_equal_localvars(self, rule_obj, strict): + '''compare if rule-specific variables are equal''' + + if not type(rule_obj) == IncludeRule: + raise AppArmorBug('Passed non-include rule: %s' % str(rule_obj)) + + if (self.path != rule_obj.path): + return False + + if (self.ifexists != rule_obj.ifexists): + return False + + if (self.ismagic != rule_obj.ismagic): + return False + + return True + + def logprof_header_localvars(self): + return [ + _('Include'), self.get_clean(), + ] + + +class IncludeRuleset(BaseRuleset): + '''Class to handle and store a collection of include rules''' + pass diff --git a/utils/test/test-include.py b/utils/test/test-include.py new file mode 100644 index 000000000..a6389ce66 --- /dev/null +++ b/utils/test/test-include.py @@ -0,0 +1,426 @@ +#!/usr/bin/python3 +# ---------------------------------------------------------------------- +# Copyright (C) 2020 Christian Boltz +# +# 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 unittest +from collections import namedtuple +from common_test import AATest, setup_all_loops + +from apparmor.rule.include import IncludeRule, IncludeRuleset +#from apparmor.rule import BaseRule +from apparmor.common import AppArmorException, AppArmorBug +#from apparmor.logparser import ReadLog +from apparmor.translations import init_translation +_ = init_translation() + +exp = namedtuple('exp', [ # 'audit', 'allow_keyword', 'deny', + 'comment', + 'path', 'ifexists', 'ismagic']) + +# --- tests for single IncludeRule --- # + +class IncludeTest(AATest): + def _compare_obj(self, obj, expected): + self.assertEqual(False, obj.allow_keyword) # not supported in include rules, expected to be always False + self.assertEqual(False, obj.audit) # not supported in include rules, expected to be always False + self.assertEqual(False, obj.deny) # not supported in include rules, expected to be always False + self.assertEqual(expected.comment, obj.comment) + + self.assertEqual(expected.path, obj.path) + self.assertEqual(expected.ifexists, obj.ifexists) + self.assertEqual(expected.ismagic, obj.ismagic) + +class IncludeTestParse(IncludeTest): + tests = [ + # IncludeRule object comment path if exists ismagic + # #include + ('#include ', exp('', 'abstractions/base', False, True )), # magic path + ('#include # comment', exp(' # comment', 'abstractions/base', False, True )), + ('#include#comment', exp(' #comment', 'abstractions/base', False, True )), + (' #include ', exp('', 'abstractions/base', False, True )), + ('#include "/foo/bar"', exp('', '/foo/bar', False, False)), # absolute path + ('#include "/foo/bar" # comment', exp(' # comment', '/foo/bar', False, False)), + ('#include "/foo/bar"#comment', exp(' #comment', '/foo/bar', False, False)), + (' #include "/foo/bar" ', exp('', '/foo/bar', False, False)), + # include (without #) + ('include ', exp('', 'abstractions/base', False, True )), # magic path + ('include # comment', exp(' # comment', 'abstractions/base', False, True )), + ('include#comment', exp(' #comment', 'abstractions/base', False, True )), + (' include ', exp('', 'abstractions/base', False, True )), + ('include "/foo/bar"', exp('', '/foo/bar', False, False)), # absolute path + ('include "/foo/bar" # comment', exp(' # comment', '/foo/bar', False, False)), + ('include "/foo/bar"#comment', exp(' #comment', '/foo/bar', False, False)), + (' include "/foo/bar" ', exp('', '/foo/bar', False, False)), + # #include if exists + ('#include if exists ', exp('', 'abstractions/base', True, True )), # magic path + ('#include if exists # comment', exp(' # comment', 'abstractions/base', True, True )), + ('#include if exists#comment', exp(' #comment', 'abstractions/base', True, True )), + (' #include if exists ', exp('', 'abstractions/base', True, True )), + ('#include if exists "/foo/bar"', exp('', '/foo/bar', True, False)), # absolute path + ('#include if exists "/foo/bar" # comment', exp(' # comment', '/foo/bar', True, False)), + ('#include if exists "/foo/bar"#comment', exp(' #comment', '/foo/bar', True, False)), + (' #include if exists "/foo/bar" ', exp('', '/foo/bar', True, False)), + # include if exists (without #) + ('include if exists ', exp('', 'abstractions/base', True, True )), # magic path + ('include if exists # comment', exp(' # comment', 'abstractions/base', True, True )), + ('include if exists#comment', exp(' #comment', 'abstractions/base', True, True )), + (' include if exists ', exp('', 'abstractions/base', True, True )), + ('include if exists "/foo/bar"', exp('', '/foo/bar', True, False)), # absolute path + ('include if exists "/foo/bar" # comment', exp(' # comment', '/foo/bar', True, False)), + ('include if exists "/foo/bar"#comment', exp(' #comment', '/foo/bar', True, False)), + (' include if exists "/foo/bar" ', exp('', '/foo/bar', True, False)), + ] + + def _run_test(self, rawrule, expected): + self.assertTrue(IncludeRule.match(rawrule)) + obj = IncludeRule.parse(rawrule) + self.assertEqual(rawrule.strip(), obj.raw_rule) + self._compare_obj(obj, expected) + +class IncludeTestParseInvalid(IncludeTest): + tests = [ +# (' some #include if exists ', AppArmorException), +# (' /etc/fstab r,', AppArmorException), +# ('/usr/include r,', AppArmorException), +# ('/include r,', AppArmorException), + ] + + def _run_test(self, rawrule, expected): + self.assertTrue(IncludeRule.match(rawrule)) # the above invalid rules still match the main regex! + with self.assertRaises(expected): + IncludeRule.parse(rawrule) + +# class IncludeTestParseFromLog(IncludeTest): # we'll never have log events for includes + +class IncludeFromInit(IncludeTest): + tests = [ + # IncludeRule object ifexists ismagic comment path ifexists ismagic + (IncludeRule('abstractions/base', False, False) , exp('', 'abstractions/base', False, False )), + (IncludeRule('foo', True, False) , exp('', 'foo', True, False )), + (IncludeRule('bar', False, True) , exp('', 'bar', False, True )), + (IncludeRule('baz', True, True) , exp('', 'baz', True, True )), + (IncludeRule('comment', False, False, comment='# cmt') , exp('# cmt', 'comment', False, False )), + ] + + def _run_test(self, obj, expected): + self._compare_obj(obj, expected) + +class InvalidIncludeInit(AATest): + tests = [ + # init params expected exception + ([False, False, False ] , AppArmorBug), # wrong type for path + (['', False, False ] , AppArmorBug), # empty path + ([None, False, False ] , AppArmorBug), # wrong type for path +# ([' ', False, False ] , AppArmorBug), # whitespace-only path + (['foo', None, False ] , AppArmorBug), # wrong type for ifexists + (['foo', '', False ] , AppArmorBug), # wrong type for ifexists + (['foo', False, None ] , AppArmorBug), # wrong type for ismagic + (['foo', False, '' ] , AppArmorBug), # wrong type for ismagic + ] + + def _run_test(self, params, expected): + with self.assertRaises(expected): + IncludeRule(params[0], params[1], params[2]) + + def test_missing_params_1(self): + with self.assertRaises(TypeError): + IncludeRule() + + def test_missing_params_2(self): + with self.assertRaises(TypeError): + IncludeRule('foo') + + def test_missing_params_3(self): + with self.assertRaises(TypeError): + IncludeRule('foo', False) + + def test_audit_true(self): + with self.assertRaises(AppArmorBug): + IncludeRule('foo', False, False, audit=True) + + def test_deny_true(self): + with self.assertRaises(AppArmorBug): + IncludeRule('foo', False, False, deny=True) + +class InvalidIncludeTest(AATest): + def _check_invalid_rawrule(self, rawrule, matches_regex = False): + obj = None + self.assertEqual(IncludeRule.match(rawrule), matches_regex) + with self.assertRaises(AppArmorException): + obj = IncludeRule.parse(rawrule) + + self.assertIsNone(obj, 'IncludeRule handed back an object unexpectedly') + + def test_invalid_include_missing_path(self): + self._check_invalid_rawrule('include', matches_regex=True) # missing path + + def test_invalid_non_IncludeRule(self): + self._check_invalid_rawrule('dbus,') # not a include rule + +# def test_empty_data_1(self): +# obj = IncludeRule('foo', False, False) +# obj.path = '' +# # no path set +# with self.assertRaises(AppArmorBug): +# obj.get_clean(1) + +class WriteIncludeTestAATest(AATest): + def _run_test(self, rawrule, expected): + self.assertTrue(IncludeRule.match(rawrule)) + obj = IncludeRule.parse(rawrule) + clean = obj.get_clean() + raw = obj.get_raw() + + self.assertEqual(expected.strip(), clean, 'unexpected clean rule') + self.assertEqual(rawrule.strip(), raw, 'unexpected raw rule') + + tests = [ + # raw rule clean rule + (' include ', 'include ' ), +# (' include foo ', 'include "foo"' ), # several test cases disabled due to implementation restrictions, see re_match_include_parse() +# (' include "foo" ', 'include "foo"' ), +# (' include /foo ', 'include "/foo"' ), + (' include "/foo" ', 'include "/foo"' ), + + (' include # bar ', 'include # bar' ), +# (' include foo # bar ', 'include "foo" # bar' ), +# (' include "foo" # bar ', 'include "foo" # bar' ), +# (' include /foo # bar ', 'include "/foo" # bar' ), + (' include "/foo" # bar ', 'include "/foo" # bar' ), + + (' include if exists ', 'include if exists ' ), +# (' include if exists foo ', 'include if exists "foo"' ), +# (' include if exists "foo" ', 'include if exists "foo"' ), +# (' include if exists /foo ', 'include if exists "/foo"' ), + (' include if exists "/foo" ', 'include if exists "/foo"' ), + + # and the same again with #include... + (' #include ', 'include ' ), +# (' #include foo ', 'include "foo"' ), +# (' #include "foo" ', 'include "foo"' ), +# (' #include /foo ', 'include "/foo"' ), + (' #include "/foo" ', 'include "/foo"' ), + + (' #include # bar ', 'include # bar' ), +# (' #include foo # bar ', 'include "foo" # bar' ), +# (' #include "foo" # bar ', 'include "foo" # bar' ), +# (' #include /foo # bar ', 'include "/foo" # bar' ), + (' #include "/foo" # bar ', 'include "/foo" # bar' ), + + (' #include if exists ', 'include if exists ' ), +# (' #include if exists foo ', 'include if exists "foo"' ), +# (' #include if exists "foo" ', 'include if exists "foo"' ), +# (' #include if exists /foo ', 'include if exists "/foo"' ), + (' #include if exists "/foo" ', 'include if exists "/foo"' ), + ] + + def test_write_manually(self): + obj = IncludeRule('abs/foo', False, True, comment=' # cmt') + + expected = ' include # cmt' + + self.assertEqual(expected, obj.get_clean(2), 'unexpected clean rule') + self.assertEqual(expected, obj.get_raw(2), 'unexpected raw rule') + + +class IncludeCoveredTest(AATest): + def _run_test(self, param, expected): + obj = IncludeRule.parse(self.rule) + check_obj = IncludeRule.parse(param) + + self.assertTrue(IncludeRule.match(param)) + + self.assertEqual(obj.is_equal(check_obj), expected[0], 'Mismatch in is_equal, expected %s' % expected[0]) + self.assertEqual(obj.is_equal(check_obj, True), expected[1], 'Mismatch in is_equal/strict, expected %s' % expected[1]) + + self.assertEqual(obj.is_covered(check_obj), expected[2], 'Mismatch in is_covered, expected %s' % expected[2]) + self.assertEqual(obj.is_covered(check_obj, True, True), expected[3], 'Mismatch in is_covered/exact, expected %s' % expected[3]) + +class IncludeCoveredTest_01(IncludeCoveredTest): + rule = 'include ' + + tests = [ + # rule equal strict equal covered covered exact + ('include ' , [ True , True , True , True ]), + ('#include ' , [ True , False , True , True ]), + ('include if exists ' , [ False , False , True , True ]), + ('#include if exists ' , [ False , False , True , True ]), + ('include ' , [ False , False , False , False ]), +# ('include "foo"' , [ False , False , False , False ]), # disabled due to implementation restrictions, see re_match_include_parse() +# ('include if exists "foo"' , [ False , False , False , False ]), + ] + +class IncludeCoveredTest_02(IncludeCoveredTest): + rule = 'include if exists ' + + tests = [ + # rule equal strict equal covered covered exact + ('include ' , [ False , False , False , False ]), + ('#include ' , [ False , False , False , False ]), + ('#include if exists ' , [ True , False , True , True ]), + ('include ' , [ False , False , False , False ]), +# ('include "foo"' , [ False , False , False , False ]), # disabled due to implementation restrictions, see re_match_include_parse() +# ('include if exists "foo"' , [ False , False , False , False ]), + ] + +#class IncludeCoveredTest_Invalid(AATest): +# def test_borked_obj_is_covered_1(self): +# obj = IncludeRule.parse('include ') + +# testobj = IncludeRule('foo', True, True) +# testobj.path = '' + +# with self.assertRaises(AppArmorBug): +# obj.is_covered(testobj) + +# def test_borked_obj_is_covered_2(self): +# obj = IncludeRule.parse('include send set=quit peer=/foo,') + +# testobj = IncludeRule('send', 'quit', '/foo') +# testobj.include = '' + +# with self.assertRaises(AppArmorBug): +# obj.is_covered(testobj) + +# def test_borked_obj_is_covered_3(self): +# obj = IncludeRule.parse('include send set=quit peer=/foo,') + +# testobj = IncludeRule('send', 'quit', '/foo') +# testobj.peer = '' + +# with self.assertRaises(AppArmorBug): +# obj.is_covered(testobj) + +# def test_invalid_is_covered(self): +# obj = IncludeRule.parse('include send,') + +# testobj = BaseRule() # different type + +# with self.assertRaises(AppArmorBug): +# obj.is_covered(testobj) + +# def test_invalid_is_equal(self): +# obj = IncludeRule.parse('include send,') + +# testobj = BaseRule() # different type + +# with self.assertRaises(AppArmorBug): +# obj.is_equal(testobj) + +class IncludeLogprofHeaderTest(AATest): +# tests = [ +# ('include,', [ _('Access mode'), _('ALL'), _('Include'), _('ALL'), _('Peer'), _('ALL'), ]), +# ('include send,', [ _('Access mode'), 'send', _('Include'), _('ALL'), _('Peer'), _('ALL'), ]), +# ('include send set=quit,', [ _('Access mode'), 'send', _('Include'), 'quit', _('Peer'), _('ALL'), ]), +# ('deny include,', [_('Qualifier'), 'deny', _('Access mode'), _('ALL'), _('Include'), _('ALL'), _('Peer'), _('ALL'), ]), +# ('allow include send,', [_('Qualifier'), 'allow', _('Access mode'), 'send', _('Include'), _('ALL'), _('Peer'), _('ALL'), ]), +# ('audit include send set=quit,', [_('Qualifier'), 'audit', _('Access mode'), 'send', _('Include'), 'quit', _('Peer'), _('ALL'), ]), +# ('audit deny include send,', [_('Qualifier'), 'audit deny', _('Access mode'), 'send', _('Include'), _('ALL'), _('Peer'), _('ALL'), ]), +# ('include set=(int, quit),', [ _('Access mode'), _('ALL'), _('Include'), 'int quit', _('Peer'), _('ALL'), ]), +# ('include set=( quit, int),', [ _('Access mode'), _('ALL'), _('Include'), 'int quit', _('Peer'), _('ALL'), ]), +# ('include (send, receive) set=( quit, int) peer=/foo,', [ _('Access mode'), 'receive send', _('Include'), 'int quit', _('Peer'), '/foo', ]), +# ] + + def _run_test(self, params, expected): + obj = IncludeRule._parse(params) + self.assertEqual(obj.logprof_header(), expected) + +## --- tests for IncludeRuleset --- # + +class IncludeRulesTest(AATest): + def test_empty_ruleset(self): + ruleset = IncludeRuleset() + ruleset_2 = IncludeRuleset() + self.assertEqual([], ruleset.get_raw(2)) + self.assertEqual([], ruleset.get_clean(2)) + self.assertEqual([], ruleset_2.get_raw(2)) + self.assertEqual([], ruleset_2.get_clean(2)) + + def test_ruleset_1(self): + ruleset = IncludeRuleset() + rules = [ + ' include ', + ' #include "/bar" ', + ] + + expected_raw = [ + 'include ', + '#include "/bar"', + '', + ] + + expected_clean = [ + 'include "/bar"', + 'include ', + '', + ] + + for rule in rules: + ruleset.add(IncludeRule.parse(rule)) + + self.assertEqual(expected_raw, ruleset.get_raw()) + self.assertEqual(expected_clean, ruleset.get_clean()) + + def test_ruleset_2(self): + ruleset = IncludeRuleset() + rules = [ + ' include ', + ' #include "/bar" ', + '#include if exists "/asdf" ', + ' include if exists ', + ] + + expected_raw = [ + 'include ', + '#include "/bar"', + '#include if exists "/asdf"', + 'include if exists ', + '', + ] + + expected_clean = [ + 'include "/bar"', + 'include ', + 'include if exists "/asdf"', + 'include if exists ', + '', + ] + + for rule in rules: + ruleset.add(IncludeRule.parse(rule)) + + self.assertEqual(expected_raw, ruleset.get_raw()) + self.assertEqual(expected_clean, ruleset.get_clean()) + +class IncludeGlobTestAATest(AATest): + def setUp(self): + self.maxDiff = None + self.ruleset = IncludeRuleset() + +# def test_glob(self): +# with self.assertRaises(NotImplementedError): +# # get_glob_ext is not available for include rules +# self.ruleset.get_glob('include send set=int,') + + def test_glob_ext(self): + with self.assertRaises(NotImplementedError): + # get_glob_ext is not available for include rules + self.ruleset.get_glob_ext('include send set=int,') + +#class IncludeDeleteTestAATest(AATest): +# pass + +setup_all_loops(__name__) +if __name__ == '__main__': + unittest.main(verbosity=1)