diff options
author | Andrew Leeming <andrew.leeming@codethink.co.uk> | 2016-09-09 14:14:38 +0100 |
---|---|---|
committer | Andrew Leeming <andrew.leeming@codethink.co.uk> | 2016-10-13 16:32:30 +0100 |
commit | efad18c41aea63c25826c7f0e0faa7644c3a9211 (patch) | |
tree | d56a2560b88b3fe9b272828dd676c89fd8a1fc91 | |
parent | e3cb6f63a891941a7bf13b764bc65abf47db9227 (diff) | |
download | sandboxlib-efad18c41aea63c25826c7f0e0faa7644c3a9211.tar.gz |
Rebasing bubblewrap branch with master.
Adds in support for the bubblewrap sandbox. Comes with a logger that
logs both to stdout (WARN or higher) and to a log file (everything)
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | .gitlab-ci.yml | 11 | ||||
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rwxr-xr-x | run-sandbox | 2 | ||||
-rw-r--r-- | sandboxlib/__init__.py | 3 | ||||
-rw-r--r-- | sandboxlib/bubblewrap.py | 294 | ||||
-rw-r--r-- | sandboxlib/chroot.py | 4 | ||||
-rw-r--r-- | tests/test_all.py | 2 |
8 files changed, 317 insertions, 5 deletions
@@ -1,2 +1,5 @@ +.cache/* +.eggs/* +.tox/* + *.pyc -.*/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..7e113b7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,11 @@ +before_script: + - echo "deb http://ftp.uk.debian.org/debian/ sid main" >> /etc/apt/sources.list + - apt-get update -qq && apt-get install -y -qq linux-user-chroot bubblewrap + - apt-get install -y -qq python-dev python-pip + - apt-get install -y -qq python2.7 python3.3 python3.4 + - pip install tox + + +testing: + script: + - tox
\ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..15ff2b5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include sandboxlib/*.conf diff --git a/run-sandbox b/run-sandbox index aa7c713..06582df 100755 --- a/run-sandbox +++ b/run-sandbox @@ -47,7 +47,7 @@ def parse_args(): help="current working directory for COMMAND") parser.add_argument( '--executor', '-e', - choices=['chroot', 'linux_user_chroot', 'linux-user-chroot'], + choices=['chroot', 'linux_user_chroot', 'linux-user-chroot', 'bubblewrap'], type=str, default='chroot', help="which sandboxing backend to use") diff --git a/sandboxlib/__init__.py b/sandboxlib/__init__.py index 1418225..cdb2fb3 100644 --- a/sandboxlib/__init__.py +++ b/sandboxlib/__init__.py @@ -246,7 +246,7 @@ def _run_command(argv, stdout, stderr, cwd=None, env=None): dev_null = None log = logging.getLogger('sandboxlib') - log.debug('Running: %s', argv_to_string(argv)) + log.debug('Running: {}'.format(argv)) try: process = subprocess.Popen( @@ -278,6 +278,7 @@ def _run_command(argv, stdout, stderr, cwd=None, env=None): # Executors import sandboxlib.chroot import sandboxlib.linux_user_chroot +import sandboxlib.bubblewrap import sandboxlib.load import sandboxlib.utils diff --git a/sandboxlib/bubblewrap.py b/sandboxlib/bubblewrap.py new file mode 100644 index 0000000..a0c005e --- /dev/null +++ b/sandboxlib/bubblewrap.py @@ -0,0 +1,294 @@ +# Copyright (C) 2016 Codethink Limited +# +# 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; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Execute command in a sandbox, using 'bubblewrap'. + +This implements an API defined in sandboxlib/__init__.py. +""" + + +import os +import logging +import logging.config +import sandboxlib + +bwrap_abspath=os.path.dirname(__file__) +logging.config.fileConfig(os.path.join(bwrap_abspath, 'logger.conf')) + +log = logging.getLogger("sandboxlib") +# FIXME copied over from `linux_user_chroot`, not sure on what is expected here. +CAPABILITIES = { + 'network': ['isolated', 'undefined'], + 'mounts': ['isolated', 'undefined'], + 'filesystem_writable_paths': ['all', 'any'], +} + + +def degrade_config_for_capabilities(in_config, warn=True): + # This backend has the most features, right now! + log.debug("Nothing to degrade in bwrap config") + return in_config + + +def run_sandbox(command, cwd=None, env=None, + filesystem_root='/', filesystem_writable_paths='all', + mounts='undefined', extra_mounts=None, + network='undefined', + stderr=sandboxlib.CAPTURE, stdout=sandboxlib.CAPTURE): + """Run 'command' in a sandboxed environment. + + Parameters: + - command: the command to run. Pass a list of parameters rather than + using spaces to separate them, e.g. ['echo', '"Hello world"']. + - cwd: the working directory of 'command', relative to 'rootfs_path'. + Defaults to '/' if "rootfs_path" is specified, and the current + directory of the calling process otherwise. + - env: environment variables to set + - filesystem_root: the path to the root of the sandbox. Defaults to '/', + which doesn't isolate the command from the host filesystem at all. + - filesystem_writable_paths: defaults to 'all', which allows the command + to write to anywhere under 'filesystem_root' that the user of the + calling process could write to. Backends may accept a list of paths + instead of 'all', and will prevent writes to any files not under a + path in that whitelist. If 'none' or an empty list is passed, the + whole file-system will be read-only. The paths should be relative + to filesystem_root. This will processed /after/ extra_mounts are + mounted. + - mounts: configures mount sharing. Defaults to 'undefined', where no + no attempt is made to isolate mounts. Backends may support + 'isolated' as well. + - extra_mounts: a list of locations to mount inside 'rootfs_path', + specified as a list of tuples of (source_path, target_path, type, + options). The 'type' and 'options' should match what would be + specified in /etc/fstab, but a backends may support only a limited + subset of values. The 'target_path' is relative to filesystem_root + and will be created before mounting if it doesn't exist. + - network: configures network sharing. Defaults to 'undefined', where + no attempt is made to either prevent or provide networking + inside the sandbox. Backends may support 'isolated' and/or other + values as well. + - stdout: whether to capture stdout, or redirect stdout to a file handle. + If set to sandboxlib.CAPTURE, the function will return the stdout + data, if not, it will return None for that. If stdout=None, the + data will be discarded -- it will NOT inherit the parent process's + stdout, unlike with subprocess.Popen(). Set 'stdout=sys.stdout' if + you want that. + - stderr: same as stdout + + Returns: + a tuple of (exit code, stdout output, stderr output). + + """ + + log.debug("cmd: {}, cwd: {}, env: {}, filesystem_root: {}, " + "filesystem_writable_paths: {}, mounts: {}, extra_mounts: {}, " + "network: {}, stderr: {}, stdout: {}".format( + command, cwd, env, filesystem_root, filesystem_writable_paths, + mounts, extra_mounts, network, stderr, stdout)) + + if type(command) == str: + command = [command] + + # Bwrap full path + bwrap_command = [bubblewrap_program()] + log.debug("/path/to/bwrap : {}".format(bwrap_command)) + + # Add in the root filesystem stuff first + # rootfs is mounted as RW initially so that further mounts can be placed on top + # If a RO root is required, after all other mounts are complete, root is + # remounted as RO + bwrap_command += ["--bind", filesystem_root, "/"] + + bwrap_command += process_network_config(network) + + if cwd is not None: + log.debug("Setting cwd to '{}'".format(cwd)) + bwrap_command.extend(['--chdir', cwd]) + + # do pre checks on mounts + extra_mounts = sandboxlib.validate_extra_mounts(extra_mounts) + create_mount_points_if_missing(filesystem_root, extra_mounts) + + # Handles the ro and rw mounts + bwrap_command += process_mounts(filesystem_root, extra_mounts, + filesystem_writable_paths) + + + + argv = bwrap_command + command + log.info("bubblewrap.run_command({}, stdou:{}, stderr:{}, env:{})" + .format(" ".join(argv), stdout, stderr, env)) + + exit, out, err = sandboxlib._run_command(argv, stdout, stderr, env=env) + + return exit, out, err + + +def run_sandbox_with_redirection(command, **sandbox_config): + """Start a subprocess in a sandbox, redirecting stderr and/or stdout. + + The sandbox_config arguments are the same as the run_command() function. + + This returns just the exit code, because if stdout or stderr are redirected + those values will be None in any case. + + """ + + exit, out, err = run_sandbox(command, **sandbox_config) + # out and err will be None + return exit + +# Non API methods below + + +def bubblewrap_program(): + # Raises sandboxlib.ProgramNotFound if not found. + return sandboxlib.utils.find_program('bwrap') + + +def create_mount_points_if_missing(filesystem_root, mount_info_list): + for source, mount_point, mount_type, mount_options in mount_info_list: + # Strip the preceeding '/' from mount_point, because it'll break + # os.path.join(). + mount_point_no_slash = os.path.abspath(mount_point).lstrip('/') + + path = os.path.join(filesystem_root, mount_point_no_slash) + if not os.path.exists(path): + log.debug("making empty '{}' directory in '{}'". + format(mount_point_no_slash, filesystem_root)) + + os.makedirs(path) + + +def process_network_config(network): + sandboxlib.utils.check_parameter('network', network, CAPABILITIES['network']) + + if network == 'isolated': + # This is all we need to do for network isolation + network_args = ['--unshare-net'] + else: + network_args = [] + + return network_args + + +def process_mounts(fs_root, mounts, writable_paths): + """ + filesystem_writable_paths: defaults to 'all', which allows the command + to write to anywhere under 'filesystem_root' that the user of the + calling process could write to. Backends may accept a list of paths + instead of 'all', and will prevent writes to any files not under a + path in that whitelist. If 'none' or an empty list is passed, the + whole file-system will be read-only. The paths should be relative + to filesystem_root. This will processed /after/ extra_mounts are + mounted. + extra_mounts: a list of locations to mount inside 'rootfs_path', + specified as a list of tuples of (source_path, target_path, type, + options). The 'type' and 'options' should match what would be + specified in /etc/fstab, but a backends may support only a limited + subset of values. The 'target_path' is relative to filesystem_root + and will be created before mounting if it doesn't exist. + """ + + log.debug("process_mounts(fs_root={}, mounts={}, writable_paths={})".format(fs_root, mounts, writable_paths)) + extra_args = [] + fs_dict = {} + + for ex_mnt in mounts: + mnt_src, mnt_target, mnt_type, mnt_options = ex_mnt + # TODO + # How to handle options? Can bwrap do this? + + if mnt_target not in fs_dict.keys(): + fs_dict[mnt_target] = {'src': mnt_src, 'type': mnt_type, 'options': mnt_options} + # already exists. should only upgrade some things + else: + # Use current files/folders from host + if fs_dict[mnt_target]['type'] == "tmpfs"\ + and is_mount_writable(mnt_target, writable_paths): + fs_dict[mnt_target]['type'] = None + fs_dict[mnt_target]['src'] = mnt_src + # else ?? + + # This needs to be done to turn tmpfs mounts into normal binded mounts + # when we are expecting data to already be inside the mount, else an + # empty mount is made. This breaks the test_mount_point_writable test + if type(writable_paths) is list: + for wr_mnt in writable_paths: + if wr_mnt not in fs_dict.keys(): + fs_dict[wr_mnt] = {} + + # fs_dict[wr_mnt]['options'] = None + # fs_dict[wr_mnt]['type'] = None + fs_dict[wr_mnt]['src'] = os.path.join(fs_root, wr_mnt.strip("/")) + + for k, v in fs_dict.items(): + mnt_src = v['src'] + mnt_target = k + mnt_type = v.get('type', None) + mnt_options = v.get('options', None) + + log.debug("mount ({},{},{},{})".format(mnt_src, mnt_target, mnt_type, mnt_options)) + + if mnt_options is "bind": + # For legacy reasons, 'bind' is set as an option for some reason, instead + # of listed in filesystem_writable_paths. We will append the path here anyway + writable_paths.append(mnt_target) + + if mnt_type == "proc": + extra_args.extend(['--proc', mnt_target]) + elif mnt_type == "tmpfs": + extra_args.extend(['--tmpfs', mnt_target]) + elif mnt_target == "/dev": + # TODO dev can be mounted in two ways in bwrap + # First is using the --dev option that mounts host /dev + # Second is using --dev-bind for moutning a [src] to [dest] + # while allowing device access. + # + # How do we diferentiate the two? + + # extra_args.extend(['--dev', mnt_target]) + + # Experiment to see if --dev-bind fixes permissions errors + log.info("Using --dev-bind instead") + extra_args.extend(['--dev-bind', mnt_src, mnt_target]) + else: + if is_mount_writable(mnt_target, writable_paths): + extra_args.extend(['--bind', mnt_src, mnt_target]) + else: + extra_args.extend(['--ro-bind', mnt_src, mnt_target]) + + # Final remount if root is read-only + if not is_mount_writable("/", writable_paths): + log.debug("/ is set as RO") + extra_args += ["--remount-ro", "/"] + + return extra_args + + +def is_mount_writable(mnt, writable_paths): + # Deal with the catch all statements first + if writable_paths == 'all': + return True + elif writable_paths in ['none', []]: + return False + elif type(writable_paths) is list: + return mnt in writable_paths + + # Default/unknown case + else: + log.warn("Unknown bubblewrap.writable_path arg type given : {} type({})" + .format(writable_paths, type(writable_paths))) + + return False diff --git a/sandboxlib/chroot.py b/sandboxlib/chroot.py index 9f5ed23..e4255b1 100644 --- a/sandboxlib/chroot.py +++ b/sandboxlib/chroot.py @@ -40,6 +40,7 @@ import os import subprocess import warnings import traceback +import sys import sandboxlib @@ -114,10 +115,11 @@ def mount(source, path, mount_type, mount_options): argv.extend(('-t', mount_type)) if not is_none(mount_options): argv.extend(('-o', mount_options)) + #If this is left empty, mount looks in fstab which will fail if not is_none(source): argv.append(source) else: - argv.append(mount_type) + argv.append("none") argv.append(path) exit, out, err = sandboxlib._run_command( diff --git a/tests/test_all.py b/tests/test_all.py index eed6d26..98f9e6a 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -26,7 +26,7 @@ from programs import ( session_tmpdir) -@pytest.fixture(params=['chroot', 'linux_user_chroot']) +@pytest.fixture(params=['chroot', 'linux_user_chroot', 'bubblewrap']) def sandboxlib_executor(request): executor = getattr(sandboxlib, request.param) |