diff mbox series

[meta-selinux,1/2] oeqa: add SELinux runtime test cases

Message ID 20261031105443.1845695-1-chenqi_hycx@cetc.com.cn
State New
Headers show
Series [meta-selinux,1/2] oeqa: add SELinux runtime test cases | expand

Commit Message

Chen Qi Oct. 31, 2026, 10:54 a.m. UTC
From: Chen Qi <chenqi_hycx@cetc.com.cn>

Add runtime test cases covering four areas of the SELinux support in this
layer: system status, access denial, the SELinux userspace tools and the
audit subsystem.

Assisted-by: Codewhale:deepseek-v4-flash
Signed-off-by: Chen Qi <chenqi_hycx@cetc.com.cn>
---
 lib/oeqa/runtime/cases/selinux.py | 470 ++++++++++++++++++++++++++++++
 1 file changed, 470 insertions(+)
 create mode 100644 lib/oeqa/runtime/cases/selinux.py
diff mbox series

Patch

diff --git a/lib/oeqa/runtime/cases/selinux.py b/lib/oeqa/runtime/cases/selinux.py
new file mode 100644
index 0000000..2559c31
--- /dev/null
+++ b/lib/oeqa/runtime/cases/selinux.py
@@ -0,0 +1,470 @@ 
+#
+# Copyright OpenEmbedded Contributors
+#
+# SPDX-License-Identifier: MIT
+#
+# Runtime (testimage) cases for the SELinux support provided by meta-selinux.
+#
+# Enable them by adding the module name to TEST_SUITES, e.g.
+#
+#     TEST_SUITES:append = " selinux"
+#
+# Every case skips itself (instead of failing) when the image does not provide
+# the packages, kernel features, SELinux domain or policy variant it needs, so
+# the module is safe to enable for any image.  An image that installs
+# packagegroup-core-selinux (as core-image-selinux does) satisfies the package,
+# feature and kernel requirements of all of them.
+#
+# The cases that inspect or change system state need an administrative domain
+# (sysadm_t, unconfined_t, secadm_t).  With refpolicy-mcs an ssh login as root
+# lands in staff_t, which cannot read the policy store, the audit log or the
+# kernel ring buffer, so those cases skip.  A console login as root gets
+# sysadm_r:sysadm_t; to get the same over ssh, uncomment the system_r:sshd_t
+# line in the policy's contexts/users/root.
+#
+# The denial, module and AVC cases additionally need a policy that confines
+# administrators (the mcs and mls variants).  The targeted and minimum variants
+# run admins as unconfined_t on purpose, so those cases skip there.
+
+import re
+import time
+
+from oeqa.runtime.case import OERuntimeTestCase
+from oeqa.core.decorator.depends import OETestDepends
+from oeqa.core.decorator.data import skipIfNotFeature
+from oeqa.runtime.decorator.package import OEHasPackage
+
+
+# Domains that are allowed to administer the SELinux configuration.
+ADMIN_DOMAINS = ('sysadm_t', 'unconfined_t', 'secadm_t')
+
+# Policy variants in which refpolicy confines administrators.  The targeted and
+# minimum variants deliberately run administrators as unconfined_t, with the
+# same access as if SELinux were disabled (see refpolicy-targeted_git.bb), so
+# the checks below that rely on an access being denied only apply here.
+CONFINING_POLICIES = ('mcs', 'mls')
+
+# Name of the policy module built by SELinuxModuleTest.
+TEST_MODULE_NAME = 'oeqa-local'
+
+# Scratch directory for the policy module sources on the target.
+TEST_MODULE_DIR = '/tmp/oeqa-selinux'
+
+# Source of the test module.  It grants the administrative domain the access
+# refpolicy withholds by default, so loading and removing the module can be
+# observed with an existing file instead of needing a reboot or a relabel.
+TEST_MODULE_TE = """\
+module %(module)s 1.0;
+
+require {
+    type %(domain)s;
+    type shadow_t;
+    class file { getattr open read };
+}
+
+allow %(domain)s shadow_t:file { getattr open read };
+"""
+
+
+def parse_sestatus(output):
+    """
+    Return the key/value pairs of the `sestatus -v` header as a dict.
+
+    `sestatus -v` prints a header block of "Key: value" lines followed by
+    indented sections describing processes, booleans and file contexts; only
+    the header is of interest here, so parsing stops at the first blank line.
+    """
+    info = {}
+    for line in output.splitlines():
+        if not line.strip():
+            break
+        key, sep, value = line.partition(':')
+        if sep and key.strip():
+            info[key.strip()] = value.strip()
+    return info
+
+
+class SELinuxTest(OERuntimeTestCase):
+    """Helpers shared by the SELinux runtime cases."""
+
+    def run_cmd(self, command, timeout=None):
+        status, output = self.target.run(command, timeout=timeout)
+        self.logger.debug('%s\n-> %d\n%s' % (command, status, output))
+        return status, output
+
+    def run_ok(self, command, timeout=None):
+        status, output = self.run_cmd(command, timeout=timeout)
+        self.assertEqual(status, 0, "Command '%s' failed with status %d:\n%s"
+                         % (command, status, output))
+        return output
+
+    def sestatus(self):
+        """Return the parsed `sestatus -v` header."""
+        return parse_sestatus(self.run_ok('sestatus -v'))
+
+    def process_context(self):
+        """Return the SELinux context of the session, or '' if unavailable."""
+        status, output = self.run_cmd('cat /proc/self/attr/current')
+        if status != 0:
+            return ''
+        # The file is not NUL terminated for all readers.
+        return output.replace('\x00', '').strip()
+
+    def selinux_domain(self):
+        """Return the type component of the session's SELinux context."""
+        parts = self.process_context().split(':')
+        return parts[2] if len(parts) > 2 else ''
+
+    def require_admin(self):
+        """
+        Skip unless the session runs in a domain that may administer SELinux.
+
+        staff_t and user_t can query some of the configuration, but they cannot
+        write the policy store, read the audit log or read the kernel ring
+        buffer, so tests that poke at those cannot be meaningful.
+        """
+        domain = self.selinux_domain()
+        if domain not in ADMIN_DOMAINS:
+            self.skipTest(
+                'This test needs an administrative SELinux domain, but the '
+                'session runs as %r.  With refpolicy-mcs an ssh login as root '
+                'lands in staff_t because contexts/users/root has no '
+                'system_r:sshd_t entry; a console login gets '
+                'sysadm_r:sysadm_t.'
+                % (domain or self.process_context()))
+        return domain
+
+    def require_confining_policy(self):
+        """
+        Skip unless the loaded policy confines administrative domains.
+
+        The refpolicy-targeted and -minimum variants explicitly give admins
+        unconfined_t, so on those images an access such as reading /etc/shadow
+        is deliberately allowed and cannot be tested for denial.
+        """
+        info = self.sestatus()
+        policy = info.get('Loaded policy name')
+        if policy not in CONFINING_POLICIES:
+            self.skipTest('Test expects a confining policy (%s), but %r is '
+                          'loaded; targeted and minimum run administrators '
+                          'unconfined on purpose'
+                          % ('/'.join(CONFINING_POLICIES), policy))
+
+    def write_file(self, path, content):
+        self.run_ok('mkdir -p %s' % path.rsplit('/', 1)[0])
+        self.run_ok("cat > %s << 'OEQA_EOF'\n%sOEQA_EOF" % (path, content))
+
+    def shadow_read_denied(self):
+        """Return True when reading /etc/shadow is blocked by SELinux."""
+        status, output = self.run_cmd('cat /etc/shadow')
+        return status != 0 and 'Permission denied' in output
+
+    def require_shadow_denial(self):
+        """Skip unless reading /etc/shadow is blocked, as this test needs it."""
+        if not self.shadow_read_denied():
+            self.skipTest('Reading /etc/shadow is allowed, so this test has '
+                          'no denied access to work with')
+
+    def memtotal_mb(self):
+        """Return the target's total RAM in MB, or None if it cannot tell."""
+        status, output = self.run_cmd(
+            "awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo")
+        if status != 0:
+            return None
+        try:
+            return int(output.strip())
+        except ValueError:
+            return None
+
+
+class SELinuxStatusTest(SELinuxTest):
+    """The system must report SELinux as enabled and enforcing."""
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('policycoreutils-sestatus')
+    @OETestDepends(['ssh.SSHTest.test_ssh'])
+    def test_sestatus(self):
+        output = self.run_ok('sestatus -v')
+        info = parse_sestatus(output)
+
+        self.assertEqual(info.get('SELinux status'), 'enabled',
+                         'SELinux is not enabled:\n%s' % output)
+        self.assertEqual(info.get('Current mode'), 'enforcing',
+                         'SELinux is not enforcing:\n%s' % output)
+        self.logger.info('SELinux enabled with policy %r, MLS %r, session '
+                         'context %r'
+                         % (info.get('Loaded policy name'),
+                            info.get('Policy MLS status'),
+                            self.process_context()))
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OETestDepends(['selinux.SELinuxStatusTest.test_sestatus'])
+    def test_kernel_enforcing(self):
+        # sestatus is userspace; /sys/fs/selinux/enforce is what the kernel
+        # actually enforces.
+        output = self.run_ok('cat /sys/fs/selinux/enforce')
+        self.assertEqual(output.strip(), '1',
+                         'The kernel is not enforcing SELinux (enforce=%r)'
+                         % output.strip())
+
+
+class SELinuxDenialTest(SELinuxTest):
+    """Access that the policy denies must be denied."""
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('policycoreutils-sestatus')
+    @OETestDepends(['selinux.SELinuxStatusTest.test_kernel_enforcing'])
+    def test_read_shadow_denied(self):
+        # /etc/shadow is readable by root alone, so a failure to read it cannot
+        # be a plain permission problem: it is the policy withholding
+        # shadow_t:file from the confined session domain.  staff_t is confined
+        # just as sysadm_t is, so this runs for either.
+        self.require_confining_policy()
+        context = self.process_context()
+
+        status, output = self.run_cmd('cat /etc/shadow')
+        self.assertNotEqual(
+            status, 0,
+            'Reading /etc/shadow as %s was allowed; the policy no longer '
+            'denies it:\n%s' % (context, output))
+        self.assertIn(
+            'Permission denied', output,
+            'Reading /etc/shadow as %s failed for a reason other than an '
+            'SELinux denial:\n%s' % (context, output))
+
+
+class SELinuxToolTest(SELinuxTest):
+    """The SELinux userspace tools must work."""
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('libselinux-bin')
+    @OETestDepends(['selinux.SELinuxStatusTest.test_kernel_enforcing'])
+    def test_getenforce(self):
+        self.assertEqual(self.run_ok('getenforce').strip(), 'Enforcing',
+                         'getenforce does not report enforcing mode')
+
+        # Writing the value the kernel already has is not a privilege check:
+        # sel_write_enforce() only asks for SECURITY__SETENFORCE inside
+        # 'if (new_value != old_value)' (security/selinux/selinuxfs.c), so this
+        # exercises setenforce and libselinux in any domain.
+        self.run_ok('setenforce 1')
+        self.assertEqual(self.run_ok('getenforce').strip(), 'Enforcing',
+                         'setenforce 1 left the system in the wrong mode')
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('libselinux-bin')
+    @OETestDepends(['selinux.SELinuxStatusTest.test_kernel_enforcing'])
+    def test_setenforce_roundtrip(self):
+        # Leaving enforcing mode is the operation that needs the
+        # security:setenforce permission; refpolicy grants it to the admin
+        # domains only.
+        self.require_admin()
+
+        status, output = self.run_cmd('setenforce 0')
+        self.assertEqual(status, 0,
+                         'setenforce 0 failed:\n%s' % output)
+        try:
+            self.assertEqual(self.run_ok('getenforce').strip(), 'Permissive',
+                             'setenforce 0 did not switch to permissive mode')
+            self.assertEqual(
+                self.run_ok('cat /sys/fs/selinux/enforce').strip(), '0',
+                'setenforce 0 did not reach the kernel')
+        finally:
+            # Never leave the target permissive behind us.
+            self.run_ok('setenforce 1')
+
+        self.assertEqual(self.run_ok('getenforce').strip(), 'Enforcing',
+                         'setenforce 1 did not switch back to enforcing mode')
+        self.assertEqual(
+            self.run_ok('cat /sys/fs/selinux/enforce').strip(), '1',
+            'setenforce 1 did not reach the kernel')
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('libselinux-bin')
+    @OETestDepends(['selinux.SELinuxStatusTest.test_kernel_enforcing'])
+    def test_matchpathcon(self):
+        output = self.run_ok('matchpathcon /etc/shadow')
+        self.assertIn('shadow_t', output,
+                      'matchpathcon did not map /etc/shadow to shadow_t:\n%s'
+                      % output)
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('libselinux-bin')
+    @OETestDepends(['selinux.SELinuxToolTest.test_matchpathcon'])
+    def test_matchpathcon_verify(self):
+        # Needs getattr on shadow_t, which only the admin domains have.
+        self.require_admin()
+        output = self.run_ok('matchpathcon -V /etc/shadow')
+        # Success is exactly "<path> verified."; a mismatch prints
+        # "<path> has context ..., should be ...", so anchor the whole line
+        # rather than looking for a word that other messages may contain.
+        self.assertRegex(output, r'^/etc/shadow verified\.$',
+                         'matchpathcon -V did not confirm the label of '
+                         '/etc/shadow:\n%s' % output)
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('selinux-python-semanage')
+    @OETestDepends(['selinux.SELinuxToolTest.test_matchpathcon'])
+    def test_semanage(self):
+        self.require_admin()
+
+        # Queries.
+        output = self.run_ok('semanage login -l')
+        self.assertIn('__default__', output,
+                      'semanage login -l did not list the default mapping:\n%s'
+                      % output)
+        self.run_ok('semanage boolean -l')
+        self.run_ok('semanage fcontext -l')
+
+        # Add/list/remove round trip.  -N keeps the running policy untouched;
+        # the rule is committed to the local store and listed from there.
+        fcontext = TEST_MODULE_DIR + '/alt(/.*)?'
+        token = TEST_MODULE_DIR + '/alt'
+        try:
+            self.run_ok("semanage fcontext -N -a -t tmp_t '%s'" % fcontext)
+            output = self.run_ok("semanage fcontext -l | grep -F '%s'" % token)
+            self.assertIn(fcontext, output,
+                          'Added fcontext rule is missing from the listing:\n%s'
+                          % output)
+        finally:
+            # Ignore failures here so that a failed add does not mask itself
+            # with a failed delete.
+            self.run_cmd("semanage fcontext -N -d '%s'" % fcontext)
+
+        status, output = self.run_cmd("semanage fcontext -l | grep -F '%s'"
+                                     % token)
+        self.assertNotEqual(status, 0,
+                            'Removed fcontext rule is still listed:\n%s'
+                            % output)
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('policycoreutils-semodule')
+    @OEHasPackage('checkpolicy')
+    @OEHasPackage('semodule-utils-semodule-package')
+    @OETestDepends(['selinux.SELinuxToolTest.test_matchpathcon'])
+    def test_semodule_load_remove(self):
+        self.require_confining_policy()
+        domain = self.require_admin()
+        self.require_shadow_denial()
+
+        # semodule re-expands the whole policy, which does not fit on a small
+        # qemu guest: it gets OOM-killed rather than reporting an error.
+        memtotal = self.memtotal_mb()
+        if memtotal is not None and memtotal < 512:
+            self.skipTest('Loading a policy module needs more memory than this '
+                          'target has (%d MB); raise it, e.g. with '
+                          'TEST_QEMUPARAMS = "-m 1024"' % memtotal)
+
+        te = '%s/%s.te' % (TEST_MODULE_DIR, TEST_MODULE_NAME)
+        mod = '%s/%s.mod' % (TEST_MODULE_DIR, TEST_MODULE_NAME)
+        pp = '%s/%s.pp' % (TEST_MODULE_DIR, TEST_MODULE_NAME)
+
+        # checkmodule needs -M when the policy has MLS/MCS enabled.
+        mls = self.sestatus().get('Policy MLS status', '').lower() == 'enabled'
+        checkmodule = 'checkmodule -m %s-o %s %s' % ('-M ' if mls else '', mod, te)
+
+        self.write_file(te, TEST_MODULE_TE % {'module': TEST_MODULE_NAME,
+                                              'domain': domain})
+        try:
+            self.run_ok(checkmodule)
+            self.run_ok('semodule_package -o %s -m %s' % (pp, mod))
+
+            self.run_ok('semodule -i %s' % pp)
+            self.assertIn(TEST_MODULE_NAME, self.run_ok('semodule -l'),
+                          'Installed module is not listed by semodule -l')
+
+            # The new rule allows the domain to read /etc/shadow.
+            status, output = self.run_cmd('cat /etc/shadow')
+            self.assertEqual(status, 0,
+                             'Reading /etc/shadow is still denied after loading '
+                             '%s for %s:\n%s'
+                             % (TEST_MODULE_NAME, domain, output))
+        finally:
+            self.run_cmd('semodule -r %s' % TEST_MODULE_NAME)
+
+        output = self.run_ok('semodule -l')
+        self.assertNotIn(TEST_MODULE_NAME, output,
+                         'Removed module is still listed by semodule -l:\n%s'
+                         % output)
+        self.assertTrue(self.shadow_read_denied(),
+                        'Reading /etc/shadow is still allowed after removing '
+                        '%s' % TEST_MODULE_NAME)
+
+
+class SELinuxAuditTest(SELinuxTest):
+    """The kernel audit subsystem must work and record SELinux denials."""
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OETestDepends(['selinux.SELinuxStatusTest.test_kernel_enforcing'])
+    def test_kernel_config(self):
+        status, output = self.run_cmd('test -r /proc/config.gz')
+        if status != 0:
+            self.skipTest('No /proc/config.gz on the target; the kernel needs '
+                          'CONFIG_IKCONFIG and CONFIG_IKCONFIG_PROC to make the '
+                          'configuration readable at runtime')
+
+        output = self.run_ok("zcat /proc/config.gz | grep -E '^CONFIG_"
+                             "(AUDIT|AUDITSYSCALL|SECURITY_SELINUX)=y$'")
+        for option in ('CONFIG_AUDIT=y', 'CONFIG_AUDITSYSCALL=y',
+                       'CONFIG_SECURITY_SELINUX=y'):
+            self.assertIn(option, output,
+                          '%s is not set in the running kernel:\n%s'
+                          % (option, output))
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('auditd')
+    @OETestDepends(['selinux.SELinuxAuditTest.test_kernel_config'])
+    def test_auditd_running(self):
+        self.require_admin()
+
+        if 'systemd' in self.td.get('VIRTUAL-RUNTIME_init_manager', ''):
+            status, output = self.run_cmd('systemctl is-active auditd.service')
+            self.assertEqual(status, 0,
+                             'auditd.service is not active:\n%s' % output)
+        else:
+            self.run_ok('pidof auditd')
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('auditd')
+    @OETestDepends(['selinux.SELinuxAuditTest.test_auditd_running'])
+    def test_auditctl_status(self):
+        self.require_admin()
+        output = self.run_ok('auditctl -s')
+
+        # 'enabled' is the kernel audit state: 0 = off, 1 = on,
+        # 2 = on and locked until reboot.
+        match = re.search(r'^enabled\s+(\d+)', output, re.MULTILINE)
+        self.assertIsNotNone(match,
+                             'auditctl -s reported no state:\n%s' % output)
+        self.assertIn(match.group(1), ('1', '2'),
+                      'Kernel auditing is disabled:\n%s' % output)
+
+    @skipIfNotFeature('selinux', 'Test requires selinux in DISTRO_FEATURES')
+    @OEHasPackage('auditd')
+    @OETestDepends(['selinux.SELinuxDenialTest.test_read_shadow_denied'])
+    def test_avc_recorded(self):
+        self.require_admin()
+
+        # The denied read of /etc/shadow, asserted by the test this depends on,
+        # is the event whose record is looked for below.
+
+        # AVC records reach the audit log when auditd is consuming them and
+        # the kernel ring buffer otherwise; accept either, but give auditd a
+        # moment to flush the record it just received.
+        deadline = time.time() + 15
+        while True:
+            status, ausearch = self.run_cmd('ausearch -m AVC -ts recent')
+            status, dmesg = self.run_cmd('dmesg')
+            records = '%s\n%s' % (ausearch, dmesg)
+            if re.search(r'avc:\s+denied', records) and 'shadow' in records:
+                self.logger.info('AVC record found in the audit log:\n%s'
+                                 % ausearch.strip())
+                return
+            if time.time() >= deadline:
+                break
+            time.sleep(3)
+
+        self.fail('No AVC record for the denied read of /etc/shadow was found '
+                  'in the audit log or the kernel ring buffer.\n'
+                  'ausearch:\n%s\ndmesg (tail):\n%s'
+                  % (ausearch, '\n'.join(dmesg.splitlines()[-20:])))