blob: f29aa40d5c7b37dc914ef39d421fe5994d5f1b97 [file] [log] [blame] [edit]
# 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 csv
import logging
import os
from collections import namedtuple, defaultdict
from autotest_lib.client.bin import test
from autotest_lib.client.common_lib import error
from autotest_lib.client.common_lib import utils
from autotest_lib.client.cros import asan
PS_FIELDS = (
'pid',
'ppid',
'comm:32',
'euser:%(usermax)d',
'ruser:%(usermax)d',
'egroup:%(groupmax)d',
'rgroup:%(groupmax)d',
'ipcns',
'mntns',
'netns',
'pidns',
'userns',
'utsns',
'args',
)
# These fields aren't available via ps, so we have to get them indirectly.
# Note: Case is significant as the fields match the /proc/PID/status file.
STATUS_FIELDS = (
'CapInh',
'CapPrm',
'CapEff',
'CapBnd',
'CapAmb',
'NoNewPrivs',
'Seccomp',
)
# These fields are not available via ps or /proc/PID/status.
EXTRA_FIELDS = (
'mountinfo',
)
PsOutput = namedtuple("PsOutput",
' '.join([field.split(':')[0].lower() for field in
PS_FIELDS + STATUS_FIELDS + EXTRA_FIELDS]))
# Constants that match the values in /proc/PID/status Seccomp field.
# See `man 5 proc` for more details.
SECCOMP_MODE_DISABLED = '0'
SECCOMP_MODE_STRICT = '1'
SECCOMP_MODE_FILTER = '2'
# For human readable strings.
SECCOMP_MAP = {
SECCOMP_MODE_DISABLED: 'disabled',
SECCOMP_MODE_STRICT: 'strict',
SECCOMP_MODE_FILTER: 'filter',
}
# These mounts only occur in test images. They should be limited to the init
# mount namespace, so no service should have them.
TEST_IMAGE_MOUNTS = (
'/usr/local',
'/var/db/pkg',
'/var/lib/portage',
)
def get_properties(service, init_process):
"""Returns a dictionary of the properties of a service.
@param service: the PsOutput of the service.
@param init_process: the PsOutput of the init process.
"""
properties = dict(service._asdict())
properties['exe'] = service.comm
properties['pidns'] = yes_or_no(service.pidns != init_process.pidns)
properties['mntns'] = yes_or_no(service.mntns != init_process.mntns)
properties['caps'] = yes_or_no(service.capeff != init_process.capeff)
properties['nonewprivs'] = yes_or_no(service.nonewprivs == '1')
properties['filter'] = yes_or_no(service.seccomp == SECCOMP_MODE_FILTER)
return properties
def yes_or_no(value):
"""Returns 'Yes' or 'No' based on the truthiness of a value.
@param value: boolean value.
"""
return 'Yes' if value else 'No'
def get_mount_info(pid):
"""Returns the contents of /proc/PID/mountinfo.
@param pid: The process id.
"""
try:
return tuple(utils.get_mount_info(process=pid))
except IOError as e:
# This process might have died already.
logging.warning('Failed to read mountinfo for pid %s: %s', pid, e)
return ()
def has_test_image_mounts(mountinfo):
"""Returns whether a process has test image mounts in its mount namespace.
@param mountinfo: A list of utils.MountInfo.
"""
return any(m.mount_point in TEST_IMAGE_MOUNTS for m in mountinfo)
class security_SandboxedServices(test.test):
"""Enforces sandboxing restrictions on the processes running
on the system.
"""
version = 1
def get_running_processes(self):
"""Returns a list of running processes as PsOutput objects."""
usermax = utils.system_output("cut -d: -f1 /etc/passwd | wc -L",
ignore_status=True)
groupmax = utils.system_output('cut -d: -f1 /etc/group | wc -L',
ignore_status=True)
# Even if the names are all short, make sure we have enough space
# to hold numeric 32-bit ids too (can come up with userns).
usermax = max(int(usermax), 10)
groupmax = max(int(groupmax), 10)
fields = {
'usermax': usermax,
'groupmax': groupmax,
}
ps_cmd = ('ps --no-headers -ww -eo ' +
(','.join(PS_FIELDS) % fields))
ps_fields_len = len(PS_FIELDS)
output = utils.system_output(ps_cmd)
logging.debug('output of ps:\n%s', output)
# Fill in fields that `ps` doesn't support but are in /proc/PID/status.
# Example line output:
# Pid:1 CapInh:0000000000000000 CapPrm:0000001fffffffff CapEff:0000001fffffffff CapBnd:0000001fffffffff Seccomp:0
cmd = (
"for f in /proc/[1-9]*/status ; do awk '$1 ~ \"^(Pid|%s):\" "
"{printf \"%%s%%s \", $1, $NF; if ($1 == \"%s:\") printf \"\\n\"}'"
" $f ; done"
) % ('|'.join(STATUS_FIELDS), STATUS_FIELDS[-1])
# Processes might exit while awk is running, so ignore its exit status.
status_output = utils.system_output(cmd, ignore_status=True)
# Turn each line into a dict.
# [
# {'pid': '1', 'CapInh': '0000000000000000', 'Seccomp': '0', ...},
# {'pid': '10', ...},
# ...,
# ]
status_list = list(dict(attr.split(':', 1) for attr in line.split())
for line in status_output.splitlines())
# Create a dict mapping a pid to its extended status data.
# {
# '1': {'pid': '1', 'CapInh': '0000000000000000', ...},
# '2': {'pid': '2', ...},
# ...,
# }
status_data = dict((x['Pid'], x) for x in status_list)
logging.debug('output of awk:\n%s', status_output)
# Now merge the two sets of process data.
running_processes = []
for line in output.splitlines():
# crbug.com/422700: Filter out zombie processes.
if '<defunct>' in line:
continue
fields = line.split(None, ps_fields_len - 1)
pid = fields[0]
# The process lists might not be exactly the same (since we gathered
# data with multiple commands), and not all fields might exist (e.g.
# older kernels might not have all the fields).
pid_data = status_data.get(pid, {})
status_fields = [pid_data.get(key) for key in STATUS_FIELDS]
extra_fields = [get_mount_info(pid)]
running_processes.append(
PsOutput(*fields + status_fields + extra_fields))
return running_processes
def load_baseline(self):
"""The baseline file lists the services we know and
whether (and how) they are sandboxed.
"""
def load(path):
"""Load baseline from |path| and return its fields and dictionary.
@param path: The baseline to load.
"""
logging.info('Loading baseline %s', path)
reader = csv.DictReader(open(path))
return reader.fieldnames, dict((d['exe'], d) for d in reader
if not d['exe'].startswith('#'))
baseline_path = os.path.join(self.bindir, 'baseline')
fields, ret = load(baseline_path)
board = utils.get_current_board()
baseline_path += '.' + board
if os.path.exists(baseline_path):
new_fields, new_entries = load(baseline_path)
if new_fields != fields:
raise error.TestError('header mismatch in %s' % baseline_path)
ret.update(new_entries)
return fields, ret
def load_exclusions(self):
"""The exclusions file lists running programs
that we don't care about (for now).
"""
exclusions_path = os.path.join(self.bindir, 'exclude')
return set(line.strip() for line in open(exclusions_path)
if not line.startswith('#'))
def dump_services(self, fieldnames, running_services_properties):
"""Leaves a list of running services in the results dir
so that we can update the baseline file if necessary.
@param fieldnames: list of fields to be written.
@param running_services_properties: list of services to be logged.
"""
file_path = os.path.join(self.resultsdir, 'running_services')
with open(file_path, 'w') as output_file:
writer = csv.DictWriter(output_file, fieldnames=fieldnames,
extrasaction='ignore')
writer.writeheader()
for service_properties in running_services_properties:
writer.writerow(service_properties)
def run_once(self):
"""Inspects the process list, looking for root and sandboxed processes
(with some exclusions). If we have a baseline entry for a given process,
confirms it's an exact match. Warns if we see root or sandboxed
processes that we have no baseline for, and warns if we have
baselines for processes not seen running.
"""
fieldnames, baseline = self.load_baseline()
exclusions = self.load_exclusions()
running_processes = self.get_running_processes()
is_asan = asan.running_on_asan()
if is_asan:
logging.info('ASAN image detected -> skipping seccomp checks')
kthreadd_pid = -1
init_process = None
running_services = {}
# Filter running processes list.
for process in running_processes:
exe = process.comm
if exe == "kthreadd":
kthreadd_pid = process.pid
continue
elif process.pid == "1":
init_process = process
continue
# Don't worry about kernel threads.
if process.ppid == kthreadd_pid:
continue
if exe in exclusions:
continue
running_services[exe] = process
if not init_process:
raise error.TestFail("Cannot find init process")
# Find differences between running services and baseline.
services_set = set(running_services.keys())
baseline_set = set(baseline.keys())
new_services = services_set.difference(baseline_set)
stale_baselines = defaultdict(list)
for exe in baseline_set.difference(services_set):
stale_baselines[exe].append('unused')
# Check baseline.
sandbox_delta = defaultdict(list)
for exe in services_set.intersection(baseline_set):
process = running_services[exe]
stale_flags = []
errors = []
# If the process is not running as the correct user.
if process.euser != baseline[exe]["euser"]:
errors.append('bad user: wanted "%s" but got "%s"' %
(baseline[exe]['euser'], process.euser))
# If the process is not running as the correct group.
if process.egroup != baseline[exe]['egroup']:
errors.append('bad group: wanted "%s" but got "%s"' %
(baseline[exe]['egroup'], process.egroup))
# Check the various sandbox settings.
if process.pidns == init_process.pidns:
if baseline[exe]['pidns'] == 'Yes':
errors.append('missing pid ns usage')
elif baseline[exe]['pidns'] != 'Yes':
stale_flags.append('pidns')
if process.mntns == init_process.mntns:
if baseline[exe]['mntns'] == 'Yes':
errors.append('missing mount ns usage')
elif has_test_image_mounts(process.mountinfo):
if baseline[exe]['mntns'] == 'Yes':
errors.append('did not call pivot_root(2)')
elif baseline[exe]['mntns'] != 'Yes':
stale_flags.append('mntns')
if process.capeff == init_process.capeff:
if baseline[exe]['caps'] == 'Yes':
errors.append('missing caps usage')
elif baseline[exe]['caps'] != 'Yes':
stale_flags.append('caps')
if process.nonewprivs != '1':
if baseline[exe]['nonewprivs'] == 'Yes':
errors.append('missing NoNewPrivs')
elif baseline[exe]['nonewprivs'] != 'Yes':
stale_flags.append('nonewprivs')
if not is_asan:
# Since Minijail disables seccomp at runtime when ASAN is
# active, we can't enforce it on ASAN bots. Just ignore
# the test entirely. (Comment applies to "is_asan" above.)
if process.seccomp != SECCOMP_MODE_FILTER:
if baseline[exe]['filter'] == 'Yes':
errors.append(
'missing seccomp usage: '
'wanted %s (%s) but got %s (%s)' %
(SECCOMP_MODE_FILTER,
SECCOMP_MAP[SECCOMP_MODE_FILTER], process.seccomp,
SECCOMP_MAP.get(process.seccomp, '???')))
elif baseline[exe]['filter'] != 'Yes':
stale_flags.append('filter')
if stale_flags:
stale_baselines[exe].append('potentially missing flags: %s' %
','.join(stale_flags))
if errors:
sandbox_delta[exe].extend(errors)
# Save current run to results dir.
running_services_properties = [get_properties(s, init_process)
for s in running_services.values()]
self.dump_services(fieldnames, running_services_properties)
if len(stale_baselines) > 0:
logging.warn('Stale baselines: %r', stale_baselines)
if len(new_services) > 0:
logging.warn('New services: %r', new_services)
# We won't complain about new non-root services (on the assumption
# that they've already somewhat sandboxed things), but we'll fail
# with new root services (on the assumption they haven't done any
# sandboxing work). If they really need to run as root, they can
# update the baseline to whitelist it.
for exe in new_services:
if running_services[exe].euser == 'root':
sandbox_delta[exe].append('missing euser')
if len(sandbox_delta) > 0:
for delta_entry in sandbox_delta:
logging.error('Failed sandboxing: %s', delta_entry)
raise error.TestFail('One or more processes failed sandboxing: %r' %
sandbox_delta)