1# Copyright (c) 2012 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import csv 6import logging 7import os 8 9from collections import namedtuple 10 11from autotest_lib.client.bin import test 12from autotest_lib.client.common_lib import error 13from autotest_lib.client.common_lib import utils 14from autotest_lib.client.cros import asan 15 16 17PS_FIELDS = ( 18 'pid', 19 'ppid', 20 'comm:32', 21 'euser:%(usermax)d', 22 'ruser:%(usermax)d', 23 'egroup:%(groupmax)d', 24 'rgroup:%(groupmax)d', 25 'ipcns', 26 'mntns', 27 'netns', 28 'pidns', 29 'userns', 30 'utsns', 31 'args', 32) 33# These fields aren't available via ps, so we have to get them indirectly. 34# Note: Case is significant as the fields match the /proc/PID/status file. 35STATUS_FIELDS = ( 36 'CapInh', 37 'CapPrm', 38 'CapEff', 39 'CapBnd', 40 'CapAmb', 41 'NoNewPrivs', 42 'Seccomp', 43) 44PsOutput = namedtuple("PsOutput", 45 ' '.join([field.split(':')[0].lower() 46 for field in PS_FIELDS + STATUS_FIELDS])) 47 48# Constants that match the values in /proc/PID/status Seccomp field. 49# See `man 5 proc` for more details. 50SECCOMP_MODE_DISABLED = '0' 51SECCOMP_MODE_STRICT = '1' 52SECCOMP_MODE_FILTER = '2' 53# For human readable strings. 54SECCOMP_MAP = { 55 SECCOMP_MODE_DISABLED: 'disabled', 56 SECCOMP_MODE_STRICT: 'strict', 57 SECCOMP_MODE_FILTER: 'filter', 58} 59 60 61def get_properties(service, init_process): 62 """Returns a dictionary of the properties of a service. 63 64 @param service: the PsOutput of the service. 65 @param init_process: the PsOutput of the init process. 66 """ 67 68 properties = dict(service._asdict()) 69 properties['exe'] = service.comm 70 properties['pidns'] = yes_or_no(service.pidns != init_process.pidns) 71 properties['caps'] = yes_or_no(service.capeff != init_process.capeff) 72 properties['nonewprivs'] = yes_or_no(service.nonewprivs == '1') 73 properties['filter'] = yes_or_no(service.seccomp == SECCOMP_MODE_FILTER) 74 return properties 75 76 77def yes_or_no(value): 78 """Returns 'Yes' or 'No' based on the truthiness of a value. 79 80 @param value: boolean value. 81 """ 82 83 return 'Yes' if value else 'No' 84 85 86class security_SandboxedServices(test.test): 87 """Enforces sandboxing restrictions on the processes running 88 on the system. 89 """ 90 91 version = 1 92 93 94 def get_running_processes(self): 95 """Returns a list of running processes as PsOutput objects.""" 96 97 usermax = utils.system_output("cut -d: -f1 /etc/passwd | wc -L", 98 ignore_status=True) 99 groupmax = utils.system_output('cut -d: -f1 /etc/group | wc -L', 100 ignore_status=True) 101 # Even if the names are all short, make sure we have enough space 102 # to hold numeric 32-bit ids too (can come up with userns). 103 usermax = max(int(usermax), 10) 104 groupmax = max(int(groupmax), 10) 105 fields = { 106 'usermax': usermax, 107 'groupmax': groupmax, 108 } 109 ps_cmd = ('ps --no-headers -ww -eo ' + 110 (','.join(PS_FIELDS) % fields)) 111 ps_fields_len = len(PS_FIELDS) 112 113 output = utils.system_output(ps_cmd) 114 logging.debug('output of ps:\n%s', output) 115 116 # Fill in fields that `ps` doesn't support but are in /proc/PID/status. 117 # Example line output: 118 # Pid:1 CapInh:0000000000000000 CapPrm:0000001fffffffff CapEff:0000001fffffffff CapBnd:0000001fffffffff Seccomp:0 119 cmd = ( 120 "for f in /proc/[1-9]*/status ; do awk '$1 ~ \"^(Pid|%s):\" " 121 "{printf \"%%s%%s \", $1, $NF; if ($1 == \"%s:\") printf \"\\n\"}'" 122 " $f ; done" 123 ) % ('|'.join(STATUS_FIELDS), STATUS_FIELDS[-1]) 124 # Processes might exit while awk is running, so ignore its exit status. 125 status_output = utils.system_output(cmd, ignore_status=True) 126 # Turn each line into a dict. 127 # [ 128 # {'pid': '1', 'CapInh': '0000000000000000', 'Seccomp': '0', ...}, 129 # {'pid': '10', ...}, 130 # ..., 131 # ] 132 status_list = list(dict(attr.split(':', 1) for attr in line.split()) 133 for line in status_output.splitlines()) 134 # Create a dict mapping a pid to its extended status data. 135 # { 136 # '1': {'pid': '1', 'CapInh': '0000000000000000', ...}, 137 # '2': {'pid': '2', ...}, 138 # ..., 139 # } 140 status_data = dict((x['Pid'], x) for x in status_list) 141 logging.debug('output of awk:\n%s', status_output) 142 143 # Now merge the two sets of process data. 144 running_processes = [] 145 for line in output.splitlines(): 146 # crbug.com/422700: Filter out zombie processes. 147 if '<defunct>' in line: 148 continue 149 150 fields = line.split(None, ps_fields_len - 1) 151 pid = fields[0] 152 # The process lists might not be exactly the same (since we gathered 153 # data with multiple commands), and not all fields might exist (e.g. 154 # older kernels might not have all the fields). 155 pid_data = status_data.get(pid, {}) 156 status_fields = [pid_data.get(key) for key in STATUS_FIELDS] 157 running_processes.append(PsOutput(*fields + status_fields)) 158 159 return running_processes 160 161 162 def load_baseline(self): 163 """The baseline file lists the services we know and 164 whether (and how) they are sandboxed. 165 """ 166 167 def load(path): 168 """Load baseline from |path| and return its fields and dictionary. 169 170 @param path: The baseline to load. 171 """ 172 logging.info('Loading baseline %s', path) 173 reader = csv.DictReader(open(path)) 174 return reader.fieldnames, dict((d['exe'], d) for d in reader 175 if not d['exe'].startswith('#')) 176 177 baseline_path = os.path.join(self.bindir, 'baseline') 178 fields, ret = load(baseline_path) 179 180 board = utils.get_current_board() 181 baseline_path += '.' + board 182 if os.path.exists(baseline_path): 183 new_fields, new_entries = load(baseline_path) 184 if new_fields != fields: 185 raise error.TestError('header mismatch in %s' % baseline_path) 186 ret.update(new_entries) 187 188 return fields, ret 189 190 191 def load_exclusions(self): 192 """The exclusions file lists running programs 193 that we don't care about (for now). 194 """ 195 196 exclusions_path = os.path.join(self.bindir, 'exclude') 197 return set(line.strip() for line in open(exclusions_path) 198 if not line.startswith('#')) 199 200 201 def dump_services(self, fieldnames, running_services_properties): 202 """Leaves a list of running services in the results dir 203 so that we can update the baseline file if necessary. 204 205 @param fieldnames: list of fields to be written. 206 @param running_services_properties: list of services to be logged. 207 """ 208 209 file_path = os.path.join(self.resultsdir, 'running_services') 210 with open(file_path, 'w') as output_file: 211 writer = csv.DictWriter(output_file, fieldnames=fieldnames, 212 extrasaction='ignore') 213 writer.writeheader() 214 for service_properties in running_services_properties: 215 writer.writerow(service_properties) 216 217 218 def run_once(self): 219 """Inspects the process list, looking for root and sandboxed processes 220 (with some exclusions). If we have a baseline entry for a given process, 221 confirms it's an exact match. Warns if we see root or sandboxed 222 processes that we have no baseline for, and warns if we have 223 baselines for processes not seen running. 224 """ 225 226 fieldnames, baseline = self.load_baseline() 227 exclusions = self.load_exclusions() 228 running_processes = self.get_running_processes() 229 is_asan = asan.running_on_asan() 230 if is_asan: 231 logging.info('ASAN image detected -> skipping seccomp checks') 232 233 kthreadd_pid = -1 234 235 init_process = None 236 running_services = {} 237 238 # Filter running processes list. 239 for process in running_processes: 240 exe = process.comm 241 242 if exe == "kthreadd": 243 kthreadd_pid = process.pid 244 continue 245 elif process.pid == "1": 246 init_process = process 247 continue 248 249 # Don't worry about kernel threads. 250 if process.ppid == kthreadd_pid: 251 continue 252 253 if exe in exclusions: 254 continue 255 256 running_services[exe] = process 257 258 if not init_process: 259 raise error.TestFail("Cannot find init process") 260 261 # Find differences between running services and baseline. 262 services_set = set(running_services.keys()) 263 baseline_set = set(baseline.keys()) 264 265 new_services = services_set.difference(baseline_set) 266 stale_baselines = baseline_set.difference(services_set) 267 268 # Check baseline. 269 sandbox_delta = [] 270 for exe in services_set.intersection(baseline_set): 271 process = running_services[exe] 272 273 # If the process is not running as the correct user. 274 if process.euser != baseline[exe]["euser"]: 275 logging.error('%s: bad user: wanted "%s" but got "%s"', 276 exe, baseline[exe]['euser'], process.euser) 277 278 sandbox_delta.append(exe) 279 continue 280 281 # If the process is not running as the correct group. 282 if process.egroup != baseline[exe]['egroup']: 283 logging.error('%s: bad group: wanted "%s" but got "%s"', 284 exe, baseline[exe]['egroup'], process.egroup) 285 286 sandbox_delta.append(exe) 287 continue 288 289 # Check the various sandbox settings. 290 if (baseline[exe]['pidns'] == 'Yes' and 291 process.pidns == init_process.pidns): 292 logging.error('%s: missing pid ns usage', exe) 293 sandbox_delta.append(exe) 294 elif (baseline[exe]['caps'] == 'Yes' and 295 process.capeff == init_process.capeff): 296 logging.error('%s: missing caps usage', exe) 297 sandbox_delta.append(exe) 298 elif (baseline[exe]['nonewprivs'] == 'Yes' and 299 process.nonewprivs != '1'): 300 logging.error('%s: missing NoNewPrivs', exe) 301 sandbox_delta.append(exe) 302 elif (baseline[exe]['filter'] == 'Yes' and 303 process.seccomp != SECCOMP_MODE_FILTER and 304 not is_asan): 305 # Since Minijail disables seccomp at runtime when ASAN is 306 # active, we can't enforce it on ASAN bots. Just ignore 307 # the test entirely. (Comment applies to "is_asan" above.) 308 logging.error('%s: missing seccomp usage: wanted %s (%s) but ' 309 'got %s (%s)', exe, SECCOMP_MODE_FILTER, 310 SECCOMP_MAP[SECCOMP_MODE_FILTER], process.seccomp, 311 SECCOMP_MAP.get(process.seccomp, '???')) 312 sandbox_delta.append(exe) 313 314 # Save current run to results dir. 315 running_services_properties = [get_properties(s, init_process) 316 for s in running_services.values()] 317 self.dump_services(fieldnames, running_services_properties) 318 319 if len(stale_baselines) > 0: 320 logging.warn('Stale baselines: %r', stale_baselines) 321 322 if len(new_services) > 0: 323 logging.warn('New services: %r', new_services) 324 325 # We won't complain about new non-root services (on the assumption 326 # that they've already somewhat sandboxed things), but we'll fail 327 # with new root services (on the assumption they haven't done any 328 # sandboxing work). If they really need to run as root, they can 329 # update the baseline to whitelist it. 330 new_root_services = [x for x in new_services 331 if running_services[x].euser == 'root'] 332 if new_root_services: 333 logging.error('New services are not allowed to run as root, ' 334 'but these are: %r', new_root_services) 335 sandbox_delta.extend(new_root_services) 336 337 if len(sandbox_delta) > 0: 338 logging.error('Failed sandboxing: %r', sandbox_delta) 339 raise error.TestFail('One or more processes failed sandboxing') 340