1# Copyright (c) 2011 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 dbus
6import json
7import logging
8import os.path
9from xml.dom.minidom import parse, parseString
10
11from autotest_lib.client.bin import test, utils
12from autotest_lib.client.common_lib import error
13from autotest_lib.client.cros import constants, login
14
15class security_DbusMap(test.test):
16    version = 2
17
18    def policy_sort_priority(self, policy):
19        """
20        Given a DOMElement representing one <policy> block from a dbus
21        configuraiton file, return a number suitable for determining
22        the order in which this policy would be applied by dbus-daemon.
23        For example, by returning:
24        0 for 'default' policies
25        1 for 'group' policies
26        2 for 'user' policies
27        ... these numbers can be used as a sort-key for sorting
28        an array of policies into the order they would be evaluated by
29        dbus-daemon.
30        """
31        # As derived from dbus-daemon(1) manpage
32        if policy.getAttribute('context') == 'default':
33            return 0
34        if policy.getAttribute('group') != '':
35            return 1
36        if policy.getAttribute('user') != '':
37            return 2
38        if policy.getAttribute('at_console') == 'true':
39            return 3
40        if policy.getAttribute('at_console') == 'false':
41            return 4
42        if policy.getAttribute('context') == 'mandatory':
43            return 5
44
45
46    def sort_policies(self, policies):
47        """
48        Given an array of DOMElements representing <policy> blocks,
49        return a sorted copy of the array. Sorting is determined by
50        the order in which dbus-daemon(1) would consider the rules.
51        This is a stable sort, so in cases where dbus would employ
52        "last rule wins," position in the input list will be honored.
53        """
54        # Use decorate-sort-undecorate to minimize calls to
55        # policy_sort_priority(). See http://wiki.python.org/moin/HowTo/Sorting
56        decorated = [(self.policy_sort_priority(policy), i, policy) for
57                     i, policy in enumerate(policies)]
58        decorated.sort()
59        return [policy for _,_,policy in decorated]
60
61
62    def check_policies(self, config_doms, dest, iface, member,
63                       user='chronos', at_console=None):
64        """
65        Given 1 or more xml.dom's representing dbus configuration
66        data, determine if the <destination, interface, member>
67        triplet specified in the arguments would be permitted for
68        the specified user.
69
70        Returns True if permitted, False otherwise.
71        See also http://dbus.freedesktop.org/doc/busconfig.dtd
72        """
73        # In the default case, if the caller doesn't specify
74        # "at_console," employ this cros-specific heuristic:
75        if user == 'chronos' and at_console == None:
76            at_console = True
77
78        # Apply the policies iteratively, in the same order
79        # that dbus-daemon(1) would consider them.
80        allow = False
81        for dom in config_doms:
82            for buscfg in dom.getElementsByTagName('busconfig'):
83                policies = self.sort_policies(
84                    buscfg.getElementsByTagName('policy'))
85                for policy in policies:
86                    ruling = self.check_one_policy(policy, dest, iface,
87                                                   member, user, at_console)
88                    if ruling is not None:
89                        allow = ruling
90        return allow
91
92
93    def check_one_policy(self, policy, dest, iface, member,
94                         user='chronos', at_console=True):
95        """
96        Given a DOMElement representing one <policy> block from a dbus
97        configuration file, determine if the <destination, interface,
98        member> triplet specified in the arguments would be permitted
99        for the specified user.
100
101        Returns True if permitted, False if prohibited, or
102        None if the policy does not apply to the triplet.
103        """
104        # While D-Bus overall is a default-deny, this individual
105        # rule may not match, and some previous rule may have already
106        # said "allow" for this interface/method. So, we work from
107        # here starting with "doesn't apply," not "deny" to avoid
108        # falsely masking any previous "allow" rules.
109        allow = None
110
111        # TODO(jimhebert) group='...' is not currently used by any
112        # Chrome OS dbus policies but could be in the future so
113        # we should add a check for it in this if-block.
114        if ((policy.getAttribute('context') != 'default') and
115            (policy.getAttribute('user') != user) and
116            (policy.getAttribute('at_console') != 'true')):
117            # In this case, the entire <policy> block does not apply
118            return None
119
120        # Alternatively, if this IS a at_console policy, but the
121        # situation being checked is not an "at_console" situation,
122        # then that's another way the policy would also not apply.
123        if (policy.getAttribute('at_console') == 'true' and not
124            at_console):
125            return None
126
127        # If the <policy> applies, try to find <allow> or <deny>
128        # child nodes that apply:
129        for node in policy.childNodes:
130            if (node.nodeType == node.ELEMENT_NODE and
131                node.localName in ['allow','deny']):
132                ruling = self.check_one_node(node, dest, iface, member)
133                if ruling is not None:
134                    allow = ruling
135        return allow
136
137
138    def check_one_node(self, node, dest, iface, member):
139        """
140        Given a DOMElement representing one <allow> or <deny> tag from a
141        dbus configuration file, determine if the <destination, interface,
142        member> triplet specified in the arguments would be permitted.
143
144        Returns True if permitted, False if prohibited, or
145        None if the policy does not apply to the triplet.
146        """
147        # Require send_destination to match (if we accept missing
148        # send_destination we end up falsely processing tags like
149        # <allow own="...">). But, do not require send_interface
150        # or send_member to exist, because omitting them is used
151        # as a way of wildcarding in dbus configuration.
152        if ((node.getAttribute('send_destination') == dest) and
153            (not node.hasAttribute('send_interface') or
154             node.getAttribute('send_interface') == iface) and
155            (not node.hasAttribute('send_member') or
156             node.getAttribute('send_member') == member)):
157            # The rule applies! Return True if it's an allow rule, else false
158            logging.debug(('%s send_destination=%s send_interface=%s '
159                           'send_member=%s applies to %s %s %s.') %
160                          (node.localName,
161                           node.getAttribute('send_destination'),
162                           node.getAttribute('send_interface'),
163                           node.getAttribute('send_member'),
164                           dest, iface, member))
165            return (node.localName == 'allow')
166        else:
167            return None
168
169
170    def load_dbus_config_doms(self, dbusdir='/etc/dbus-1/system.d'):
171        """
172        Given a path to a directory containing valid dbus configuration
173        files (http://dbus.freedesktop.org/doc/busconfig.dtd), return
174        a series of parsed DOMs representing the configuration.
175        This function implements the same requirements as dbus-daemon
176        itself -- notably, that valid config files must be named
177        with a ".conf" extension.
178        Returns: a list of DOMs
179        """
180        config_doms = []
181        for dirent in os.listdir(dbusdir):
182            dirent = os.path.join(dbusdir, dirent)
183            if os.path.isfile(dirent) and dirent.endswith('.conf'):
184                config_doms.append(parse(dirent))
185        return config_doms
186
187
188    def mutual_compare(self, dbus_list, baseline, context='all'):
189        """
190        This is a front-end for compare_dbus_trees which handles
191        comparison in both directions, discovering not only what is
192        missing from the baseline, but what is missing from the system.
193
194        The optional 'context' argument is (only) used to for
195        providing more detailed context in the debug-logging
196        that occurs.
197
198        Returns: True if the two exactly match. False otherwise.
199        """
200        self.sort_dbus_tree(dbus_list)
201        self.sort_dbus_tree(baseline)
202
203        # Compare trees to find added API's.
204        newapis = self.compare_dbus_trees(dbus_list, baseline)
205        if (len(newapis) > 0):
206            logging.error("New (accessible to %s) API's to review:" % context)
207            logging.error(json.dumps(newapis, sort_keys=True, indent=2))
208
209        # Swap arguments to find missing API's.
210        missing_apis = self.compare_dbus_trees(baseline, dbus_list)
211        if (len(missing_apis) > 0):
212            logging.error("Missing API's (expected to be accessible to %s):" %
213                          context)
214            logging.error(json.dumps(missing_apis, sort_keys=True, indent=2))
215
216        return (len(newapis) + len(missing_apis) == 0)
217
218
219    def add_member(self, dbus_list, dest, iface, member):
220        return self._add_surface(dbus_list, dest, iface, member, 'methods')
221
222
223    def add_signal(self, dbus_list, dest, iface, signal):
224        return self._add_surface(dbus_list, dest, iface, signal, 'signals')
225
226
227    def add_property(self, dbus_list, dest, iface, signal):
228        return self._add_surface(dbus_list, dest, iface, signal, 'properties')
229
230
231    def _add_surface(self, dbus_list, dest, iface, member, slot):
232        """
233        This can add an entry for a member function to a given
234        dbus list. It behaves somewhat like "mkdir -p" in that
235        it creates any missing, necessary intermediate portions
236        of the data structure. For example, if this is the first
237        member being added for a given interface, the interface
238        will not already be mentioned in dbus_list, and this
239        function initializes the interface dictionary appropriately.
240        Returns: None
241        """
242        # Ensure the Destination object exists in the data structure.
243        dest_idx = -1
244        for (i, objdict) in enumerate(dbus_list):
245            if objdict['Object_name'] == dest:
246                dest_idx = i
247        if dest_idx == -1:
248            dbus_list.append({'Object_name': dest, 'interfaces': []})
249
250        # Ensure the Interface entry exists for that Destination object.
251        iface_idx = -1
252        for (i, ifacedict) in enumerate(dbus_list[dest_idx]['interfaces']):
253            if ifacedict['interface'] == iface:
254                iface_idx = i
255        if iface_idx == -1:
256            dbus_list[dest_idx]['interfaces'].append({'interface': iface,
257                                                      'signals': [],
258                                                      'properties': [],
259                                                      'methods': []})
260
261        # Ensure the slot exists.
262        if not slot in dbus_list[dest_idx]['interfaces'][iface_idx]:
263            dbus_list[dest_idx]['interfaces'][iface_idx][slot] = []
264
265        # Add member so long as it's not a duplicate.
266        if not member in (
267            dbus_list[dest_idx]['interfaces'][iface_idx][slot]):
268            dbus_list[dest_idx]['interfaces'][iface_idx][slot].append(
269                member)
270
271
272    def list_baselined_users(self):
273        """
274        Return a list of usernames for which we keep user-specific
275        attack-surface baselines.
276        """
277        bdir = os.path.dirname(os.path.abspath(__file__))
278        users = []
279        for item in os.listdir(bdir):
280            # Pick up baseline.username files but ignore emacs backups.
281            if item.startswith('baseline.') and not item.endswith('~'):
282                users.append(item.partition('.')[2])
283        return users
284
285
286    def load_baseline(self, user=''):
287        """
288        Return a list of interface names we expect to be owned
289        by chronos.
290        """
291        # The overall baseline is 'baseline'. User-specific baselines are
292        # stored in files named 'baseline.<username>'.
293        baseline_name = 'baseline'
294        if user:
295            baseline_name = '%s.%s' % (baseline_name, user)
296
297        # Figure out path to baseline file, by looking up our own path.
298        bpath = os.path.abspath(__file__)
299        bpath = os.path.join(os.path.dirname(bpath), baseline_name)
300        return self.load_dbus_data_from_disk(bpath)
301
302
303    def write_dbus_data_to_disk(self, dbus_list, file_path):
304        """Writes the given dbus data to a given path to a json file.
305        Args:
306            dbus_list: list of dbus dictionaries to write to disk.
307            file_path: the path to the file to write the data to.
308        """
309        file_handle = open(file_path, 'w')
310        my_json = json.dumps(dbus_list, sort_keys=True, indent=2)
311        # The json dumper has a trailing whitespace problem, and lacks
312        # a final newline. Fix both here.
313        file_handle.write(my_json.replace(', \n',',\n') + '\n')
314        file_handle.close()
315
316
317    def load_dbus_data_from_disk(self, file_path):
318        """Loads dbus data from a given path to a json file.
319        Args:
320            file_path: path to the file as a string.
321        Returns:
322            A list of the dictionary representation of the dbus data loaded.
323            The dictionary format is the same as returned by walk_object().
324        """
325        file_handle = open(file_path, 'r')
326        dbus_data = json.loads(file_handle.read())
327        file_handle.close()
328        return dbus_data
329
330
331    def sort_dbus_tree(self, tree):
332        """Sorts a an aray of dbus dictionaries in alphabetical order.
333             All levels of the tree are sorted.
334        Args:
335            tree: the array to sort. Modified in-place.
336        """
337        tree.sort(key=lambda x: x['Object_name'])
338        for dbus_object in tree:
339            dbus_object['interfaces'].sort(key=lambda x: x['interface'])
340            for interface in dbus_object['interfaces']:
341                interface['methods'].sort()
342                interface['signals'].sort()
343                interface['properties'].sort()
344
345
346    def compare_dbus_trees(self, current, baseline):
347        """Compares two dbus dictionaries and return the delta.
348           The comparison only returns what is in the current (LHS) and not
349           in the baseline (RHS). If you want the reverse, call again
350           with the arguments reversed.
351        Args:
352            current: dbus tree you want to compare against the baseline.
353            baseline: dbus tree baseline.
354        Returns:
355            A list of dictionary representations of the additional dbus
356            objects, if there is a difference. Otherwise it returns an
357            empty list. The format of the dictionaries is the same as the
358            one returned in walk_object().
359        """
360        # Build the key map of what is in the baseline.
361        bl_object_names = [bl_object['Object_name'] for bl_object in baseline]
362
363        new_items = []
364        for dbus_object in current:
365            if dbus_object['Object_name'] in bl_object_names:
366                index = bl_object_names.index(dbus_object['Object_name'])
367                bl_object_interfaces = baseline[index]['interfaces']
368                bl_interface_names = [name['interface'] for name in
369                                      bl_object_interfaces]
370
371                # If we have a new interface/method we need to build the shell.
372                new_object = {'Object_name':dbus_object['Object_name'],
373                              'interfaces':[]}
374
375                for interface in dbus_object['interfaces']:
376                    if interface['interface'] in bl_interface_names:
377                        # The interface was baselined, check everything.
378                        diffslots = {}
379                        for slot in ['methods', 'signals', 'properties']:
380                            index = bl_interface_names.index(
381                                interface['interface'])
382                            bl_methods = set(bl_object_interfaces[index][slot])
383                            methods = set(interface[slot])
384                            difference = methods.difference(bl_methods)
385                            diffslots[slot] = list(difference)
386                        if (diffslots['methods'] or diffslots['signals'] or
387                            diffslots['properties']):
388                            # This is a new thing we need to track.
389                            new_methods = {'interface':interface['interface'],
390                                           'methods': diffslots['methods'],
391                                           'signals': diffslots['signals'],
392                                           'properties': diffslots['properties']
393                                           }
394                            new_object['interfaces'].append(new_methods)
395                            new_items.append(new_object)
396                    else:
397                        # This is a new interface we need to track.
398                        new_object['interfaces'].append(interface)
399                        new_items.append(new_object)
400            else:
401                # This is a new object we need to track.
402                new_items.append(dbus_object)
403        return new_items
404
405
406    def walk_object(self, bus, object_name, start_path, dbus_objects):
407        """Walks the given bus and object returns a dictionary representation.
408           The formate of the dictionary is as follows:
409           {
410               Object_name: "string"
411               interfaces:
412               [
413                   interface: "string"
414                   methods:
415                   [
416                       "string1",
417                       "string2"
418                   ]
419               ]
420           }
421           Note that the decision to capitalize Object_name is just
422           a way to force it to appear above the interface-list it
423           corresponds to, when pretty-printed by the json dumper.
424           This makes it more logical for humans to read/edit.
425        Args:
426            bus: the bus to query, usually system.
427            object_name: the name of the dbus object to walk.
428            start_path: the path inside of the object in which to start walking
429            dbus_objects: current list of dbus objects in the given object
430        Returns:
431            A dictionary representation of a dbus object
432        """
433        remote_object = bus.get_object(object_name,start_path)
434        unknown_iface = dbus.Interface(remote_object,
435                                       'org.freedesktop.DBus.Introspectable')
436        # Convert the string to an xml DOM object we can walk.
437        xml = parseString(unknown_iface.Introspect())
438        for child in xml.childNodes:
439            if ((child.nodeType == 1) and (child.localName == u'node')):
440                interfaces = child.getElementsByTagName('interface')
441                for interface in interfaces:
442                    interface_name = interface.getAttribute('name')
443                    # First get the methods.
444                    methods = interface.getElementsByTagName('method')
445                    method_list = []
446                    for method in methods:
447                        method_list.append(method.getAttribute('name'))
448                    # Repeat the process for signals.
449                    signals = interface.getElementsByTagName('signal')
450                    signal_list = []
451                    for signal in signals:
452                        signal_list.append(signal.getAttribute('name'))
453                    # Properties have to be discovered via API call.
454                    prop_list = []
455                    try:
456                        prop_iface = dbus.Interface(remote_object,
457                            'org.freedesktop.DBus.Properties')
458                        prop_list = prop_iface.GetAll(interface_name).keys()
459                    except dbus.exceptions.DBusException:
460                        # Many daemons do not support this interface,
461                        # which means they have no properties.
462                        pass
463                    # Create the dictionary with all the above.
464                    dictionary = {'interface':interface_name,
465                                  'methods':method_list, 'signals':signal_list,
466                                  'properties':prop_list}
467                    if dictionary not in dbus_objects:
468                        dbus_objects.append(dictionary)
469                nodes = child.getElementsByTagName('node')
470                for node in nodes:
471                    name = node.getAttribute('name')
472                    if start_path[-1] != '/':
473                            start_path = start_path + '/'
474                    new_name = start_path + name
475                    self.walk_object(bus, object_name, new_name, dbus_objects)
476        return {'Object_name':('%s' % object_name), 'interfaces':dbus_objects}
477
478
479    def mapper_main(self):
480        # Currently we only dump the SystemBus. Accessing the SessionBus says:
481        # "ExecFailed: /usr/bin/dbus-launch terminated abnormally with the
482        # following error: Autolaunch requested, but X11 support not compiled
483        # in."
484        # If this changes at a later date, add dbus.SessionBus() to the dict.
485        # We've left the code structured to support walking more than one bus
486        # for such an eventuality.
487
488        buses = {'System Bus': dbus.SystemBus()}
489
490        for busname in buses.keys():
491            bus = buses[busname]
492            remote_dbus_object = bus.get_object('org.freedesktop.DBus',
493                                                '/org/freedesktop/DBus')
494            iface = dbus.Interface(remote_dbus_object, 'org.freedesktop.DBus')
495            dbus_list = []
496            for i in iface.ListNames():
497                # There are some strange listings like ":1" which appear after
498                # certain names. Ignore these since we just need the names.
499                if i.startswith(':'):
500                    continue
501                dbus_list.append(self.walk_object(bus, i, '/', []))
502
503        # Dump the complete observed dataset to disk. In the somewhat
504        # typical case, that we will want to rev the baseline to
505        # match current reality, these files are easily copied and
506        # checked in as a new baseline.
507        self.sort_dbus_tree(dbus_list)
508        observed_data_path = os.path.join(self.outputdir, 'observed')
509        self.write_dbus_data_to_disk(dbus_list, observed_data_path)
510
511        baseline = self.load_baseline()
512        test_pass = self.mutual_compare(dbus_list, baseline)
513
514        # Figure out which of the observed API's are callable by specific users
515        # whose attack surface we are particularly sensitive to:
516        dbus_cfg = self.load_dbus_config_doms()
517        for user in self.list_baselined_users():
518            user_baseline = self.load_baseline(user)
519            user_observed = []
520            # user_observed will be a subset of dbus_list. Iterate and check
521            # against the configured dbus policies as we go:
522            for objdict in dbus_list:
523                for ifacedict in objdict['interfaces']:
524                    for meth in ifacedict['methods']:
525                        if (self.check_policies(dbus_cfg,
526                                                objdict['Object_name'],
527                                                ifacedict['interface'], meth,
528                                                user=user)):
529                            self.add_member(user_observed,
530                                            objdict['Object_name'],
531                                            ifacedict['interface'], meth)
532                    # We don't do permission-checking on signals because
533                    # signals are allow-all by default. Just copy them over.
534                    for sig in ifacedict['signals']:
535                        self.add_signal(user_observed,
536                                        objdict['Object_name'],
537                                        ifacedict['interface'], sig)
538                    # A property might be readable, or even writable, to
539                    # a given user if they can reach the Get/Set interface
540                    access = []
541                    if (self.check_policies(dbus_cfg, objdict['Object_name'],
542                                            'org.freedesktop.DBus.Properties',
543                                            'Set', user=user)):
544                        access.append('Set')
545                    if (self.check_policies(dbus_cfg, objdict['Object_name'],
546                                            'org.freedesktop.DBus.Properties',
547                                            'Get', user=user) or
548                        self.check_policies(dbus_cfg, objdict['Object_name'],
549                                            'org.freedesktop.DBus.Properties',
550                                            'GetAll', user=user)):
551                        access.append('Get')
552                    if access:
553                        access = ','.join(access)
554                        for prop in ifacedict['properties']:
555                            self.add_property(user_observed,
556                                              objdict['Object_name'],
557                                              ifacedict['interface'],
558                                              '%s (%s)' % (prop, access))
559
560            self.write_dbus_data_to_disk(user_observed,
561                                         '%s.%s' % (observed_data_path, user))
562            test_pass = test_pass and self.mutual_compare(user_observed,
563                                                          user_baseline, user)
564        if not test_pass:
565            raise error.TestFail('Baseline mismatch(es)')
566
567
568    def run_once(self):
569        """
570        Enumerates all discoverable interfaces, methods, and signals
571        in dbus-land. Verifies that it matches an expected set.
572        """
573        login.wait_for_browser()
574        self.mapper_main()
575