diff --git a/.travis.yml b/.travis.yml index 198b4e15..50618add 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ install: - pip install --quiet -r ci/requirements.txt - git clone https://github.com/"${TRAVIS_REPO_SLUG%%/*}"/qubes-builder ~/qubes-builder script: - - PYTHONPATH=test-packages pylint --rcfile=ci/pylintrc qubes + - PYTHONPATH=test-packages pylint --rcfile=ci/pylintrc qubes qubespolicy - ./run-tests --no-syslog - ~/qubes-builder/scripts/travis-build env: diff --git a/doc/index.rst b/doc/index.rst index 39c5284f..3ace08e2 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -20,6 +20,7 @@ manpages and API documentation. For primary user documentation, see qubes-ext qubes-log qubes-mgmt + qubes-policy qubes-tools/index qubes-tests qubes-dochelpers diff --git a/doc/qubes-policy.rst b/doc/qubes-policy.rst new file mode 100644 index 00000000..0be819ca --- /dev/null +++ b/doc/qubes-policy.rst @@ -0,0 +1,87 @@ +:py:mod:`qubes.policy` -- Qubes RPC policy +========================================== + +Every Qubes domain can trigger various RPC services, but if such call would be +allowed depends on Qubes RPC policy (qrexec policy in short). + +Qrexec policy format +-------------------- + +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 + +2. Target specification, one of: + + - domain name + - `$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 + - `$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` + +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 + pointed by target VM's `default_user` property + - `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 + - `default_target=` - suggest this target when prompting the user for + confirmation + +Alternatively, a line may consist of a single keyword `$include:` followed by a +path. This will load a given file as its content would be in place of +`$include` line. Relative paths are resolved relative to +`/etc/qubes-rpc/policy` directory. + +Evaluating `ask` action +----------------------- + +When qrexec policy specify `ask` action, the user is asked whether the call +should be allowed or denied. In addition to that, user also need to choose +target domain. User have to choose from a set of targets specified by the +policy. Such set is calculated using the algorithm below: + +1. If `ask` action have `target=` option specified, only that target is +considered. A prompt window will allow to choose only this value and it will +also be pre-filled value. + +2. If no `target=` option is specified, all rules are evaluated to see what +target domains (for a given source domain) would result in `ask` or `allow` +action. If any of them have `target=` option set, that value is used instead of +the one specified in "target" column (for this particular line). Then the user +is presented with a confirmation dialog and an option to choose from those +domains. + +3. If `default_target=` option is set, it is used as +suggested value, otherwise no suggestion is made (regardless of calling domain +specified any target or not). + + + +Module contents +--------------- + +.. automodule:: qubespolicy + :members: + :show-inheritance: + +.. vim: ts=3 sw=3 et diff --git a/qubespolicy/__init__.py b/qubespolicy/__init__.py new file mode 100755 index 00000000..df9f9b60 --- /dev/null +++ b/qubespolicy/__init__.py @@ -0,0 +1,646 @@ +# coding=utf-8 +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2013-2015 Joanna Rutkowska +# Copyright (C) 2013-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. + +''' Qrexec policy parser and evaluator ''' +import json +import os +import os.path +import socket +import subprocess +import enum +import itertools + +# don't import 'qubes.config' please, it takes 0.3s +QREXEC_CLIENT = '/usr/lib/qubes/qrexec-client' +QUBES_RPC_MULTIPLEXER_PATH = '/usr/lib/qubes/qubes-rpc-multiplexer' +POLICY_DIR = '/etc/qubes-rpc/policy' +QUBESD_INTERNAL_SOCK = '/var/run/qubesd.internal.sock' + + +class AccessDenied(Exception): + ''' Raised when qrexec policy denied access ''' + pass + + +class PolicySyntaxError(AccessDenied): + ''' Syntax error in qrexec policy, abort parsing ''' + def __init__(self, filename, lineno, msg): + super(PolicySyntaxError, self).__init__( + '{}:{}: {}'.format(filename, lineno, msg)) + + +class Action(enum.Enum): + ''' Action as defined by policy ''' + allow = 1 + deny = 2 + ask = 3 + + +def verify_target_value(system_info, value): + ''' Check if given value names valid target + + This function check if given value is not only syntactically correct, + but also if names valid service call target (existing domain, + or valid $dispvm like keyword) + + :param system_info: information about the system + :param value: value to be checked + ''' + if value == '$dispvm': + return True + elif value.startswith('$dispvm:'): + dispvm_base = value.split(':', 1)[1] + if dispvm_base not in system_info['domains']: + return False + dispvm_base_info = system_info['domains'][dispvm_base] + return bool(dispvm_base_info['dispvm_allowed']) + else: + return value in system_info['domains'] + + +def verify_special_value(value, for_target=True): + ''' + Verify if given special VM-specifier ('$...') is valid + + :param value: value to verify + :param for_target: should classify target-only values as valid ( + '$default', '$dispvm') + :return: True or False + ''' + # pylint: disable=too-many-return-statements + + if value.startswith('$tag:') and len(value) > len('$tag:'): + return True + elif value.startswith('$type:') and len(value) > len('$type:'): + return True + elif value == '$anyvm': + return True + elif value.startswith('$dispvm:') and for_target: + return True + elif value == '$dispvm' and for_target: + return True + elif value == '$default' and for_target: + return True + return False + + +class PolicyRule(object): + ''' A single line of policy file ''' + def __init__(self, line, filename=None, lineno=None): + ''' + Load a single line of qrexec policy and check its syntax. + Do not verify existence of named objects. + + :raise PolicySyntaxError: when syntax error is found + + :param line: a single line of actual qrexec policy (not a comment, + empty line or $include) + :param filename: name of the file from which this line is loaded + :param lineno: line number from which this line is loaded + ''' + + self.lineno = lineno + self.filename = filename + + try: + self.source, self.target, self.full_action = line.split() + except ValueError: + raise PolicySyntaxError(filename, lineno, 'wrong number of fields') + + (action, *params) = self.full_action.split(',') + try: + self.action = Action[action] + except KeyError: + raise PolicySyntaxError(filename, lineno, + 'invalid action: {}'.format(action)) + + #: alternative target, used instead of the one specified by the caller + self.override_target = None + + #: alternative user, used instead of vm.default_user + self.override_user = None + + #: default target when asking the user for confirmation + self.default_target = None + + for param in params: + try: + param_name, value = param.split('=') + except ValueError: + raise PolicySyntaxError(filename, lineno, + 'invalid action parameter syntax: {}'.format(param)) + if param_name == 'target': + if self.action == Action.deny: + raise PolicySyntaxError(filename, lineno, + 'target= option not allowed for deny action') + self.override_target = value + elif param_name == 'user': + if self.action == Action.deny: + raise PolicySyntaxError(filename, lineno, + 'user= option not allowed for deny action') + self.override_user = value + elif param_name == 'default_target': + if self.action != Action.ask: + raise PolicySyntaxError(filename, lineno, + 'default_target= option allowed only for ask action') + self.default_target = value + else: + raise PolicySyntaxError(filename, lineno, + 'invalid option {} for {} action'.format(param, action)) + + # verify special values + if self.source.startswith('$'): + if not verify_special_value(self.source, False): + raise PolicySyntaxError(filename, lineno, + 'invalid source specification: {}'.format(self.source)) + + if self.target.startswith('$'): + if not verify_special_value(self.target, True): + raise PolicySyntaxError(filename, lineno, + 'invalid target specification: {}'.format(self.target)) + + if self.target == '$default' \ + and self.action == Action.allow \ + and self.override_target is None: + raise PolicySyntaxError(filename, lineno, + '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'): + raise PolicySyntaxError(filename, lineno, + 'target= option needs to name specific target') + + @staticmethod + def is_match_single(system_info, policy_value, value): + ''' + Evaluate if a single value (VM name or '$default') matches policy + specification + + :param system_info: information about the system + :param policy_value: value from qrexec policy (either self.source or + self.target) + :param value: value to be compared (source or target) + :return: True or False + ''' + # pylint: disable=too-many-return-statements + + # not specified target matches only with $default and $anyvm policy + # entry + if value == '$default' or value == '': + return policy_value in ('$default', '$anyvm') + + # if specific target used, check if it's valid + # this function (is_match_single) is also used for checking call source + # values, but this isn't a problem, because it will always be a + # domain name (not $dispvm or such) - this is guaranteed by a nature + # of qrexec call + if not verify_target_value(system_info, value): + return False + + # allow any _valid_, non-dom0 target + if policy_value == '$anyvm': + return value != 'dom0' + + # exact match, including $dispvm* + if value == policy_value: + return True + + # if $dispvm* not matched above, reject it; missing ':' is + # intentional - handle both '$dispvm' and '$dispvm:xxx' + if value.startswith('$dispvm'): + return False + + # at this point, value name a specific target + domain_info = system_info['domains'][value] + + if policy_value.startswith('$tag:'): + tag = policy_value.split(':', 1)[1] + return tag in domain_info['tags'] + + if policy_value.startswith('$type:'): + type_ = policy_value.split(':', 1)[1] + return type_ == domain_info['type'] + + return False + + def is_match(self, system_info, source, target): + ''' + 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` + :param source: name of the source VM + :param target: name of the target VM, or None if not specified + :return: True or False + ''' + + if not self.is_match_single(system_info, self.source, source): + return False + if not self.is_match_single(system_info, self.target, target): + return False + return True + + def expand_target(self, system_info): + ''' + Return domains matching target of this policy line + + :param system_info: information about the system + :return: matching domains + ''' + + if self.target.startswith('$tag:'): + tag = self.target.split(':', 1)[1] + for name, domain in system_info['domains'].items(): + if tag in domain['tags']: + yield name + elif self.target.startswith('$type:'): + type_ = self.target.split(':', 1)[1] + for name, domain in system_info['domains'].items(): + if type_ == domain['type']: + yield name + elif self.target == '$anyvm': + for name, domain in system_info['domains'].items(): + if name != 'dom0': + yield name + if domain['dispvm_allowed']: + yield '$dispvm:' + name + yield '$dispvm' + elif self.target.startswith('$dispvm:'): + dispvm_base = self.target.split(':', 1)[1] + try: + if system_info['domains'][dispvm_base]['dispvm_allowed']: + yield self.target + except KeyError: + # TODO log a warning? + pass + elif self.target == '$dispvm': + yield self.target + else: + if self.target in system_info['domains']: + yield self.target + + def expand_override_target(self, system_info, source): + ''' + Replace '$dispvm' with specific '$dispvm:...' value, based on qrexec + call source. + + :param system_info: System information + :param source: Source domain name + :return: :py:attr:`override_target` with '$dispvm' substituted + ''' + if self.override_target == '$dispvm': + if system_info['domains'][source]['default_dispvm'] is None: + return None + return '$dispvm:' + system_info['domains'][source]['default_dispvm'] + else: + return self.override_target + + +class PolicyAction(object): + ''' Object representing positive policy evaluation result - + either ask or allow action ''' + def __init__(self, service, source, target, rule, original_target, + targets_for_ask=None): + #: service name + self.service = service + #: calling domain + self.source = source + #: target domain the service should be connected to, None if + # not chosen yet + if targets_for_ask is None or target in targets_for_ask: + self.target = target + else: + # TODO: log a warning? + self.target = None + #: original target specified by the caller + self.original_target = original_target + #: targets for the user to choose from + self.targets_for_ask = targets_for_ask + #: policy rule from which this action is derived + self.rule = rule + if rule.action == Action.deny: + # this should be really rejected by Policy.eval() + raise AccessDenied( + 'denied by policy {}:{}'.format(rule.filename, rule.lineno)) + elif rule.action == Action.ask: + assert targets_for_ask is not None + elif rule.action == Action.allow: + assert targets_for_ask is None + assert target is not None + self.action = rule.action + + def handle_user_response(self, response, target=None): + ''' + Handle user response for the 'ask' action + + :param response: whether the call was allowed or denied (bool) + :param target: target chosen by the user (if reponse==True) + :return: None + ''' + assert self.action == Action.ask + assert self.target is None + if response: + assert target in self.targets_for_ask + self.target = target + self.action = Action.allow + else: + self.action = Action.deny # pylint: disable=redefined-variable-type + raise AccessDenied( + 'denied by the user {}:{}'.format(self.rule.filename, + self.rule.lineno)) + + def execute(self, caller_ident): + ''' Execute allowed service call + + :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 == 'dom0': + cmd = '{multiplexer} {service} {source} {original_target}'.format( + multiplexer=QUBES_RPC_MULTIPLEXER_PATH, + service=self.service, + source=self.source, + original_target=self.original_target) + else: + cmd = '{user}:QUBESRPC {service} {source}'.format( + user=(self.rule.override_user or 'DEFAULT'), + service=self.service, + source=self.source) + if self.target.startswith('$dispvm:'): + target = self.spawn_dispvm() + dispvm = True + else: + target = self.target + dispvm = False + self.ensure_target_running() + qrexec_opts = ['-d', target, '-c', caller_ident] + if dispvm: + qrexec_opts.append('-W') + try: + subprocess.call([QREXEC_CLIENT] + qrexec_opts + [cmd]) + finally: + if dispvm: + self.cleanup_dispvm(target) + + + def spawn_dispvm(self): + ''' + Create and start Disposable VM based on AppVM specified in + :py:attr:`target` + :return: name of new Disposable VM + ''' + base_appvm = self.target.split(':', 1)[1] + dispvm_name = qubesd_call(base_appvm, 'mgmtinternal.vm.Create.DispVM') + dispvm_name = dispvm_name.decode('ascii') + qubesd_call(dispvm_name, 'mgmtinternal.vm.Start') + return dispvm_name + + def ensure_target_running(self): + ''' + Start domain if not running already + + :return: None + ''' + try: + qubesd_call(self.target, 'mgmtinternal.vm.Start') + except QubesMgmtException as e: + if e.exc_type == 'QubesVMNotHaltedError': + pass + else: + raise + + @staticmethod + def cleanup_dispvm(dispvm): + ''' + Kill and remove Disposable VM + + :param dispvm: name of Disposable VM + :return: None + ''' + qubesd_call(dispvm, 'mgmtinternal.vm.CleanupDispVM') + + +class Policy(object): + ''' Full policy for a given service + + Usage: + >>> system_info = get_system_info() + >>> 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 ...) + >>> 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) + 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]) + + #: service name + self.service = service + + #: list of PolicyLine objects + self.policy_rules = [] + try: + self.load_policy_file(policy_file) + except OSError as e: + raise AccessDenied( + 'failed to load {} file: {!s}'.format(e.filename, e)) + + def load_policy_file(self, path): + ''' Load policy file and append rules to :py:attr:`policy_rules` + + :param path: file to load + ''' + with open(path) as policy_file: + for lineno, line in zip(itertools.count(start=1), + policy_file.readlines()): + line = line.strip() + if not line: + # skip empty lines + continue + if line[0] == '#': + # skip comments + continue + if line.startswith('$include:'): + 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) + self.load_policy_file(include_path) + else: + self.policy_rules.append(PolicyRule(line, path, lineno)) + + def find_matching_rule(self, system_info, source, target): + ''' Find the first rule matching given arguments ''' + + for rule in self.policy_rules: + if rule.is_match(system_info, source, target): + return rule + raise AccessDenied('no matching rule found') + + + def collect_targets_for_ask(self, system_info, source): + ''' Collect targets the user can choose from in 'ask' action + + Word 'targets' is used intentionally instead of 'domains', because it + can also contains $dispvm like keywords. + ''' + targets = set() + + # iterate over rules in reversed order to easier handle 'deny' + # actions - simply remove matching domains from allowed set + for rule in reversed(self.policy_rules): + if rule.is_match_single(system_info, rule.source, source): + if rule.action == Action.deny: + targets -= set(rule.expand_target(system_info)) + else: + if rule.override_target is not None: + override_target = rule.expand_override_target( + system_info, source) + if verify_target_value(system_info, override_target): + targets.add(rule.override_target) + else: + targets.update(rule.expand_target(system_info)) + + # expand default DispVM + if '$dispvm' in targets: + targets.remove('$dispvm') + if system_info['domains'][source]['default_dispvm'] is not None: + dispvm = '$dispvm:' + \ + system_info['domains'][source]['default_dispvm'] + if verify_target_value(system_info, dispvm): + targets.add(dispvm) + + return targets + + def evaluate(self, system_info, source, target): + ''' Evaluate policy + + :raise AccessDenied: when action should be denied unconditionally + + :return tuple(rule, considered_targets) - where considered targets is a + list of possible targets for 'ask' action (rule.action == Action.ask) + ''' + rule = self.find_matching_rule(system_info, source, target) + if rule.action == Action.deny: + raise AccessDenied( + 'denied by policy {}:{}'.format(rule.filename, rule.lineno)) + + if rule.override_target is not None: + override_target = rule.expand_override_target(system_info, source) + if not verify_target_value(system_info, override_target): + raise AccessDenied('invalid target= value in {}:{}'.format( + rule.filename, rule.lineno)) + actual_target = override_target + else: + actual_target = target + + if rule.action == Action.ask: + if rule.override_target is not None: + targets = [actual_target] + else: + targets = list( + self.collect_targets_for_ask(system_info, source)) + if len(targets) == 0: + raise AccessDenied( + 'policy define \'ask\' action at {}:{} but no target is ' + 'available to choose from'.format( + rule.filename, rule.lineno)) + return PolicyAction(self.service, source, rule.default_target, + rule, target, targets) + elif rule.action == Action.allow: + if actual_target == '$default': + raise AccessDenied( + 'policy define \'allow\' action at {}:{} but no target is ' + 'specified by caller or policy'.format( + rule.filename, rule.lineno)) + return PolicyAction(self.service, source, + actual_target, rule, target) + else: + # should be unreachable + raise AccessDenied( + 'invalid action?! {}:{}'.format(rule.filename, rule.lineno)) + + +class QubesMgmtException(Exception): + ''' Exception returned by qubesd ''' + def __init__(self, exc_type): + super(QubesMgmtException, self).__init__() + self.exc_type = exc_type + + +def qubesd_call(dest, method, arg=None, payload=None): + try: + client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client_socket.connect(QUBESD_INTERNAL_SOCK) + except IOError: + # TODO: + raise + + # src, method, dest, arg + for call_arg in ('dom0', method, dest, arg): + if call_arg is not None: + client_socket.sendall(call_arg.encode('ascii')) + client_socket.sendall(b'\0') + if payload is not None: + client_socket.sendall(payload) + + client_socket.shutdown(socket.SHUT_WR) + + return_data = client_socket.makefile('rb').read() + if return_data.startswith(b'0\x00'): + return return_data[2:] + elif return_data.startswith(b'2\x00'): + (_, exc_type, _traceback, _format_string, _args) = \ + return_data.split(b'\x00', 4) + raise QubesMgmtException(exc_type.decode('ascii')) + else: + raise AssertionError( + 'invalid qubesd response: {!r}'.format(return_data)) + + +def get_system_info(): + ''' Get system information + + This retrieve information necessary to process qrexec policy. Returned + 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 + + ''' + + system_info = qubesd_call('dom0', 'mgmtinternal.GetSystemInfo') + return json.loads(system_info) diff --git a/rpm_spec/core-dom0.spec b/rpm_spec/core-dom0.spec index 5389bdce..49e3c26d 100644 --- a/rpm_spec/core-dom0.spec +++ b/rpm_spec/core-dom0.spec @@ -378,6 +378,11 @@ fi %{python3_sitelib}/qubes/qmemman/algo.py %{python3_sitelib}/qubes/qmemman/client.py +%dir %{python3_sitelib}/qubespolicy +%dir %{python3_sitelib}/qubespolicy/__pycache__ +%{python3_sitelib}/qubespolicy/__pycache__/* +%{python3_sitelib}/qubespolicy/__init__.py + /usr/lib/qubes/unbind-pci-device.sh /usr/lib/qubes/cleanup-dispvms /usr/lib/qubes/qfile-daemon-dvm*