2017-03-20 22:22:20 +01:00
|
|
|
# coding=utf-8
|
|
|
|
# The Qubes OS Project, https://www.qubes-os.org/
|
|
|
|
#
|
|
|
|
# Copyright (C) 2013-2015 Joanna Rutkowska <joanna@invisiblethingslab.com>
|
|
|
|
# Copyright (C) 2013-2017 Marek Marczykowski-Górecki
|
|
|
|
# <marmarek@invisiblethingslab.com>
|
|
|
|
#
|
2017-10-12 00:11:50 +02:00
|
|
|
# This library is free software; you can redistribute it and/or
|
|
|
|
# modify it under the terms of the GNU Lesser General Public
|
|
|
|
# License as published by the Free Software Foundation; either
|
|
|
|
# version 2.1 of the License, or (at your option) any later version.
|
2017-03-20 22:22:20 +01:00
|
|
|
#
|
2017-10-12 00:11:50 +02:00
|
|
|
# This library is distributed in the hope that it will be useful,
|
2017-03-20 22:22:20 +01:00
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
2017-10-12 00:11:50 +02:00
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
|
|
# Lesser General Public License for more details.
|
2017-03-20 22:22:20 +01:00
|
|
|
#
|
2017-10-12 00:11:50 +02:00
|
|
|
# You should have received a copy of the GNU Lesser General Public
|
|
|
|
# License along with this library; if not, see <https://www.gnu.org/licenses/>.
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
''' Qrexec policy parser and evaluator '''
|
2017-06-01 11:27:08 +02:00
|
|
|
import enum
|
|
|
|
import itertools
|
2017-03-20 22:22:20 +01:00
|
|
|
import json
|
|
|
|
import os
|
|
|
|
import os.path
|
|
|
|
import socket
|
|
|
|
import subprocess
|
|
|
|
|
|
|
|
# don't import 'qubes.config' please, it takes 0.3s
|
|
|
|
QREXEC_CLIENT = '/usr/lib/qubes/qrexec-client'
|
|
|
|
POLICY_DIR = '/etc/qubes-rpc/policy'
|
|
|
|
QUBESD_INTERNAL_SOCK = '/var/run/qubesd.internal.sock'
|
2017-08-06 12:35:35 +02:00
|
|
|
QUBESD_SOCK = '/var/run/qubesd.sock'
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
2017-08-13 02:36:26 +02:00
|
|
|
class PolicyNotFound(AccessDenied):
|
|
|
|
''' Policy was not found for this service '''
|
|
|
|
def __init__(self, service_name):
|
|
|
|
super(PolicyNotFound, self).__init__(
|
|
|
|
'Policy not found for service {}'.format(service_name))
|
|
|
|
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
class Action(enum.Enum):
|
|
|
|
''' Action as defined by policy '''
|
|
|
|
allow = 1
|
|
|
|
deny = 2
|
|
|
|
ask = 3
|
|
|
|
|
2018-02-16 04:36:42 +01:00
|
|
|
def is_special_value(value):
|
|
|
|
'''Check if given source/target specification is special (keyword) value
|
|
|
|
'''
|
2018-02-16 05:17:20 +01:00
|
|
|
return value.startswith('@')
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
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,
|
2018-02-16 05:17:20 +01:00
|
|
|
or valid @dispvm like keyword)
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
:param system_info: information about the system
|
|
|
|
:param value: value to be checked
|
|
|
|
'''
|
2018-02-16 05:17:20 +01:00
|
|
|
if value == '@dispvm':
|
2017-03-20 22:22:20 +01:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
elif value == '@adminvm':
|
2017-06-27 02:36:42 +02:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
elif value.startswith('@dispvm:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
dispvm_base = value.split(':', 1)[1]
|
|
|
|
if dispvm_base not in system_info['domains']:
|
|
|
|
return False
|
|
|
|
dispvm_base_info = system_info['domains'][dispvm_base]
|
2017-09-03 03:11:48 +02:00
|
|
|
return bool(dispvm_base_info['template_for_dispvms'])
|
2017-03-20 22:22:20 +01:00
|
|
|
else:
|
|
|
|
return value in system_info['domains']
|
|
|
|
|
|
|
|
|
2017-09-11 14:21:46 +02:00
|
|
|
def verify_special_value(value, for_target=True, specific_target=False):
|
2017-03-20 22:22:20 +01:00
|
|
|
'''
|
2018-02-16 05:17:20 +01:00
|
|
|
Verify if given special VM-specifier ('@...') is valid
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
:param value: value to verify
|
|
|
|
:param for_target: should classify target-only values as valid (
|
2018-02-16 05:17:20 +01:00
|
|
|
'@default', '@dispvm')
|
2017-09-11 14:21:46 +02:00
|
|
|
:param specific_target: allow only values naming specific target
|
|
|
|
(for use with target=, default= etc)
|
2017-03-20 22:22:20 +01:00
|
|
|
:return: True or False
|
|
|
|
'''
|
|
|
|
# pylint: disable=too-many-return-statements
|
|
|
|
|
2017-09-11 14:21:46 +02:00
|
|
|
# values used only for matching VMs, not naming specific one (for actual
|
|
|
|
# call target)
|
|
|
|
if not specific_target:
|
2018-02-16 05:17:20 +01:00
|
|
|
if value.startswith('@tag:') and len(value) > len('@tag:'):
|
2017-09-11 14:21:46 +02:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
if value.startswith('@type:') and len(value) > len('@type:'):
|
2017-09-11 14:21:46 +02:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
if for_target and value.startswith('@dispvm:@tag:') and \
|
|
|
|
len(value) > len('@dispvm:@tag:'):
|
2017-09-11 14:21:46 +02:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
if value == '@anyvm':
|
2017-09-11 14:21:46 +02:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
if for_target and value == '@default':
|
2017-09-11 14:21:46 +02:00
|
|
|
return True
|
|
|
|
|
|
|
|
# those can be used to name one specific call VM
|
2018-02-16 05:17:20 +01:00
|
|
|
if value == '@adminvm':
|
2017-03-20 22:22:20 +01:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
# allow only specific dispvm, not based on any @xxx keyword - don't name
|
|
|
|
# @tag here specifically, to work also with any future keywords
|
|
|
|
if for_target and value.startswith('@dispvm:') and \
|
|
|
|
not value.startswith('@dispvm:@'):
|
2017-03-20 22:22:20 +01:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
if for_target and value == '@dispvm':
|
2017-03-20 22:22:20 +01:00
|
|
|
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,
|
2018-02-16 05:17:20 +01:00
|
|
|
empty line or @include)
|
2017-03-20 22:22:20 +01:00
|
|
|
: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:
|
2017-06-27 02:01:46 +02:00
|
|
|
self.source, self.target, self.full_action = line.split(maxsplit=2)
|
2017-03-20 22:22:20 +01:00
|
|
|
except ValueError:
|
|
|
|
raise PolicySyntaxError(filename, lineno, 'wrong number of fields')
|
|
|
|
|
2017-07-04 12:49:26 +02:00
|
|
|
(action, *params) = self.full_action.replace(',', ' ').split()
|
2017-03-20 22:22:20 +01:00
|
|
|
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
|
2018-02-16 05:17:20 +01:00
|
|
|
if is_special_value(self.source):
|
2017-09-11 14:21:46 +02:00
|
|
|
if not verify_special_value(self.source, False, False):
|
2017-03-20 22:22:20 +01:00
|
|
|
raise PolicySyntaxError(filename, lineno,
|
|
|
|
'invalid source specification: {}'.format(self.source))
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
if is_special_value(self.target):
|
2017-09-11 14:21:46 +02:00
|
|
|
if not verify_special_value(self.target, True, False):
|
2017-03-20 22:22:20 +01:00
|
|
|
raise PolicySyntaxError(filename, lineno,
|
|
|
|
'invalid target specification: {}'.format(self.target))
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
if self.target == '@default' \
|
2017-03-20 22:22:20 +01:00
|
|
|
and self.action == Action.allow \
|
|
|
|
and self.override_target is None:
|
|
|
|
raise PolicySyntaxError(filename, lineno,
|
2018-02-16 05:17:20 +01:00
|
|
|
'allow action for @default rule must specify target= option')
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
if self.override_target is not None:
|
2018-02-16 05:17:20 +01:00
|
|
|
if is_special_value(self.override_target) and \
|
2017-09-11 14:21:46 +02:00
|
|
|
not verify_special_value(self.override_target, True, True):
|
|
|
|
raise PolicySyntaxError(filename, lineno,
|
|
|
|
'target= option needs to name specific target')
|
|
|
|
|
|
|
|
if self.default_target is not None:
|
2018-02-16 05:17:20 +01:00
|
|
|
if is_special_value(self.default_target) and \
|
2017-09-11 14:21:46 +02:00
|
|
|
not verify_special_value(self.default_target, True, True):
|
2017-03-20 22:22:20 +01:00
|
|
|
raise PolicySyntaxError(filename, lineno,
|
|
|
|
'target= option needs to name specific target')
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def is_match_single(system_info, policy_value, value):
|
|
|
|
'''
|
2018-02-16 05:17:20 +01:00
|
|
|
Evaluate if a single value (VM name or '@default') matches policy
|
2017-03-20 22:22:20 +01:00
|
|
|
specification
|
|
|
|
|
|
|
|
:param system_info: information about the system
|
|
|
|
:param policy_value: value from qrexec policy (either self.source or
|
2017-06-27 02:49:13 +02:00
|
|
|
self.target)
|
2017-03-20 22:22:20 +01:00
|
|
|
:param value: value to be compared (source or target)
|
|
|
|
:return: True or False
|
|
|
|
'''
|
|
|
|
# pylint: disable=too-many-return-statements
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
# not specified target matches only with @default and @anyvm policy
|
2017-03-20 22:22:20 +01:00
|
|
|
# entry
|
2018-02-16 05:17:20 +01:00
|
|
|
if value == '@default' or value == '':
|
|
|
|
return policy_value in ('@default', '@anyvm')
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
# 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
|
2018-02-16 05:17:20 +01:00
|
|
|
# domain name (not @dispvm or such) - this is guaranteed by a nature
|
2017-03-20 22:22:20 +01:00
|
|
|
# of qrexec call
|
|
|
|
if not verify_target_value(system_info, value):
|
|
|
|
return False
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
# handle @adminvm keyword
|
2017-06-27 02:36:42 +02:00
|
|
|
if policy_value == 'dom0':
|
|
|
|
# TODO: log a warning in Qubes 4.1
|
2018-02-16 05:17:20 +01:00
|
|
|
policy_value = '@adminvm'
|
2017-06-27 02:36:42 +02:00
|
|
|
|
|
|
|
if value == 'dom0':
|
2018-02-16 05:17:20 +01:00
|
|
|
value = '@adminvm'
|
2017-06-27 02:36:42 +02:00
|
|
|
|
2017-03-20 22:22:20 +01:00
|
|
|
# allow any _valid_, non-dom0 target
|
2018-02-16 05:17:20 +01:00
|
|
|
if policy_value == '@anyvm':
|
|
|
|
return value != '@adminvm'
|
2017-03-20 22:22:20 +01:00
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
# exact match, including @dispvm* and @adminvm
|
2017-03-20 22:22:20 +01:00
|
|
|
if value == policy_value:
|
|
|
|
return True
|
|
|
|
|
2017-09-02 20:12:37 +02:00
|
|
|
# DispVM request, using tags to match
|
2018-02-16 05:17:20 +01:00
|
|
|
if policy_value.startswith('@dispvm:@tag:') \
|
|
|
|
and value.startswith('@dispvm:'):
|
2017-09-02 20:12:37 +02:00
|
|
|
tag = policy_value.split(':', 2)[2]
|
|
|
|
dispvm_base = value.split(':', 1)[1]
|
|
|
|
# already checked for existence by verify_target_value call
|
|
|
|
dispvm_base_info = system_info['domains'][dispvm_base]
|
|
|
|
return tag in dispvm_base_info['tags']
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
# if @dispvm* not matched above, reject it; default DispVM (bare
|
|
|
|
# @dispvm) was resolved by the caller
|
|
|
|
if value.startswith('@dispvm:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
return False
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
# require @adminvm to be matched explicitly (not through @tag or @type)
|
2017-06-27 02:36:42 +02:00
|
|
|
# - if not matched already, reject it
|
2018-02-16 05:17:20 +01:00
|
|
|
if value == '@adminvm':
|
2017-06-27 02:36:42 +02:00
|
|
|
return False
|
|
|
|
|
2017-03-20 22:22:20 +01:00
|
|
|
# at this point, value name a specific target
|
|
|
|
domain_info = system_info['domains'][value]
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
if policy_value.startswith('@tag:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
tag = policy_value.split(':', 1)[1]
|
|
|
|
return tag in domain_info['tags']
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
if policy_value.startswith('@type:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
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,
|
2017-06-27 02:49:13 +02:00
|
|
|
their types, labels, tags etc. as returned by
|
|
|
|
:py:func:`app_to_system_info`
|
2017-03-20 22:22:20 +01:00
|
|
|
: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
|
2018-02-16 05:17:20 +01:00
|
|
|
# @dispvm in policy matches _only_ @dispvm (but not @dispvm:some-vm,
|
2017-09-02 20:12:37 +02:00
|
|
|
# even if that would be the default one)
|
2018-02-16 05:17:20 +01:00
|
|
|
if self.target == '@dispvm' and target == '@dispvm':
|
2017-09-02 20:12:37 +02:00
|
|
|
return True
|
2018-02-16 05:17:20 +01:00
|
|
|
if target == '@dispvm':
|
|
|
|
# resolve default DispVM, to check all kinds of @dispvm:*
|
2017-09-02 20:12:37 +02:00
|
|
|
default_dispvm = system_info['domains'][source]['default_dispvm']
|
|
|
|
if default_dispvm is None:
|
2018-02-16 05:17:20 +01:00
|
|
|
# if this VM have no default DispVM, match only with @anyvm
|
|
|
|
return self.target == '@anyvm'
|
|
|
|
target = '@dispvm:' + default_dispvm
|
2017-03-20 22:22:20 +01:00
|
|
|
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
|
|
|
|
'''
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
if self.target.startswith('@tag:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
tag = self.target.split(':', 1)[1]
|
|
|
|
for name, domain in system_info['domains'].items():
|
|
|
|
if tag in domain['tags']:
|
|
|
|
yield name
|
2018-02-16 05:17:20 +01:00
|
|
|
elif self.target.startswith('@type:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
type_ = self.target.split(':', 1)[1]
|
|
|
|
for name, domain in system_info['domains'].items():
|
|
|
|
if type_ == domain['type']:
|
|
|
|
yield name
|
2018-02-16 05:17:20 +01:00
|
|
|
elif self.target == '@anyvm':
|
2017-03-20 22:22:20 +01:00
|
|
|
for name, domain in system_info['domains'].items():
|
|
|
|
if name != 'dom0':
|
|
|
|
yield name
|
2017-09-03 03:11:48 +02:00
|
|
|
if domain['template_for_dispvms']:
|
2018-02-16 05:17:20 +01:00
|
|
|
yield '@dispvm:' + name
|
|
|
|
yield '@dispvm'
|
|
|
|
elif self.target.startswith('@dispvm:@tag:'):
|
2017-09-02 20:12:37 +02:00
|
|
|
tag = self.target.split(':', 2)[2]
|
|
|
|
for name, domain in system_info['domains'].items():
|
|
|
|
if tag in domain['tags']:
|
|
|
|
if domain['template_for_dispvms']:
|
2018-02-16 05:17:20 +01:00
|
|
|
yield '@dispvm:' + name
|
|
|
|
elif self.target.startswith('@dispvm:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
dispvm_base = self.target.split(':', 1)[1]
|
|
|
|
try:
|
2017-09-03 03:11:48 +02:00
|
|
|
if system_info['domains'][dispvm_base]['template_for_dispvms']:
|
2017-03-20 22:22:20 +01:00
|
|
|
yield self.target
|
|
|
|
except KeyError:
|
|
|
|
# TODO log a warning?
|
|
|
|
pass
|
2018-02-16 05:17:20 +01:00
|
|
|
elif self.target == '@adminvm':
|
2017-06-27 02:36:42 +02:00
|
|
|
yield self.target
|
2018-02-16 05:17:20 +01:00
|
|
|
elif self.target == '@dispvm':
|
2017-03-20 22:22:20 +01:00
|
|
|
yield self.target
|
|
|
|
else:
|
|
|
|
if self.target in system_info['domains']:
|
|
|
|
yield self.target
|
|
|
|
|
|
|
|
def expand_override_target(self, system_info, source):
|
|
|
|
'''
|
2018-02-16 05:17:20 +01:00
|
|
|
Replace '@dispvm' with specific '@dispvm:...' value, based on qrexec
|
2017-03-20 22:22:20 +01:00
|
|
|
call source.
|
|
|
|
|
|
|
|
:param system_info: System information
|
|
|
|
:param source: Source domain name
|
2018-02-16 05:17:20 +01:00
|
|
|
:return: :py:attr:`override_target` with '@dispvm' substituted
|
2017-03-20 22:22:20 +01:00
|
|
|
'''
|
2018-02-16 05:17:20 +01:00
|
|
|
if self.override_target == '@dispvm':
|
2017-03-20 22:22:20 +01:00
|
|
|
if system_info['domains'][source]['default_dispvm'] is None:
|
|
|
|
return None
|
2018-02-16 05:17:20 +01:00
|
|
|
return '@dispvm:' + system_info['domains'][source]['default_dispvm']
|
2017-03-20 22:22:20 +01:00
|
|
|
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
|
|
|
|
if response:
|
|
|
|
assert target in self.targets_for_ask
|
|
|
|
self.target = target
|
|
|
|
self.action = Action.allow
|
|
|
|
else:
|
2017-04-21 15:43:46 +02:00
|
|
|
self.action = Action.deny
|
2017-03-20 22:22:20 +01:00
|
|
|
raise AccessDenied(
|
|
|
|
'denied by the user {}:{}'.format(self.rule.filename,
|
|
|
|
self.rule.lineno))
|
|
|
|
|
|
|
|
def execute(self, caller_ident):
|
|
|
|
''' Execute allowed service call
|
|
|
|
|
2017-06-27 02:49:13 +02:00
|
|
|
:param caller_ident: Service caller ident
|
|
|
|
(`process_ident,source_name, source_id`)
|
2017-03-20 22:22:20 +01:00
|
|
|
'''
|
|
|
|
assert self.action == Action.allow
|
|
|
|
assert self.target is not None
|
|
|
|
|
2018-02-16 05:17:20 +01:00
|
|
|
if self.target == '@adminvm':
|
2017-06-27 02:36:42 +02:00
|
|
|
self.target = 'dom0'
|
2017-03-20 22:22:20 +01:00
|
|
|
if self.target == 'dom0':
|
2018-02-16 04:36:42 +01:00
|
|
|
original_target_type = \
|
|
|
|
'keyword' if is_special_value(self.original_target) else 'name'
|
2018-02-16 05:17:20 +01:00
|
|
|
original_target = self.original_target.lstrip('@')
|
2018-02-16 04:30:32 +01:00
|
|
|
cmd = \
|
2018-02-16 04:36:42 +01:00
|
|
|
'QUBESRPC {service} {source} {original_target_type} ' \
|
|
|
|
'{original_target}'.format(
|
2018-02-16 04:30:32 +01:00
|
|
|
service=self.service,
|
|
|
|
source=self.source,
|
2018-02-16 04:36:42 +01:00
|
|
|
original_target_type=original_target_type,
|
|
|
|
original_target=original_target)
|
2017-03-20 22:22:20 +01:00
|
|
|
else:
|
|
|
|
cmd = '{user}:QUBESRPC {service} {source}'.format(
|
|
|
|
user=(self.rule.override_user or 'DEFAULT'),
|
|
|
|
service=self.service,
|
|
|
|
source=self.source)
|
2018-02-16 05:17:20 +01:00
|
|
|
if self.target.startswith('@dispvm:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
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]
|
2017-08-06 12:35:35 +02:00
|
|
|
dispvm_name = qubesd_call(base_appvm, 'admin.vm.CreateDisposable')
|
2017-03-20 22:22:20 +01:00
|
|
|
dispvm_name = dispvm_name.decode('ascii')
|
2017-08-06 12:35:35 +02:00
|
|
|
qubesd_call(dispvm_name, 'admin.vm.Start')
|
2017-03-20 22:22:20 +01:00
|
|
|
return dispvm_name
|
|
|
|
|
|
|
|
def ensure_target_running(self):
|
|
|
|
'''
|
|
|
|
Start domain if not running already
|
|
|
|
|
|
|
|
:return: None
|
|
|
|
'''
|
2017-08-06 12:35:35 +02:00
|
|
|
if self.target == 'dom0':
|
|
|
|
return
|
2017-03-20 22:22:20 +01:00
|
|
|
try:
|
2017-08-06 12:35:35 +02:00
|
|
|
qubesd_call(self.target, 'admin.vm.Start')
|
2017-03-20 22:22:20 +01:00
|
|
|
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
|
|
|
|
'''
|
2017-08-06 12:35:35 +02:00
|
|
|
qubesd_call(dispvm, 'admin.vm.Kill')
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
|
|
|
|
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:
|
2017-06-27 02:49:13 +02:00
|
|
|
>>> # ... ask the user, see action.targets_for_ask ...
|
2017-03-20 22:22:20 +01:00
|
|
|
>>> action.handle_user_response(response, target_chosen_by_user)
|
|
|
|
>>> action.execute('process-ident')
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
2017-06-27 05:38:54 +02:00
|
|
|
def __init__(self, service, policy_dir=POLICY_DIR):
|
|
|
|
policy_file = os.path.join(policy_dir, service)
|
2017-03-20 22:22:20 +01:00
|
|
|
if not os.path.exists(policy_file):
|
|
|
|
# fallback to policy without specific argument set (if any)
|
2017-06-27 05:38:54 +02:00
|
|
|
policy_file = os.path.join(policy_dir, service.split('+')[0])
|
2017-08-13 02:36:26 +02:00
|
|
|
if not os.path.exists(policy_file):
|
|
|
|
raise PolicyNotFound(service)
|
2017-06-27 05:38:54 +02:00
|
|
|
|
|
|
|
#: policy storage directory
|
|
|
|
self.policy_dir = policy_dir
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
#: 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()
|
2018-02-16 05:17:20 +01:00
|
|
|
# compatibility with old keywords notation
|
|
|
|
line = line.replace('$', '@')
|
2017-03-20 22:22:20 +01:00
|
|
|
if not line:
|
|
|
|
# skip empty lines
|
|
|
|
continue
|
|
|
|
if line[0] == '#':
|
|
|
|
# skip comments
|
|
|
|
continue
|
2018-02-16 05:17:20 +01:00
|
|
|
if line.startswith('@include:'):
|
2017-03-20 22:22:20 +01:00
|
|
|
include_path = line.split(':', 1)[1]
|
|
|
|
# os.path.join will leave include_path unchanged if it's
|
|
|
|
# already absolute
|
2017-06-27 05:38:54 +02:00
|
|
|
include_path = os.path.join(self.policy_dir, include_path)
|
2017-03-20 22:22:20 +01:00
|
|
|
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
|
2018-02-16 05:17:20 +01:00
|
|
|
can also contains @dispvm like keywords.
|
2017-03-20 22:22:20 +01:00
|
|
|
'''
|
|
|
|
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
|
2018-02-16 05:17:20 +01:00
|
|
|
if '@dispvm' in targets:
|
|
|
|
targets.remove('@dispvm')
|
2017-03-20 22:22:20 +01:00
|
|
|
if system_info['domains'][source]['default_dispvm'] is not None:
|
2018-02-16 05:17:20 +01:00
|
|
|
dispvm = '@dispvm:' + \
|
2017-03-20 22:22:20 +01:00
|
|
|
system_info['domains'][source]['default_dispvm']
|
|
|
|
if verify_target_value(system_info, dispvm):
|
|
|
|
targets.add(dispvm)
|
|
|
|
|
2017-11-06 14:37:08 +01:00
|
|
|
# expand other keywords
|
2018-02-16 05:17:20 +01:00
|
|
|
if '@adminvm' in targets:
|
|
|
|
targets.remove('@adminvm')
|
2017-11-06 14:37:08 +01:00
|
|
|
targets.add('dom0')
|
|
|
|
|
2017-03-20 22:22:20 +01:00
|
|
|
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))
|
2017-04-21 15:43:46 +02:00
|
|
|
if not targets:
|
2017-03-20 22:22:20 +01:00
|
|
|
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:
|
2018-02-16 05:17:20 +01:00
|
|
|
if actual_target == '@default':
|
2017-03-20 22:22:20 +01:00
|
|
|
raise AccessDenied(
|
|
|
|
'policy define \'allow\' action at {}:{} but no target is '
|
|
|
|
'specified by caller or policy'.format(
|
|
|
|
rule.filename, rule.lineno))
|
2018-02-16 05:17:20 +01:00
|
|
|
if actual_target == '@dispvm':
|
2017-06-27 05:30:40 +02:00
|
|
|
if system_info['domains'][source]['default_dispvm'] is None:
|
|
|
|
raise AccessDenied(
|
2018-02-16 05:17:20 +01:00
|
|
|
'policy define \'allow\' action to @dispvm at {}:{} '
|
2017-06-27 05:30:40 +02:00
|
|
|
'but no DispVM base is set for this VM'.format(
|
|
|
|
rule.filename, rule.lineno))
|
2018-02-16 05:17:20 +01:00
|
|
|
actual_target = '@dispvm:' + \
|
2017-06-27 05:30:40 +02:00
|
|
|
system_info['domains'][source]['default_dispvm']
|
|
|
|
|
2017-03-20 22:22:20 +01:00
|
|
|
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):
|
2017-08-06 12:35:35 +02:00
|
|
|
if method.startswith('internal.'):
|
|
|
|
socket_path = QUBESD_INTERNAL_SOCK
|
|
|
|
else:
|
|
|
|
socket_path = QUBESD_SOCK
|
2017-03-20 22:22:20 +01:00
|
|
|
try:
|
|
|
|
client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
2017-08-06 12:35:35 +02:00
|
|
|
client_socket.connect(socket_path)
|
2017-03-20 22:22:20 +01:00
|
|
|
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:
|
2017-06-27 02:49:13 +02:00
|
|
|
- `<domain name>`:
|
|
|
|
- tags: list of tags
|
|
|
|
- type: domain type
|
2017-09-03 03:11:48 +02:00
|
|
|
- template_for_dispvms: should DispVM based on this VM be allowed
|
2017-06-27 02:49:13 +02:00
|
|
|
- default_dispvm: name of default AppVM for DispVMs started from here
|
2017-03-20 22:22:20 +01:00
|
|
|
|
|
|
|
'''
|
|
|
|
|
2017-05-12 19:14:29 +02:00
|
|
|
system_info = qubesd_call('dom0', 'internal.GetSystemInfo')
|
2017-03-21 11:48:20 +01:00
|
|
|
return json.loads(system_info.decode('utf-8'))
|