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