diff --git a/Documentation/qrexec-policy-daemon.rst b/Documentation/qrexec-policy-daemon.rst new file mode 100644 index 00000000..db79156d --- /dev/null +++ b/Documentation/qrexec-policy-daemon.rst @@ -0,0 +1,33 @@ +Qubes Policy Request Daemon +=========================== + +Protocol +^^^^^^^^ + +Request +------- + +Newline-separated: + +- domain_id= +- source= +- intended_target= +- service_and_arg= +- process_ident= + +Optional arguments: + +- assume_yes_for_ask=yes +- just_evaluate=yes + + +Response +-------- + +`result=allow/deny` + +Any possible extensions may be placed on next lines. +All responses that do not start with `result=allow` or `result=deny` are +incorrect and will be rejected. + +End of response and request is always an empty line. \ No newline at end of file diff --git a/qrexec/__init__.py b/qrexec/__init__.py index cc48e46e..83e7d1c0 100644 --- a/qrexec/__init__.py +++ b/qrexec/__init__.py @@ -52,6 +52,7 @@ QUBESD_SOCK = '/var/run/qubesd.sock' POLICYPATH = pathlib.Path('/etc/qubes/policy.d') +POLICYSOCKET = pathlib.Path('/var/run/qubes/policy.sock') INCLUDEPATH = POLICYPATH / 'include' POLICYSUFFIX = '.policy' POLICYPATH_OLD = pathlib.Path('/etc/qubes-rpc/policy') diff --git a/qrexec/tests/qrexec_policy_daemon.py b/qrexec/tests/qrexec_policy_daemon.py new file mode 100644 index 00000000..38c07033 --- /dev/null +++ b/qrexec/tests/qrexec_policy_daemon.py @@ -0,0 +1,178 @@ +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2019 Marta Marczykowska-Górecka +# +# +# 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. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . +# + +import asyncio +from contextlib import suppress + +import pytest +from unittest.mock import Mock +import functools + +import unittest +import unittest.mock + +from ..tools import qrexec_policy_daemon + +class TestPolicyDaemon: + @pytest.fixture + def mock_request(self, monkeypatch): + mock_request = Mock() + monkeypatch.setattr('qrexec.tools.qrexec_policy_daemon.handle_request', + mock_request) + return mock_request + + @pytest.fixture + async def async_server(self, tmp_path, request): + log = unittest.mock.Mock() + server = await asyncio.start_unix_server( + functools.partial(qrexec_policy_daemon.handle_client_connection, + log, "path"), + path=str(tmp_path / "socket.d")) + + yield server + + server.close() + + async def send_data(self, server, path, data): + reader, writer = await asyncio.open_unix_connection( + str(path / "socket.d")) + writer.write(data) + await writer.drain() + + await reader.read() + + writer.close() + + server.close() + + await server.wait_closed() + + + @pytest.mark.asyncio + async def test_simple_request(self, mock_request, async_server, tmp_path): + + data = b'domain_id=a\n' \ + b'source=b\n' \ + b'intended_target=c\n' \ + b'service_and_arg=d\n' \ + b'process_ident=1 9\n\n' + + await self.send_data(async_server, tmp_path, data) + + mock_request.assert_called_once_with( + domain_id='a', source='b', intended_target='c', + service_and_arg='d', process_ident='1 9', log=unittest.mock.ANY, + path="path") + + @pytest.mark.asyncio + async def test_complex_request(self, mock_request, async_server, tmp_path): + + data = b'domain_id=a\n' \ + b'source=b\n' \ + b'intended_target=c\n' \ + b'service_and_arg=d\n' \ + b'process_ident=9\n' \ + b'assume_yes_for_ask=yes\n' \ + b'just_evaluate=yes\n\n' + + await self.send_data(async_server, tmp_path, data) + + mock_request.assert_called_once_with( + domain_id='a', source='b', intended_target='c', + service_and_arg='d', process_ident='9', log=unittest.mock.ANY, + assume_yes_for_ask=True, just_evaluate=True, path="path") + + @pytest.mark.asyncio + async def test_complex_request2(self, mock_request, async_server, tmp_path): + + data = b'domain_id=a\n' \ + b'source=b\n' \ + b'intended_target=c\n' \ + b'service_and_arg=d\n' \ + b'process_ident=9\n' \ + b'assume_yes_for_ask=no\n' \ + b'just_evaluate=no\n\n' + + await self.send_data(async_server, tmp_path, data) + + mock_request.assert_called_once_with( + domain_id='a', source='b', intended_target='c', + service_and_arg='d', process_ident='9', log=unittest.mock.ANY, + assume_yes_for_ask=False, just_evaluate=False, path="path") + + @pytest.mark.asyncio + async def test_unfinished_request( + self, mock_request, async_server, tmp_path): + + data = b'unfinished' + + task = self.send_data(async_server, tmp_path, data) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(task, timeout=2) + + for task in asyncio.Task.all_tasks(): + task.cancel() + + with suppress(asyncio.CancelledError): + await asyncio.sleep(1) + + mock_request.assert_not_called() + + @pytest.mark.asyncio + async def test_too_short_request( + self, mock_request, async_server, tmp_path): + + data = b'domain_id=None\n\n' + + await self.send_data(async_server, tmp_path, data) + + mock_request.assert_not_called() + + @pytest.mark.asyncio + async def test_duplicate_arg(self, mock_request, async_server, tmp_path): + + data = b'domain_id=a\n' \ + b'source=b\n' \ + b'intended_target=c\n' \ + b'service_and_arg=d\n' \ + b'process_ident=9\n' \ + b'assume_yes_for_ask=no\n' \ + b'just_evaluate=no\n' \ + b'domain_id=a\n\n' + + await self.send_data(async_server, tmp_path, data) + + mock_request.assert_not_called() + + @pytest.mark.asyncio + async def test_wrong_arg(self, mock_request, async_server, tmp_path): + + data = b'domains_id=a\n' \ + b'source=b\n' \ + b'intended_target=c\n' \ + b'service_and_arg=d\n' \ + b'process_ident=9\n' \ + b'assume_yes_for_ask=no\n' \ + b'just_evaluate=no\n\n' + + await self.send_data(async_server, tmp_path, data) + + mock_request.assert_not_called() diff --git a/qrexec/tools/qrexec_policy_daemon.py b/qrexec/tools/qrexec_policy_daemon.py new file mode 100644 index 00000000..de573a06 --- /dev/null +++ b/qrexec/tools/qrexec_policy_daemon.py @@ -0,0 +1,124 @@ +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2019 Marta Marczykowska-Górecka +# +# +# 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. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . +# + +import argparse +import functools +import pathlib +import asyncio +import logging + +from .. import POLICYPATH, POLICYSOCKET + +from .qrexec_policy_exec import handle_request + +argparser = argparse.ArgumentParser(description='Evaluate qrexec policy daemon') + +argparser.add_argument('--policy-path', + type=pathlib.Path, default=POLICYPATH, + help='Use alternative policy path') +argparser.add_argument('--socket-path', + type=pathlib.Path, default=POLICYSOCKET, + help='Use alternative policy socket path') + +REQUIRED_REQUEST_ARGUMENTS = ('domain_id', 'source', 'intended_target', + 'service_and_arg', 'process_ident') + +OPTIONAL_REQUEST_ARGUMENTS = ('assume_yes_for_ask', 'just_evaluate') + +ALLOWED_REQUEST_ARGUMENTS = REQUIRED_REQUEST_ARGUMENTS + \ + OPTIONAL_REQUEST_ARGUMENTS + + +async def handle_client_connection(log, policy_path, reader, writer): + + args = {} + + try: + while True: + line = await reader.readline() + line = line.decode('ascii').rstrip('\n') + + if not line: + break + + argument, value = line.split('=', 1) + if argument in args: + log.error( + 'error parsing policy request: ' + 'duplicate argument {}'.format(argument)) + return + if argument not in ALLOWED_REQUEST_ARGUMENTS: + log.error( + 'error parsing policy request: unknown argument {}'.format( + argument)) + return + + if argument in ('assume_yes_for_ask', 'just_evaluate'): + if value == 'yes': + value = True + elif value == 'no': + value = False + else: + log.error( + 'error parsing policy request: invalid bool value ' + '{} for argument {}'.format(value, argument)) + return + + args[argument] = value + + if not all(arg in args for arg in REQUIRED_REQUEST_ARGUMENTS): + log.error( + 'error parsing policy request: required argument missing') + return + + result = handle_request(**args, log=log, path=policy_path) + + writer.write(b"result=deny\n" if result else b"result=allow\n") + + await writer.drain() + + finally: + writer.close() + + +async def start_serving(args=None): + args = argparser.parse_args(args) + + log = logging.getLogger('policy') + log.setLevel(logging.INFO) + if not log.handlers: + handler = logging.handlers.SysLogHandler(address='/dev/log') + log.addHandler(handler) + + server = await asyncio.start_unix_server( + functools.partial(handle_client_connection, log, args.policy_path), + path=args.socket_path) + + await server.serve_forever() + + +def main(args=None): + # pylint: disable=no-member + # due to travis' limitations we have to use python 3.5 in pylint + asyncio.run(start_serving(args)) + + +if __name__ == '__main__': + main() diff --git a/qrexec/tools/qrexec_policy_exec.py b/qrexec/tools/qrexec_policy_exec.py index e917cffa..06e090d5 100644 --- a/qrexec/tools/qrexec_policy_exec.py +++ b/qrexec/tools/qrexec_policy_exec.py @@ -112,37 +112,51 @@ def prepare_resolution_types(*, just_evaluate, assume_yes_for_ask): def main(args=None): args = argparser.parse_args(args) - # Add source domain information, required by qrexec-client for establishing - # connection - caller_ident = args.process_ident + "," + args.source + "," + args.domain_id log = logging.getLogger('policy') log.setLevel(logging.INFO) if not log.handlers: handler = logging.handlers.SysLogHandler(address='/dev/log') log.addHandler(handler) + + return handle_request( + args.domain_id, + args.source, + args.intended_target, + args.service_and_arg, + args.process_ident, + log, + path=args.path, + just_evaluate=args.just_evaluate, + assume_yes_for_ask=args.assume_yes_for_ask) + +# pylint: disable=too-many-arguments +def handle_request(domain_id, source, intended_target, service_and_arg, + process_ident, log, path=POLICYPATH, just_evaluate=False, + assume_yes_for_ask=False): + # Add source domain information, required by qrexec-client for establishing + # connection + caller_ident = process_ident + "," + source + "," + domain_id log_prefix = 'qrexec: {}: {} -> {}:'.format( - args.service_and_arg, args.source, args.intended_target) + service_and_arg, source, intended_target) try: system_info = utils.get_system_info() except exc.QubesMgmtException as err: log.error('%s error getting system info: %s', log_prefix, err) return 1 - try: - i = args.service_and_arg.index('+') - service, argument = args.service_and_arg[:i], args.service_and_arg[i:] + i = service_and_arg.index('+') + service, argument = service_and_arg[:i], service_and_arg[i:] except ValueError: - service, argument = args.service_and_arg, '+' - + service, argument = service_and_arg, '+' try: - policy = parser.FilePolicy(policy_path=args.path) + policy = parser.FilePolicy(policy_path=path) request = parser.Request( - service, argument, args.source, args.intended_target, + service, argument, source, intended_target, system_info=system_info, **prepare_resolution_types( - just_evaluate=args.just_evaluate, - assume_yes_for_ask=args.assume_yes_for_ask)) + just_evaluate=just_evaluate, + assume_yes_for_ask=assume_yes_for_ask)) resolution = policy.evaluate(request) result = resolution.execute(caller_ident) if result is not None: @@ -157,5 +171,6 @@ def main(args=None): return 1 return 0 + if __name__ == '__main__': sys.exit(main()) diff --git a/rpm_spec/qubes-qrexec.spec.in b/rpm_spec/qubes-qrexec.spec.in index 6a0c4724..c20d9fac 100644 --- a/rpm_spec/qubes-qrexec.spec.in +++ b/rpm_spec/qubes-qrexec.spec.in @@ -97,6 +97,7 @@ rm -f %{name}-%{version} %{_bindir}/qrexec-policy-agent %{_bindir}/qrexec-policy-graph %{_bindir}/qrexec-policy-restore +%{_bindir}/qrexec-policy-daemon %{_bindir}/qubes-policy %{_bindir}/qrexec-policy @@ -129,6 +130,7 @@ rm -f %{name}-%{version} %{python3_sitelib}/qrexec/tools/qubes_policy.py %{python3_sitelib}/qrexec/tools/qrexec_policy_agent.py %{python3_sitelib}/qrexec/tools/qrexec_policy_exec.py +%{python3_sitelib}/qrexec/tools/qrexec_policy_daemon.py %{python3_sitelib}/qrexec/tools/qrexec_policy_graph.py %{python3_sitelib}/qrexec/tools/qrexec_policy_restore.py @@ -141,6 +143,7 @@ rm -f %{name}-%{version} %{python3_sitelib}/qrexec/tests/rpcconfirmation.py %{python3_sitelib}/qrexec/tests/policy_api.py %{python3_sitelib}/qrexec/tests/policy_parser.py +%{python3_sitelib}/qrexec/tests/qrexec_policy_daemon.py %dir %{python3_sitelib}/qrexec/glade %{python3_sitelib}/qrexec/glade/PolicyCreateConfirmationWindow.glade