diff --git a/Makefile b/Makefile index eaf706b2..195e42c5 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,6 @@ OS ?= Linux PYTHON ?= python3 ADMIN_API_METHODS_SIMPLE = \ - admin.vm.List \ admin.vmclass.List \ admin.Events \ admin.backup.Execute \ @@ -17,6 +16,7 @@ ADMIN_API_METHODS_SIMPLE = \ admin.label.Create \ admin.label.Get \ admin.label.List \ + admin.label.Index \ admin.label.Remove \ admin.pool.Add \ admin.pool.Info \ @@ -83,6 +83,8 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.tag.List \ admin.vm.tag.Remove \ admin.vm.tag.Set \ + admin.vm.volume.CloneFrom \ + admin.vm.volume.CloneTo \ admin.vm.volume.Info \ admin.vm.volume.List \ admin.vm.volume.ListSnapshots \ @@ -90,10 +92,6 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.volume.Revert \ $(null) -ADMIN_API_METHODS := $(ADMIN_API_METHODS_SIMPLE) \ - admin.vm.volume.Import \ - $(null) - ifeq ($(OS),Linux) DATADIR ?= /var/lib/qubes STATEDIR ?= /var/run/qubes @@ -172,15 +170,26 @@ endif install qubes-rpc/qubesd-query-fast $(DESTDIR)/usr/libexec/qubes/ for method in $(ADMIN_API_METHODS_SIMPLE); do \ ln -s ../../usr/libexec/qubes/qubesd-query-fast \ - $(DESTDIR)/etc/qubes-rpc/$$method; \ + $(DESTDIR)/etc/qubes-rpc/$$method || exit 1; \ done install qubes-rpc/admin.vm.volume.Import $(DESTDIR)/etc/qubes-rpc/ - for method in $(ADMIN_API_METHODS); do \ - install -m 0644 qubes-rpc-policy/admin-default \ - $(DESTDIR)/etc/qubes-rpc/policy/$$method; \ + PYTHONPATH=.:test-packages qubes-rpc-policy/generate-admin-policy \ + --destdir=$(DESTDIR)/etc/qubes-rpc/policy \ + --exclude admin.vm.Create.AdminVM \ + admin.vm.CreateInPool.AdminVM \ + admin.vm.device.testclass.Attach \ + admin.vm.device.testclass.Detach \ + admin.vm.device.testclass.List \ + admin.vm.device.testclass.Available + # sanity check + for method in $(DESTDIR)/etc/qubes-rpc/policy/admin.*; do \ + ls $(DESTDIR)/etc/qubes-rpc/$$(basename $$method) >/dev/null || exit 1; \ done install -d $(DESTDIR)/etc/qubes-rpc/policy/include - install -m 0644 qubes-rpc-policy/admin-all \ + install -m 0644 qubes-rpc-policy/admin-local-ro \ + qubes-rpc-policy/admin-local-rwx \ + qubes-rpc-policy/admin-global-ro \ + qubes-rpc-policy/admin-global-rwx \ $(DESTDIR)/etc/qubes-rpc/policy/include/ mkdir -p "$(DESTDIR)$(FILESDIR)" diff --git a/ci/coveragerc b/ci/coveragerc index 2d47c77e..8dccfa52 100644 --- a/ci/coveragerc +++ b/ci/coveragerc @@ -1,3 +1,3 @@ [run] -source = qubes -omit = qubes/tests/* +source = qubes, qubespolicy +omit = qubes/tests/*, qubespolicy/tests/* diff --git a/doc/conf.py b/doc/conf.py index f3f5480a..59c5ce24 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -243,6 +243,8 @@ _man_pages_author = [] man_pages = [ ('manpages/qubesd-query', 'qubesd-query', u'Low-level qubesd interrogation tool', _man_pages_author, 1), + ('manpages/qrexec-policy-graph', 'qrexec-policy-graph', + u'Graph qrexec policy', _man_pages_author, 1), ] if os.path.exists('sandbox.rst'): diff --git a/doc/manpages/qrexec-policy-graph.rst b/doc/manpages/qrexec-policy-graph.rst new file mode 100644 index 00000000..6e95bea3 --- /dev/null +++ b/doc/manpages/qrexec-policy-graph.rst @@ -0,0 +1,73 @@ +.. program:: qrexec-policy-graph + +:program:`qrexec-policy-graph` -- Graph qrexec policy +===================================================== + +Synopsis +-------- + +:command:`qrexec-policy-graph` [-h] [--include-ask] [--source *SOURCE* [*SOURCE* ...]] [--target *TARGET* [*TARGET* ...]] [--service *SERVICE* [*SERVICE* ...]] [--output *OUTPUT*] [--policy-dir POLICY_DIR] [--system-info SYSTEM_INFO] + + +Options +------- + +.. option:: --help, -h + + show this help message and exit + +.. option:: --include-ask + + Include `ask` action in graph. In most cases produce unreadable graphs + because many services contains `$anyvm $anyvm ask` rules. It's recommended to + limit graph using other options. + +.. option:: --source + + Limit graph to calls from *source*. You can specify multiple names. + +.. option:: --target + + Limit graph to calls to *target*. You can specify multiple names. + +.. option:: --service + + Limit graph to *service*. You can specify multiple names. This can be either + bare service name, or service with argument (joined with `+`). If bare + service name is given, output will contain also policies for specific + arguments. + +.. option:: --output + + Write to *output* instead of stdout. The file will be overwritten without + confirmation. + +.. option:: --policy-dir + + Look for policy in *policy-dir*. This can be useful to process policy + extracted from other system. This option adjust only base directory, if any + policy file contains `$include:path` with absolute path, it will try to load + the file from that location. + See also --system-info option. + +.. option:: --system-info + + Load system information from file instead of querying local qubesd instance. + The file should be in json format, as returned by `internal.GetSystemInfo` + qubesd method. This can be obtained by running in dom0: + + qubesd-query -e -c /var/run/qubesd.internal.sock dom0 \ + internal.GetSystemInfo dom0 | cut -b 3- + +.. option:: --skip-labels + + Do not include service names on the graph. Also, include only a single + connection between qubes if any service call is allowed there. + + +Authors +------- + +| Marek Marczykowski-Górecki + +.. vim: ts=3 sw=3 et tw=80 diff --git a/doc/qubes-policy.rst b/doc/qubes-policy.rst index 0be819ca..1c74545c 100644 --- a/doc/qubes-policy.rst +++ b/doc/qubes-policy.rst @@ -11,13 +11,14 @@ Policy consists of a file, which is parsed line-by-line. First matching line is used as an action. Each line consist of three values separated by white characters (space(s), tab(s)): + 1. Source specification, which is one of: - domain name - `$anyvm` - any domain - `$tag:some-tag` - VM having tag `some-tag` - `$type:vm-type` - VM of `vm-type` type, available types: - AppVM, TemplateVM, StandaloneVM, DispVM + AppVM, TemplateVM, StandaloneVM, DispVM 2. Target specification, one of: @@ -25,15 +26,20 @@ Each line consist of three values separated by white characters (space(s), tab(s - `$anyvm` - any domain, excluding dom0 - `$tag:some-tag` - domain having tag `some-tag` - `$type:vm-type` - domain of `vm-type` type, available types: - AppVM, TemplateVM, StandaloneVM, DispVM + AppVM, TemplateVM, StandaloneVM, DispVM - `$default` - used when caller did not specified any VM - `$dispvm:vm-name` - _new_ Disposable VM created from AppVM `vm-name` - `$dispvm` - _new_ Disposable VM created from AppVM pointed by caller property `default_dispvm`, which defaults to global property `default_dispvm` + - `$adminvm` - Admin VM aka dom0 + + Dom0 can only be matched explicitly - either as `dom0` or `$adminvm` keyword. + None of `$anyvm`, `$tag:some-tag`, `$type:AdminVM` will match. 3. Action and optional action parameters, one of: - `allow` - allow the call, without further questions; optional parameters: + - `target=` - override caller provided call target - possible values are: domain name, `$dispvm` or `$dispvm:vm-name` - `user=` - call the service using this user, instead of the user @@ -41,6 +47,7 @@ Each line consist of three values separated by white characters (space(s), tab(s - `deny` - deny the call, without further questions; no optional parameters are supported - `ask` - ask the user for confirmation; optional parameters: + - `target=` - override user provided call target - `user=` - call the service using this user, instead of the user pointed by target VM's `default_user` property diff --git a/qubes-rpc-policy/admin-default b/qubes-rpc-policy/admin-default deleted file mode 100644 index fc4ed4a7..00000000 --- a/qubes-rpc-policy/admin-default +++ /dev/null @@ -1,13 +0,0 @@ -## Note that policy parsing stops at the first match, -## so adding anything below "$anyvm $anyvm action" line will have no effect - -## Please use a single # to start your custom comments - -## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions - -## Include a single file for all admin.* methods to ease setting up Management VM. -## To allow only specific actions, edit specific policy file, like this one. To -## allow all of them, edit /etc/qubes-rpc/include/admin-all. -$include:/etc/qubes-rpc/policy/include/admin-all - -$anyvm $anyvm deny diff --git a/qubes-rpc-policy/admin-global-ro b/qubes-rpc-policy/admin-global-ro new file mode 100644 index 00000000..48cf561a --- /dev/null +++ b/qubes-rpc-policy/admin-global-ro @@ -0,0 +1,13 @@ +## This file is included from all global read-only admin.* policy files +## _in default configuration_. To allow only specific action, +## edit specific policy file. + +## Note that policy parsing stops at the first match, + +## Please use a single # to start your custom comments + +## Include all already having write access +$include:include/admin-global-rwx + +## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions + diff --git a/qubes-rpc-policy/admin-global-rwx b/qubes-rpc-policy/admin-global-rwx new file mode 100644 index 00000000..02c0a8d2 --- /dev/null +++ b/qubes-rpc-policy/admin-global-rwx @@ -0,0 +1,11 @@ +## This file is included from all global read-write admin.* policy files +## _in default configuration_. To allow only specific action, +## edit specific policy file. + +## Note that policy parsing stops at the first match, +## so adding anything below "$anyvm $anyvm action" line will have no effect + +## Please use a single # to start your custom comments + +## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions + diff --git a/qubes-rpc-policy/admin-local-ro b/qubes-rpc-policy/admin-local-ro new file mode 100644 index 00000000..b16e9f5e --- /dev/null +++ b/qubes-rpc-policy/admin-local-ro @@ -0,0 +1,14 @@ +## This file is included from all local read-only admin.* policy files +## _in default configuration_. To allow only specific action, +## edit specific policy file. + +## Note that policy parsing stops at the first match, +## so adding anything below "$anyvm $anyvm action" line will have no effect + +## Please use a single # to start your custom comments + +## Include all already having write access +$include:include/admin-local-rwx + +## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions + diff --git a/qubes-rpc-policy/admin-all b/qubes-rpc-policy/admin-local-rwx similarity index 62% rename from qubes-rpc-policy/admin-all rename to qubes-rpc-policy/admin-local-rwx index 33e391ba..8c827af3 100644 --- a/qubes-rpc-policy/admin-all +++ b/qubes-rpc-policy/admin-local-rwx @@ -1,5 +1,6 @@ -## This file is included from all admin.* policy files _in default -## configuration_. To allow only specific action, edit specific policy file. +## This file is included from all local read-write admin.* policy files +## _in default configuration_. To allow only specific action, +## edit specific policy file. ## Note that policy parsing stops at the first match, ## so adding anything below "$anyvm $anyvm action" line will have no effect @@ -8,4 +9,3 @@ ## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions -$anyvm $anyvm deny diff --git a/qubes-rpc-policy/generate-admin-policy b/qubes-rpc-policy/generate-admin-policy new file mode 100755 index 00000000..8cac5219 --- /dev/null +++ b/qubes-rpc-policy/generate-admin-policy @@ -0,0 +1,96 @@ +#!/usr/bin/python3 +# coding=utf-8 +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2017 Marek Marczykowski-Górecki +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import argparse +import os + +import sys + +import qubes.api.admin + +parser = argparse.ArgumentParser( + description='Generate default Admin API policy') +parser.add_argument('--include-base', action='store', + default='include', + help='Base path for included paths (default: %(default)s)') +parser.add_argument('--destdir', action='store', + default='/etc/qubes-rpc/policy', + help='Directory where write output files to (default: %(default)s)') +parser.add_argument('--verbose', action='store_true', default=False, + help='Be verbose') +parser.add_argument('--exclude', action='store', nargs='*', + help='Exclude service') +parser.add_argument('service', nargs='*', action='store', + help='Generate policy for those services (default: all)') + +default_policy_header = '''\ +## Note that policy parsing stops at the first match. +## Anything not specifically allowed here (or in included file) will be denied. + +## Please use a single # to start your custom comments + +## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions + +## Include a common file for all admin.* methods to ease setting up +## Management VM. +## To allow only specific actions, edit specific policy file, like this one. To +## allow all of them, edit appropriate /etc/qubes-rpc/include/admin-*. + +''' + +def write_default_policy(args, apiname, clasifiers): + ''' Write single default policy for given API call ''' + assert 'scope' in clasifiers, \ + 'Method {} lack scope classifier'.format(apiname) + assert any(attr in clasifiers for attr in ('read', 'write', 'execute')), \ + 'Method {} lack read/write/execute classifier'.format(apiname) + assert clasifiers['scope'] in ('local', 'global'), \ + 'Method {} have invalid scope: {}'.format(apiname, clasifiers['scope']) + + file_to_include = 'admin-{scope}-{rwx}'.format( + scope=clasifiers['scope'], + rwx=('rwx' if clasifiers.get('write', False) or + clasifiers.get('execute', False) + else 'ro')) + + if args.verbose: + print('Service {}: include {}'.format(apiname, file_to_include), + file=sys.stderr) + with open(os.path.join(args.destdir, apiname), 'w') as f: + f.write(default_policy_header) + f.write('$include:{}\n'.format( + os.path.join(args.include_base, file_to_include))) + + +def main(args=None): + ''' Main function of default-admin-policy tool''' + args = parser.parse_args(args) + + for func, apiname, _ in qubes.api.admin.QubesAdminAPI.list_methods(): + if args.service and apiname not in args.service: + continue + if args.exclude and apiname in args.exclude: + continue + write_default_policy(args, apiname, func.classifiers) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/qubes/__init__.py b/qubes/__init__.py index 30d270d5..aedd6ba8 100644 --- a/qubes/__init__.py +++ b/qubes/__init__.py @@ -329,8 +329,8 @@ class property(object): # pylint: disable=redefined-builtin,invalid-name setter. Can raise QubesValueError if the value is invalid. :param untrusted_newvalue: value to be validated - :return sanitized value - :raises qubes.exc.QubesValueError + :return: sanitized value + :raises: qubes.exc.QubesValueError ''' # do not treat type='str' as sufficient validation if self.type is not None and self.type is not str: diff --git a/qubes/api/__init__.py b/qubes/api/__init__.py index 4716c4d9..b6c6dcb7 100644 --- a/qubes/api/__init__.py +++ b/qubes/api/__init__.py @@ -41,7 +41,7 @@ class PermissionDenied(Exception): pass -def method(name, *, no_payload=False, endpoints=None): +def method(name, *, no_payload=False, endpoints=None, **classifiers): '''Decorator factory for methods intended to appear in API. The decorated method can be called from public API using a child of @@ -51,6 +51,8 @@ def method(name, *, no_payload=False, endpoints=None): :param str name: qrexec rpc method name :param bool no_payload: if :py:obj:`True`, will barf on non-empty payload; \ also will not pass payload at all to the method + :param iterable endpoints: if specified, method serve multiple API calls + generated by replacing `{endpoint}` with each value in this iterable The expected function method should have one argument (other than usual *self*), ``untrusted_payload``, which will contain the payload. @@ -75,11 +77,14 @@ def method(name, *, no_payload=False, endpoints=None): # pylint: disable=protected-access if endpoints is None: - func._rpcname = ((name, None),) + func.rpcnames = ((name, None),) else: - func._rpcname = tuple( + func.rpcnames = tuple( (name.format(endpoint=endpoint), endpoint) for endpoint in endpoints) + + func.classifiers = classifiers + return func return decorator @@ -133,43 +138,45 @@ class AbstractQubesAPI(object): #: is this operation cancellable? self.cancellable = False - untrusted_candidates = [] - for attr in dir(self): - func = getattr(self, attr) + candidates = list(self.list_methods(self.method)) + if not candidates: + raise ProtocolError('no such method: {!r}'.format(self.method)) + + assert len(candidates) == 1, \ + 'multiple candidates for method {!r}'.format(self.method) + + #: the method to execute + self._handler = candidates[0] + self._running_handler = None + + @classmethod + def list_methods(cls, select_method=None): + for attr in dir(cls): + func = getattr(cls, attr) if not callable(func): continue try: # pylint: disable=protected-access - for mname, endpoint in func._rpcname: - if mname != self.method: - continue - untrusted_candidates.append((func, endpoint)) + rpcnames = func.rpcnames except AttributeError: continue - if not untrusted_candidates: - raise ProtocolError('no such method: {!r}'.format(self.method)) - - assert len(untrusted_candidates) == 1, \ - 'multiple candidates for method {!r}'.format(self.method) - - #: the method to execute - self._handler = untrusted_candidates[0] - self._running_handler = None - del untrusted_candidates + for mname, endpoint in rpcnames: + if select_method is None or mname == select_method: + yield (func, mname, endpoint) def execute(self, *, untrusted_payload): '''Execute management operation. This method is a coroutine. ''' - handler, endpoint = self._handler + handler, _, endpoint = self._handler kwargs = {} if endpoint is not None: kwargs['endpoint'] = endpoint - self._running_handler = asyncio.ensure_future(handler( + self._running_handler = asyncio.ensure_future(handler(self, untrusted_payload=untrusted_payload, **kwargs)) return self._running_handler diff --git a/qubes/api/admin.py b/qubes/api/admin.py index 8a9fb05c..5f3b9060 100644 --- a/qubes/api/admin.py +++ b/qubes/api/admin.py @@ -76,7 +76,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): SOCKNAME = '/var/run/qubesd.sock' - @qubes.api.method('admin.vmclass.List', no_payload=True) + @qubes.api.method('admin.vmclass.List', no_payload=True, + scope='global', read=True) @asyncio.coroutine def vmclass_list(self): '''List all VM classes''' @@ -89,7 +90,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}\n'.format(ep.name) for ep in entrypoints) - @qubes.api.method('admin.vm.List', no_payload=True) + @qubes.api.method('admin.vm.List', no_payload=True, + scope='global', read=True) @asyncio.coroutine def vm_list(self): '''List all the domains''' @@ -106,13 +108,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): vm.get_power_state()) for vm in sorted(domains)) - @qubes.api.method('admin.vm.property.List', no_payload=True) + @qubes.api.method('admin.vm.property.List', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_property_list(self): '''List all properties on a qube''' return self._property_list(self.dest) - @qubes.api.method('admin.property.List', no_payload=True) + @qubes.api.method('admin.property.List', no_payload=True, + scope='global', read=True) @asyncio.coroutine def property_list(self): '''List all global properties''' @@ -126,13 +130,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}\n'.format(prop.__name__) for prop in properties) - @qubes.api.method('admin.vm.property.Get', no_payload=True) + @qubes.api.method('admin.vm.property.Get', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_property_get(self): '''Get a value of one property''' return self._property_get(self.dest) - @qubes.api.method('admin.property.Get', no_payload=True) + @qubes.api.method('admin.property.Get', no_payload=True, + scope='global', read=True) @asyncio.coroutine def property_get(self): '''Get a value of one global property''' @@ -168,14 +174,16 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): property_type, str(value) if value is not None else '') - @qubes.api.method('admin.vm.property.Set') + @qubes.api.method('admin.vm.property.Set', + scope='local', write=True) @asyncio.coroutine def vm_property_set(self, untrusted_payload): '''Set property value''' return self._property_set(self.dest, untrusted_payload=untrusted_payload) - @qubes.api.method('admin.property.Set') + @qubes.api.method('admin.property.Set', + scope='global', write=True) @asyncio.coroutine def property_set(self, untrusted_payload): '''Set property value''' @@ -195,13 +203,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): setattr(dest, self.arg, newvalue) self.app.save() - @qubes.api.method('admin.vm.property.Help', no_payload=True) + @qubes.api.method('admin.vm.property.Help', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_property_help(self): '''Get help for one property''' return self._property_help(self.dest) - @qubes.api.method('admin.property.Help', no_payload=True) + @qubes.api.method('admin.property.Help', no_payload=True, + scope='global', read=True) @asyncio.coroutine def property_help(self): '''Get help for one property''' @@ -221,13 +231,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return qubes.utils.format_doc(doc) - @qubes.api.method('admin.vm.property.Reset', no_payload=True) + @qubes.api.method('admin.vm.property.Reset', no_payload=True, + scope='local', write=True) @asyncio.coroutine def vm_property_reset(self): '''Reset a property to a default value''' return self._property_reset(self.dest) - @qubes.api.method('admin.property.Reset', no_payload=True) + @qubes.api.method('admin.property.Reset', no_payload=True, + scope='global', write=True) @asyncio.coroutine def property_reset(self): '''Reset a property to a default value''' @@ -243,7 +255,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): delattr(dest, self.arg) self.app.save() - @qubes.api.method('admin.vm.volume.List', no_payload=True) + @qubes.api.method('admin.vm.volume.List', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_volume_list(self): assert not self.arg @@ -251,7 +264,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): volume_names = self.fire_event_for_filter(self.dest.volumes.keys()) return ''.join('{}\n'.format(name) for name in volume_names) - @qubes.api.method('admin.vm.volume.Info', no_payload=True) + @qubes.api.method('admin.vm.volume.Info', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_volume_info(self): assert self.arg in self.dest.volumes.keys() @@ -266,7 +280,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}={}\n'.format(key, getattr(volume, key)) for key in volume_properties) - @qubes.api.method('admin.vm.volume.ListSnapshots', no_payload=True) + @qubes.api.method('admin.vm.volume.ListSnapshots', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_volume_listsnapshots(self): assert self.arg in self.dest.volumes.keys() @@ -277,7 +292,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}\n'.format(revision) for revision in revisions) - @qubes.api.method('admin.vm.volume.Revert') + @qubes.api.method('admin.vm.volume.Revert', + scope='local', write=True) @asyncio.coroutine def vm_volume_revert(self, untrusted_payload): assert self.arg in self.dest.volumes.keys() @@ -294,7 +310,10 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.dest.storage.get_pool(volume).revert(revision) self.app.save() - @qubes.api.method('admin.vm.volume.CloneFrom', no_payload=True) + # write=True because this allow to clone VM - and most likely modify that + # one - still having the same data + @qubes.api.method('admin.vm.volume.CloneFrom', no_payload=True, + scope='local', write=True) @asyncio.coroutine def vm_volume_clone_from(self): assert self.arg in self.dest.volumes.keys() @@ -314,7 +333,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.app.api_admin_pending_clone[token] = volume return token - @qubes.api.method('admin.vm.volume.CloneTo') + @qubes.api.method('admin.vm.volume.CloneTo', + scope='local', write=True) @asyncio.coroutine def vm_volume_clone_to(self, untrusted_payload): assert self.arg in self.dest.volumes.keys() @@ -347,7 +367,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.dest.volumes[self.arg] = op_retval self.app.save() - @qubes.api.method('admin.vm.volume.Resize') + @qubes.api.method('admin.vm.volume.Resize', + scope='local', write=True) @asyncio.coroutine def vm_volume_resize(self, untrusted_payload): assert self.arg in self.dest.volumes.keys() @@ -363,7 +384,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.dest.storage.resize(self.arg, size) self.app.save() - @qubes.api.method('admin.vm.volume.Import', no_payload=True) + @qubes.api.method('admin.vm.volume.Import', no_payload=True, + scope='local', write=True) @asyncio.coroutine def vm_volume_import(self): '''Import volume data. @@ -392,7 +414,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return '{} {}'.format(size, path) - @qubes.api.method('admin.vm.tag.List', no_payload=True) + @qubes.api.method('admin.vm.tag.List', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_tag_list(self): assert not self.arg @@ -403,7 +426,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}\n'.format(tag) for tag in sorted(tags)) - @qubes.api.method('admin.vm.tag.Get', no_payload=True) + @qubes.api.method('admin.vm.tag.Get', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_tag_get(self): qubes.vm.Tags.validate_tag(self.arg) @@ -412,7 +436,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return '1' if self.arg in self.dest.tags else '0' - @qubes.api.method('admin.vm.tag.Set', no_payload=True) + @qubes.api.method('admin.vm.tag.Set', no_payload=True, + scope='local', write=True) @asyncio.coroutine def vm_tag_set(self): qubes.vm.Tags.validate_tag(self.arg) @@ -422,7 +447,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.dest.tags.add(self.arg) self.app.save() - @qubes.api.method('admin.vm.tag.Remove', no_payload=True) + @qubes.api.method('admin.vm.tag.Remove', no_payload=True, + scope='local', write=True) @asyncio.coroutine def vm_tag_remove(self): qubes.vm.Tags.validate_tag(self.arg) @@ -435,7 +461,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): raise qubes.exc.QubesTagNotFoundError(self.dest, self.arg) self.app.save() - @qubes.api.method('admin.pool.List', no_payload=True) + @qubes.api.method('admin.pool.List', no_payload=True, + scope='global', read=True) @asyncio.coroutine def pool_list(self): assert not self.arg @@ -445,7 +472,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}\n'.format(pool) for pool in pools) - @qubes.api.method('admin.pool.ListDrivers', no_payload=True) + @qubes.api.method('admin.pool.ListDrivers', no_payload=True, + scope='global', read=True) @asyncio.coroutine def pool_listdrivers(self): assert self.dest.name == 'dom0' @@ -458,7 +486,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): ' '.join(qubes.storage.driver_parameters(driver))) for driver in drivers) - @qubes.api.method('admin.pool.Info', no_payload=True) + @qubes.api.method('admin.pool.Info', no_payload=True, + scope='global', read=True) @asyncio.coroutine def pool_info(self): assert self.dest.name == 'dom0' @@ -471,7 +500,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}={}\n'.format(prop, val) for prop, val in sorted(pool.config.items())) - @qubes.api.method('admin.pool.Add') + @qubes.api.method('admin.pool.Add', + scope='global', write=True) @asyncio.coroutine def pool_add(self, untrusted_payload): assert self.dest.name == 'dom0' @@ -506,7 +536,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.app.add_pool(name=pool_name, driver=self.arg, **pool_config) self.app.save() - @qubes.api.method('admin.pool.Remove', no_payload=True) + @qubes.api.method('admin.pool.Remove', no_payload=True, + scope='global', write=True) @asyncio.coroutine def pool_remove(self): assert self.dest.name == 'dom0' @@ -517,7 +548,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.app.remove_pool(self.arg) self.app.save() - @qubes.api.method('admin.label.List', no_payload=True) + @qubes.api.method('admin.label.List', no_payload=True, + scope='global', read=True) @asyncio.coroutine def label_list(self): assert self.dest.name == 'dom0' @@ -527,7 +559,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}\n'.format(label.name) for label in labels) - @qubes.api.method('admin.label.Get', no_payload=True) + @qubes.api.method('admin.label.Get', no_payload=True, + scope='global', read=True) @asyncio.coroutine def label_get(self): assert self.dest.name == 'dom0' @@ -541,7 +574,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return label.color - @qubes.api.method('admin.label.Index', no_payload=True) + @qubes.api.method('admin.label.Index', no_payload=True, + scope='global', read=True) @asyncio.coroutine def label_index(self): assert self.dest.name == 'dom0' @@ -555,7 +589,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return str(label.index) - @qubes.api.method('admin.label.Create') + @qubes.api.method('admin.label.Create', + scope='global', write=True) @asyncio.coroutine def label_create(self, untrusted_payload): assert self.dest.name == 'dom0' @@ -591,7 +626,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.app.labels[new_index] = label self.app.save() - @qubes.api.method('admin.label.Remove', no_payload=True) + @qubes.api.method('admin.label.Remove', no_payload=True, + scope='global', write=True) @asyncio.coroutine def label_remove(self): assert self.dest.name == 'dom0' @@ -613,7 +649,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): del self.app.labels[label.index] self.app.save() - @qubes.api.method('admin.vm.Start', no_payload=True) + @qubes.api.method('admin.vm.Start', no_payload=True, + scope='local', execute=True) @asyncio.coroutine def vm_start(self): assert not self.arg @@ -625,35 +662,40 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): raise qubes.exc.QubesException('Start failed: ' + str(e)) - @qubes.api.method('admin.vm.Shutdown', no_payload=True) + @qubes.api.method('admin.vm.Shutdown', no_payload=True, + scope='local', execute=True) @asyncio.coroutine def vm_shutdown(self): assert not self.arg self.fire_event_for_permission() yield from self.dest.shutdown() - @qubes.api.method('admin.vm.Pause', no_payload=True) + @qubes.api.method('admin.vm.Pause', no_payload=True, + scope='local', execute=True) @asyncio.coroutine def vm_pause(self): assert not self.arg self.fire_event_for_permission() yield from self.dest.pause() - @qubes.api.method('admin.vm.Unpause', no_payload=True) + @qubes.api.method('admin.vm.Unpause', no_payload=True, + scope='local', execute=True) @asyncio.coroutine def vm_unpause(self): assert not self.arg self.fire_event_for_permission() yield from self.dest.unpause() - @qubes.api.method('admin.vm.Kill', no_payload=True) + @qubes.api.method('admin.vm.Kill', no_payload=True, + scope='local', execute=True) @asyncio.coroutine def vm_kill(self): assert not self.arg self.fire_event_for_permission() yield from self.dest.kill() - @qubes.api.method('admin.Events', no_payload=True) + @qubes.api.method('admin.Events', no_payload=True, + scope='global', read=True) @asyncio.coroutine def events(self): assert not self.arg @@ -694,14 +736,16 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): else: self.dest.remove_handler('*', dispatcher.vm_handler) - @qubes.api.method('admin.vm.feature.List', no_payload=True) + @qubes.api.method('admin.vm.feature.List', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_feature_list(self): assert not self.arg features = self.fire_event_for_filter(self.dest.features.keys()) return ''.join('{}\n'.format(feature) for feature in features) - @qubes.api.method('admin.vm.feature.Get', no_payload=True) + @qubes.api.method('admin.vm.feature.Get', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_feature_get(self): # validation of self.arg done by qrexec-policy is enough @@ -713,7 +757,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg) return value - @qubes.api.method('admin.vm.feature.CheckWithTemplate', no_payload=True) + @qubes.api.method('admin.vm.feature.CheckWithTemplate', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_feature_checkwithtemplate(self): # validation of self.arg done by qrexec-policy is enough @@ -725,7 +770,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg) return value - @qubes.api.method('admin.vm.feature.Remove', no_payload=True) + @qubes.api.method('admin.vm.feature.Remove', no_payload=True, + scope='local', write=True) @asyncio.coroutine def vm_feature_remove(self): # validation of self.arg done by qrexec-policy is enough @@ -737,7 +783,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg) self.app.save() - @qubes.api.method('admin.vm.feature.Set') + @qubes.api.method('admin.vm.feature.Set', + scope='local', write=True) @asyncio.coroutine def vm_feature_set(self, untrusted_payload): # validation of self.arg done by qrexec-policy is enough @@ -749,14 +796,16 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.app.save() @qubes.api.method('admin.vm.Create.{endpoint}', endpoints=(ep.name - for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT))) + for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT)), + scope='global', write=True) @asyncio.coroutine def vm_create(self, endpoint, untrusted_payload=None): return self._vm_create(endpoint, allow_pool=False, untrusted_payload=untrusted_payload) @qubes.api.method('admin.vm.CreateInPool.{endpoint}', endpoints=(ep.name - for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT))) + for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT)), + scope='global', write=True) @asyncio.coroutine def vm_create_in_pool(self, endpoint, untrusted_payload=None): return self._vm_create(endpoint, allow_pool=True, @@ -846,7 +895,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): raise self.app.save() - @qubes.api.method('admin.vm.Remove', no_payload=True) + @qubes.api.method('admin.vm.Remove', no_payload=True, + scope='global', write=True) @asyncio.coroutine def vm_remove(self): assert not self.arg @@ -867,7 +917,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): @qubes.api.method('admin.vm.device.{endpoint}.Available', endpoints=(ep.name for ep in pkg_resources.iter_entry_points('qubes.devices')), - no_payload=True) + no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_device_available(self, endpoint): devclass = endpoint @@ -901,7 +952,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): @qubes.api.method('admin.vm.device.{endpoint}.List', endpoints=(ep.name for ep in pkg_resources.iter_entry_points('qubes.devices')), - no_payload=True) + no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_device_list(self, endpoint): devclass = endpoint @@ -932,8 +984,12 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{} {}\n'.format(ident, dev_info[ident]) for ident in sorted(dev_info)) + # Attach/Detach action can both modify persistent state (with + # persistent=True) and volatile state of running VM (with persistent=False). + # For this reason, write=True + execute=True @qubes.api.method('admin.vm.device.{endpoint}.Attach', endpoints=(ep.name - for ep in pkg_resources.iter_entry_points('qubes.devices'))) + for ep in pkg_resources.iter_entry_points('qubes.devices')), + scope='local', write=True, execute=True) @asyncio.coroutine def vm_device_attach(self, endpoint, untrusted_payload): devclass = endpoint @@ -972,9 +1028,13 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): yield from self.dest.devices[devclass].attach(assignment) self.app.save() + # Attach/Detach action can both modify persistent state (with + # persistent=True) and volatile state of running VM (with persistent=False). + # For this reason, write=True + execute=True @qubes.api.method('admin.vm.device.{endpoint}.Detach', endpoints=(ep.name for ep in pkg_resources.iter_entry_points('qubes.devices')), - no_payload=True) + no_payload=True, + scope='local', write=True, execute=True) @asyncio.coroutine def vm_device_detach(self, endpoint): devclass = endpoint @@ -994,7 +1054,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): yield from self.dest.devices[devclass].detach(assignment) self.app.save() - @qubes.api.method('admin.vm.firewall.Get', no_payload=True) + @qubes.api.method('admin.vm.firewall.Get', no_payload=True, + scope='local', read=True) @asyncio.coroutine def vm_firewall_get(self): assert not self.arg @@ -1004,7 +1065,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): return ''.join('{}\n'.format(rule.api_rule) for rule in self.dest.firewall.rules) - @qubes.api.method('admin.vm.firewall.Set') + @qubes.api.method('admin.vm.firewall.Set', + scope='local', write=True) @asyncio.coroutine def vm_firewall_set(self, untrusted_payload): assert not self.arg @@ -1020,7 +1082,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI): self.dest.firewall.rules = rules self.dest.firewall.save() - @qubes.api.method('admin.vm.firewall.Reload', no_payload=True) + @qubes.api.method('admin.vm.firewall.Reload', no_payload=True, + scope='local', execute=True) @asyncio.coroutine def vm_firewall_reload(self): assert not self.arg diff --git a/qubes/backup.py b/qubes/backup.py index 1a26221a..e432697e 100644 --- a/qubes/backup.py +++ b/qubes/backup.py @@ -119,6 +119,7 @@ class BackupHeader(object): :param untrusted_header_text: header content :type untrusted_header_text: basestring + .. warning:: This function may be exposed to not yet verified header, so is security critical. @@ -864,9 +865,9 @@ def handle_streams(stream_in, streams_out, processes, size_limit=None, :param processes: dict of subprocess.Popen objects to monitor :param size_limit: int maximum data amount to process :param progress_callback: callable function to report progress, will be - given copied data size (it should accumulate internally) + given copied data size (it should accumulate internally) :return: failed process name, failed stream name, "size_limit" or None ( - no error) + no error) ''' buffer_size = 409600 bytes_copied = 0 diff --git a/qubes/dochelpers.py b/qubes/dochelpers.py index 45ef93c4..2d633830 100644 --- a/qubes/dochelpers.py +++ b/qubes/dochelpers.py @@ -249,7 +249,7 @@ class OptionsCheckVisitor(docutils.nodes.SparseNodeVisitor): While the documentation talks about a 'SparseNodeVisitor.depart_document()' function, this function does not exists. (For details see implementation of - :py:method:`NodeVisitor.dispatch_departure()`) So we need to + :py:meth:`NodeVisitor.dispatch_departure()`) So we need to manually call this. ''' if ignored_options is None: @@ -316,7 +316,7 @@ class CommandCheckVisitor(docutils.nodes.SparseNodeVisitor): While the documentation talks about a 'SparseNodeVisitor.depart_document()' function, this function does not exists. (For details see implementation of - :py:method:`NodeVisitor.dispatch_departure()`) So we need to + :py:meth:`NodeVisitor.dispatch_departure()`) So we need to manually call this. ''' if self.sub_commands: diff --git a/qubes/tests/__init__.py b/qubes/tests/__init__.py index 2d2c812c..98a528fb 100644 --- a/qubes/tests/__init__.py +++ b/qubes/tests/__init__.py @@ -177,7 +177,7 @@ def expectedFailureIfTemplate(templates): Decorator for marking specific test as expected to fail only for some templates. Template name is compared as substring, so 'whonix' will handle both 'whonix-ws' and 'whonix-gw'. - templates can be either a single string, or an iterable + templates can be either a single string, or an iterable """ def decorator(func): @functools.wraps(func) diff --git a/qubes/tools/__init__.py b/qubes/tools/__init__.py index 5ee69f0b..18df3214 100644 --- a/qubes/tools/__init__.py +++ b/qubes/tools/__init__.py @@ -303,11 +303,13 @@ class QubesArgumentParser(argparse.ArgumentParser): :py:class:`qubes.Qubes` object, just add argument for custom xml file :param bool want_force_root: add ``--force-root`` option :param mixed vmname_nargs: The number of ``VMNAME`` arguments that should be - consumed. Values include: - - N (an integer) consumes N arguments (and produces a list) - - '?' consumes zero or one arguments - - '*' consumes zero or more arguments (and produces a list) - - '+' consumes one or more arguments (and produces a list) + consumed. Values include: + + - N (an integer) consumes N arguments (and produces a list) + - '?' consumes zero or one arguments + - '*' consumes zero or more arguments (and produces a list) + - '+' consumes one or more arguments (and produces a list) + *kwargs* are passed to :py:class:`argparser.ArgumentParser`. Currenty supported options: diff --git a/qubes/vm/qubesvm.py b/qubes/vm/qubesvm.py index 03049c4d..6d3edb3e 100644 --- a/qubes/vm/qubesvm.py +++ b/qubes/vm/qubesvm.py @@ -594,8 +594,8 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): @property def block_devices(self): - ''' Return all :py:class:`qubes.storage.BlockDevice`s for current domain - for serialization in the libvirt XML template as . + ''' Return all :py:class:`qubes.storage.BlockDevice` for current domain + for serialization in the libvirt XML template as . ''' for v in self.volumes.values(): block_dev = v.block_device() @@ -1050,7 +1050,7 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): :param str service: service name :param qubes.vm.qubesvm.QubesVM source: source domain as presented to - this VM + this VM :param str user: username to run service as :param bool filter_esc: filter escape sequences to protect terminal \ emulator @@ -1221,7 +1221,7 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): This function take care to run it as appropriate user. :param command: command to run (array for - :py:meth:`subprocess.check_call`) + :py:meth:`subprocess.check_call`) :param kwargs: args for :py:meth:`subprocess.check_call` :return: None ''' # pylint: disable=redefined-builtin diff --git a/qubespolicy/__init__.py b/qubespolicy/__init__.py index bcd7614e..3473bfc9 100755 --- a/qubespolicy/__init__.py +++ b/qubespolicy/__init__.py @@ -66,6 +66,8 @@ def verify_target_value(system_info, value): ''' if value == '$dispvm': return True + elif value == '$adminvm': + return True elif value.startswith('$dispvm:'): dispvm_base = value.split(':', 1)[1] if dispvm_base not in system_info['domains']: @@ -82,7 +84,7 @@ def verify_special_value(value, for_target=True): :param value: value to verify :param for_target: should classify target-only values as valid ( - '$default', '$dispvm') + '$default', '$dispvm') :return: True or False ''' # pylint: disable=too-many-return-statements @@ -93,6 +95,8 @@ def verify_special_value(value, for_target=True): return True elif value == '$anyvm': return True + elif value == '$adminvm': + return True elif value.startswith('$dispvm:') and for_target: return True elif value == '$dispvm' and for_target: @@ -121,11 +125,11 @@ class PolicyRule(object): self.filename = filename try: - self.source, self.target, self.full_action = line.split() + self.source, self.target, self.full_action = line.split(maxsplit=2) except ValueError: raise PolicySyntaxError(filename, lineno, 'wrong number of fields') - (action, *params) = self.full_action.split(',') + (action, *params) = self.full_action.replace(',', ' ').split() try: self.action = Action[action] except KeyError: @@ -184,8 +188,9 @@ class PolicyRule(object): 'allow action for $default rule must specify target= option') if self.override_target is not None: - if self.override_target.startswith('$') and not \ - self.override_target.startswith('$dispvm'): + if self.override_target.startswith('$') and \ + not self.override_target.startswith('$dispvm') and \ + self.override_target != '$adminvm': raise PolicySyntaxError(filename, lineno, 'target= option needs to name specific target') @@ -197,7 +202,7 @@ class PolicyRule(object): :param system_info: information about the system :param policy_value: value from qrexec policy (either self.source or - self.target) + self.target) :param value: value to be compared (source or target) :return: True or False ''' @@ -216,11 +221,19 @@ class PolicyRule(object): if not verify_target_value(system_info, value): return False + # handle $adminvm keyword + if policy_value == 'dom0': + # TODO: log a warning in Qubes 4.1 + policy_value = '$adminvm' + + if value == 'dom0': + value = '$adminvm' + # allow any _valid_, non-dom0 target if policy_value == '$anyvm': - return value != 'dom0' + return value != '$adminvm' - # exact match, including $dispvm* + # exact match, including $dispvm* and $adminvm if value == policy_value: return True @@ -229,6 +242,11 @@ class PolicyRule(object): if value.startswith('$dispvm'): return False + # require $adminvm to be matched explicitly (not through $tag or $type) + # - if not matched already, reject it + if value == '$adminvm': + return False + # at this point, value name a specific target domain_info = system_info['domains'][value] @@ -247,8 +265,8 @@ class PolicyRule(object): Check if given (source, target) matches this policy line. :param system_info: information about the system - available VMs, - their types, labels, tags etc. as returned by - :py:func:`app_to_system_info` + their types, labels, tags etc. as returned by + :py:func:`app_to_system_info` :param source: name of the source VM :param target: name of the target VM, or None if not specified :return: True or False @@ -293,6 +311,8 @@ class PolicyRule(object): except KeyError: # TODO log a warning? pass + elif self.target == '$adminvm': + yield self.target elif self.target == '$dispvm': yield self.target else: @@ -372,12 +392,14 @@ class PolicyAction(object): def execute(self, caller_ident): ''' Execute allowed service call - :param caller_ident: Service caller ident (`process_ident,source_name, - source_id`) + :param caller_ident: Service caller ident + (`process_ident,source_name, source_id`) ''' assert self.action == Action.allow assert self.target is not None + if self.target == '$adminvm': + self.target = 'dom0' if self.target == 'dom0': cmd = '{multiplexer} {service} {source} {original_target}'.format( multiplexer=QUBES_RPC_MULTIPLEXER_PATH, @@ -451,17 +473,20 @@ class Policy(object): >>> policy = Policy('some-service') >>> action = policy.evaluate(system_info, 'source-name', 'target-name') >>> if action.action == Action.ask: - (... ask the user, see action.targets_for_ask ...) + >>> # ... ask the user, see action.targets_for_ask ... >>> action.handle_user_response(response, target_chosen_by_user) >>> action.execute('process-ident') ''' - def __init__(self, service): - policy_file = os.path.join(POLICY_DIR, service) + def __init__(self, service, policy_dir=POLICY_DIR): + policy_file = os.path.join(policy_dir, service) if not os.path.exists(policy_file): # fallback to policy without specific argument set (if any) - policy_file = os.path.join(POLICY_DIR, service.split('+')[0]) + policy_file = os.path.join(policy_dir, service.split('+')[0]) + + #: policy storage directory + self.policy_dir = policy_dir #: service name self.service = service @@ -493,7 +518,7 @@ class Policy(object): include_path = line.split(':', 1)[1] # os.path.join will leave include_path unchanged if it's # already absolute - include_path = os.path.join(POLICY_DIR, include_path) + include_path = os.path.join(self.policy_dir, include_path) self.load_policy_file(include_path) else: self.policy_rules.append(PolicyRule(line, path, lineno)) @@ -582,6 +607,15 @@ class Policy(object): 'policy define \'allow\' action at {}:{} but no target is ' 'specified by caller or policy'.format( rule.filename, rule.lineno)) + if actual_target == '$dispvm': + if system_info['domains'][source]['default_dispvm'] is None: + raise AccessDenied( + 'policy define \'allow\' action to $dispvm at {}:{} ' + 'but no DispVM base is set for this VM'.format( + rule.filename, rule.lineno)) + actual_target = '$dispvm:' + \ + system_info['domains'][source]['default_dispvm'] + return PolicyAction(self.service, source, actual_target, rule, target) else: @@ -634,11 +668,11 @@ def get_system_info(): data is nested dict structure with this structure: - domains: - - : - - tags: list of tags - - type: domain type - - dispvm_allowed: should DispVM based on this VM be allowed - - default_dispvm: name of default AppVM for DispVMs started from here + - ``: + - tags: list of tags + - type: domain type + - dispvm_allowed: should DispVM based on this VM be allowed + - default_dispvm: name of default AppVM for DispVMs started from here ''' diff --git a/qubespolicy/graph.py b/qubespolicy/graph.py new file mode 100644 index 00000000..eff64bcb --- /dev/null +++ b/qubespolicy/graph.py @@ -0,0 +1,122 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2017 Marek Marczykowski-Górecki +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + +import argparse +import json +import os + +import sys + +import qubespolicy + +parser = argparse.ArgumentParser(description='Graph qrexec policy') +parser.add_argument('--include-ask', action='store_true', + help='Include `ask` action in graph') +parser.add_argument('--source', action='store', nargs='+', + help='Limit graph to calls from *source*') +parser.add_argument('--target', action='store', nargs='+', + help='Limit graph to calls to *target*') +parser.add_argument('--service', action='store', nargs='+', + help='Limit graph to *service*') +parser.add_argument('--output', action='store', + help='Write to *output* instead of stdout') +parser.add_argument('--policy-dir', action='store', + default=qubespolicy.POLICY_DIR, + help='Look for policy in *policy-dir*') +parser.add_argument('--system-info', action='store', + help='Load system information from file instead of querying qubesd') +parser.add_argument('--skip-labels', action='store_true', + help='Do not include service names on the graph, also deduplicate ' + 'connections.') + +def handle_single_action(args, action): + '''Get single policy action and output (or not) a line to add''' + if args.skip_labels: + service = '' + else: + service = action.service + if action.action == qubespolicy.Action.ask: + if args.include_ask: + # handle forced target= + if len(action.targets_for_ask) == 1: + return ' "{}" -> "{}" [label="{}" color=orange];\n'.format( + action.source, action.targets_for_ask[0], service) + return ' "{}" -> "{}" [label="{}" color=orange];\n'.format( + action.source, action.original_target, service) + elif action.action == qubespolicy.Action.allow: + return ' "{}" -> "{}" [label="{}" color=red];\n'.format( + action.source, action.target, service) + return '' + +def main(args=None): + args = parser.parse_args(args) + + output = sys.stdout + if args.output: + output = open(args.output, 'w') + + if args.system_info: + with open(args.system_info) as f_system_info: + system_info = json.load(f_system_info) + else: + system_info = qubespolicy.get_system_info() + + sources = list(system_info['domains'].keys()) + if args.source: + sources = args.source + + targets = list(system_info['domains'].keys()) + if args.target: + targets = args.target + else: + targets.append('$dispvm') + targets.extend('$dispvm:' + dom for dom in system_info['domains'] + if system_info['domains'][dom]['dispvm_allowed']) + + connections = set() + + output.write('digraph g {\n') + for service in os.listdir(args.policy_dir): + if os.path.isdir(os.path.join(args.policy_dir, service)): + continue + if args.service and service not in args.service and \ + not any(service.startswith(srv + '+') for srv in args.service): + continue + + policy = qubespolicy.Policy(service, args.policy_dir) + for source in sources: + for target in targets: + try: + action = policy.evaluate(system_info, source, target) + line = handle_single_action(args, action) + if line in connections: + continue + if line: + output.write(line) + connections.add(line) + except qubespolicy.AccessDenied: + continue + + output.write('}\n') + if args.output: + output.close() + +if __name__ == '__main__': + sys.exit(main()) diff --git a/qubespolicy/tests/__init__.py b/qubespolicy/tests/__init__.py index f82d149d..536d1b22 100644 --- a/qubespolicy/tests/__init__.py +++ b/qubespolicy/tests/__init__.py @@ -31,7 +31,7 @@ tmp_policy_dir = '/tmp/policy' system_info = { 'domains': { 'dom0': { - 'tags': [], + 'tags': ['dom0-tag'], 'type': 'AdminVM', 'default_dispvm': 'default-dvm', 'dispvm_allowed': False, @@ -102,6 +102,8 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase): qubespolicy.verify_target_value(system_info, 'test-template')) self.assertTrue( qubespolicy.verify_target_value(system_info, 'test-standalone')) + self.assertTrue( + qubespolicy.verify_target_value(system_info, '$adminvm')) self.assertFalse( qubespolicy.verify_target_value(system_info, 'no-such-vm')) self.assertFalse( @@ -127,6 +129,8 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase): for_target=False)) self.assertTrue(qubespolicy.verify_special_value('$type:AppVM', for_target=False)) + self.assertTrue(qubespolicy.verify_special_value('$adminvm', + for_target=False)) self.assertFalse(qubespolicy.verify_special_value('$default', for_target=False)) self.assertFalse(qubespolicy.verify_special_value('$dispvm', @@ -155,15 +159,16 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase): self.assertIsNone(line.default_target) def test_021_line_simple(self): + # also check spaces in action field line = qubespolicy.PolicyRule( - '$tag:tag1 $type:AppVM ask,target=test-vm2,user=user', + '$tag:tag1 $type:AppVM ask, target=test-vm2, user=user', 'filename', 12) self.assertEqual(line.filename, 'filename') self.assertEqual(line.lineno, 12) self.assertEqual(line.action, qubespolicy.Action.ask) self.assertEqual(line.source, '$tag:tag1') self.assertEqual(line.target, '$type:AppVM') - self.assertEqual(line.full_action, 'ask,target=test-vm2,user=user') + self.assertEqual(line.full_action, 'ask, target=test-vm2, user=user') self.assertEqual(line.override_target, 'test-vm2') self.assertEqual(line.override_user, 'user') self.assertIsNone(line.default_target) @@ -196,6 +201,20 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase): self.assertIsNone(line.override_user) self.assertEqual(line.default_target, 'test-vm1') + def test_024_line_simple(self): + line = qubespolicy.PolicyRule( + '$anyvm $adminvm ask,default_target=$adminvm', + 'filename', 12) + self.assertEqual(line.filename, 'filename') + self.assertEqual(line.lineno, 12) + self.assertEqual(line.action, qubespolicy.Action.ask) + self.assertEqual(line.source, '$anyvm') + self.assertEqual(line.target, '$adminvm') + self.assertEqual(line.full_action, 'ask,default_target=$adminvm') + self.assertIsNone(line.override_target) + self.assertIsNone(line.override_user) + self.assertEqual(line.default_target, '$adminvm') + def test_030_line_invalid(self): invalid_lines = [ '$dispvm $default allow', # $dispvm can't be a source @@ -235,6 +254,9 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase): self.assertTrue(is_match_single(system_info, '$anyvm', '$dispvm:default-dvm')) self.assertTrue(is_match_single(system_info, '$dispvm', '$dispvm')) + self.assertTrue(is_match_single(system_info, '$adminvm', '$adminvm')) + self.assertTrue(is_match_single(system_info, '$adminvm', 'dom0')) + self.assertTrue(is_match_single(system_info, 'dom0', '$adminvm')) self.assertTrue(is_match_single(system_info, 'dom0', 'dom0')) self.assertTrue(is_match_single(system_info, '$dispvm:default-dvm', '$dispvm:default-dvm')) @@ -253,6 +275,15 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase): self.assertFalse(is_match_single(system_info, '$dispvm:test-vm1', '$dispvm:test-vm1')) self.assertFalse(is_match_single(system_info, '$anyvm', 'dom0')) + self.assertFalse(is_match_single(system_info, '$anyvm', '$adminvm')) + self.assertFalse(is_match_single(system_info, + '$tag:dom0-tag', '$adminvm')) + self.assertFalse(is_match_single(system_info, + '$type:AdminVM', '$adminvm')) + self.assertFalse(is_match_single(system_info, + '$tag:dom0-tag', 'dom0')) + self.assertFalse(is_match_single(system_info, + '$type:AdminVM', 'dom0')) self.assertFalse(is_match_single(system_info, '$tag:tag1', 'dom0')) self.assertFalse(is_match_single(system_info, '$anyvm', '$tag:tag1')) self.assertFalse(is_match_single(system_info, '$anyvm', '$type:AppVM')) @@ -338,6 +369,13 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase): line.expand_override_target(system_info, 'test-no-dvm'), 'dom0') + def test_075_expand_override_target_dom0(self): + line = qubespolicy.PolicyRule( + '$anyvm $anyvm allow,target=$adminvm') + self.assertEqual( + line.expand_override_target(system_info, 'test-no-dvm'), + '$adminvm') + class TC_10_PolicyAction(qubes.tests.QubesTestCase): def test_000_init(self): @@ -485,7 +523,6 @@ class TC_10_PolicyAction(qubes.tests.QubesTestCase): [unittest.mock.call('test-vm2', 'internal.vm.Start')]) self.assertEqual(mock_subprocess.mock_calls, []) -@unittest.mock.patch('qubespolicy.POLICY_DIR', tmp_policy_dir) class TC_20_Policy(qubes.tests.QubesTestCase): def setUp(self): @@ -505,7 +542,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): f.write('test-vm2 test-vm3 ask\n') f.write(' # comment \n') f.write('$anyvm $anyvm ask\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) self.assertEqual(policy.service, 'test.service') self.assertEqual(len(policy.policy_rules), 3) self.assertEqual(policy.policy_rules[0].source, 'test-vm1') @@ -515,7 +552,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): def test_001_not_existent(self): with self.assertRaises(qubespolicy.AccessDenied): - qubespolicy.Policy('no-such.service') + qubespolicy.Policy('no-such.service', tmp_policy_dir) def test_002_include(self): with open(os.path.join(tmp_policy_dir, 'test.service'), 'w') as f: @@ -524,7 +561,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): f.write('$anyvm $anyvm deny\n') with open(os.path.join(tmp_policy_dir, 'test.service2'), 'w') as f: f.write('test-vm3 $default allow,target=test-vm2\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) self.assertEqual(policy.service, 'test.service') self.assertEqual(len(policy.policy_rules), 3) self.assertEqual(policy.policy_rules[0].source, 'test-vm1') @@ -557,7 +594,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): f.write('test-vm2 $tag:tag2 allow\n') f.write('$type:AppVM $default allow,target=test-vm3\n') f.write('$tag:tag1 $type:AppVM allow\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) self.assertEqual(policy.find_matching_rule( system_info, 'test-vm1', 'test-vm2'), policy.policy_rules[0]) self.assertEqual(policy.find_matching_rule( @@ -593,7 +630,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): f.write('$tag:tag1 $type:AppVM allow\n') f.write('test-no-dvm $dispvm allow\n') f.write('test-standalone $dispvm allow\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) self.assertCountEqual(policy.collect_targets_for_ask(system_info, 'test-vm1'), ['test-vm1', 'test-vm2', 'test-vm3', '$dispvm:test-vm3', @@ -614,7 +651,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): with open(os.path.join(tmp_policy_dir, 'test.service'), 'w') as f: f.write('test-vm1 test-vm2 allow\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) action = policy.evaluate(system_info, 'test-vm1', 'test-vm2') self.assertEqual(action.rule, policy.policy_rules[0]) self.assertEqual(action.action, qubespolicy.Action.allow) @@ -633,7 +670,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): f.write('$tag:tag2 $anyvm allow\n') f.write('test-vm3 $anyvm deny\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) action = policy.evaluate(system_info, 'test-vm1', '$default') self.assertEqual(action.rule, policy.policy_rules[1]) self.assertEqual(action.action, qubespolicy.Action.allow) @@ -655,7 +692,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): f.write('$tag:tag2 $anyvm allow\n') f.write('test-vm3 $anyvm deny\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) action = policy.evaluate(system_info, 'test-standalone', 'test-vm2') self.assertEqual(action.rule, policy.policy_rules[2]) self.assertEqual(action.action, qubespolicy.Action.ask) @@ -676,7 +713,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase): f.write('$tag:tag2 $anyvm allow\n') f.write('test-vm3 $anyvm deny\n') - policy = qubespolicy.Policy('test.service') + policy = qubespolicy.Policy('test.service', tmp_policy_dir) action = policy.evaluate(system_info, 'test-standalone', 'test-vm3') self.assertEqual(action.rule, policy.policy_rules[3]) self.assertEqual(action.action, qubespolicy.Action.ask) @@ -688,6 +725,19 @@ class TC_20_Policy(qubes.tests.QubesTestCase): 'default-dvm', '$dispvm:default-dvm', 'test-invalid-dvm', 'test-no-dvm', 'test-template', 'test-standalone']) + def test_034_eval_resolve_dispvm(self): + with open(os.path.join(tmp_policy_dir, 'test.service'), 'w') as f: + f.write('test-vm3 $dispvm allow\n') + + policy = qubespolicy.Policy('test.service', tmp_policy_dir) + action = policy.evaluate(system_info, 'test-vm3', '$dispvm') + self.assertEqual(action.rule, policy.policy_rules[0]) + self.assertEqual(action.action, qubespolicy.Action.allow) + self.assertEqual(action.target, '$dispvm:default-dvm') + self.assertEqual(action.original_target, '$dispvm') + self.assertEqual(action.service, 'test.service') + self.assertIsNone(action.targets_for_ask) + class TC_30_Misc(qubes.tests.QubesTestCase): @unittest.mock.patch('socket.socket') diff --git a/rpm_spec/core-dom0.spec b/rpm_spec/core-dom0.spec index ff825337..75edab27 100644 --- a/rpm_spec/core-dom0.spec +++ b/rpm_spec/core-dom0.spec @@ -213,7 +213,9 @@ fi /usr/bin/qubesd* /usr/bin/qrexec-policy /usr/bin/qrexec-policy-agent +/usr/bin/qrexec-policy-graph +%{_mandir}/man1/qrexec-policy-graph.1* %{_mandir}/man1/qubes*.1* %dir %{python3_sitelib}/qubes-*.egg-info @@ -372,6 +374,7 @@ fi %{python3_sitelib}/qubespolicy/gtkhelpers.py %{python3_sitelib}/qubespolicy/rpcconfirmation.py %{python3_sitelib}/qubespolicy/utils.py +%{python3_sitelib}/qubespolicy/graph.py %dir %{python3_sitelib}/qubespolicy/tests %dir %{python3_sitelib}/qubespolicy/tests/__pycache__ @@ -410,7 +413,10 @@ fi /etc/xen/scripts/block-origin /etc/xen/scripts/vif-route-qubes %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/admin.* -%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-all +%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-local-ro +%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-local-rwx +%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-global-ro +%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-global-rwx %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/qubes.FeaturesRequest %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/qubes.Filecopy %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/qubes.GetImageRGBA diff --git a/setup.py b/setup.py index d5d5262e..0f1f40e1 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ if __name__ == '__main__': 'console_scripts': list(get_console_scripts()) + [ 'qrexec-policy = qubespolicy.cli:main', 'qrexec-policy-agent = qubespolicy.agent:main', + 'qrexec-policy-graph = qubespolicy.graph:main', ], 'qubes.vm': [ 'AppVM = qubes.vm.appvm:AppVM',