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