diff options
author | Paul Sherwood <paul.sherwood@codethink.co.uk> | 2016-10-21 14:51:50 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-10-21 14:51:50 +0100 |
commit | 7e2a551189b5ffb7a0124db63964bdec69ead3e8 (patch) | |
tree | 1647bc6775e8f3dd3a871d30776b8b5972af3bc7 | |
parent | 0ba8e54c70f7aa96c89df9879545397ef118b3b6 (diff) | |
parent | ff7d57ba33c22ab2b20822a225dd7b1f3c991e85 (diff) | |
download | sandboxlib-7e2a551189b5ffb7a0124db63964bdec69ead3e8.tar.gz |
Merge pull request #25 from CodethinkLabs/leeming/bwrap-rebase
Rebasing bubblewrap branch with master.
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | .gitlab-ci.yml | 11 | ||||
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rw-r--r-- | README.rst | 3 | ||||
-rwxr-xr-x | run-sandbox | 2 | ||||
-rw-r--r-- | sandboxlib/__init__.py | 29 | ||||
-rw-r--r-- | sandboxlib/bubblewrap.py | 300 | ||||
-rw-r--r-- | sandboxlib/chroot.py | 4 | ||||
-rw-r--r-- | sandboxlib/linux_user_chroot.py | 3 | ||||
-rw-r--r-- | tests/test_all.py | 2 |
10 files changed, 344 insertions, 16 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 @@ -35,18 +35,17 @@ Current backends - chroot: any POSIX OS, requires 'root' priviliges - linux-user-chroot_: Linux-only, does not require 'root', requires ``linux-user-chroot`` to be installed and setuid root +- bubblewrap: Does not require 'root', requires ``bwrap`` to be installed. Possible future backends ======================== -- Bubblewrap_ - Firejail_ - runC_ - `Security Enhanced Linux`_ (SELinux): see https://danwalsh.livejournal.com/28545.html - systemd-nspawn_ - Warden_ -.. _Bubblewrap: https://github.com/alexlarsson/bubblewrap .. _Firejail: https://github.com/netblue30/firejail/ .. _runC: http://runc.io/ .. _Security Enhanced Linux: http://selinuxproject.org/page/Main_Page 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..47aff9d 100644 --- a/sandboxlib/__init__.py +++ b/sandboxlib/__init__.py @@ -23,12 +23,14 @@ docstrings that describe the different parameters. import logging +import logging.config import os import platform import pipes import subprocess import warnings +logging.config.fileConfig(os.path.join(os.path.dirname(__file__), 'logger.conf')) class ProgramNotFound(Exception): pass @@ -170,13 +172,19 @@ def executor_for_platform(): "value %s." % backend_name) if backend is None and platform.uname()[0] == 'Linux': - log.info("Linux detected, looking for 'linux-user-chroot'.") - try: - program = sandboxlib.linux_user_chroot.linux_user_chroot_program() - log.info("Found %s, choosing 'linux_user_chroot' module.", program) - backend = sandboxlib.linux_user_chroot - except sandboxlib.ProgramNotFound as e: - log.debug("Did not find 'linux-user-chroot': %s", e) + # Not all backends may exist, so try them one by one in order of preference + prefered_backends = ['bubblewrap', 'linux-user-chroot'] + for backend_name in prefered_backends: + + log.info("Linux detected, looking for '{}'.".format(backend_name)) + try: + executor = get_executor(backend_name) + program = executor.get_program() + log.info("Found {}, choosing '{}' module.".format(program,backend_name)) + backend = executor + break + except sandboxlib.ProgramNotFound as e: + log.warn("Did not find '{}': {}".format(backend_name, e)) if backend is None: log.info("Choosing 'chroot' sandbox module.") @@ -187,7 +195,7 @@ def executor_for_platform(): def validate_extra_mounts(extra_mounts): '''Validate and fill in default values for 'extra_mounts' setting.''' - if extra_mounts == None: + if extra_mounts is None: return [] new_extra_mounts = [] @@ -220,7 +228,6 @@ def validate_extra_mounts(extra_mounts): return new_extra_mounts - def argv_to_string(argv): return ' '.join(map(pipes.quote, argv)) @@ -246,7 +253,8 @@ 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: {} ENV: {}'.format(argv,env)) + log.debug(cwd) try: process = subprocess.Popen( @@ -278,6 +286,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..e03b7a0 --- /dev/null +++ b/sandboxlib/bubblewrap.py @@ -0,0 +1,300 @@ +# 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) + + # Set UID and GUI + bwrap_command.extend(['--unshare-user', '--uid', '0', '--gid', '0']) + + argv = bwrap_command + command + log.info("bubblewrap.run_command({}, stdout:{}, 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 + +def get_program(): + return bubblewrap_program() + +# 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? + # Check if we are mounting host root to target + if "/" in fs_dict.keys() and fs_dict['/']['src'] == "/": + log.info("Using --dev to share host dev") + extra_args.extend(['--dev', mnt_target]) + else: + log.info("Using --dev-bind for local dev") + 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/sandboxlib/linux_user_chroot.py b/sandboxlib/linux_user_chroot.py index cab5344..f427b33 100644 --- a/sandboxlib/linux_user_chroot.py +++ b/sandboxlib/linux_user_chroot.py @@ -279,6 +279,9 @@ def create_mount_points_if_missing(filesystem_root, mount_info_list): os.makedirs(path) +def get_program(): + return linux_user_chroot_program() + def linux_user_chroot_program(): # Raises sandboxlib.ProgramNotFound if not found. return sandboxlib.utils.find_program('linux-user-chroot') 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) |