| # Copyright (c) 2012 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import logging |
| import pwd |
| import re |
| |
| from autotest_lib.client.bin import test, utils |
| from autotest_lib.client.common_lib import error |
| |
| class security_StatefulPermissions(test.test): |
| """ |
| Report all unexpected writable paths in the /mnt/stateful_partition |
| tree. |
| """ |
| version = 1 |
| _STATEFUL_ROOT = "/mnt/stateful_partition" |
| _AID_SYSTEM = 1000 |
| _AID_CACHE = 2001 |
| |
| # Note that chronos permissions in /home are covered in greater detail |
| # by 'security_ProfilePermissions'. |
| _masks_byuser = {"adm": [], |
| "android-root": ["/encrypted/var/log/android.kmsg"], |
| "attestation": ["/unencrypted/preserve", |
| "/unencrypted/preserve/attestation.epb"], |
| "avfs": [], |
| "bin": [], |
| "bluetooth": ["/encrypted/var/lib/bluetooth"], |
| "chaps": ["/encrypted/var/lib/chaps"], |
| "chronos": ["/encrypted/chronos", |
| "/encrypted/var/cache/app_pack", |
| "/encrypted/var/cache/camera", |
| "/encrypted/var/cache/device_local_account_component_policy", |
| "/encrypted/var/cache/device_local_account_extensions", |
| "/encrypted/var/cache/device_local_account_external_policy_data", |
| "/encrypted/var/cache/display_profiles", |
| "/encrypted/var/cache/echo", |
| "/encrypted/var/cache/external_cache", |
| "/encrypted/var/cache/shared_extensions", |
| "/encrypted/var/cache/signin_profile_component_policy", |
| "/encrypted/var/cache/touch_trial/selection", |
| "/encrypted/var/lib/cromo", |
| "/encrypted/var/lib/opencryptoki", |
| "/encrypted/var/lib/timezone", |
| "/encrypted/var/lib/Synaptics/chronos.1000", |
| "/encrypted/var/log/chrome", |
| "/encrypted/var/log/connectivity.bak", |
| "/encrypted/var/log/connectivity.log", |
| "/encrypted/var/log/metrics", |
| "/encrypted/var/minidumps", |
| "/home/user"], |
| "chronos-access": [], |
| "cras": [], |
| "cros-disks": [], |
| "cups": ["/encrypted/var/cache/cups", |
| "/encrypted/var/spool/cups"], |
| "daemon": [], |
| "debugd": [], |
| "dhcp": ["/encrypted/var/lib/dhcpcd"], |
| "dlm": ["/encrypted/var/log/displaylink"], |
| "imageloaderd": ["/encrypted/var/lib/imageloader"], |
| "input": [], |
| "ipsec": [], |
| "lp": [], |
| "messagebus": [], |
| "mtp": [], |
| "news": [], |
| "nobody": [], |
| "ntfs-3g": [], |
| "openvpn": [], |
| "portage": ["/encrypted/var/log/emerge.log"], |
| "power": ["/encrypted/var/lib/power_manager", |
| "/encrypted/var/log/power_manager"], |
| "pkcs11": [], |
| "root": None, |
| "sshd": [], |
| "syslog": ["/encrypted/var/log"], |
| "tcpdump": [], |
| "tlsdate": [], |
| "tpm_manager": ["/encrypted/var/lib/tpm_manager", |
| "/unencrypted/preserve"], |
| "tss": ["/var/lib/tpm"], |
| "uucp": [], |
| "wpa": [], |
| "xorg": ["/encrypted/var/lib/xkb", |
| "/encrypted/var/log/xorg", |
| "/encrypted/var/log/Xorg.0.log"] |
| } |
| |
| |
| def systemwide_exclusions(self): |
| """Returns a list of paths that are only present on test images |
| and therefore should be excluded from all 'find' commands. |
| """ |
| paths = [] |
| |
| # 'preserve/log' is test-only. |
| paths.append("/unencrypted/preserve/log") |
| |
| # Cover up Portage noise. |
| paths.append("/encrypted/var/cache/edb") |
| paths.append("/encrypted/var/lib/gentoo") |
| paths.append("/encrypted/var/log/portage") |
| |
| # Cover up Autotest noise. |
| paths.append("/dev_image") |
| paths.append("/var_overlay") |
| |
| return paths |
| |
| |
| def generate_prune_arguments_android(self): |
| """Returns a command-line fragment to make 'find' exclude |
| android-data/cache: uid=AID_SYSTEM, gid=AID_CACHE |
| android-data/data: uid=AID_SYSTEM, gid=AID_SYSTEM |
| Files under these paths are created by various android users. |
| """ |
| try: |
| aroot_uid = pwd.getpwnam('android-root').pw_uid |
| except KeyError: |
| # android-root not found, so don't prune anything |
| return "" |
| |
| # On ecryptfs backend, the Android data path is |
| # /home/.shadow/hash/vault/root/ENCRYPTED<android-data>/... |
| # while on ext4crypto backend, it is: |
| # /home/.shadow/hash/mount/ENCRYPTED<root>/ENCRYPTED<android-data>/... |
| cmd = "-regextype posix-extended -regex STATEFUL_ROOT/home/.shadow/" |
| cmd += "[[:alnum:]]{40}/(vault/root|mount/[^/]*)/[^/]*/[^/]* " |
| cmd += "-uid {0} \\( -gid {1} -o -gid {2} \\) -prune -o ".format( |
| aroot_uid + self._AID_SYSTEM, |
| aroot_uid + self._AID_SYSTEM, aroot_uid + self._AID_CACHE) |
| return cmd |
| |
| |
| def generate_prune_arguments(self, prunelist): |
| """Returns a command-line fragment to make 'find' exclude the entries |
| in |prunelist|. |
| |
| @param prunelist: list of paths to ignore |
| """ |
| fragment = "-path STATEFUL_ROOT%s -prune -o" |
| fragments = [fragment % path for path in prunelist] |
| return " ".join(fragments) |
| |
| |
| def generate_find(self, user, prunelist): |
| """ |
| Generates the "find" command that spits out all files in stateful |
| writable by a given user, with the given list of directories removed. |
| |
| @param user: report writable paths owned by this user |
| @param prunelist: list of paths to ignore |
| """ |
| if prunelist is None: |
| return "true" # return a no-op shell command, e.g. for root. |
| |
| # Exclude world-writeable stuff. |
| # '/var/lib/metrics/uma-events' is world-writeable: crbug.com/198054. |
| prunelist.append("/encrypted/var/lib/metrics/uma-events") |
| # '/run/lock' is world-writeable. |
| prunelist.append("/encrypted/var/lock") |
| # '/var/log/asan' should be world-writeable: crbug.com/453579 |
| prunelist.append("/encrypted/var/log/asan") |
| |
| # Add system-wide exclusions. |
| prunelist.extend(self.systemwide_exclusions()) |
| |
| cmd = "find STATEFUL_ROOT " |
| cmd += self.generate_prune_arguments(prunelist) |
| # Note that we don't "prune" all of /var/tmp's contents, just mask |
| # the dir itself. Any contents are still interesting. |
| cmd += " -path STATEFUL_ROOT/encrypted/var/tmp -o " |
| cmd += " -writable -ls -o -user %s -ls 2>/dev/null" % user |
| return cmd |
| |
| |
| def expected_owners(self): |
| """Returns the set of file/directory owners expected in stateful.""" |
| # In other words, this is basically the users mentioned in |
| # tests_byuser, except for any expected to actually own zero files. |
| # Currently, there's no exclusions. |
| return set(self._masks_byuser.keys()) |
| |
| |
| def observed_owners(self): |
| """Returns the set of file/directory owners present in stateful.""" |
| |
| cmd = "find STATEFUL_ROOT " |
| cmd += self.generate_prune_arguments_android() |
| cmd += self.generate_prune_arguments(self.systemwide_exclusions()) |
| cmd += " -printf '%u\\n' | sort -u" |
| return set(self.subst_run(cmd).splitlines()) |
| |
| |
| def owners_lacking_coverage(self): |
| """ |
| Determines the set of owners not covered by any of the |
| per-owner tests implemented in this class. Returns |
| a set of usernames (possibly the empty set). |
| """ |
| return self.observed_owners().difference(self.expected_owners()) |
| |
| |
| def log_owned_files(self, owner): |
| """ |
| Sends information about all files in the stateful partition |
| owned by a given owner to the standard logging facility. |
| |
| @param owner: paths owned by this user will be reported |
| """ |
| cmd = "find STATEFUL_ROOT -user %s -ls" % owner |
| cmd_output = self.subst_run(cmd) |
| logging.error(cmd_output) |
| |
| |
| def subst_run(self, cmd, stateful_root=_STATEFUL_ROOT): |
| """ |
| Replace "STATEFUL_ROOT" with the actual stateful partition path. |
| |
| @param cmd: string containing the command to examine |
| @param stateful_root: path used to replace "STATEFUL_ROOT" |
| """ |
| cmd = cmd.replace("STATEFUL_ROOT", stateful_root) |
| return utils.system_output(cmd, ignore_status=True) |
| |
| |
| def run_once(self): |
| """ |
| Accounts for the contents of the stateful partition |
| piece-wise, inspecting the level of access which can |
| be obtained by each of the privilege levels (usernames) |
| used in CrOS. |
| |
| The test passes if each of the owner-specific sub-tests pass, |
| and if there are no files unaccounted for (i.e., no unexpected |
| file-owners for which we have no tests.) |
| """ |
| testfail = False |
| |
| unexpected_owners = self.owners_lacking_coverage() |
| if unexpected_owners: |
| testfail = True |
| for o in unexpected_owners: |
| self.log_owned_files(o) |
| |
| # Now run the sub-tests. |
| for user, mask in self._masks_byuser.items(): |
| cmd = self.generate_find(user, mask) |
| |
| try: |
| pwd.getpwnam(user) |
| except KeyError, err: |
| logging.warning('Skipping missing user: %s', err) |
| continue |
| |
| # The 'EOF' below helps us distinguish 2 types of failures. |
| # We have to use ignore_status=True because many of these |
| # find-based tests will exit(nonzero) to signal that they |
| # encountered inaccessible directories, which we expect by-design. |
| # This creates ambiguity as to whether empty output means |
| # the test ran, and passed, or the su failed. Including an |
| # expected 'EOF' output disambiguates these cases. |
| cmd = "su -s /bin/sh -c '%s;echo EOF' %s" % (cmd, user) |
| cmd_output = self.subst_run(cmd) |
| |
| if not cmd_output: |
| # we never got 'EOF', su failed |
| testfail = True |
| logging.error("su failed while attempting to run:") |
| logging.error(cmd) |
| logging.error("[Got %s]", cmd_output) |
| elif not re.search("^\s*EOF\s*$", cmd_output): |
| # we got test failures before 'EOF' |
| testfail = True |
| logging.error("Test for '%s' found unexpected files:\n%s", |
| user, cmd_output) |
| |
| if testfail: |
| raise error.TestFail("Unexpected files/perms in stateful") |