Commit 4a9e7c99 by J. Cliff Dyer

Added Jail and Language abstractions.

Jails provide a broader configuration for a given command.  Language
provides programming-language configuration, so multiple jails can exist
for a given language without duplicating configuration.

* jail_code and safe_exec now live on the the Jail object
* codejail does not autoconfigure an insecure codejail at import time
* safe_exec can now be run for python3 codejails
parent 1903bc9f
...@@ -6,3 +6,4 @@ James Tauber <jtauber@jtauber.com> ...@@ -6,3 +6,4 @@ James Tauber <jtauber@jtauber.com>
Piotr Mitros <pmitros@edx.org> Piotr Mitros <pmitros@edx.org>
Feanil Patel <feanil@edx.org> Feanil Patel <feanil@edx.org>
Dave St.Germain <dstgermain@edx.org> Dave St.Germain <dstgermain@edx.org>
Cliff Dyer <cdyer@edx.org>
CodeJail CodeJail
Copyright 2013-2015, edX Copyright 2013-2016, edX
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this work except in compliance with the License. you may not use this work except in compliance with the License.
......
...@@ -61,10 +61,7 @@ To secure Python execution, you'll be creating a new virtualenv. This means ...@@ -61,10 +61,7 @@ To secure Python execution, you'll be creating a new virtualenv. This means
you'll have two: the main virtualenv for your project, and the new one for you'll have two: the main virtualenv for your project, and the new one for
sandboxed Python code. sandboxed Python code.
Choose a place for the new virtualenv, call it **<SANDENV>**. It will be Choose a place for the new virtualenv, call it **<SANDENV>**.
automatically detected and used if you put it right alongside your existing
virtualenv, but with `-sandbox` appended. So if your existing virtualenv is in
`/home/chris/ve/myproj`, make **<SANDENV>** be `/home/chris/ve/myproj-sandbox`.
The user running the LMS is **<SANDBOX_CALLER>**, for example, you on The user running the LMS is **<SANDBOX_CALLER>**, for example, you on
your dev machine, or `www-data` on a server. your dev machine, or `www-data` on a server.
......
"""
Primary codejail exports.
import codejail
codejail.configure('python78', '/path/to/python3', user='root', lang=codejail.python3)
try:
jail = codejail.get_codejail('python78')
except JailError:
raise
try:
jail.safe_exec("print('foo')", {'print': print}, slug='hotcode')
except codejail.CodeJailException:
# raises a JailError if the jail can't handle safe_exec.
# raises a SafeExecException if the code fails.
raise
jail.jail_code("print('foo')", slug='hotcode')
"""
from .exceptions import CodeJailException, JailError, SafeExecException
from .jail import configure, is_configured, get_codejail
from .languages import python2, python3, other
...@@ -7,7 +7,7 @@ Code to glue codejail into a Django environment. ...@@ -7,7 +7,7 @@ Code to glue codejail into a Django environment.
from django.core.exceptions import MiddlewareNotUsed from django.core.exceptions import MiddlewareNotUsed
from django.conf import settings from django.conf import settings
import codejail.jail_code import codejail.limits
class ConfigureCodeJailMiddleware(object): class ConfigureCodeJailMiddleware(object):
...@@ -27,6 +27,6 @@ class ConfigureCodeJailMiddleware(object): ...@@ -27,6 +27,6 @@ class ConfigureCodeJailMiddleware(object):
limits = settings.CODE_JAIL.get('limits', {}) limits = settings.CODE_JAIL.get('limits', {})
for name, value in limits.items(): for name, value in limits.items():
codejail.jail_code.set_limit(name, value) codejail.limits.set_limit(name, value)
raise MiddlewareNotUsed raise MiddlewareNotUsed
"""
Codejail exceptions.
"""
class CodeJailException(Exception):
"""
Top of the hierarchy of codejail exceptions
"""
pass
class JailError(CodeJailException):
"""
There was a problem configuring a codejail or accessing a configured codejail.
"""
pass
class SafeExecException(CodeJailException):
"""
Python code running in the sandbox has failed.
The message will be the stdout of the sandboxed process, which will usually
contain the original exception message.
"""
pass
"""
Language-specific configuration for codejails.
A language consists of:
`name` A human readable name for the language.
`argv` A list of extra language-arguments to pass to the Jail's
executable.
`safe_exec_template` Wrapper code to make safe_exec work properly. If a
language has safe_exec_template set to None, commands configured to use
that language will not be able to use `codejail.jail.Jail.safe_exec`, and
will need to use `codejail.jail.Jail.jail_code` instead.
Preconfigured `Language` objects exist for `python2`, `python3`, and `other`, but
applications can define their own languages by instantiating `Language`.
"""
from collections import namedtuple
import textwrap
Language = namedtuple('Language', ['name', 'argv', 'safe_exec_template'])
# Allow lowercased names at module level
# pylint: disable=invalid-name
other = Language(
name='other',
argv=[],
safe_exec_template=None,
)
python2 = Language(
name='python2',
argv=[
'-E', # Ignore the environment variables PYTHON*
'-B', # Don't write .pyc files.
],
safe_exec_template=textwrap.dedent(
"""
import sys
try:
import simplejson as json
except ImportError:
import json
"""
# We need to prevent the sandboxed code from printing to stdout,
# or it will pollute the json we print there. This isn't a
# security concern (they can put any values in the json output
# anyway, either by writing to sys.__stdout__, or just by defining
# global values), but keeps accidents from happening.
"""
class DevNull(object):
def write(self, *args, **kwargs):
pass
sys.stdout = DevNull()
"""
# Read the code and the globals from the stdin.
"""
code, g_dict = json.load(sys.stdin)
"""
# Update the python path (this will be generated outside the template)
"""
%(python_path)s
"""
# Execute the sandboxed code.
"""
exec code in g_dict
"""
# Clean the globals for sending back as JSON over stdout.
"""
ok_types = (
type(None), int, long, float, str, unicode, list, tuple, dict
)
bad_keys = ("__builtins__",)
def jsonable(v):
if not isinstance(v, ok_types):
return False
try:
json.dumps(v)
except Exception:
return False
return True
g_dict = {
k:v
for k,v in g_dict.iteritems()
if jsonable(v) and k not in bad_keys
}
"""
# Write the globals back to the calling process.
"""
json.dump(g_dict, sys.__stdout__)
"""
),
)
python3 = Language(
name='python3',
argv=[
'-E', # Ignore the environment variables PYTHON*
'-B', # Don't write .pyc files.
],
safe_exec_template=textwrap.dedent(
# See explanatory comments in python2 version
"""
import sys
import json
class DevNull:
def write(self, *args, **kwargs):
pass
sys.stdout = DevNull()
code, g_dict = json.load(sys.stdin)
%(python_path)s
exec(code, g_dict)
ok_types = (
type(None), int, float, str, bytes, list, tuple, dict
)
bad_keys = ("__builtins__",)
def jsonable(v):
if not isinstance(v, ok_types):
return False
try:
json.dumps(v)
except Exception:
return False
return True
g_dict = {
k:v
for k,v in g_dict.items()
if jsonable(v) and k not in bad_keys
}
json.dump(g_dict, sys.__stdout__)
"""
),
)
"""
Configurable system resource limits
"""
import resource
LIMITS = {
# CPU seconds, defaulting to 1.
"CPU": 1,
# Real time, defaulting to 1 second.
"REALTIME": 1,
# Total process virutal memory, in bytes, defaulting to unlimited.
"VMEM": 0,
# Size of files creatable, in bytes, defaulting to nothing can be written.
"FSIZE": 0,
# Whether to use a proxy process or not. None means use an environment
# variable to decide. NOTE: using a proxy process is NOT THREAD-SAFE, only
# one thread can use CodeJail at a time if you are using a proxy process.
"PROXY": None,
}
def set_limit(limit_name, value):
"""
Set a limit for jailed code.
`limit_name` is a string, the name of the limit to set. `value` is the
value to use for that limit. The type, meaning, default, and range of
accepted values depend on `limit_name`.
These limits are available:
* `"CPU"`: the maximum number of CPU seconds the jailed code can use.
The value is an integer, defaulting to 1.
* `"REALTIME"`: the maximum number of seconds the jailed code can run,
in real time. The default is 1 second.
* `"VMEM"`: the total virtual memory available to the jailed code, in
bytes. The default is 0 (no memory limit).
* `"FSIZE"`: the maximum size of files creatable by the jailed code,
in bytes. The default is 0 (no files may be created).
* `"PROXY"`: 1 to use a proxy process, 0 to not use one. This isn't
really a limit, sorry about that.
Limits are process-wide, and will affect all future calls to jail_code.
Providing a limit of 0 will disable that limit.
"""
LIMITS[limit_name] = value
def create_rlimits():
"""
Create a list of resource limits for our jailed processes.
"""
rlimits = []
# No subprocesses.
rlimits.append((resource.RLIMIT_NPROC, (0, 0)))
# CPU seconds, not wall clock time.
cpu = LIMITS["CPU"]
if cpu:
# Set the soft limit and the hard limit differently. When the process
# reaches the soft limit, a SIGXCPU will be sent, which should kill the
# process. If you set the soft and hard limits the same, then the hard
# limit is reached, and a SIGKILL is sent, which is less distinctive.
rlimits.append((resource.RLIMIT_CPU, (cpu, cpu+1)))
# Total process virtual memory.
vmem = LIMITS["VMEM"]
if vmem:
rlimits.append((resource.RLIMIT_AS, (vmem, vmem)))
# Size of written files. Can be zero (nothing can be written).
fsize = LIMITS["FSIZE"]
rlimits.append((resource.RLIMIT_FSIZE, (fsize, fsize)))
return rlimits
...@@ -4,194 +4,58 @@ import logging ...@@ -4,194 +4,58 @@ import logging
import os.path import os.path
import shutil import shutil
import sys import sys
import textwrap
try: from .exceptions import SafeExecException
import simplejson as json from .jail import get_codejail, is_configured
except ImportError: from .util import temp_directory, change_directory, json_safe
import json
from codejail import jail_code
from codejail.util import temp_directory, change_directory
log = logging.getLogger("codejail") log = logging.getLogger("codejail")
# Flags to let developers temporarily change some behavior in this file. # Flags to let developers temporarily change some behavior in this file.
# Set this to True to log all the code and globals being executed.
LOG_ALL_CODE = False
# Set this to True to use the unsafe code, so that you can debug it. # Set this to True to use the unsafe code, so that you can debug it.
ALWAYS_BE_UNSAFE = False ALWAYS_BE_UNSAFE = False
class SafeExecException(Exception):
"""
Python code running in the sandbox has failed.
The message will be the stdout of the sandboxed process, which will usually
contain the original exception message.
"""
pass
def safe_exec(code, globals_dict, files=None, python_path=None, slug=None, def safe_exec(code, globals_dict, files=None, python_path=None, slug=None,
extra_files=None): extra_files=None):
""" """
Execute code as "exec" does, but safely. Execute code as "exec" does, but safely.
`code` is a string of Python code. `globals_dict` is used as the globals This function calls through to the `safe_exec()` method on the `Jail`
during execution. Modifications the code makes to `globals_dict` are object with the command name `"python"`.
reflected in the dictionary on return.
`files` is a list of file paths, either files or directories. They will be
copied into the temp directory used for execution. No attempt is made to
determine whether the file is appropriate or safe to copy. The caller must
determine which files to provide to the code.
`python_path` is a list of directory or file paths. These names will be
added to `sys.path` so that modules they contain can be imported. Only
directories and zip files are supported. If the name is not provided in
`extras_files`, it will be copied just as if it had been listed in `files`.
`slug` is an arbitrary string, a description that's meaningful to the Arguments and behavior are documented at `codejail.jail.Jail.safe_exec`
caller, that will be used in log messages.
`extra_files` is a list of pairs, each pair is a filename and a bytestring This function exists primarily for backwards compatibility, and to
of contents to write into that file. These files will be created in the support the unsafe_exec functionality. If you do not need these features,
temp directory and cleaned up automatically. No subdirectories are consider using `codejail.jail.Jail.safe_exec` instead.
supported in the filename.
Returns None. Changes made by `code` are visible in `globals_dict`. If
the code raises an exception, this function will raise `SafeExecException`
with the stderr of the sandbox process, which usually includes the original
exception message and traceback.
>>> import codejail.jail
>>> jail = codejail.jail.get_codejail("python")
>>> jail.safe_exec(...)
""" """
the_code = [] jail = get_codejail("python")
files = list(files or ()) jail.safe_exec(
extra_files = extra_files or () code=code,
python_path = python_path or () globals_dict=globals_dict,
files=files,
extra_names = set(name for name, contents in extra_files) python_path=python_path,
slug=slug,
the_code.append(textwrap.dedent( extra_files=extra_files
"""
import sys
try:
import simplejson as json
except ImportError:
import json
"""
# We need to prevent the sandboxed code from printing to stdout,
# or it will pollute the json we print there. This isn't a
# security concern (they can put any values in the json output
# anyway, either by writing to sys.__stdout__, or just by defining
# global values), but keeps accidents from happening.
"""
class DevNull(object):
def write(self, *args, **kwargs):
pass
sys.stdout = DevNull()
"""
# Read the code and the globals from the stdin.
"""
code, g_dict = json.load(sys.stdin)
"""))
for pydir in python_path:
pybase = os.path.basename(pydir)
the_code.append("sys.path.append(%r)\n" % pybase)
if pybase not in extra_names:
files.append(pydir)
the_code.append(textwrap.dedent(
# Execute the sandboxed code.
"""
exec code in g_dict
"""
# Clean the globals for sending back as JSON over stdout.
"""
ok_types = (
type(None), int, long, float, str, unicode, list, tuple, dict
)
bad_keys = ("__builtins__",)
def jsonable(v):
if not isinstance(v, ok_types):
return False
try:
json.dumps(v)
except Exception:
return False
return True
g_dict = {
k:v
for k,v in g_dict.iteritems()
if jsonable(v) and k not in bad_keys
}
"""
# Write the globals back to the calling process.
"""
json.dump(g_dict, sys.__stdout__)
"""))
stdin = json.dumps([code, json_safe(globals_dict)])
jailed_code = "".join(the_code)
# Turn this on to see what's being executed.
if LOG_ALL_CODE: # pragma: no cover
log.debug("Jailed code: %s", jailed_code)
log.debug("Exec: %s", code)
log.debug("Stdin: %s", stdin)
res = jail_code.jail_code(
"python", code=jailed_code, stdin=stdin, files=files, slug=slug,
extra_files=extra_files,
) )
if res.status != 0:
raise SafeExecException((
"Couldn't execute jailed code: stdout: {res.stdout!r}, "
"stderr: {res.stderr!r} with status code: {res.status}"
).format(res=res))
globals_dict.update(json.loads(res.stdout))
def json_safe(d):
"""
Return only the JSON-safe part of d.
Used to emulate reading data through a serialization straw.
""" def not_safe_exec(
ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) code,
bad_keys = ("__builtins__",) globals_dict,
jd = {} files=None,
for k, v in d.iteritems(): python_path=None,
if not isinstance(v, ok_types): slug=None,
continue extra_files=None
if k in bad_keys: ): # pylint: disable=unused-argument
continue
try:
# Python's JSON encoder will produce output that
# the JSON decoder cannot parse if the input string
# contains unicode "unpaired surrogates" (only on Linux)
# To test for this, we try decoding the output and check
# for a ValueError
json.loads(json.dumps(v))
# Also ensure that the keys encode/decode correctly
json.loads(json.dumps(k))
except (TypeError, ValueError):
continue
else:
jd[k] = v
return json.loads(json.dumps(jd))
def not_safe_exec(code, globals_dict, files=None, python_path=None, slug=None,
extra_files=None):
""" """
Another implementation of `safe_exec`, but not safe. Another implementation of `safe_exec`, but not safe.
...@@ -199,7 +63,6 @@ def not_safe_exec(code, globals_dict, files=None, python_path=None, slug=None, ...@@ -199,7 +63,6 @@ def not_safe_exec(code, globals_dict, files=None, python_path=None, slug=None,
This is not thread-safe, due to temporarily changing the current directory This is not thread-safe, due to temporarily changing the current directory
and modifying sys.path. and modifying sys.path.
""" """
g_dict = json_safe(globals_dict) g_dict = json_safe(globals_dict)
...@@ -211,19 +74,19 @@ def not_safe_exec(code, globals_dict, files=None, python_path=None, slug=None, ...@@ -211,19 +74,19 @@ def not_safe_exec(code, globals_dict, files=None, python_path=None, slug=None,
shutil.copyfile(filename, dest) shutil.copyfile(filename, dest)
for filename, contents in extra_files or (): for filename, contents in extra_files or ():
dest = os.path.join(tmpdir, filename) dest = os.path.join(tmpdir, filename)
with open(dest, "w") as f: with open(dest, "w") as target_file:
f.write(contents) target_file.write(contents)
original_path = sys.path original_path = sys.path
if python_path: if python_path:
sys.path.extend(python_path) sys.path.extend(python_path)
try: try:
exec code in g_dict exec code in g_dict # pylint: disable=exec-used
except Exception as e: except Exception as exc:
# Wrap the exception in a SafeExecException, but we don't # Wrap the exception in a SafeExecException, but we don't
# try here to include the traceback, since this is just a # try here to include the traceback, since this is just a
# substitute implementation. # substitute implementation.
msg = "{0.__class__.__name__}: {0!s}".format(e) msg = "{0.__class__.__name__}: {0!s}".format(exc)
raise SafeExecException(msg) raise SafeExecException(msg)
finally: finally:
sys.path = original_path sys.path = original_path
...@@ -231,11 +94,7 @@ def not_safe_exec(code, globals_dict, files=None, python_path=None, slug=None, ...@@ -231,11 +94,7 @@ def not_safe_exec(code, globals_dict, files=None, python_path=None, slug=None,
globals_dict.update(json_safe(g_dict)) globals_dict.update(json_safe(g_dict))
# If the developer wants us to be unsafe (ALWAYS_BE_UNSAFE), or if there isn't if ALWAYS_BE_UNSAFE: # pragma: no cover
# a configured jail for Python, then we'll be UNSAFE.
UNSAFE = ALWAYS_BE_UNSAFE or not jail_code.is_configured("python")
if UNSAFE: # pragma: no cover
# Make safe_exec actually call not_safe_exec, but log that we're doing so. # Make safe_exec actually call not_safe_exec, but log that we're doing so.
def safe_exec(*args, **kwargs): # pylint: disable=E0102 def safe_exec(*args, **kwargs): # pylint: disable=E0102
......
...@@ -3,28 +3,84 @@ Helper code to facilitate testing ...@@ -3,28 +3,84 @@ Helper code to facilitate testing
""" """
from contextlib import contextmanager from contextlib import contextmanager
import os.path
import sys
from unittest import TestCase
from codejail import jail
from codejail import languages
from codejail import jail_code
SAME = object() SAME = object()
@contextmanager @contextmanager
def override_configuration(command, bin_path, user): def override_configuration(command, bin_path=SAME, user=SAME, lang=SAME):
""" """
Context manager to temporarily alter the configuration of a codejail Context manager to temporarily alter the configuration of a codejail
command. command.
""" """
old = jail_code.COMMANDS.get(command) old = jail.COMMANDS.get(command)
if bin_path is SAME: if bin_path is SAME:
bin_path = old['cmdline_start'][0] bin_path = old.bin_path
if user is SAME: if user is SAME:
user = old['user'] user = old.user
if lang is SAME:
lang = old.lang
try: try:
jail_code.configure(command, bin_path, user) jail.configure(command, bin_path, user, lang)
yield yield
finally: finally:
if old is None: if old is None:
del jail_code.COMMANDS[command] del jail.COMMANDS[command]
else:
jail.COMMANDS[command] = old
class JailMixin(TestCase):
"""
Mixin to add a default "python" jail environment.
Can be configured by specifying `CODEJAIL_TEST_VENV` and
`CODEJAIL_TEST_USER` environment variables. Defaults to the path of the
current virtualenv with -sandbox appended and the user named `'sandbox'`.
"""
_codejail_venv = os.environ.get('CODEJAIL_TEST_VENV')
_codejail_user = os.environ.get('CODEJAIL_TEST_USER', 'sandbox')
def setUp(self):
super(JailMixin, self).setUp()
if not jail.is_configured("python"):
if not self._codejail_venv:
self._codejail_venv = self._autoconfigure_codejail_venv()
if not self._codejail_user:
# User explicitly requested no su user via environment variable
self._codejail_user = None
bin_path = os.path.join(self._codejail_venv, 'bin/python2')
jail.configure("python", bin_path, user=self._codejail_user, lang=languages.python2)
def _autoconfigure_codejail_venv(self):
"""
For the purposes of tests, look for a sandbox alongside the currently
running python.
"""
codejail_venv = '{}-sandbox'.format(sys.prefix)
if os.path.isdir(codejail_venv):
return codejail_venv
else: else:
jail_code.COMMANDS[command] = old self.fail("No virtualenv found for codejail")
class Python3Mixin(object):
"""
TestCase Mixin to set up a python3 codejail. Skips all tests if no python3 executable can be found
"""
def setUp(self):
super(Python3Mixin, self).setUp()
for path in ['/usr/bin/python3', '/usr/local/bin/python3']:
if os.path.exists(path):
self.python3_jail = jail.configure('python3', path, lang=languages.python3)
break
else: # nobreak
self.fail("No Python 3 executable found")
...@@ -14,7 +14,9 @@ import unittest ...@@ -14,7 +14,9 @@ import unittest
import mock import mock
from nose.plugins.skip import SkipTest from nose.plugins.skip import SkipTest
from codejail.jail_code import jail_code, is_configured, set_limit, LIMITS from codejail.jail import JailResult
from codejail.jail_code import jail_code
from codejail.limits import LIMITS, set_limit
from codejail import proxy from codejail import proxy
from . import helpers from . import helpers
...@@ -52,12 +54,8 @@ def text_of_logs(mock_calls): ...@@ -52,12 +54,8 @@ def text_of_logs(mock_calls):
return text return text
class JailCodeHelpers(object): class JailCodeMixin(helpers.JailMixin):
"""Assert helpers for jail_code tests.""" """Assert helpers for jail_code tests."""
def setUp(self):
super(JailCodeHelpers, self).setUp()
if not is_configured("python"):
raise SkipTest
def assertResultOk(self, res): def assertResultOk(self, res):
"""Assert that `res` exited well (0), and had no stderr output.""" """Assert that `res` exited well (0), and had no stderr output."""
...@@ -67,7 +65,7 @@ class JailCodeHelpers(object): ...@@ -67,7 +65,7 @@ class JailCodeHelpers(object):
self.assertEqual(res.status, 0) # pylint: disable=E1101 self.assertEqual(res.status, 0) # pylint: disable=E1101
class TestFeatures(JailCodeHelpers, unittest.TestCase): class TestFeatures(JailCodeMixin, unittest.TestCase):
"""Test features of how `jail_code` runs Python.""" """Test features of how `jail_code` runs Python."""
def test_hello_world(self): def test_hello_world(self):
...@@ -240,7 +238,7 @@ class TestFeatures(JailCodeHelpers, unittest.TestCase): ...@@ -240,7 +238,7 @@ class TestFeatures(JailCodeHelpers, unittest.TestCase):
self.assertRegexpMatches(log_text, r"INFO: Executed jailed code HELLO in .*, with PID .*") self.assertRegexpMatches(log_text, r"INFO: Executed jailed code HELLO in .*, with PID .*")
class TestLimits(JailCodeHelpers, unittest.TestCase): class TestLimits(JailCodeMixin, unittest.TestCase):
"""Tests of the resource limits, and changing them.""" """Tests of the resource limits, and changing them."""
def setUp(self): def setUp(self):
...@@ -355,10 +353,10 @@ class TestLimits(JailCodeHelpers, unittest.TestCase): ...@@ -355,10 +353,10 @@ class TestLimits(JailCodeHelpers, unittest.TestCase):
self.assertResultOk(res) self.assertResultOk(res)
self.assertIn("Expected exception", res.stdout) self.assertIn("Expected exception", res.stdout)
@unittest.skip("There's nothing checking total file size yet.")
def test_cant_write_many_small_temp_files(self): def test_cant_write_many_small_temp_files(self):
# We would like this to fail, but there's nothing that checks total # We would like this to fail, but there's nothing that checks total
# file size written, so the sandbox does not prevent it yet. # file size written, so the sandbox does not prevent it yet.
raise SkipTest("There's nothing checking total file size yet.")
set_limit('FSIZE', 1000) set_limit('FSIZE', 1000)
res = jailpy(code="""\ res = jailpy(code="""\
import os, tempfile import os, tempfile
...@@ -431,11 +429,12 @@ class TestLimits(JailCodeHelpers, unittest.TestCase): ...@@ -431,11 +429,12 @@ class TestLimits(JailCodeHelpers, unittest.TestCase):
self.assertNotEqual(res.status, 0) self.assertNotEqual(res.status, 0)
class TestSymlinks(JailCodeHelpers, unittest.TestCase): class TestSymlinks(JailCodeMixin, unittest.TestCase):
"""Testing symlink behavior.""" """Testing symlink behavior."""
def setUp(self): def setUp(self):
# Make a temp dir, and arrange to have it removed when done. # Make a temp dir, and arrange to have it removed when done.
super(TestSymlinks, self).setUp()
tmp_dir = tempfile.mkdtemp() tmp_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, tmp_dir) self.addCleanup(shutil.rmtree, tmp_dir)
...@@ -486,7 +485,7 @@ class TestSymlinks(JailCodeHelpers, unittest.TestCase): ...@@ -486,7 +485,7 @@ class TestSymlinks(JailCodeHelpers, unittest.TestCase):
self.assertIn("ermission denied", res.stderr) self.assertIn("ermission denied", res.stderr)
class TestMalware(JailCodeHelpers, unittest.TestCase): class TestMalware(JailCodeMixin, unittest.TestCase):
"""Tests that attempt actual malware against the interpreter or system.""" """Tests that attempt actual malware against the interpreter or system."""
def test_crash_cpython(self): def test_crash_cpython(self):
...@@ -533,14 +532,14 @@ class TestMalware(JailCodeHelpers, unittest.TestCase): ...@@ -533,14 +532,14 @@ class TestMalware(JailCodeHelpers, unittest.TestCase):
self.assertEqual(res.stdout, "Done.\n") self.assertEqual(res.stdout, "Done.\n")
class TestProxyProcess(JailCodeHelpers, unittest.TestCase): class TestProxyProcess(JailCodeMixin, unittest.TestCase):
"""Tests of the proxy process.""" """Tests of the proxy process."""
def setUp(self): def setUp(self):
# During testing, the proxy is used if the environment variable is set. # During testing, the proxy is used if the environment variable is set.
# Skip these tests if we aren't using the proxy. # Skip these tests if we aren't using the proxy.
if not int(os.environ.get("CODEJAIL_PROXY", "0")): if not int(os.environ.get("CODEJAIL_PROXY", "0")):
raise SkipTest() raise SkipTest("No proxy configured")
super(TestProxyProcess, self).setUp() super(TestProxyProcess, self).setUp()
...@@ -582,3 +581,30 @@ class TestProxyProcess(JailCodeHelpers, unittest.TestCase): ...@@ -582,3 +581,30 @@ class TestProxyProcess(JailCodeHelpers, unittest.TestCase):
pid = proxy.PROXY_PROCESS.pid pid = proxy.PROXY_PROCESS.pid
self.assertNotIn(pid, pids) self.assertNotIn(pid, pids)
pids.add(pid) pids.add(pid)
class TestPython3JailCode(helpers.Python3Mixin, unittest.TestCase):
"""
Test that python 3 codejails can run jail_code
"""
def test_jail_code(self):
result = self.python3_jail.jail_code('print("Huzzah")')
self.assertEqual(result, JailResult(status=0, stdout='Huzzah\n', stderr=''))
def test_jail_code_error(self):
result = self.python3_jail.jail_code('print "Huzzah"')
stderr = textwrap.dedent("""\
File "jailed_code", line 1
print "Huzzah"
^
SyntaxError: {}
""")
self.assertEqual(result.status, 1)
self.assertEqual(result.stdout, '')
allowed_error_messages = ['invalid syntax', "Missing parentheses in call to 'print'"]
self.assertIn(result.stderr, [stderr.format(msg) for msg in allowed_error_messages])
def test_jail_code_functional_invocation(self):
result = jail_code('python3', 'print("Huzzah")')
self.assertEqual(result, JailResult(status=0, stdout='Huzzah\n', stderr=''))
...@@ -3,7 +3,7 @@ Test JSON serialization straw ...@@ -3,7 +3,7 @@ Test JSON serialization straw
""" """
import unittest import unittest
from codejail.safe_exec import json_safe from codejail.util import json_safe
class JsonSafeTest(unittest.TestCase): class JsonSafeTest(unittest.TestCase):
......
...@@ -6,12 +6,16 @@ import textwrap ...@@ -6,12 +6,16 @@ import textwrap
import unittest import unittest
import zipfile import zipfile
from nose.plugins.skip import SkipTest from mock import patch
from codejail.exceptions import JailError, SafeExecException
from codejail.jail import get_codejail
from codejail import languages
from codejail import safe_exec from codejail import safe_exec
from . import helpers
class SafeExecTests(unittest.TestCase): class SafeExecTests(helpers.JailMixin, unittest.TestCase):
"""The tests for `safe_exec`, to be mixed into specific test classes.""" """The tests for `safe_exec`, to be mixed into specific test classes."""
# SafeExecTests is a TestCase so pylint understands the methods it can # SafeExecTests is a TestCase so pylint understands the methods it can
...@@ -134,17 +138,42 @@ class TestSafeExec(SafeExecTests, unittest.TestCase): ...@@ -134,17 +138,42 @@ class TestSafeExec(SafeExecTests, unittest.TestCase):
def safe_exec(self, *args, **kwargs): def safe_exec(self, *args, **kwargs):
safe_exec.safe_exec(*args, **kwargs) safe_exec.safe_exec(*args, **kwargs)
@patch('codejail.safe_exec.not_safe_exec')
def test_not_safe_exec_not_called(self, mock_not_safe_exec):
# Make sure safe_exec doesn't alias not_safe_exec
# Otherwise, our test suite won't be useful
self.safe_exec('x = 1', {})
self.assertFalse(mock_not_safe_exec.called)
class TestNotSafeExec(SafeExecTests, unittest.TestCase): class TestNotSafeExec(SafeExecTests, unittest.TestCase):
"""Run SafeExecTests, with not_safe_exec.""" """Run SafeExecTests, with not_safe_exec."""
__test__ = True __test__ = True
def setUp(self):
# If safe_exec is actually an alias to not_safe_exec, then there's no
# point running these tests.
if safe_exec.UNSAFE: # pragma: no cover
raise SkipTest
def safe_exec(self, *args, **kwargs): def safe_exec(self, *args, **kwargs):
safe_exec.not_safe_exec(*args, **kwargs) safe_exec.not_safe_exec(*args, **kwargs)
class TestPython3SafeExec(helpers.Python3Mixin, unittest.TestCase):
"""
Test that python 3 codejails can run safe_exec
"""
def test_safe_exec_basic(self):
globals_dict = {
'starter': 8
}
self.python3_jail.safe_exec('happy_result = str(starter) + "-)"', globals_dict)
self.assertEqual(globals_dict, {'starter': 8, 'happy_result': '8-)'})
def test_safe_exec_error(self):
with self.assertRaises(SafeExecException) as exc:
self.python3_jail.safe_exec('"foo".decode("utf-8")', {})
self.assertIn('object has no attribute', exc.exception.message)
def test_safe_exec_unconfigured(self):
with helpers.override_configuration('python3', lang=languages.other):
jail = get_codejail('python3')
with self.assertRaises(JailError):
jail.safe_exec('print("hello")', {})
...@@ -5,6 +5,11 @@ import os ...@@ -5,6 +5,11 @@ import os
import shutil import shutil
import tempfile import tempfile
try:
import simplejson as json
except ImportError:
import json
@contextlib.contextmanager @contextlib.contextmanager
def temp_directory(): def temp_directory():
...@@ -31,3 +36,34 @@ def change_directory(new_dir): ...@@ -31,3 +36,34 @@ def change_directory(new_dir):
yield new_dir yield new_dir
finally: finally:
os.chdir(old_dir) os.chdir(old_dir)
def json_safe(input_dict):
"""
Return a new `dict` containing only the JSON-safe part of `input_dict`.
Used to emulate reading data through a serialization straw.
"""
ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict)
bad_keys = ("__builtins__",)
json_dict = {}
for key, value in input_dict.iteritems():
if not isinstance(value, ok_types):
continue
if key in bad_keys:
continue
try:
# Python's JSON encoder will produce output that
# the JSON decoder cannot parse if the input string
# contains unicode "unpaired surrogates" (only on Linux)
# To test for this, we try decoding the output and check
# for a ValueError
json.loads(json.dumps(value))
# Also ensure that the keys encode/decode correctly
json.loads(json.dumps(key))
except (TypeError, ValueError):
continue
else:
json_dict[key] = value
return json.loads(json.dumps(json_dict))
"""CodeJail: manages execution of untrusted code in secure sandboxes.""" """
CodeJail: manages execution of untrusted code in secure sandboxes.
"""
from setuptools import setup from setuptools import setup
setup( setup(
name="codejail", name="codejail",
version="0.1", version="1.0",
packages=['codejail'], packages=['codejail'],
description="Manages execution of untrusted code in secure sandboxes.",
license="Apache Software License, version 2.0",
classifiers=[ classifiers=[
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Ubuntu", "Operating System :: POSIX :: Ubuntu",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment