__init__.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. # coding=utf-8
  2. # The Qubes OS Project, https://www.qubes-os.org/
  3. #
  4. # Copyright (C) 2013-2015 Joanna Rutkowska <joanna@invisiblethingslab.com>
  5. # Copyright (C) 2013-2017 Marek Marczykowski-Górecki
  6. # <marmarek@invisiblethingslab.com>
  7. #
  8. # This program is free software; you can redistribute it and/or modify
  9. # it under the terms of the GNU General Public License as published by
  10. # the Free Software Foundation; either version 2 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This program is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License along
  19. # with this program; if not, write to the Free Software Foundation, Inc.,
  20. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  21. ''' Qrexec policy parser and evaluator '''
  22. import enum
  23. import itertools
  24. import json
  25. import os
  26. import os.path
  27. import socket
  28. import subprocess
  29. # don't import 'qubes.config' please, it takes 0.3s
  30. QREXEC_CLIENT = '/usr/lib/qubes/qrexec-client'
  31. QUBES_RPC_MULTIPLEXER_PATH = '/usr/lib/qubes/qubes-rpc-multiplexer'
  32. POLICY_DIR = '/etc/qubes-rpc/policy'
  33. QUBESD_INTERNAL_SOCK = '/var/run/qubesd.internal.sock'
  34. class AccessDenied(Exception):
  35. ''' Raised when qrexec policy denied access '''
  36. pass
  37. class PolicySyntaxError(AccessDenied):
  38. ''' Syntax error in qrexec policy, abort parsing '''
  39. def __init__(self, filename, lineno, msg):
  40. super(PolicySyntaxError, self).__init__(
  41. '{}:{}: {}'.format(filename, lineno, msg))
  42. class Action(enum.Enum):
  43. ''' Action as defined by policy '''
  44. allow = 1
  45. deny = 2
  46. ask = 3
  47. def verify_target_value(system_info, value):
  48. ''' Check if given value names valid target
  49. This function check if given value is not only syntactically correct,
  50. but also if names valid service call target (existing domain,
  51. or valid $dispvm like keyword)
  52. :param system_info: information about the system
  53. :param value: value to be checked
  54. '''
  55. if value == '$dispvm':
  56. return True
  57. elif value == '$adminvm':
  58. return True
  59. elif value.startswith('$dispvm:'):
  60. dispvm_base = value.split(':', 1)[1]
  61. if dispvm_base not in system_info['domains']:
  62. return False
  63. dispvm_base_info = system_info['domains'][dispvm_base]
  64. return bool(dispvm_base_info['dispvm_allowed'])
  65. else:
  66. return value in system_info['domains']
  67. def verify_special_value(value, for_target=True):
  68. '''
  69. Verify if given special VM-specifier ('$...') is valid
  70. :param value: value to verify
  71. :param for_target: should classify target-only values as valid (
  72. '$default', '$dispvm')
  73. :return: True or False
  74. '''
  75. # pylint: disable=too-many-return-statements
  76. if value.startswith('$tag:') and len(value) > len('$tag:'):
  77. return True
  78. elif value.startswith('$type:') and len(value) > len('$type:'):
  79. return True
  80. elif value == '$anyvm':
  81. return True
  82. elif value == '$adminvm':
  83. return True
  84. elif value.startswith('$dispvm:') and for_target:
  85. return True
  86. elif value == '$dispvm' and for_target:
  87. return True
  88. elif value == '$default' and for_target:
  89. return True
  90. return False
  91. class PolicyRule(object):
  92. ''' A single line of policy file '''
  93. def __init__(self, line, filename=None, lineno=None):
  94. '''
  95. Load a single line of qrexec policy and check its syntax.
  96. Do not verify existence of named objects.
  97. :raise PolicySyntaxError: when syntax error is found
  98. :param line: a single line of actual qrexec policy (not a comment,
  99. empty line or $include)
  100. :param filename: name of the file from which this line is loaded
  101. :param lineno: line number from which this line is loaded
  102. '''
  103. self.lineno = lineno
  104. self.filename = filename
  105. try:
  106. self.source, self.target, self.full_action = line.split(maxsplit=2)
  107. except ValueError:
  108. raise PolicySyntaxError(filename, lineno, 'wrong number of fields')
  109. (action, *params) = self.full_action.replace(',', ' ').split()
  110. try:
  111. self.action = Action[action]
  112. except KeyError:
  113. raise PolicySyntaxError(filename, lineno,
  114. 'invalid action: {}'.format(action))
  115. #: alternative target, used instead of the one specified by the caller
  116. self.override_target = None
  117. #: alternative user, used instead of vm.default_user
  118. self.override_user = None
  119. #: default target when asking the user for confirmation
  120. self.default_target = None
  121. for param in params:
  122. try:
  123. param_name, value = param.split('=')
  124. except ValueError:
  125. raise PolicySyntaxError(filename, lineno,
  126. 'invalid action parameter syntax: {}'.format(param))
  127. if param_name == 'target':
  128. if self.action == Action.deny:
  129. raise PolicySyntaxError(filename, lineno,
  130. 'target= option not allowed for deny action')
  131. self.override_target = value
  132. elif param_name == 'user':
  133. if self.action == Action.deny:
  134. raise PolicySyntaxError(filename, lineno,
  135. 'user= option not allowed for deny action')
  136. self.override_user = value
  137. elif param_name == 'default_target':
  138. if self.action != Action.ask:
  139. raise PolicySyntaxError(filename, lineno,
  140. 'default_target= option allowed only for ask action')
  141. self.default_target = value
  142. else:
  143. raise PolicySyntaxError(filename, lineno,
  144. 'invalid option {} for {} action'.format(param, action))
  145. # verify special values
  146. if self.source.startswith('$'):
  147. if not verify_special_value(self.source, False):
  148. raise PolicySyntaxError(filename, lineno,
  149. 'invalid source specification: {}'.format(self.source))
  150. if self.target.startswith('$'):
  151. if not verify_special_value(self.target, True):
  152. raise PolicySyntaxError(filename, lineno,
  153. 'invalid target specification: {}'.format(self.target))
  154. if self.target == '$default' \
  155. and self.action == Action.allow \
  156. and self.override_target is None:
  157. raise PolicySyntaxError(filename, lineno,
  158. 'allow action for $default rule must specify target= option')
  159. if self.override_target is not None:
  160. if self.override_target.startswith('$') and \
  161. not self.override_target.startswith('$dispvm') and \
  162. self.override_target != '$adminvm':
  163. raise PolicySyntaxError(filename, lineno,
  164. 'target= option needs to name specific target')
  165. @staticmethod
  166. def is_match_single(system_info, policy_value, value):
  167. '''
  168. Evaluate if a single value (VM name or '$default') matches policy
  169. specification
  170. :param system_info: information about the system
  171. :param policy_value: value from qrexec policy (either self.source or
  172. self.target)
  173. :param value: value to be compared (source or target)
  174. :return: True or False
  175. '''
  176. # pylint: disable=too-many-return-statements
  177. # not specified target matches only with $default and $anyvm policy
  178. # entry
  179. if value == '$default' or value == '':
  180. return policy_value in ('$default', '$anyvm')
  181. # if specific target used, check if it's valid
  182. # this function (is_match_single) is also used for checking call source
  183. # values, but this isn't a problem, because it will always be a
  184. # domain name (not $dispvm or such) - this is guaranteed by a nature
  185. # of qrexec call
  186. if not verify_target_value(system_info, value):
  187. return False
  188. # handle $adminvm keyword
  189. if policy_value == 'dom0':
  190. # TODO: log a warning in Qubes 4.1
  191. policy_value = '$adminvm'
  192. if value == 'dom0':
  193. value = '$adminvm'
  194. # allow any _valid_, non-dom0 target
  195. if policy_value == '$anyvm':
  196. return value != '$adminvm'
  197. # exact match, including $dispvm* and $adminvm
  198. if value == policy_value:
  199. return True
  200. # if $dispvm* not matched above, reject it; missing ':' is
  201. # intentional - handle both '$dispvm' and '$dispvm:xxx'
  202. if value.startswith('$dispvm'):
  203. return False
  204. # require $adminvm to be matched explicitly (not through $tag or $type)
  205. # - if not matched already, reject it
  206. if value == '$adminvm':
  207. return False
  208. # at this point, value name a specific target
  209. domain_info = system_info['domains'][value]
  210. if policy_value.startswith('$tag:'):
  211. tag = policy_value.split(':', 1)[1]
  212. return tag in domain_info['tags']
  213. if policy_value.startswith('$type:'):
  214. type_ = policy_value.split(':', 1)[1]
  215. return type_ == domain_info['type']
  216. return False
  217. def is_match(self, system_info, source, target):
  218. '''
  219. Check if given (source, target) matches this policy line.
  220. :param system_info: information about the system - available VMs,
  221. their types, labels, tags etc. as returned by
  222. :py:func:`app_to_system_info`
  223. :param source: name of the source VM
  224. :param target: name of the target VM, or None if not specified
  225. :return: True or False
  226. '''
  227. if not self.is_match_single(system_info, self.source, source):
  228. return False
  229. if not self.is_match_single(system_info, self.target, target):
  230. return False
  231. return True
  232. def expand_target(self, system_info):
  233. '''
  234. Return domains matching target of this policy line
  235. :param system_info: information about the system
  236. :return: matching domains
  237. '''
  238. if self.target.startswith('$tag:'):
  239. tag = self.target.split(':', 1)[1]
  240. for name, domain in system_info['domains'].items():
  241. if tag in domain['tags']:
  242. yield name
  243. elif self.target.startswith('$type:'):
  244. type_ = self.target.split(':', 1)[1]
  245. for name, domain in system_info['domains'].items():
  246. if type_ == domain['type']:
  247. yield name
  248. elif self.target == '$anyvm':
  249. for name, domain in system_info['domains'].items():
  250. if name != 'dom0':
  251. yield name
  252. if domain['dispvm_allowed']:
  253. yield '$dispvm:' + name
  254. yield '$dispvm'
  255. elif self.target.startswith('$dispvm:'):
  256. dispvm_base = self.target.split(':', 1)[1]
  257. try:
  258. if system_info['domains'][dispvm_base]['dispvm_allowed']:
  259. yield self.target
  260. except KeyError:
  261. # TODO log a warning?
  262. pass
  263. elif self.target == '$adminvm':
  264. yield self.target
  265. elif self.target == '$dispvm':
  266. yield self.target
  267. else:
  268. if self.target in system_info['domains']:
  269. yield self.target
  270. def expand_override_target(self, system_info, source):
  271. '''
  272. Replace '$dispvm' with specific '$dispvm:...' value, based on qrexec
  273. call source.
  274. :param system_info: System information
  275. :param source: Source domain name
  276. :return: :py:attr:`override_target` with '$dispvm' substituted
  277. '''
  278. if self.override_target == '$dispvm':
  279. if system_info['domains'][source]['default_dispvm'] is None:
  280. return None
  281. return '$dispvm:' + system_info['domains'][source]['default_dispvm']
  282. else:
  283. return self.override_target
  284. class PolicyAction(object):
  285. ''' Object representing positive policy evaluation result -
  286. either ask or allow action '''
  287. def __init__(self, service, source, target, rule, original_target,
  288. targets_for_ask=None):
  289. #: service name
  290. self.service = service
  291. #: calling domain
  292. self.source = source
  293. #: target domain the service should be connected to, None if
  294. # not chosen yet
  295. if targets_for_ask is None or target in targets_for_ask:
  296. self.target = target
  297. else:
  298. # TODO: log a warning?
  299. self.target = None
  300. #: original target specified by the caller
  301. self.original_target = original_target
  302. #: targets for the user to choose from
  303. self.targets_for_ask = targets_for_ask
  304. #: policy rule from which this action is derived
  305. self.rule = rule
  306. if rule.action == Action.deny:
  307. # this should be really rejected by Policy.eval()
  308. raise AccessDenied(
  309. 'denied by policy {}:{}'.format(rule.filename, rule.lineno))
  310. elif rule.action == Action.ask:
  311. assert targets_for_ask is not None
  312. elif rule.action == Action.allow:
  313. assert targets_for_ask is None
  314. assert target is not None
  315. self.action = rule.action
  316. def handle_user_response(self, response, target=None):
  317. '''
  318. Handle user response for the 'ask' action
  319. :param response: whether the call was allowed or denied (bool)
  320. :param target: target chosen by the user (if reponse==True)
  321. :return: None
  322. '''
  323. assert self.action == Action.ask
  324. assert self.target is None
  325. if response:
  326. assert target in self.targets_for_ask
  327. self.target = target
  328. self.action = Action.allow
  329. else:
  330. self.action = Action.deny
  331. raise AccessDenied(
  332. 'denied by the user {}:{}'.format(self.rule.filename,
  333. self.rule.lineno))
  334. def execute(self, caller_ident):
  335. ''' Execute allowed service call
  336. :param caller_ident: Service caller ident
  337. (`process_ident,source_name, source_id`)
  338. '''
  339. assert self.action == Action.allow
  340. assert self.target is not None
  341. if self.target == '$adminvm':
  342. self.target = 'dom0'
  343. if self.target == 'dom0':
  344. cmd = '{multiplexer} {service} {source} {original_target}'.format(
  345. multiplexer=QUBES_RPC_MULTIPLEXER_PATH,
  346. service=self.service,
  347. source=self.source,
  348. original_target=self.original_target)
  349. else:
  350. cmd = '{user}:QUBESRPC {service} {source}'.format(
  351. user=(self.rule.override_user or 'DEFAULT'),
  352. service=self.service,
  353. source=self.source)
  354. if self.target.startswith('$dispvm:'):
  355. target = self.spawn_dispvm()
  356. dispvm = True
  357. else:
  358. target = self.target
  359. dispvm = False
  360. self.ensure_target_running()
  361. qrexec_opts = ['-d', target, '-c', caller_ident]
  362. if dispvm:
  363. qrexec_opts.append('-W')
  364. try:
  365. subprocess.call([QREXEC_CLIENT] + qrexec_opts + [cmd])
  366. finally:
  367. if dispvm:
  368. self.cleanup_dispvm(target)
  369. def spawn_dispvm(self):
  370. '''
  371. Create and start Disposable VM based on AppVM specified in
  372. :py:attr:`target`
  373. :return: name of new Disposable VM
  374. '''
  375. base_appvm = self.target.split(':', 1)[1]
  376. dispvm_name = qubesd_call(base_appvm, 'internal.vm.Create.DispVM')
  377. dispvm_name = dispvm_name.decode('ascii')
  378. qubesd_call(dispvm_name, 'internal.vm.Start')
  379. return dispvm_name
  380. def ensure_target_running(self):
  381. '''
  382. Start domain if not running already
  383. :return: None
  384. '''
  385. try:
  386. qubesd_call(self.target, 'internal.vm.Start')
  387. except QubesMgmtException as e:
  388. if e.exc_type == 'QubesVMNotHaltedError':
  389. pass
  390. else:
  391. raise
  392. @staticmethod
  393. def cleanup_dispvm(dispvm):
  394. '''
  395. Kill and remove Disposable VM
  396. :param dispvm: name of Disposable VM
  397. :return: None
  398. '''
  399. qubesd_call(dispvm, 'internal.vm.CleanupDispVM')
  400. class Policy(object):
  401. ''' Full policy for a given service
  402. Usage:
  403. >>> system_info = get_system_info()
  404. >>> policy = Policy('some-service')
  405. >>> action = policy.evaluate(system_info, 'source-name', 'target-name')
  406. >>> if action.action == Action.ask:
  407. >>> # ... ask the user, see action.targets_for_ask ...
  408. >>> action.handle_user_response(response, target_chosen_by_user)
  409. >>> action.execute('process-ident')
  410. '''
  411. def __init__(self, service, policy_dir=POLICY_DIR):
  412. policy_file = os.path.join(policy_dir, service)
  413. if not os.path.exists(policy_file):
  414. # fallback to policy without specific argument set (if any)
  415. policy_file = os.path.join(policy_dir, service.split('+')[0])
  416. #: policy storage directory
  417. self.policy_dir = policy_dir
  418. #: service name
  419. self.service = service
  420. #: list of PolicyLine objects
  421. self.policy_rules = []
  422. try:
  423. self.load_policy_file(policy_file)
  424. except OSError as e:
  425. raise AccessDenied(
  426. 'failed to load {} file: {!s}'.format(e.filename, e))
  427. def load_policy_file(self, path):
  428. ''' Load policy file and append rules to :py:attr:`policy_rules`
  429. :param path: file to load
  430. '''
  431. with open(path) as policy_file:
  432. for lineno, line in zip(itertools.count(start=1),
  433. policy_file.readlines()):
  434. line = line.strip()
  435. if not line:
  436. # skip empty lines
  437. continue
  438. if line[0] == '#':
  439. # skip comments
  440. continue
  441. if line.startswith('$include:'):
  442. include_path = line.split(':', 1)[1]
  443. # os.path.join will leave include_path unchanged if it's
  444. # already absolute
  445. include_path = os.path.join(self.policy_dir, include_path)
  446. self.load_policy_file(include_path)
  447. else:
  448. self.policy_rules.append(PolicyRule(line, path, lineno))
  449. def find_matching_rule(self, system_info, source, target):
  450. ''' Find the first rule matching given arguments '''
  451. for rule in self.policy_rules:
  452. if rule.is_match(system_info, source, target):
  453. return rule
  454. raise AccessDenied('no matching rule found')
  455. def collect_targets_for_ask(self, system_info, source):
  456. ''' Collect targets the user can choose from in 'ask' action
  457. Word 'targets' is used intentionally instead of 'domains', because it
  458. can also contains $dispvm like keywords.
  459. '''
  460. targets = set()
  461. # iterate over rules in reversed order to easier handle 'deny'
  462. # actions - simply remove matching domains from allowed set
  463. for rule in reversed(self.policy_rules):
  464. if rule.is_match_single(system_info, rule.source, source):
  465. if rule.action == Action.deny:
  466. targets -= set(rule.expand_target(system_info))
  467. else:
  468. if rule.override_target is not None:
  469. override_target = rule.expand_override_target(
  470. system_info, source)
  471. if verify_target_value(system_info, override_target):
  472. targets.add(rule.override_target)
  473. else:
  474. targets.update(rule.expand_target(system_info))
  475. # expand default DispVM
  476. if '$dispvm' in targets:
  477. targets.remove('$dispvm')
  478. if system_info['domains'][source]['default_dispvm'] is not None:
  479. dispvm = '$dispvm:' + \
  480. system_info['domains'][source]['default_dispvm']
  481. if verify_target_value(system_info, dispvm):
  482. targets.add(dispvm)
  483. return targets
  484. def evaluate(self, system_info, source, target):
  485. ''' Evaluate policy
  486. :raise AccessDenied: when action should be denied unconditionally
  487. :return tuple(rule, considered_targets) - where considered targets is a
  488. list of possible targets for 'ask' action (rule.action == Action.ask)
  489. '''
  490. rule = self.find_matching_rule(system_info, source, target)
  491. if rule.action == Action.deny:
  492. raise AccessDenied(
  493. 'denied by policy {}:{}'.format(rule.filename, rule.lineno))
  494. if rule.override_target is not None:
  495. override_target = rule.expand_override_target(system_info, source)
  496. if not verify_target_value(system_info, override_target):
  497. raise AccessDenied('invalid target= value in {}:{}'.format(
  498. rule.filename, rule.lineno))
  499. actual_target = override_target
  500. else:
  501. actual_target = target
  502. if rule.action == Action.ask:
  503. if rule.override_target is not None:
  504. targets = [actual_target]
  505. else:
  506. targets = list(
  507. self.collect_targets_for_ask(system_info, source))
  508. if not targets:
  509. raise AccessDenied(
  510. 'policy define \'ask\' action at {}:{} but no target is '
  511. 'available to choose from'.format(
  512. rule.filename, rule.lineno))
  513. return PolicyAction(self.service, source, rule.default_target,
  514. rule, target, targets)
  515. elif rule.action == Action.allow:
  516. if actual_target == '$default':
  517. raise AccessDenied(
  518. 'policy define \'allow\' action at {}:{} but no target is '
  519. 'specified by caller or policy'.format(
  520. rule.filename, rule.lineno))
  521. if actual_target == '$dispvm':
  522. if system_info['domains'][source]['default_dispvm'] is None:
  523. raise AccessDenied(
  524. 'policy define \'allow\' action to $dispvm at {}:{} '
  525. 'but no DispVM base is set for this VM'.format(
  526. rule.filename, rule.lineno))
  527. actual_target = '$dispvm:' + \
  528. system_info['domains'][source]['default_dispvm']
  529. return PolicyAction(self.service, source,
  530. actual_target, rule, target)
  531. else:
  532. # should be unreachable
  533. raise AccessDenied(
  534. 'invalid action?! {}:{}'.format(rule.filename, rule.lineno))
  535. class QubesMgmtException(Exception):
  536. ''' Exception returned by qubesd '''
  537. def __init__(self, exc_type):
  538. super(QubesMgmtException, self).__init__()
  539. self.exc_type = exc_type
  540. def qubesd_call(dest, method, arg=None, payload=None):
  541. try:
  542. client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  543. client_socket.connect(QUBESD_INTERNAL_SOCK)
  544. except IOError:
  545. # TODO:
  546. raise
  547. # src, method, dest, arg
  548. for call_arg in ('dom0', method, dest, arg):
  549. if call_arg is not None:
  550. client_socket.sendall(call_arg.encode('ascii'))
  551. client_socket.sendall(b'\0')
  552. if payload is not None:
  553. client_socket.sendall(payload)
  554. client_socket.shutdown(socket.SHUT_WR)
  555. return_data = client_socket.makefile('rb').read()
  556. if return_data.startswith(b'0\x00'):
  557. return return_data[2:]
  558. elif return_data.startswith(b'2\x00'):
  559. (_, exc_type, _traceback, _format_string, _args) = \
  560. return_data.split(b'\x00', 4)
  561. raise QubesMgmtException(exc_type.decode('ascii'))
  562. else:
  563. raise AssertionError(
  564. 'invalid qubesd response: {!r}'.format(return_data))
  565. def get_system_info():
  566. ''' Get system information
  567. This retrieve information necessary to process qrexec policy. Returned
  568. data is nested dict structure with this structure:
  569. - domains:
  570. - `<domain name>`:
  571. - tags: list of tags
  572. - type: domain type
  573. - dispvm_allowed: should DispVM based on this VM be allowed
  574. - default_dispvm: name of default AppVM for DispVMs started from here
  575. '''
  576. system_info = qubesd_call('dom0', 'internal.GetSystemInfo')
  577. return json.loads(system_info.decode('utf-8'))