Repository: a13xp0p0v/kernel-hardening-checker

Issue:
Implement parsing of the `CONFIG_LSM` kconfig option
The `CONFIG_LSM` kconfig option contains a list of LSM modules loaded by the kernel.
Let's create a simple mechanism for checking that it contains the needed LSM module.

The checking rule might look like this:
```
l += [KconfigCheck('self_protection', 'kspp', 'LSM', '*lockdown*')]
``` 
Here `'*lockdown*'` means that `lockdown` is in the comma-separated list.

Hints from issue comments:

Solution patch:
diff --git a/kernel_hardening_checker/__init__.py b/kernel_hardening_checker/__init__.py
index 7d10ed3..e635980 100755
--- a/kernel_hardening_checker/__init__.py
+++ b/kernel_hardening_checker/__init__.py
@@ -545,7 +545,7 @@ def main() -> None:
         add_kconfig_checks(config_checklist, arch)
         print(f'CONFIG_{arch}=y') # the Kconfig fragment should describe the microarchitecture
         for opt in config_checklist:
-            if opt.name in ('CONFIG_ARCH_MMAP_RND_BITS', 'CONFIG_ARCH_MMAP_RND_COMPAT_BITS'):
+            if opt.name in ('CONFIG_ARCH_MMAP_RND_BITS', 'CONFIG_ARCH_MMAP_RND_COMPAT_BITS', 'CONFIG_LSM'):
                 continue # don't add Kconfig options with a value that needs refinement
             if opt.expected == 'is not off':
                 continue # don't add Kconfig options without explicitly recommended values
diff --git a/kernel_hardening_checker/checks.py b/kernel_hardening_checker/checks.py
index ebb89af..64b4ad5 100755
--- a/kernel_hardening_checker/checks.py
+++ b/kernel_hardening_checker/checks.py
@@ -153,6 +153,7 @@ def add_kconfig_checks(l: List[ChecklistObjType], arch: str) -> None:
     l += [KconfigCheck('self_protection', 'kspp', 'STATIC_USERMODEHELPER', 'y')] # needs userspace support
     l += [KconfigCheck('self_protection', 'kspp', 'SCHED_CORE', 'y')]
     l += [KconfigCheck('self_protection', 'kspp', 'SECURITY_LOCKDOWN_LSM', 'y')]
+    l += [KconfigCheck('self_protection', 'kspp', 'LSM', '*lockdown*')]
     l += [KconfigCheck('self_protection', 'kspp', 'SECURITY_LOCKDOWN_LSM_EARLY', 'y')]
     l += [KconfigCheck('self_protection', 'kspp', 'LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY', 'y')]
     l += [OR(KconfigCheck('self_protection', 'kspp', 'LIST_HARDENED', 'y'),
@@ -290,7 +291,9 @@ def add_kconfig_checks(l: List[ChecklistObjType], arch: str) -> None:
     if arch == 'ARM':
         l += [KconfigCheck('security_policy', 'kspp', 'SECURITY', 'y')]
     l += [KconfigCheck('security_policy', 'kspp', 'SECURITY_YAMA', 'y')]
+    l += [KconfigCheck('security_policy', 'kspp', 'LSM', '*yama*')]
     l += [KconfigCheck('security_policy', 'kspp', 'SECURITY_LANDLOCK', 'y')]
+    l += [KconfigCheck('security_policy', 'kspp', 'LSM', '*landlock*')]
     l += [KconfigCheck('security_policy', 'kspp', 'SECURITY_SELINUX_DISABLE', 'is not set')]
     l += [KconfigCheck('security_policy', 'kspp', 'SECURITY_SELINUX_BOOTPARAM', 'is not set')]
     l += [KconfigCheck('security_policy', 'kspp', 'SECURITY_SELINUX_DEVELOP', 'is not set')]
@@ -300,6 +303,12 @@ def add_kconfig_checks(l: List[ChecklistObjType], arch: str) -> None:
              KconfigCheck('security_policy', 'a13xp0p0v', 'SECURITY_APPARMOR', 'y'),
              KconfigCheck('security_policy', 'a13xp0p0v', 'SECURITY_SMACK', 'y'),
              KconfigCheck('security_policy', 'a13xp0p0v', 'SECURITY_TOMOYO', 'y'))] # one of major LSMs implementing MAC
+    l += [OR(KconfigCheck('security_policy', 'a13xp0p0v', 'LSM', '*selinux*'),
+             KconfigCheck('security_policy', 'a13xp0p0v', 'LSM', '*apparmor*'),
+             KconfigCheck('security_policy', 'a13xp0p0v', 'LSM', '*smack*'),
+             KconfigCheck('security_policy', 'a13xp0p0v', 'LSM', '*tomoyo*'))]
+             # N.B. Here we check that one of major LSMs implementing MAC is in the CONFIG_LSM list,
+             # but we can't be sure that it's the same module that was detected in the check above.
 
     # N.B. We don't use 'if arch' for the 'cut_attack_surface' checks that require 'is not set'.
     # It makes the maintainance easier. These kernel options should be disabled anyway.
diff --git a/kernel_hardening_checker/engine.py b/kernel_hardening_checker/engine.py
index 038e45c..3de443c 100755
--- a/kernel_hardening_checker/engine.py
+++ b/kernel_hardening_checker/engine.py
@@ -81,6 +81,22 @@ class OptCheck:
         self.state = data
 
     def check(self) -> None:
+        # handle the '*value*' check
+        # (search value in a comma-separated list enclosed in double quotes)
+        if self.expected.startswith('*') and self.expected.endswith('*'):
+            if self.state is None:
+                self.result = 'FAIL: is not found'
+                return
+            assert(self.state.startswith('"') and self.state.endswith('"')), \
+                   f'not a list enclosed in double quotes: {self.name}={self.state}'
+            val = self.expected.strip('*')
+            val_list = self.state.strip('"').split(',')
+            if val in val_list:
+                self.result = f'OK: in {self.state}'
+            else:
+                self.result = f'FAIL: not in {self.state}'
+            return
+
         # handle the 'is present' check
         if self.expected == 'is present':
             if self.state is None:
@@ -278,6 +294,8 @@ class OR(ComplexOptCheck):
                     else:
                         if opt.result == 'OK':
                             self.result = f'OK: {opt.name} is "{opt.expected}"'
+                        elif opt.result.startswith('OK: in \"'):
+                            self.result = f'OK: "{opt.expected.strip("*")}" is in {opt.name}'
                         elif opt.result == 'OK: is not found':
                             self.result = f'OK: {opt.name} is not found'
                         elif opt.result == 'OK: is present':
@@ -314,6 +332,8 @@ class AND(ComplexOptCheck):
                 else:
                     if opt.result.startswith('FAIL: \"') or opt.result == 'FAIL: is not found':
                         self.result = f'FAIL: {opt.name} is not "{opt.expected}"'
+                    elif opt.result.startswith('FAIL: not in \"'):
+                        self.result = f'FAIL: "{opt.expected.strip("*")}" is not in {opt.name}'
                     elif opt.result == 'FAIL: is not present':
                         self.result = f'FAIL: {opt.name} is not present'
                     elif opt.result in ('FAIL: is off', 'FAIL: is off, "0"', 'FAIL: is off, "is not set"'):


Test patch:
diff --git a/kernel_hardening_checker/test_engine.py b/kernel_hardening_checker/test_engine.py
index 8e8312f..c4e3584 100755
--- a/kernel_hardening_checker/test_engine.py
+++ b/kernel_hardening_checker/test_engine.py
@@ -130,6 +130,9 @@ class TestEngine(unittest.TestCase):
         config_checklist += [KconfigCheck('reason_8', 'decision_8', 'NAME_8', 'is not off')]
         config_checklist += [KconfigCheck('reason_9', 'decision_9', 'NAME_9', 'is not off')]
         config_checklist += [KconfigCheck('reason_10', 'decision_10', 'NAME_10', 'is not off')]
+        config_checklist += [KconfigCheck('reason_11', 'decision_11', 'NAME_11', '*expected_11*')]
+        config_checklist += [KconfigCheck('reason_12', 'decision_12', 'NAME_12', '*expected_12*')]
+        config_checklist += [KconfigCheck('reason_13', 'decision_13', 'NAME_13', '*expected_13*')]
 
         # 2. prepare the parsed kconfig options
         parsed_kconfig_options  = {}
@@ -139,6 +142,8 @@ class TestEngine(unittest.TestCase):
         parsed_kconfig_options['CONFIG_NAME_7'] = 'really_not_off'
         parsed_kconfig_options['CONFIG_NAME_8'] = 'off'
         parsed_kconfig_options['CONFIG_NAME_9'] = '0'
+        parsed_kconfig_options['CONFIG_NAME_11'] = '"expected_11,something,UNexpected2"'
+        parsed_kconfig_options['CONFIG_NAME_12'] = '"UNexpected_12,something"'
 
         # 3. run the engine
         self.run_engine(config_checklist, parsed_kconfig_options, None, None, None)
@@ -157,7 +162,10 @@ class TestEngine(unittest.TestCase):
                  {'option_name': 'CONFIG_NAME_7', 'type': 'kconfig', 'desired_val': 'is not off', 'decision': 'decision_7', 'reason': 'reason_7', 'check_result': 'OK: is not off, "really_not_off"', 'check_result_bool': True},
                  {'option_name': 'CONFIG_NAME_8', 'type': 'kconfig', 'desired_val': 'is not off', 'decision': 'decision_8', 'reason': 'reason_8', 'check_result': 'FAIL: is off', 'check_result_bool': False},
                  {'option_name': 'CONFIG_NAME_9', 'type': 'kconfig', 'desired_val': 'is not off', 'decision': 'decision_9', 'reason': 'reason_9', 'check_result': 'FAIL: is off, "0"', 'check_result_bool': False},
-                 {'option_name': 'CONFIG_NAME_10', 'type': 'kconfig', 'desired_val': 'is not off', 'decision': 'decision_10', 'reason': 'reason_10', 'check_result': 'FAIL: is off, not found', 'check_result_bool': False}]
+                 {'option_name': 'CONFIG_NAME_10', 'type': 'kconfig', 'desired_val': 'is not off', 'decision': 'decision_10', 'reason': 'reason_10', 'check_result': 'FAIL: is off, not found', 'check_result_bool': False},
+                 {'option_name': 'CONFIG_NAME_11', 'type': 'kconfig', 'desired_val': '*expected_11*', 'decision': 'decision_11', 'reason': 'reason_11', 'check_result': 'OK: in "expected_11,something,UNexpected2"', 'check_result_bool': True},
+                 {'option_name': 'CONFIG_NAME_12', 'type': 'kconfig', 'desired_val': '*expected_12*', 'decision': 'decision_12', 'reason': 'reason_12', 'check_result': 'FAIL: not in "UNexpected_12,something"', 'check_result_bool': False},
+                 {'option_name': 'CONFIG_NAME_13', 'type': 'kconfig', 'desired_val': '*expected_13*', 'decision': 'decision_13', 'reason': 'reason_13', 'check_result': 'FAIL: is not found', 'check_result_bool': False}]
         )
 
     def test_simple_cmdline(self) -> None: