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, defaultdict
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)
44# These fields are not available via ps or /proc/PID/status.
45EXTRA_FIELDS = (
46    'mountinfo',
47)
48PsOutput = namedtuple("PsOutput",
49                      ' '.join([field.split(':')[0].lower() for field in
50                                PS_FIELDS + STATUS_FIELDS + EXTRA_FIELDS]))
51
52# Constants that match the values in /proc/PID/status Seccomp field.
53# See `man 5 proc` for more details.
54SECCOMP_MODE_DISABLED = '0'
55SECCOMP_MODE_STRICT = '1'
56SECCOMP_MODE_FILTER = '2'
57# For human readable strings.
58SECCOMP_MAP = {
59    SECCOMP_MODE_DISABLED: 'disabled',
60    SECCOMP_MODE_STRICT: 'strict',
61    SECCOMP_MODE_FILTER: 'filter',
62}
63
64# These mounts only occur in test images. They should be limited to the init
65# mount namespace, so no service should have them.
66TEST_IMAGE_MOUNTS = (
67    '/usr/local',
68    '/var/db/pkg',
69    '/var/lib/portage',
70)
71
72
73def get_properties(service, init_process):
74    """Returns a dictionary of the properties of a service.
75
76    @param service: the PsOutput of the service.
77    @param init_process: the PsOutput of the init process.
78    """
79
80    properties = dict(service._asdict())
81    properties['exe'] = service.comm
82    properties['pidns'] = yes_or_no(service.pidns != init_process.pidns)
83    properties['mntns'] = yes_or_no(service.mntns != init_process.mntns)
84    properties['caps'] = yes_or_no(service.capeff != init_process.capeff)
85    properties['nonewprivs'] = yes_or_no(service.nonewprivs == '1')
86    properties['filter'] = yes_or_no(service.seccomp == SECCOMP_MODE_FILTER)
87    return properties
88
89
90def yes_or_no(value):
91    """Returns 'Yes' or 'No' based on the truthiness of a value.
92
93    @param value: boolean value.
94    """
95
96    return 'Yes' if value else 'No'
97
98
99def get_mount_info(pid):
100    """Returns the contents of /proc/PID/mountinfo.
101
102    @param pid: The process id.
103    """
104    try:
105        return tuple(utils.get_mount_info(process=pid))
106    except IOError as e:
107        # This process might have died already.
108        logging.warning('Failed to read mountinfo for pid %s: %s', pid, e)
109        return ()
110
111
112def has_test_image_mounts(mountinfo):
113    """Returns whether a process has test image mounts in its mount namespace.
114
115    @param mountinfo: A list of utils.MountInfo.
116    """
117    return any(m.mount_point in TEST_IMAGE_MOUNTS for m in mountinfo)
118
119
120class security_SandboxedServices(test.test):
121    """Enforces sandboxing restrictions on the processes running
122    on the system.
123    """
124
125    version = 1
126
127
128    def get_running_processes(self):
129        """Returns a list of running processes as PsOutput objects."""
130
131        usermax = utils.system_output("cut -d: -f1 /etc/passwd | wc -L",
132                                      ignore_status=True)
133        groupmax = utils.system_output('cut -d: -f1 /etc/group | wc -L',
134                                       ignore_status=True)
135        # Even if the names are all short, make sure we have enough space
136        # to hold numeric 32-bit ids too (can come up with userns).
137        usermax = max(int(usermax), 10)
138        groupmax = max(int(groupmax), 10)
139        fields = {
140            'usermax': usermax,
141            'groupmax': groupmax,
142        }
143        ps_cmd = ('ps --no-headers -ww -eo ' +
144                  (','.join(PS_FIELDS) % fields))
145        ps_fields_len = len(PS_FIELDS)
146
147        output = utils.system_output(ps_cmd)
148        logging.debug('output of ps:\n%s', output)
149
150        # Fill in fields that `ps` doesn't support but are in /proc/PID/status.
151        # Example line output:
152        # Pid:1 CapInh:0000000000000000 CapPrm:0000001fffffffff CapEff:0000001fffffffff CapBnd:0000001fffffffff Seccomp:0
153        cmd = (
154            "for f in /proc/[1-9]*/status ; do awk '$1 ~ \"^(Pid|%s):\" "
155            "{printf \"%%s%%s \", $1, $NF; if ($1 == \"%s:\") printf \"\\n\"}'"
156            " $f ; done"
157        ) % ('|'.join(STATUS_FIELDS), STATUS_FIELDS[-1])
158        # Processes might exit while awk is running, so ignore its exit status.
159        status_output = utils.system_output(cmd, ignore_status=True)
160        # Turn each line into a dict.
161        # [
162        #   {'pid': '1', 'CapInh': '0000000000000000', 'Seccomp': '0', ...},
163        #   {'pid': '10', ...},
164        #   ...,
165        # ]
166        status_list = list(dict(attr.split(':', 1) for attr in line.split())
167                           for line in status_output.splitlines())
168        # Create a dict mapping a pid to its extended status data.
169        # {
170        #   '1': {'pid': '1', 'CapInh': '0000000000000000', ...},
171        #   '2': {'pid': '2', ...},
172        #   ...,
173        # }
174        status_data = dict((x['Pid'], x) for x in status_list)
175        logging.debug('output of awk:\n%s', status_output)
176
177        # Now merge the two sets of process data.
178        running_processes = []
179        for line in output.splitlines():
180            # crbug.com/422700: Filter out zombie processes.
181            if '<defunct>' in line:
182                continue
183
184            fields = line.split(None, ps_fields_len - 1)
185            pid = fields[0]
186            # The process lists might not be exactly the same (since we gathered
187            # data with multiple commands), and not all fields might exist (e.g.
188            # older kernels might not have all the fields).
189            pid_data = status_data.get(pid, {})
190            status_fields = [pid_data.get(key) for key in STATUS_FIELDS]
191            extra_fields = [get_mount_info(pid)]
192            running_processes.append(
193                PsOutput(*fields + status_fields + extra_fields))
194
195        return running_processes
196
197
198    def load_baseline(self):
199        """The baseline file lists the services we know and
200        whether (and how) they are sandboxed.
201        """
202
203        def load(path):
204            """Load baseline from |path| and return its fields and dictionary.
205
206            @param path: The baseline to load.
207            """
208            logging.info('Loading baseline %s', path)
209            reader = csv.DictReader(open(path))
210            return reader.fieldnames, dict((d['exe'], d) for d in reader
211                                           if not d['exe'].startswith('#'))
212
213        baseline_path = os.path.join(self.bindir, 'baseline')
214        fields, ret = load(baseline_path)
215
216        board = utils.get_current_board()
217        baseline_path += '.' + board
218        if os.path.exists(baseline_path):
219            new_fields, new_entries = load(baseline_path)
220            if new_fields != fields:
221                raise error.TestError('header mismatch in %s' % baseline_path)
222            ret.update(new_entries)
223
224        return fields, ret
225
226
227    def load_exclusions(self):
228        """The exclusions file lists running programs
229        that we don't care about (for now).
230        """
231
232        exclusions_path = os.path.join(self.bindir, 'exclude')
233        return set(line.strip() for line in open(exclusions_path)
234                   if not line.startswith('#'))
235
236
237    def dump_services(self, fieldnames, running_services_properties):
238        """Leaves a list of running services in the results dir
239        so that we can update the baseline file if necessary.
240
241        @param fieldnames: list of fields to be written.
242        @param running_services_properties: list of services to be logged.
243        """
244
245        file_path = os.path.join(self.resultsdir, 'running_services')
246        with open(file_path, 'w') as output_file:
247            writer = csv.DictWriter(output_file, fieldnames=fieldnames,
248                                    extrasaction='ignore')
249            writer.writeheader()
250            for service_properties in running_services_properties:
251                writer.writerow(service_properties)
252
253
254    def run_once(self):
255        """Inspects the process list, looking for root and sandboxed processes
256        (with some exclusions). If we have a baseline entry for a given process,
257        confirms it's an exact match. Warns if we see root or sandboxed
258        processes that we have no baseline for, and warns if we have
259        baselines for processes not seen running.
260        """
261
262        fieldnames, baseline = self.load_baseline()
263        exclusions = self.load_exclusions()
264        running_processes = self.get_running_processes()
265        is_asan = asan.running_on_asan()
266        if is_asan:
267            logging.info('ASAN image detected -> skipping seccomp checks')
268
269        kthreadd_pid = -1
270
271        init_process = None
272        running_services = {}
273
274        # Filter running processes list.
275        for process in running_processes:
276            exe = process.comm
277
278            if exe == "kthreadd":
279                kthreadd_pid = process.pid
280                continue
281            elif process.pid == "1":
282                init_process = process
283                continue
284
285            # Don't worry about kernel threads.
286            if process.ppid == kthreadd_pid:
287                continue
288
289            if exe in exclusions:
290                continue
291
292            running_services[exe] = process
293
294        if not init_process:
295            raise error.TestFail("Cannot find init process")
296
297        # Find differences between running services and baseline.
298        services_set = set(running_services.keys())
299        baseline_set = set(baseline.keys())
300
301        new_services = services_set.difference(baseline_set)
302        stale_baselines = defaultdict(list)
303
304        for exe in baseline_set.difference(services_set):
305            stale_baselines[exe].append('unused')
306
307        # Check baseline.
308        sandbox_delta = defaultdict(list)
309        for exe in services_set.intersection(baseline_set):
310            process = running_services[exe]
311            stale_flags = []
312            errors = []
313
314            # If the process is not running as the correct user.
315            if process.euser != baseline[exe]["euser"]:
316                errors.append('bad user: wanted "%s" but got "%s"' %
317                              (baseline[exe]['euser'], process.euser))
318
319            # If the process is not running as the correct group.
320            if process.egroup != baseline[exe]['egroup']:
321                errors.append('bad group: wanted "%s" but got "%s"' %
322                              (baseline[exe]['egroup'], process.egroup))
323
324            # Check the various sandbox settings.
325            if process.pidns == init_process.pidns:
326                if baseline[exe]['pidns'] == 'Yes':
327                    errors.append('missing pid ns usage')
328            elif baseline[exe]['pidns'] != 'Yes':
329                stale_flags.append('pidns')
330
331            if process.mntns == init_process.mntns:
332                if baseline[exe]['mntns'] == 'Yes':
333                    errors.append('missing mount ns usage')
334            elif has_test_image_mounts(process.mountinfo):
335                if baseline[exe]['mntns'] == 'Yes':
336                    errors.append('did not call pivot_root(2)')
337            elif baseline[exe]['mntns'] != 'Yes':
338                stale_flags.append('mntns')
339
340            if process.capeff == init_process.capeff:
341                if baseline[exe]['caps'] == 'Yes':
342                    errors.append('missing caps usage')
343            elif baseline[exe]['caps'] != 'Yes':
344                stale_flags.append('caps')
345
346            if process.nonewprivs != '1':
347                if baseline[exe]['nonewprivs'] == 'Yes':
348                    errors.append('missing NoNewPrivs')
349            elif baseline[exe]['nonewprivs'] != 'Yes':
350                stale_flags.append('nonewprivs')
351
352            if not is_asan:
353                # Since Minijail disables seccomp at runtime when ASAN is
354                # active, we can't enforce it on ASAN bots.  Just ignore
355                # the test entirely.  (Comment applies to "is_asan" above.)
356                if process.seccomp != SECCOMP_MODE_FILTER:
357                    if baseline[exe]['filter'] == 'Yes':
358                        errors.append(
359                            'missing seccomp usage: '
360                            'wanted %s (%s) but got %s (%s)' %
361                            (SECCOMP_MODE_FILTER,
362                             SECCOMP_MAP[SECCOMP_MODE_FILTER], process.seccomp,
363                             SECCOMP_MAP.get(process.seccomp, '???')))
364                elif baseline[exe]['filter'] != 'Yes':
365                    stale_flags.append('filter')
366
367            if stale_flags:
368                stale_baselines[exe].append('potentially missing flags: %s' %
369                                            ','.join(stale_flags))
370            if errors:
371                sandbox_delta[exe].extend(errors)
372
373        # Save current run to results dir.
374        running_services_properties = [get_properties(s, init_process)
375                                       for s in running_services.values()]
376        self.dump_services(fieldnames, running_services_properties)
377
378        if len(stale_baselines) > 0:
379            logging.warn('Stale baselines: %r', stale_baselines)
380
381        if len(new_services) > 0:
382            logging.warn('New services: %r', new_services)
383
384            # We won't complain about new non-root services (on the assumption
385            # that they've already somewhat sandboxed things), but we'll fail
386            # with new root services (on the assumption they haven't done any
387            # sandboxing work).  If they really need to run as root, they can
388            # update the baseline to whitelist it.
389            for exe in new_services:
390                if running_services[exe].euser == 'root':
391                    sandbox_delta[exe].append('missing euser')
392
393        if len(sandbox_delta) > 0:
394            for delta_entry in sandbox_delta:
395                logging.error('Failed sandboxing: %s', delta_entry)
396            raise error.TestFail('One or more processes failed sandboxing: %r' %
397                                 sandbox_delta)
398