@@ -18,15 +18,20 @@
# 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.
+# kernel ring buffer, so those cases run their commands through newrole:
+#
+# newrole -r sysadm_r -t sysadm_t -- -c '<command>'
+#
+# The '--' arguments are the arguments of the login shell newrole execs, hence
+# the -c. This needs a root account PAM can authenticate without a prompt,
+# i.e. IMAGE_FEATURES "empty-root-password"; without that the cases skip.
#
# 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 shlex
import time
from oeqa.runtime.case import OERuntimeTestCase
@@ -35,7 +40,9 @@ from oeqa.core.decorator.data import skipIfNotFeature
from oeqa.runtime.decorator.package import OEHasPackage
-# Domains that are allowed to administer the SELinux configuration.
+# Domains that may administer SELinux. With refpolicy-mcs an ssh login as
+# root lands in staff_t, but newrole can assume sysadm_r from there, which is
+# how the cases below reach an administrative domain on a stock image.
ADMIN_DOMAINS = ('sysadm_t', 'unconfined_t', 'secadm_t')
# Policy variants in which refpolicy confines administrators. The targeted and
@@ -47,9 +54,14 @@ 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.
+# Directory on the target used for scratch files and for the output files of
+# commands run through newrole.
TEST_MODULE_DIR = '/tmp/oeqa-selinux'
+# newrole execs the login shell, so the command has to be given as the shell's
+# own arguments: 'newrole ... -- -c <command>', not 'newrole ... -- <command>'.
+NEWROLE = 'newrole -r sysadm_r -t sysadm_t -- -c '
+
# 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.
@@ -87,6 +99,10 @@ def parse_sestatus(output):
class SELinuxTest(OERuntimeTestCase):
"""Helpers shared by the SELinux runtime cases."""
+ # Cached result of probing whether newrole reaches an admin domain; None
+ # until the first test asks. Shared by all the cases.
+ _admin_domain = None
+
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))
@@ -117,23 +133,92 @@ class SELinuxTest(OERuntimeTestCase):
def require_admin(self):
"""
- Skip unless the session runs in a domain that may administer SELinux.
+ Return the domain administrative commands run in, or skip.
- 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.
+ The session itself may already be administrative; if it is not, the
+ commands are run through newrole, which needs the root password to be
+ empty so that PAM can authenticate without a prompt.
"""
- domain = self.selinux_domain()
- if domain not in ADMIN_DOMAINS:
+ domain = self.admin_domain()
+ if not domain:
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()))
+ 'session runs as %r and newrole cannot assume sysadm_r. '
+ 'The command then needs policycoreutils-newrole installed and '
+ 'a root account PAM authenticates without a prompt, i.e. '
+ 'IMAGE_FEATURES "empty-root-password"'
+ % (self.selinux_domain() or self.process_context()))
return domain
+ def admin_domain(self):
+ """
+ Return the domain the administrative commands end up in, or ''.
+
+ Probed once per class, because the probe costs a command round trip.
+ """
+ if self.selinux_domain() in ADMIN_DOMAINS:
+ return self.selinux_domain()
+
+ cls = type(self)
+ if cls._admin_domain is None:
+ status, output = self.run_admin('cat /proc/self/attr/current')
+ domain = ''
+ if status == 0:
+ parts = output.replace('\x00', '').strip().split(':')
+ if len(parts) > 2 and parts[2] in ADMIN_DOMAINS:
+ domain = parts[2]
+ cls._admin_domain = domain
+ self.logger.info('newrole gives domain %r' % domain)
+ return cls._admin_domain
+
+ def _newrole(self, command):
+ """
+ Build a shell command line running `command' through newrole.
+
+ newrole's child inherits the ssh pipes, but its output is not reliably
+ readable from them, and some of the tools used here (auditctl,
+ semanage, semodule) print nothing when their stdout is a regular file.
+ So the command's output is piped through cat into a file, and the exit
+ status of the left-hand side of the pipeline is recorded with
+ PIPESTATUS.
+ """
+ redirect = ('{ %s ; } 2>&1 | cat > %s/out; echo "${PIPESTATUS[0]}" > %s/rc'
+ % (command, TEST_MODULE_DIR, TEST_MODULE_DIR))
+ return 'mkdir -p %s; %s%s' % (TEST_MODULE_DIR, NEWROLE,
+ shlex.quote(redirect))
+
+ def run_admin(self, command, timeout=None):
+ """Run `command' in an administrative domain, return (status, output)."""
+ if self.selinux_domain() in ADMIN_DOMAINS:
+ return self.run_cmd(command, timeout)
+
+ self.run_cmd(self._newrole(command), timeout)
+ # The output file may not end in a newline, so emit one before the
+ # marker or the marker would be glued onto the last output line.
+ status, output = self.run_cmd(
+ "cat %s/out 2>/dev/null; echo; echo OEQA-RC; cat %s/rc 2>/dev/null"
+ % (TEST_MODULE_DIR, TEST_MODULE_DIR))
+ body, _, tail = output.rpartition('\nOEQA-RC\n')
+ body = body.replace('\x00', '')
+ try:
+ rc = int(tail.strip())
+ except ValueError:
+ # newrole itself did not run, e.g. policycoreutils-newrole is not
+ # installed; report no status rather than failing here, so that
+ # require_admin() can turn it into a skip.
+ self.logger.info('newrole did not run %r; it printed:\n%s'
+ % (command, output))
+ return None, ''
+ self.logger.debug('newrole: %s\n-> %d\n%s' % (command, rc, body))
+ return rc, body
+
+ def run_admin_ok(self, command, timeout=None):
+ status, output = self.run_admin(command, timeout)
+ self.assertEqual(status, 0,
+ "Command '%s' failed in the admin domain with status "
+ "%s:\n%s" % (command, status, output))
+ return output
+
def require_confining_policy(self):
"""
Skip unless the loaded policy confines administrative domains.
@@ -260,7 +345,7 @@ class SELinuxToolTest(SELinuxTest):
# domains only.
self.require_admin()
- status, output = self.run_cmd('setenforce 0')
+ status, output = self.run_admin('setenforce 0')
self.assertEqual(status, 0,
'setenforce 0 failed:\n%s' % output)
try:
@@ -271,7 +356,7 @@ class SELinuxToolTest(SELinuxTest):
'setenforce 0 did not reach the kernel')
finally:
# Never leave the target permissive behind us.
- self.run_ok('setenforce 1')
+ self.run_admin_ok('setenforce 1')
self.assertEqual(self.run_ok('getenforce').strip(), 'Enforcing',
'setenforce 1 did not switch back to enforcing mode')
@@ -294,7 +379,7 @@ class SELinuxToolTest(SELinuxTest):
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')
+ output = self.run_admin_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.
@@ -309,30 +394,32 @@ class SELinuxToolTest(SELinuxTest):
self.require_admin()
# Queries.
- output = self.run_ok('semanage login -l')
+ output = self.run_admin_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')
+ self.run_admin_ok('semanage boolean -l')
+ self.run_admin_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.run_admin_ok("semanage fcontext -N -a -t tmp_t '%s'"
+ % fcontext)
+ output = self.run_admin_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)
+ self.run_admin("semanage fcontext -N -d '%s'" % fcontext)
- status, output = self.run_cmd("semanage fcontext -l | grep -F '%s'"
- % token)
+ status, output = self.run_admin("semanage fcontext -l | grep -F '%s'"
+ % token)
self.assertNotEqual(status, 0,
'Removed fcontext rule is still listed:\n%s'
% output)
@@ -369,26 +456,28 @@ class SELinuxToolTest(SELinuxTest):
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'),
+ self.run_admin_ok('semodule -i %s' % pp)
+ self.assertIn(TEST_MODULE_NAME, self.run_admin_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')
+ status, output = self.run_admin('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)
+ self.run_admin('semodule -r %s' % TEST_MODULE_NAME)
- output = self.run_ok('semodule -l')
+ output = self.run_admin_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)
+ status, output = self.run_admin('cat /etc/shadow')
+ self.assertNotEqual(status, 0,
+ 'Reading /etc/shadow is still allowed after '
+ 'removing %s:\n%s'
+ % (TEST_MODULE_NAME, output))
class SELinuxAuditTest(SELinuxTest):
@@ -418,18 +507,18 @@ class SELinuxAuditTest(SELinuxTest):
self.require_admin()
if 'systemd' in self.td.get('VIRTUAL-RUNTIME_init_manager', ''):
- status, output = self.run_cmd('systemctl is-active auditd.service')
+ status, output = self.run_admin('systemctl is-active auditd.service')
self.assertEqual(status, 0,
'auditd.service is not active:\n%s' % output)
else:
- self.run_ok('pidof auditd')
+ self.run_admin_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')
+ output = self.run_admin_ok('auditctl -s')
# 'enabled' is the kernel audit state: 0 = off, 1 = on,
# 2 = on and locked until reboot.
@@ -453,8 +542,8 @@ class SELinuxAuditTest(SELinuxTest):
# 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')
+ status, ausearch = self.run_admin('ausearch -m AVC -ts recent')
+ status, dmesg = self.run_admin('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'