From 2717360de9ffbdfd267a0157159d2538bad46f4e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 6 Feb 2013 10:12:26 -0500 Subject: [PATCH 001/120] Make this work with non-Django test suites also. --- runone.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/runone.py b/runone.py index 2227ae0adf..a644aa077b 100755 --- a/runone.py +++ b/runone.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -from django.core import management import argparse import os @@ -42,21 +41,34 @@ def main(argv): test_py_path = find_full_path(test_py_path) test_spec = "%s:%s.%s" % (test_py_path, test_class, test_method) + settings = None if test_py_path.startswith('cms'): settings = 'cms.envs.test' elif test_py_path.startswith('lms'): settings = 'lms.envs.test' + + if settings: + # Run as a django test suite + from django.core import management + + django_args = ["django-admin.py", "test", "--pythonpath=."] + django_args.append("--settings=%s" % settings) + if args.nocapture: + django_args.append("-s") + django_args.append(test_spec) + + print " ".join(django_args) + management.execute_from_command_line(django_args) else: - raise Exception("Couldn't determine settings to use!") + # Run as a nose test suite + import nose.core + nose_args = ["nosetests"] + if args.nocapture: + nose_args.append("-s") + nose_args.append(test_spec) + print " ".join(nose_args) + nose.core.main(argv=nose_args) - django_args = ["django-admin.py", "test", "--pythonpath=."] - django_args.append("--settings=%s" % settings) - if args.nocapture: - django_args.append("-s") - django_args.append(test_spec) - - print " ".join(django_args) - management.execute_from_command_line(django_args) if __name__ == "__main__": main(sys.argv[1:]) From 70c37130ac9561f2cb1b843959eace77e6015d80 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 6 Feb 2013 16:15:43 -0500 Subject: [PATCH 002/120] A codejail package to run code securely. --- common/lib/codejail/codejail/__init__.py | 0 common/lib/codejail/codejail/jailpy.py | 122 ++++++++++++++++++ common/lib/codejail/codejail/safe_exec.py | 34 +++++ .../lib/codejail/codejail/tests/__init__.py | 0 .../codejail/codejail/tests/test_jailpy.py | 48 +++++++ common/lib/codejail/codejail/util.py | 30 +++++ common/lib/codejail/jailed_code.py | 1 + common/lib/codejail/setup.py | 7 + local-requirements.txt | 1 + 9 files changed, 243 insertions(+) create mode 100644 common/lib/codejail/codejail/__init__.py create mode 100644 common/lib/codejail/codejail/jailpy.py create mode 100644 common/lib/codejail/codejail/safe_exec.py create mode 100644 common/lib/codejail/codejail/tests/__init__.py create mode 100644 common/lib/codejail/codejail/tests/test_jailpy.py create mode 100644 common/lib/codejail/codejail/util.py create mode 100644 common/lib/codejail/jailed_code.py create mode 100644 common/lib/codejail/setup.py diff --git a/common/lib/codejail/codejail/__init__.py b/common/lib/codejail/codejail/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jailpy.py new file mode 100644 index 0000000000..198c8bffdb --- /dev/null +++ b/common/lib/codejail/codejail/jailpy.py @@ -0,0 +1,122 @@ +"""Run a python process in a jail.""" + +# Instructions: +# - AppArmor.md from xserver +# XXX- apt-get install timelimit + +import os, os.path +import resource +import shutil +import subprocess +import threading +import time + +from .util import temp_directory + +# TODO: limit too much stdout data? + +DEBUG = False +STRICT = True + +# Configure the Python command + +SANDBOX_PYTHON = "/usr/bin/python-sandbox" + +if os.path.exists(SANDBOX_PYTHON): + # Python -S inhibits loading site.py, which prevent Ubuntu from adding + # specialized traceback handlers that fail in the sandbox. + PYTHON_CMD = [ + #'timelimit', '-t', '1', '-s', '9', + 'sudo', '-u', 'sandbox', + SANDBOX_PYTHON, '-S' + ] +elif STRICT: + raise Exception("Couldn't find Python sandbox") +else: + PYTHON_CMD = ['python', '-S'] + + +class JailResult(object): + """A passive object for us to return from jailpy.""" + pass + +def jailpy(code, files=None, argv=None, stdin=None): + """ + Run Python code in a jailed subprocess. + + `code` is a string containing the Python code to run. + + `files` is a list of file paths. + + Return an object with: + + .stdout: stdout of the program, a string + .stderr: stderr of the program, a string + .status: return status of the process: an int, 0 for successful + + """ + with temp_directory(delete_when_done=True) as tmpdir: + + # All the supporting files are copied into our directory. + for filename in files or (): + dest = os.path.join(tmpdir, os.path.basename(filename)) + shutil.copyfile(filename, dest) + + # Create the main file. + with open(os.path.join(tmpdir, "jailed_code.py"), "w") as jailed: + jailed.write(code) + + cmd = PYTHON_CMD + ['jailed_code.py'] + (argv or []) + + subproc = subprocess.Popen( + cmd, preexec_fn=set_process_limits, cwd=tmpdir, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + # TODO: time limiting + + killer = ProcessKillerThread(subproc) + killer.start() + result = JailResult() + result.stdout, result.stderr = subproc.communicate(stdin) + result.status = subproc.returncode + killer.join() + + return result + + +def set_process_limits(): + """ + Set limits on this processs, to be used first in a child process. + """ + resource.setrlimit(resource.RLIMIT_CPU, (1, 1)) # 1 second of CPU--not wall clock time + resource.setrlimit(resource.RLIMIT_NPROC, (0, 0)) # no subprocesses + resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)) # no files + + mem = 32 * 2**20 # 32 MB should be enough for anyone, right? :) + resource.setrlimit(resource.RLIMIT_STACK, (mem, mem)) + resource.setrlimit(resource.RLIMIT_RSS, (mem, mem)) + resource.setrlimit(resource.RLIMIT_DATA, (mem, mem)) + + +class ProcessKillerThread(threading.Thread): + def __init__(self, subproc, limit=1): + super(ProcessKillerThread, self).__init__() + self.subproc = subproc + self.limit = limit + + def run(self): + time.sleep(self.limit) + if self.subproc.poll() is None: + # Can't use subproc.kill because we launched the subproc with sudo. + #killargs = ["sudo", "-u", "sandbox", "kill", "-9", str(self.subproc.pid)] + killargs = ["sudo", "-u", "sandbox", "ps", str(self.subproc.pid)] + print killargs + kill = subprocess.Popen(killargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = kill.communicate() + print out + print err + print "Return status: %r" % kill.returncode + #if ret: + #print "Couldn't kill: %r" % ret + #os.system("sudo -u sandbox kill -9 %s" % self.subproc.pid) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py new file mode 100644 index 0000000000..64700fbbce --- /dev/null +++ b/common/lib/codejail/codejail/safe_exec.py @@ -0,0 +1,34 @@ +"""Safe execution of untrusted Python code.""" + +import json + +def straw(v): + return json.loads(json.dumps(jsonable_dict(v))) + +def jsonable_dict(d): + jd = {} + for k,v in d.iteritems(): + try: + json.dumps(v) + except TypeError: + continue + else: + jd[k] = v + return jd + +def safe_exec(code, globals_dict, locals_dict=None, future_division=False, assumed_imports=None): + if future_division: + code = "from __future__ import division\n" + code + + g_dict = straw(globals_dict) + + if locals_dict is None: + l_dict = g_dict + else: + l_dict = straw(locals_dict) + + exec code in g_dict, l_dict + + globals_dict.update(straw(g_dict)) + if locals_dict is not None: + locals_dict.update(straw(l_dict)) diff --git a/common/lib/codejail/codejail/tests/__init__.py b/common/lib/codejail/codejail/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py new file mode 100644 index 0000000000..c7a56d7bda --- /dev/null +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -0,0 +1,48 @@ +import textwrap +import unittest + +from codejail.jailpy import jailpy + +dedent = textwrap.dedent + +class TestFeatures(unittest.TestCase): + def test_hello_world(self): + res = jailpy("print 'Hello, world!'") + self.assertEqual(res.status, 0) + self.assertEqual(res.stdout, 'Hello, world!\n') + + def test_argv(self): + res = jailpy( + "import sys; print ':'.join(sys.argv[1:])", + argv=["Hello", "world", "-x"] + ) + self.assertEqual(res.status, 0) + self.assertEqual(res.stdout, "Hello:world:-x\n") + + def test_ends_with_exception(self): + res = jailpy("""raise Exception('FAIL')""") + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "") + self.assertEqual(res.stderr, dedent("""\ + Traceback (most recent call last): + File "jailed_code.py", line 1, in + raise Exception('FAIL') + Exception: FAIL + """)) + + +class TestLimits(unittest.TestCase): + def test_cant_use_too_much_memory(self): + res = jailpy("print sum(range(100000000))") + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "") + + def test_cant_use_too_much_cpu(self): + res = jailpy("print sum(xrange(100000000))") + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "") + + def test_cant_use_too_much_time(self): + res = jailpy("import time; time.sleep(5); print 'Done!'") + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "") diff --git a/common/lib/codejail/codejail/util.py b/common/lib/codejail/codejail/util.py new file mode 100644 index 0000000000..7ce0c7051b --- /dev/null +++ b/common/lib/codejail/codejail/util.py @@ -0,0 +1,30 @@ +"""Helpers for codejail.""" + +import contextlib +import os +import shutil +import tempfile + +class TempDirectory(object): + def __init__(self, delete_when_done=True): + self.delete_when_done = delete_when_done + self.temp_dir = tempfile.mkdtemp(prefix="codejail-") + # Make directory readable by other users ('sandbox' user needs to be able to read it) + os.chmod(self.temp_dir, 0775) + + def clean_up(self): + if self.delete_when_done: + # if this errors, something is genuinely wrong, so don't ignore errors. + shutil.rmtree(self.temp_dir) + +@contextlib.contextmanager +def temp_directory(delete_when_done=True): + """ + A context manager to make and use a temp directory. If `delete_when_done` + is true (the default), the directory will be removed when done. + """ + tmp = TempDirectory(delete_when_done) + try: + yield tmp.temp_dir + finally: + tmp.clean_up() diff --git a/common/lib/codejail/jailed_code.py b/common/lib/codejail/jailed_code.py new file mode 100644 index 0000000000..f439268c4c --- /dev/null +++ b/common/lib/codejail/jailed_code.py @@ -0,0 +1 @@ +import time; time.sleep(5); print 'Done!' \ No newline at end of file diff --git a/common/lib/codejail/setup.py b/common/lib/codejail/setup.py new file mode 100644 index 0000000000..c4dcf2b0f7 --- /dev/null +++ b/common/lib/codejail/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup + +setup( + name="codejail", + version="0.1", + packages=['codejail'], +) diff --git a/local-requirements.txt b/local-requirements.txt index 201467d11e..053a223605 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,4 +1,5 @@ # Python libraries to install that are local to the mitx repo -e common/lib/capa -e common/lib/xmodule +-e common/lib/codejail -e . From a9979b8aae4504bd49fbc121afba0b19f66e6220 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 7 Feb 2013 11:36:52 -0500 Subject: [PATCH 003/120] Killing processes isn't working. --- common/lib/codejail/codejail/jailpy.py | 28 ++++++++----------- .../codejail/codejail/tests/test_jailpy.py | 8 +++++- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jailpy.py index 198c8bffdb..45842d715f 100644 --- a/common/lib/codejail/codejail/jailpy.py +++ b/common/lib/codejail/codejail/jailpy.py @@ -2,7 +2,6 @@ # Instructions: # - AppArmor.md from xserver -# XXX- apt-get install timelimit import os, os.path import resource @@ -26,8 +25,7 @@ if os.path.exists(SANDBOX_PYTHON): # Python -S inhibits loading site.py, which prevent Ubuntu from adding # specialized traceback handlers that fail in the sandbox. PYTHON_CMD = [ - #'timelimit', '-t', '1', '-s', '9', - 'sudo', '-u', 'sandbox', + 'sudo', '-u', 'sandbox', SANDBOX_PYTHON, '-S' ] elif STRICT: @@ -46,9 +44,9 @@ def jailpy(code, files=None, argv=None, stdin=None): `code` is a string containing the Python code to run. - `files` is a list of file paths. + `files` is a list of file paths. - Return an object with: + Return an object with: .stdout: stdout of the program, a string .stderr: stderr of the program, a string @@ -80,7 +78,6 @@ def jailpy(code, files=None, argv=None, stdin=None): result = JailResult() result.stdout, result.stderr = subproc.communicate(stdin) result.status = subproc.returncode - killer.join() return result @@ -106,17 +103,16 @@ class ProcessKillerThread(threading.Thread): self.limit = limit def run(self): - time.sleep(self.limit) + start = time.time() + while (time.time() - start) < self.limit: + time.sleep(.1) + if self.subproc.poll() is not None: + # Process ended, no need for us any more. + return + if self.subproc.poll() is None: # Can't use subproc.kill because we launched the subproc with sudo. - #killargs = ["sudo", "-u", "sandbox", "kill", "-9", str(self.subproc.pid)] - killargs = ["sudo", "-u", "sandbox", "ps", str(self.subproc.pid)] - print killargs + killargs = ["sudo", "kill", "-9", str(self.subproc.pid)] kill = subprocess.Popen(killargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = kill.communicate() - print out - print err - print "Return status: %r" % kill.returncode - #if ret: - #print "Couldn't kill: %r" % ret - #os.system("sudo -u sandbox kill -9 %s" % self.subproc.pid) + # TODO: This doesn't actually kill the process.... :( diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index c7a56d7bda..408bb2ba30 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -1,5 +1,6 @@ import textwrap import unittest +from nose.plugins.skip import SkipTest from codejail.jailpy import jailpy @@ -43,6 +44,11 @@ class TestLimits(unittest.TestCase): self.assertEqual(res.stdout, "") def test_cant_use_too_much_time(self): - res = jailpy("import time; time.sleep(5); print 'Done!'") + raise SkipTest # TODO: test this once we can kill sleeping processes. + res = jailpy(dedent("""\ + import time + time.sleep(5) + print 'Done!' + """)) self.assertNotEqual(res.status, 0) self.assertEqual(res.stdout, "") From f8c53053528d7a4137126690e4e635a0176aa64c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 7 Feb 2013 11:37:06 -0500 Subject: [PATCH 004/120] Add some malware tests --- .../codejail/codejail/tests/test_jailpy.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index 408bb2ba30..ff9be079d1 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -52,3 +52,51 @@ class TestLimits(unittest.TestCase): """)) self.assertNotEqual(res.status, 0) self.assertEqual(res.stdout, "") + + # TODO: write files + # TODO: read network + # TODO: fork + +class TestMalware(unittest.TestCase): + def test_crash_cpython(self): + # http://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html + res = jailpy(dedent("""\ + import new, sys + crash_me = new.function(new.code(0,0,0,0,"KABOOM",(),(),(),"","",0,""), {}) + print "Here we go..." + sys.stdout.flush() + crash_me() + print "The afterlife!" + """)) + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "Here we go...\n") + self.assertEqual(res.stderr, "") + + def test_read_etc_passwd(self): + res = jailpy(dedent("""\ + bytes = len(open('/etc/passwd').read()) + print 'Gotcha', bytes + """)) + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "") + self.assertIn("ermission denied", res.stderr) + + def test_find_other_sandboxes(self): + res = jailpy(dedent(""" + import os; + places = [ + "..", "/tmp", "/", "/home", "/etc", + "/var" + ] + for place in places: + try: + files = os.listdir(place) + except Exception: + # darn + pass + else: + print "Files in %r: %r" % (place, files) + print "Done." + """)) + self.assertEqual(res.status, 0) + self.assertEqual(res.stdout, "Done.\n") From 17f9e4b27df59b6226446e9a1f6b3497e2826227 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 7 Feb 2013 12:15:34 -0500 Subject: [PATCH 005/120] A turd left over from a test --- common/lib/codejail/jailed_code.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 common/lib/codejail/jailed_code.py diff --git a/common/lib/codejail/jailed_code.py b/common/lib/codejail/jailed_code.py deleted file mode 100644 index f439268c4c..0000000000 --- a/common/lib/codejail/jailed_code.py +++ /dev/null @@ -1 +0,0 @@ -import time; time.sleep(5); print 'Done!' \ No newline at end of file From 6c609afdb1fb122e3f0ab0aa459f600122302865 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 7 Feb 2013 14:06:01 -0500 Subject: [PATCH 006/120] LazyModule for lazily proxying module imports. --- common/lib/codejail/codejail/lazymod.py | 41 +++++++++++++++++++ .../codejail/codejail/tests/test_lazymod.py | 26 ++++++++++++ common/lib/codejail/codejail/util.py | 21 ++++++++++ 3 files changed, 88 insertions(+) create mode 100644 common/lib/codejail/codejail/lazymod.py create mode 100644 common/lib/codejail/codejail/tests/test_lazymod.py diff --git a/common/lib/codejail/codejail/lazymod.py b/common/lib/codejail/codejail/lazymod.py new file mode 100644 index 0000000000..936a8d263a --- /dev/null +++ b/common/lib/codejail/codejail/lazymod.py @@ -0,0 +1,41 @@ +"""A module proxy for delayed importing of modules. + +Lifted from http://barnesc.blogspot.com/2006/06/automatic-python-imports-with-autoimp.html + +""" + +import sys + +class LazyModule(object): + """A lazy module proxy.""" + + def __init__(self, modname): + self.__dict__['__name__'] = modname + self._set_mod(None) + + def _set_mod(self, mod): + if mod is not None: + self.__dict__ = mod.__dict__ + self.__dict__['_lazymod_mod'] = mod + + def _load_mod(self): + __import__(self.__name__) + self._set_mod(sys.modules[self.__name__]) + + def __getattr__(self, name): + if self.__dict__['_lazymod_mod'] is None: + self._load_mod() + + mod = self.__dict__['_lazymod_mod'] + + if hasattr(mod, name): + return getattr(mod, name) + else: + try: + subname = '%s.%s' % (self.__name__, name) + __import__(subname) + submod = getattr(mod, name) + except ImportError: + raise AttributeError("'module' object has no attribute %r" % name) + self.__dict__[name] = LazyModule(subname, submod) + return self.__dict__[name] diff --git a/common/lib/codejail/codejail/tests/test_lazymod.py b/common/lib/codejail/codejail/tests/test_lazymod.py new file mode 100644 index 0000000000..eb853060d0 --- /dev/null +++ b/common/lib/codejail/codejail/tests/test_lazymod.py @@ -0,0 +1,26 @@ +"""Test lazymod.py""" + +import sys +import unittest + +from codejail.lazymod import LazyModule +from codejail.util import ModuleIsolation + + +class TestLazyMod(unittest.TestCase): + + def setUp(self): + # Each test will remove modules that it imported. + self.addCleanup(ModuleIsolation().clean_up) + + def test_simple(self): + # Import some stdlib module that has not been imported before + self.assertNotIn("colorsys", sys.modules) + colorsys = LazyModule("colorsys") + hsv = colorsys.rgb_to_hsv(.3, .4, .2) + self.assertEqual(hsv[0], 0.25) + + def test_dotted(self): + self.assertNotIn("email.utils", sys.modules) + email_utils = LazyModule("email.utils") + self.assertEqual(email_utils.quote('"hi"'), r'\"hi\"') diff --git a/common/lib/codejail/codejail/util.py b/common/lib/codejail/codejail/util.py index 7ce0c7051b..88ecc1b319 100644 --- a/common/lib/codejail/codejail/util.py +++ b/common/lib/codejail/codejail/util.py @@ -3,8 +3,10 @@ import contextlib import os import shutil +import sys import tempfile + class TempDirectory(object): def __init__(self, delete_when_done=True): self.delete_when_done = delete_when_done @@ -28,3 +30,22 @@ def temp_directory(delete_when_done=True): yield tmp.temp_dir finally: tmp.clean_up() + + +class ModuleIsolation(object): + """ + Manage changes to sys.modules so that we can roll back imported modules. + + Create this object, it will snapshot the currently imported modules. When + you call `clean_up()`, it will delete any module imported since its creation. + """ + def __init__(self): + # Save all the names of all the imported modules. + self.mods = set(sys.modules) + + def clean_up(self): + # Get a list of modules that didn't exist when we were created + new_mods = [m for m in sys.modules if m not in self.mods] + # and delete them all so another import will run code for real again. + for m in new_mods: + del sys.modules[m] From 9cc43f1d9b4f53662386ad2c20b30d91281070d8 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 8 Feb 2013 10:31:24 -0500 Subject: [PATCH 007/120] Simplify this test setup. --- lms/djangoapps/courseware/tests/tests.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 4c9f592797..eb132abc59 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -814,11 +814,7 @@ class TestCourseGrader(LoginEnrollmentTestCase): xmodule.modulestore.django._MODULESTORES = {} courses = modulestore().get_courses() - def find_course(course_id): - """Assumes the course is present""" - return [c for c in courses if c.id == course_id][0] - - self.graded_course = find_course("edX/graded/2012_Fall") + self.graded_course = modulestore().get_course("edX/graded/2012_Fall") # create a test student self.student = 'view@test.com' From 4bb5d14f700c313dfaa3b5301611cbdeb31b3031 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 8 Feb 2013 10:34:03 -0500 Subject: [PATCH 008/120] Test that we can't write files --- common/lib/codejail/codejail/tests/test_jailpy.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index ff9be079d1..d1133d745d 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -1,3 +1,5 @@ +"""Test jailpy.py""" + import textwrap import unittest from nose.plugins.skip import SkipTest @@ -53,6 +55,18 @@ class TestLimits(unittest.TestCase): self.assertNotEqual(res.status, 0) self.assertEqual(res.stdout, "") + def test_cant_write_files(self): + res = jailpy(dedent("""\ + print "Trying" + with open("mydata.txt", "w") as f: + f.write("hello") + with open("mydata.txt") as f2: + print "Got this:", f2.read() + """)) + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "Trying\n") + self.assertIn("ermission denied", res.stderr) + # TODO: write files # TODO: read network # TODO: fork From 9827a0e218414328192fbf1a7240df7423c61884 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 8 Feb 2013 11:02:04 -0500 Subject: [PATCH 009/120] Oops, this line can go too. --- lms/djangoapps/courseware/tests/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index eb132abc59..5345630957 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -812,7 +812,6 @@ class TestCourseGrader(LoginEnrollmentTestCase): def setUp(self): xmodule.modulestore.django._MODULESTORES = {} - courses = modulestore().get_courses() self.graded_course = modulestore().get_course("edX/graded/2012_Fall") From 0c47f1e0b9f8bb280ea52d0cf42d9aa703b77b34 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 8 Feb 2013 15:52:43 -0500 Subject: [PATCH 010/120] safe_exec can load modules for you. --- common/lib/codejail/codejail/safe_exec.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 64700fbbce..eeb62d5bbd 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -2,6 +2,8 @@ import json +from .lazymod import LazyModule + def straw(v): return json.loads(json.dumps(jsonable_dict(v))) @@ -27,6 +29,13 @@ def safe_exec(code, globals_dict, locals_dict=None, future_division=False, assum else: l_dict = straw(locals_dict) + for modname in assumed_imports or (): + if isinstance(modname, tuple): + name, modname = modname + else: + name = modname + g_dict[name] = LazyModule(modname) + exec code in g_dict, l_dict globals_dict.update(straw(g_dict)) From e69a073161d37f7b62a3a904c06ebb4067195b70 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 11 Feb 2013 14:08:11 -0500 Subject: [PATCH 011/120] Add a test for the Python in schemaresponse, and refactor the tests while I was in there. --- common/test/data/embedded_python/course.xml | 1 + .../embedded_python/course/2013_Spring.xml | 95 ++++++++++++++++ .../embedded_python/roots/2013_Spring.xml | 1 + lms/djangoapps/courseware/tests/tests.py | 104 +++++++++++++----- 4 files changed, 171 insertions(+), 30 deletions(-) create mode 100644 common/test/data/embedded_python/course.xml create mode 100644 common/test/data/embedded_python/course/2013_Spring.xml create mode 100644 common/test/data/embedded_python/roots/2013_Spring.xml diff --git a/common/test/data/embedded_python/course.xml b/common/test/data/embedded_python/course.xml new file mode 100644 index 0000000000..1662543b4d --- /dev/null +++ b/common/test/data/embedded_python/course.xml @@ -0,0 +1 @@ + diff --git a/common/test/data/embedded_python/course/2013_Spring.xml b/common/test/data/embedded_python/course/2013_Spring.xml new file mode 100644 index 0000000000..35e0990d18 --- /dev/null +++ b/common/test/data/embedded_python/course/2013_Spring.xml @@ -0,0 +1,95 @@ + + + + + +
+# for a schematic response, submission[i] is the json representation +# of the diagram and analysis results for the i-th schematic tag + +def get_tran(json,signal): + for element in json: + if element[0] == 'transient': + return element[1].get(signal,[]) + return [] + +def get_value(at,output): + for (t,v) in output: + if at == t: return v + return None + +output = get_tran(submission[0],'Z') +okay = True + +# output should be 1, 1, 1, 1, 1, 0, 0, 0 +if get_value(0.0000004,output) < 2.7: okay = False; +if get_value(0.0000009,output) < 2.7: okay = False; +if get_value(0.0000014,output) < 2.7: okay = False; +if get_value(0.0000019,output) < 2.7: okay = False; +if get_value(0.0000024,output) < 2.7: okay = False; +if get_value(0.0000029,output) > 0.25: okay = False; +if get_value(0.0000034,output) > 0.25: okay = False; +if get_value(0.0000039,output) > 0.25: okay = False; + +correct = ['correct' if okay else 'incorrect'] + +
+ + + + +
+ +
+ + + +
+
diff --git a/common/test/data/embedded_python/roots/2013_Spring.xml b/common/test/data/embedded_python/roots/2013_Spring.xml new file mode 100644 index 0000000000..1662543b4d --- /dev/null +++ b/common/test/data/embedded_python/roots/2013_Spring.xml @@ -0,0 +1 @@ + diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 5345630957..3a25b2b62d 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -372,6 +372,7 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): '''Check that all pages in test courses load properly from XML''' def setUp(self): + super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() self.setup_viewtest_user() xmodule.modulestore.django._MODULESTORES = {} @@ -390,6 +391,7 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): '''Check that all pages in test courses load properly from Mongo''' def setUp(self): + super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() self.setup_viewtest_user() xmodule.modulestore.django._MODULESTORES = {} modulestore().collection.drop() @@ -479,9 +481,6 @@ class TestDraftModuleStore(TestCase): class TestViewAuth(LoginEnrollmentTestCase): """Check that view authentication works properly""" - # NOTE: setUpClass() runs before override_settings takes effect, so - # can't do imports there without manually hacking settings. - def setUp(self): xmodule.modulestore.django._MODULESTORES = {} @@ -804,38 +803,61 @@ class TestViewAuth(LoginEnrollmentTestCase): @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCourseGrader(LoginEnrollmentTestCase): +class TestSubmittingProblems(LoginEnrollmentTestCase): """Check that a course gets graded properly""" - # NOTE: setUpClass() runs before override_settings takes effect, so - # can't do imports there without manually hacking settings. + # Subclasses should specify the course slug + course_slug = "UNKNOWN" + course_when = "UNKNOWN" def setUp(self): xmodule.modulestore.django._MODULESTORES = {} - self.graded_course = modulestore().get_course("edX/graded/2012_Fall") + course_name = "edX/%s/%s" % (self.course_slug, self.course_when) + self.course = modulestore().get_course(course_name) + assert self.course, "Couldn't load course %r" % course_name # create a test student self.student = 'view@test.com' self.password = 'foo' self.create_account('u1', self.student, self.password) self.activate_user(self.student) - self.enroll(self.graded_course) + self.enroll(self.course) self.student_user = get_user(self.student) self.factory = RequestFactory() + def problem_location(self, problem_url_name): + return "i4x://edX/{}/problem/{}".format(self.course_slug, problem_url_name) + + def modx_url(self, problem_location, dispatch): + return reverse( + 'modx_dispatch', + kwargs={ + 'course_id': self.course.id, + 'location': problem_location, + 'dispatch': dispatch, + } + ) + + +class TestCourseGrader(TestSubmittingProblems): + """Check that a course gets graded properly""" + + course_slug = "graded" + course_when = "2012_Fall" + def get_grade_summary(self): '''calls grades.grade for current user and course''' model_data_cache = ModelDataCache.cache_for_descriptor_descendents( - self.graded_course.id, self.student_user, self.graded_course) + self.course.id, self.student_user, self.course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.course.id})) return grades.grade(self.student_user, fake_request, - self.graded_course, model_data_cache) + self.course, model_data_cache) def get_homework_scores(self): '''get scores for homeworks''' @@ -844,10 +866,10 @@ class TestCourseGrader(LoginEnrollmentTestCase): def get_progress_summary(self): '''return progress summary structure for current user and course''' model_data_cache = ModelDataCache.cache_for_descriptor_descendents( - self.graded_course.id, self.student_user, self.graded_course) + self.course.id, self.student_user, self.course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.course.id})) progress_summary = grades.progress_summary(self.student_user, fake_request, @@ -868,13 +890,8 @@ class TestCourseGrader(LoginEnrollmentTestCase): input_i4x-edX-graded-problem-H1P3_2_1 input_i4x-edX-graded-problem-H1P3_2_2 """ - problem_location = "i4x://edX/graded/problem/%s" % problem_url_name - - modx_url = reverse('modx_dispatch', - kwargs={'course_id': self.graded_course.id, - 'location': problem_location, - 'dispatch': 'problem_check', }) - + problem_location = self.problem_location(problem_url_name) + modx_url = self.modx_url(problem_location, 'problem_check') resp = self.client.post(modx_url, { 'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0], 'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1], @@ -884,19 +901,10 @@ class TestCourseGrader(LoginEnrollmentTestCase): return resp - def problem_location(self, problem_url_name): - '''Get location string for problem, assuming hardcoded course_id''' - return "i4x://edX/graded/problem/{0}".format(problem_url_name) - def reset_question_answer(self, problem_url_name): '''resets specified problem for current user''' problem_location = self.problem_location(problem_url_name) - - modx_url = reverse('modx_dispatch', - kwargs={'course_id': self.graded_course.id, - 'location': problem_location, - 'dispatch': 'problem_reset', }) - + modx_url = self.modx_url(problem_location, 'problem_reset') resp = self.client.post(modx_url) return resp @@ -962,3 +970,39 @@ class TestCourseGrader(LoginEnrollmentTestCase): # Now we answer the final question (worth half of the grade) self.submit_question_answer('FinalQuestion', ['Correct', 'Correct']) self.check_grade_percent(1.0) # Hooray! We got 100% + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestSchematicResponse(TestSubmittingProblems): + """Check that a course gets graded properly""" + + course_slug = "embedded_python" + course_when = "2013_Spring" + + def submit_question_answer(self, problem_url_name, responses): + """Particular to the embedded_python/2013_Spring course.""" + problem_location = self.problem_location(problem_url_name) + modx_url = self.modx_url(problem_location, 'problem_check') + resp = self.client.post(modx_url, { + 'input_i4x-edX-embedded_python-problem-{0}_2_1'.format(problem_url_name): json.dumps(responses), + }) + print "modx_url", modx_url, "responses", responses + print "resp", resp + + return resp + + def test_get_graded(self): + resp = self.submit_question_answer('H1P1', + [['transient', {'Z': [ + [0.0000004, 2.8], + [0.0000009, 2.8], + [0.0000014, 2.8], + [0.0000019, 2.8], + [0.0000024, 2.8], + [0.0000029, 0.2], + [0.0000034, 0.2], + [0.0000039, 0.2] + ]}]] + ) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'correct') From 9249bafd001a79c140327f417d4e6af2b5d2eed2 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 11 Feb 2013 15:37:23 -0500 Subject: [PATCH 012/120] Add a test of a bad answer also. --- lms/djangoapps/courseware/tests/tests.py | 38 +++++++++++++++--------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 3a25b2b62d..2f469fe750 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -841,6 +841,13 @@ class TestSubmittingProblems(LoginEnrollmentTestCase): } ) + def reset_question_answer(self, problem_url_name): + '''resets specified problem for current user''' + problem_location = self.problem_location(problem_url_name) + modx_url = self.modx_url(problem_location, 'problem_reset') + resp = self.client.post(modx_url) + return resp + class TestCourseGrader(TestSubmittingProblems): """Check that a course gets graded properly""" @@ -896,16 +903,6 @@ class TestCourseGrader(TestSubmittingProblems): 'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0], 'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1], }) - print "modx_url", modx_url, "responses", responses - print "resp", resp - - return resp - - def reset_question_answer(self, problem_url_name): - '''resets specified problem for current user''' - problem_location = self.problem_location(problem_url_name) - modx_url = self.modx_url(problem_location, 'problem_reset') - resp = self.client.post(modx_url) return resp def test_get_graded(self): @@ -974,7 +971,7 @@ class TestCourseGrader(TestSubmittingProblems): @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestSchematicResponse(TestSubmittingProblems): - """Check that a course gets graded properly""" + """Check that we can submit a schematic response, and it answers properly.""" course_slug = "embedded_python" course_when = "2013_Spring" @@ -986,9 +983,6 @@ class TestSchematicResponse(TestSubmittingProblems): resp = self.client.post(modx_url, { 'input_i4x-edX-embedded_python-problem-{0}_2_1'.format(problem_url_name): json.dumps(responses), }) - print "modx_url", modx_url, "responses", responses - print "resp", resp - return resp def test_get_graded(self): @@ -1006,3 +1000,19 @@ class TestSchematicResponse(TestSubmittingProblems): ) respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'correct') + + self.reset_question_answer('H1P1') + resp = self.submit_question_answer('H1P1', + [['transient', {'Z': [ + [0.0000004, 2.8], + [0.0000009, 0.0], # wrong. + [0.0000014, 2.8], + [0.0000019, 2.8], + [0.0000024, 2.8], + [0.0000029, 0.2], + [0.0000034, 0.2], + [0.0000039, 0.2] + ]}]] + ) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'incorrect') From 0a6761c9a5ebdd50b932b60133e3558f8a381984 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 12 Feb 2013 11:41:33 -0500 Subject: [PATCH 013/120] Clean up this xml data file. --- .../embedded_python/course/2013_Spring.xml | 60 +++---------------- 1 file changed, 7 insertions(+), 53 deletions(-) diff --git a/common/test/data/embedded_python/course/2013_Spring.xml b/common/test/data/embedded_python/course/2013_Spring.xml index 35e0990d18..cfa67ff0f3 100644 --- a/common/test/data/embedded_python/course/2013_Spring.xml +++ b/common/test/data/embedded_python/course/2013_Spring.xml @@ -1,9 +1,13 @@ - + -
+ +
+ +
+ # for a schematic response, submission[i] is the json representation # of the diagram and analysis results for the i-th schematic tag @@ -39,57 +43,7 @@ correct = ['correct' if okay else 'incorrect']
- +
- - -
From 33abe54e0dd6dd11b4e1734cdfd835cf10ba4738 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 14 Feb 2013 10:17:07 -0500 Subject: [PATCH 014/120] Work in progress to sandbox the uses of eval in LMS. --- common/lib/capa/capa/capa_problem.py | 44 +++++++++---- common/lib/capa/capa/responsetypes.py | 66 ++++++++----------- common/lib/codejail/codejail/safe_exec.py | 5 ++ .../embedded_python/course/2013_Spring.xml | 54 ++++++++++++++- lms/djangoapps/courseware/tests/tests.py | 34 +++++++++- 5 files changed, 147 insertions(+), 56 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 6580114bcc..6973deb4a0 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -22,7 +22,6 @@ import numpy import os import random import re -import scipy import struct import sys @@ -30,6 +29,7 @@ from lxml import etree from xml.sax.saxutils import unescape from copy import deepcopy +<<<<<<< HEAD import chem import chem.miller import chem.chemcalc @@ -38,8 +38,9 @@ import verifiers import verifiers.draganddrop import calc +======= +>>>>>>> Work in progress to sandbox the uses of eval in LMS. from .correctmap import CorrectMap -import eia import inputtypes import customrender from .util import contextualize_text, convert_files_to_filenames @@ -48,6 +49,8 @@ import xqueue_interface # to be replaced with auto-registering import responsetypes +from codejail.safe_exec import safe_exec + # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) @@ -63,6 +66,7 @@ html_transforms = {'problem': {'tag': 'div'}, "math": {'tag': 'span'}, } +<<<<<<< HEAD global_context = {'random': random, 'numpy': numpy, 'math': math, @@ -73,6 +77,20 @@ global_context = {'random': random, 'chemtools': chem.chemtools, 'miller': chem.miller, 'draganddrop': verifiers.draganddrop} +======= +safe_exec_assumed_imports = [ + "random", + "numpy", + "math", + "scipy", + "calc", + "eia", + ("chemcalc", "chem.chemcalc"), + ("chemtools", "chem.chemtools"), + ("miller", "chem.miller"), + ("draganddrop", "verifiers.draganddrop"), +] +>>>>>>> Work in progress to sandbox the uses of eval in LMS. # These should be removed from HTML output, including all subelements html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"] @@ -144,7 +162,7 @@ class LoncapaProblem(object): self._process_includes() # construct script processor context (eg for customresponse problems) - self.context = self._extract_context(self.tree, seed=self.seed) + self.context = self._extract_context(self.tree) # Pre-parse the XML tree: modifies it to add ID's and perform some in-place # transformations. This also creates the dict (self.responders) of Response @@ -451,7 +469,7 @@ class LoncapaProblem(object): return path - def _extract_context(self, tree, seed=struct.unpack('i', os.urandom(4))[0]): # private + def _extract_context(self, tree): ''' Extract content of from the problem.xml file, and exec it in the context of this problem. Provides ability to randomize problems, and also set @@ -460,14 +478,18 @@ class LoncapaProblem(object): Problem XML goes to Python execution context. Runs everything in script tags. ''' random.seed(self.seed) - # save global context in here also - context = {'global_context': global_context} - - # initialize context to have stuff in global_context - context.update(global_context) + # TODO: REMOVE THIS COMMENTED OUT CODE. + ## save global context in here also + #context = {'global_context': global_context} + # + ## initialize context to have stuff in global_context + #context.update(global_context) + # # put globals there also - context['__builtins__'] = globals()['__builtins__'] + #context['__builtins__'] = globals()['__builtins__'] + + context = {} # pass instance of LoncapaProblem in context['the_lcp'] = self @@ -501,7 +523,7 @@ class LoncapaProblem(object): context['script_code'] += code try: # use "context" for global context; thus defs in code are global within code - exec code in context, context + safe_exec(code, context, future_division=True, assumed_imports=safe_exec_assumed_imports) except Exception as err: log.exception("Error while execing script code: " + code) msg = "Error while executing script code: %s" % str(err).replace('<', '<') diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index b06a62ffc6..f732c9fc84 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -37,6 +37,8 @@ from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? import xqueue_interface +from codejail.safe_exec import safe_exec + log = logging.getLogger(__name__) @@ -968,14 +970,20 @@ def sympy_check2(): cfn = xml.get('cfn') if cfn: log.debug("cfn = %s" % cfn) - if cfn in self.context: - self.code = self.context[cfn] - else: - msg = "%s: can't find cfn %s in context" % ( - unicode(self), cfn) - msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', - '') - raise LoncapaProblemError(msg) + + def make_check_function(script_code, cfn): + def check_function(expect, ans): + code = (script_code + "\n" + + "cfn_return = %s(expect, ans)\n" % cfn) + globals_dict = { + 'expect': expect, + 'ans': ans, + } + safe_exec(code, globals_dict) + return globals_dict['cfn_return'] + return check_function + + self.code = make_check_function(self.context['script_code'], cfn) if not self.code: if answer is None: @@ -1074,6 +1082,7 @@ def sympy_check2(): # exec the check function if isinstance(self.code, basestring): try: + raise Exception("exec 1") exec self.code in self.context['global_context'], self.context correct = self.context['correct'] messages = self.context['messages'] @@ -1083,32 +1092,15 @@ def sympy_check2(): self._handle_exec_exception(err) else: - # self.code is not a string; assume its a function + # self.code is not a string; it's a function we created earlier. # this is an interface to the Tutor2 check functions fn = self.code ret = None log.debug(" submission = %s" % submission) try: - answer_given = submission[0] if ( - len(idset) == 1) else submission - # handle variable number of arguments in check function, for backwards compatibility - # with various Tutor2 check functions - args = [self.expect, answer_given, - student_answers, self.answer_ids[0]] - argspec = inspect.getargspec(fn) - nargs = len(argspec.args) - len(argspec.defaults or []) - kwargs = {} - for argname in argspec.args[nargs:]: - kwargs[argname] = self.context[ - argname] if argname in self.context else None - - log.debug('[customresponse] answer_given=%s' % answer_given) - log.debug('nargs=%d, args=%s, kwargs=%s' % ( - nargs, args, kwargs)) - - ret = fn(*args[:nargs], **kwargs) - + answer_given = submission[0] if (len(idset) == 1) else submission + ret = fn(self.expect, answer_given) except Exception as err: self._handle_exec_exception(err) @@ -1265,6 +1257,7 @@ class SymbolicResponse(CustomResponse): def setup_response(self): self.xml.set('cfn', 'symmath_check') code = "from symmath import *" + raise Exception("exec 2") exec code in self.context, self.context CustomResponse.setup_response(self) @@ -1378,6 +1371,7 @@ class CodeResponse(LoncapaResponse): penv = {} penv['__builtins__'] = globals()['__builtins__'] try: + raise Exception("exec 3") exec(code, penv, penv) except Exception as err: log.error( @@ -1925,18 +1919,12 @@ class SchematicResponse(LoncapaResponse): self.code = answer.text def get_score(self, student_answers): - from capa_problem import global_context - submission = [json.loads(student_answers[ - k]) for k in sorted(self.answer_ids)] + #from capa_problem import global_context + submission = [ + json.loads(student_answers[k]) for k in sorted(self.answer_ids) + ] self.context.update({'submission': submission}) - - try: - exec self.code in global_context, self.context - - except Exception as err: - _, _, traceback_obj = sys.exc_info() - raise ResponseError, ResponseError(err.message), traceback_obj - + safe_exec(self.code, {}, self.context) cmap = CorrectMap() cmap.set_dict(dict(zip(sorted( self.answer_ids), self.context['correct']))) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index eeb62d5bbd..92beba98cc 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -19,6 +19,11 @@ def jsonable_dict(d): return jd def safe_exec(code, globals_dict, locals_dict=None, future_division=False, assumed_imports=None): + """Execute code safely. + + Returns None. The code can modify globals in `global_dict`. + + """ if future_division: code = "from __future__ import division\n" + code diff --git a/common/test/data/embedded_python/course/2013_Spring.xml b/common/test/data/embedded_python/course/2013_Spring.xml index cfa67ff0f3..15e11befd1 100644 --- a/common/test/data/embedded_python/course/2013_Spring.xml +++ b/common/test/data/embedded_python/course/2013_Spring.xml @@ -1,11 +1,14 @@ - + - +
- +
# for a schematic response, submission[i] is the json representation @@ -44,6 +47,51 @@ correct = ['correct' if okay else 'incorrect']
+ + + + + +
    +
  1. +
    +num = 0
    +while num <= 5:
    +    print(num)
    +    num += 1
    +
    +print("Outside of loop")
    +print(num)
    + 
    +

    + + + +

    +
  2. +
+
+
+
diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 2f469fe750..acad3e9c84 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -986,7 +986,7 @@ class TestSchematicResponse(TestSubmittingProblems): return resp def test_get_graded(self): - resp = self.submit_question_answer('H1P1', + resp = self.submit_question_answer('schematic_problem', [['transient', {'Z': [ [0.0000004, 2.8], [0.0000009, 2.8], @@ -1001,8 +1001,8 @@ class TestSchematicResponse(TestSubmittingProblems): respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'correct') - self.reset_question_answer('H1P1') - resp = self.submit_question_answer('H1P1', + self.reset_question_answer('schematic_problem') + resp = self.submit_question_answer('schematic_problem', [['transient', {'Z': [ [0.0000004, 2.8], [0.0000009, 0.0], # wrong. @@ -1016,3 +1016,31 @@ class TestSchematicResponse(TestSubmittingProblems): ) respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'incorrect') + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestCustomResponseCfnFunction(TestSubmittingProblems): + """Check that cfn functions work properly.""" + + course_slug = "embedded_python" + course_when = "2013_Spring" + + def submit_question_answer(self, problem_url_name, responses): + """Particular to the embedded_python/2013_Spring course.""" + problem_location = self.problem_location(problem_url_name) + modx_url = self.modx_url(problem_location, 'problem_check') + resp = self.client.post(modx_url, { + 'input_i4x-edX-embedded_python-problem-{0}_2_1'.format(problem_url_name): responses, + }) + return resp + + def test_get_graded(self): + resp = self.submit_question_answer('cfn_problem', "0, 1, 2, 3, 4, 5, 'Outside of loop', 6") + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'correct') + + self.reset_question_answer('cfn_problem') + + resp = self.submit_question_answer('cfn_problem', "xyzzy!") + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'incorrect') From ff1df569cb15d0c7f5f2c907cd0bcd0aea4b311d Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 14 Feb 2013 12:40:47 -0500 Subject: [PATCH 015/120] Refactor submitting problems so we don't need custom code for each test. --- lms/djangoapps/courseware/tests/tests.py | 123 ++++++++++------------- 1 file changed, 55 insertions(+), 68 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index acad3e9c84..39914d5b8d 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -841,6 +841,23 @@ class TestSubmittingProblems(LoginEnrollmentTestCase): } ) + def submit_question_answer(self, problem_url_name, responses): + """ + Submit answers to a question. + + Responses is a dict mapping problem ids (not sure of the right term) + to answers: + {'2_1': 'Correct', '2_2': 'Incorrect'} + + """ + problem_location = self.problem_location(problem_url_name) + modx_url = self.modx_url(problem_location, 'problem_check') + answer_key_prefix = 'input_i4x-edX-{}-problem-{}_'.format(self.course_slug, problem_url_name) + resp = self.client.post(modx_url, + { (answer_key_prefix + k): v for k,v in responses.items() } + ) + return resp + def reset_question_answer(self, problem_url_name): '''resets specified problem for current user''' problem_location = self.problem_location(problem_url_name) @@ -889,22 +906,6 @@ class TestCourseGrader(TestSubmittingProblems): grade_summary = self.get_grade_summary() self.assertEqual(grade_summary['percent'], percent) - def submit_question_answer(self, problem_url_name, responses): - """ - The field names of a problem are hard to determine. This method only works - for the problems used in the edX/graded course, which has fields named in the - following form: - input_i4x-edX-graded-problem-H1P3_2_1 - input_i4x-edX-graded-problem-H1P3_2_2 - """ - problem_location = self.problem_location(problem_url_name) - modx_url = self.modx_url(problem_location, 'problem_check') - resp = self.client.post(modx_url, { - 'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0], - 'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1], - }) - return resp - def test_get_graded(self): #### Check that the grader shows we have 0% in the course self.check_grade_percent(0) @@ -922,27 +923,27 @@ class TestCourseGrader(TestSubmittingProblems): return [s.earned for s in hw_section['scores']] # Only get half of the first problem correct - self.submit_question_answer('H1P1', ['Correct', 'Incorrect']) + self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Incorrect'}) self.check_grade_percent(0.06) self.assertEqual(earned_hw_scores(), [1.0, 0, 0]) # Order matters self.assertEqual(score_for_hw('Homework1'), [1.0, 0.0]) # Get both parts of the first problem correct self.reset_question_answer('H1P1') - self.submit_question_answer('H1P1', ['Correct', 'Correct']) + self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.13) self.assertEqual(earned_hw_scores(), [2.0, 0, 0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 0.0]) # This problem is shown in an ABTest - self.submit_question_answer('H1P2', ['Correct', 'Correct']) + self.submit_question_answer('H1P2', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.25) self.assertEqual(earned_hw_scores(), [4.0, 0.0, 0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) # This problem is hidden in an ABTest. # Getting it correct doesn't change total grade - self.submit_question_answer('H1P3', ['Correct', 'Correct']) + self.submit_question_answer('H1P3', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.25) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) @@ -951,21 +952,21 @@ class TestCourseGrader(TestSubmittingProblems): # This problem is also weighted to be 4 points (instead of default of 2) # If the problem was unweighted the percent would have been 0.38 so we # know it works. - self.submit_question_answer('H2P1', ['Correct', 'Correct']) + self.submit_question_answer('H2P1', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.42) self.assertEqual(earned_hw_scores(), [4.0, 4.0, 0]) # Third homework - self.submit_question_answer('H3P1', ['Correct', 'Correct']) + self.submit_question_answer('H3P1', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.42) # Score didn't change self.assertEqual(earned_hw_scores(), [4.0, 4.0, 2.0]) - self.submit_question_answer('H3P2', ['Correct', 'Correct']) + self.submit_question_answer('H3P2', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.5) # Now homework2 dropped. Score changes self.assertEqual(earned_hw_scores(), [4.0, 4.0, 4.0]) # Now we answer the final question (worth half of the grade) - self.submit_question_answer('FinalQuestion', ['Correct', 'Correct']) + self.submit_question_answer('FinalQuestion', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(1.0) # Hooray! We got 100% @@ -976,44 +977,39 @@ class TestSchematicResponse(TestSubmittingProblems): course_slug = "embedded_python" course_when = "2013_Spring" - def submit_question_answer(self, problem_url_name, responses): - """Particular to the embedded_python/2013_Spring course.""" - problem_location = self.problem_location(problem_url_name) - modx_url = self.modx_url(problem_location, 'problem_check') - resp = self.client.post(modx_url, { - 'input_i4x-edX-embedded_python-problem-{0}_2_1'.format(problem_url_name): json.dumps(responses), - }) - return resp - - def test_get_graded(self): + def test_schematic(self): resp = self.submit_question_answer('schematic_problem', - [['transient', {'Z': [ - [0.0000004, 2.8], - [0.0000009, 2.8], - [0.0000014, 2.8], - [0.0000019, 2.8], - [0.0000024, 2.8], - [0.0000029, 0.2], - [0.0000034, 0.2], - [0.0000039, 0.2] - ]}]] - ) + { '2_1': json.dumps( + [['transient', {'Z': [ + [0.0000004, 2.8], + [0.0000009, 2.8], + [0.0000014, 2.8], + [0.0000019, 2.8], + [0.0000024, 2.8], + [0.0000029, 0.2], + [0.0000034, 0.2], + [0.0000039, 0.2] + ]}]] + ) + }) respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'correct') self.reset_question_answer('schematic_problem') resp = self.submit_question_answer('schematic_problem', - [['transient', {'Z': [ - [0.0000004, 2.8], - [0.0000009, 0.0], # wrong. - [0.0000014, 2.8], - [0.0000019, 2.8], - [0.0000024, 2.8], - [0.0000029, 0.2], - [0.0000034, 0.2], - [0.0000039, 0.2] - ]}]] - ) + { '2_1': json.dumps( + [['transient', {'Z': [ + [0.0000004, 2.8], + [0.0000009, 0.0], # wrong. + [0.0000014, 2.8], + [0.0000019, 2.8], + [0.0000024, 2.8], + [0.0000029, 0.2], + [0.0000034, 0.2], + [0.0000039, 0.2] + ]}]] + ) + }) respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'incorrect') @@ -1025,22 +1021,13 @@ class TestCustomResponseCfnFunction(TestSubmittingProblems): course_slug = "embedded_python" course_when = "2013_Spring" - def submit_question_answer(self, problem_url_name, responses): - """Particular to the embedded_python/2013_Spring course.""" - problem_location = self.problem_location(problem_url_name) - modx_url = self.modx_url(problem_location, 'problem_check') - resp = self.client.post(modx_url, { - 'input_i4x-edX-embedded_python-problem-{0}_2_1'.format(problem_url_name): responses, - }) - return resp - - def test_get_graded(self): - resp = self.submit_question_answer('cfn_problem', "0, 1, 2, 3, 4, 5, 'Outside of loop', 6") + def test_check_function(self): + resp = self.submit_question_answer('cfn_problem', {'2_1': "0, 1, 2, 3, 4, 5, 'Outside of loop', 6"}) respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'correct') self.reset_question_answer('cfn_problem') - resp = self.submit_question_answer('cfn_problem', "xyzzy!") + resp = self.submit_question_answer('cfn_problem', {'2_1': "xyzzy!"}) respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'incorrect') From 6297d64528afa1cae676aa25a7aac9ee97616aea Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 14 Feb 2013 12:43:06 -0500 Subject: [PATCH 016/120] Now these can be in the same test class --- lms/djangoapps/courseware/tests/tests.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 39914d5b8d..ba9799b0df 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1013,14 +1013,6 @@ class TestSchematicResponse(TestSubmittingProblems): respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'incorrect') - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCustomResponseCfnFunction(TestSubmittingProblems): - """Check that cfn functions work properly.""" - - course_slug = "embedded_python" - course_when = "2013_Spring" - def test_check_function(self): resp = self.submit_question_answer('cfn_problem', {'2_1': "0, 1, 2, 3, 4, 5, 'Outside of loop', 6"}) respdata = json.loads(resp.content) From 37ca6bf77ef3b4479d5c4d53a141e6466cdf6d90 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 14 Feb 2013 13:14:15 -0500 Subject: [PATCH 017/120] Move our specialization of safe_exec into a new module to avoid circular imports. --- common/lib/capa/capa/capa_problem.py | 54 +-------------------------- common/lib/capa/capa/responsetypes.py | 11 ++++-- common/lib/capa/capa/safe_exec.py | 20 ++++++++++ 3 files changed, 30 insertions(+), 55 deletions(-) create mode 100644 common/lib/capa/capa/safe_exec.py diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 6973deb4a0..5518da8318 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -13,8 +13,6 @@ Main module which shows problems (of "capa" type). This is used by capa_module. ''' -from __future__ import division - from datetime import datetime import logging import math @@ -29,17 +27,6 @@ from lxml import etree from xml.sax.saxutils import unescape from copy import deepcopy -<<<<<<< HEAD -import chem -import chem.miller -import chem.chemcalc -import chem.chemtools -import verifiers -import verifiers.draganddrop - -import calc -======= ->>>>>>> Work in progress to sandbox the uses of eval in LMS. from .correctmap import CorrectMap import inputtypes import customrender @@ -48,8 +35,7 @@ import xqueue_interface # to be replaced with auto-registering import responsetypes - -from codejail.safe_exec import safe_exec +import safe_exec # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) @@ -66,32 +52,6 @@ html_transforms = {'problem': {'tag': 'div'}, "math": {'tag': 'span'}, } -<<<<<<< HEAD -global_context = {'random': random, - 'numpy': numpy, - 'math': math, - 'scipy': scipy, - 'calc': calc, - 'eia': eia, - 'chemcalc': chem.chemcalc, - 'chemtools': chem.chemtools, - 'miller': chem.miller, - 'draganddrop': verifiers.draganddrop} -======= -safe_exec_assumed_imports = [ - "random", - "numpy", - "math", - "scipy", - "calc", - "eia", - ("chemcalc", "chem.chemcalc"), - ("chemtools", "chem.chemtools"), - ("miller", "chem.miller"), - ("draganddrop", "verifiers.draganddrop"), -] ->>>>>>> Work in progress to sandbox the uses of eval in LMS. - # These should be removed from HTML output, including all subelements html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"] @@ -479,16 +439,6 @@ class LoncapaProblem(object): ''' random.seed(self.seed) - # TODO: REMOVE THIS COMMENTED OUT CODE. - ## save global context in here also - #context = {'global_context': global_context} - # - ## initialize context to have stuff in global_context - #context.update(global_context) - # - # put globals there also - #context['__builtins__'] = globals()['__builtins__'] - context = {} # pass instance of LoncapaProblem in @@ -523,7 +473,7 @@ class LoncapaProblem(object): context['script_code'] += code try: # use "context" for global context; thus defs in code are global within code - safe_exec(code, context, future_division=True, assumed_imports=safe_exec_assumed_imports) + safe_exec.safe_exec(code, context) except Exception as err: log.exception("Error while execing script code: " + code) msg = "Error while executing script code: %s" % str(err).replace('<', '<') diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index f732c9fc84..1b40742419 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -37,7 +37,7 @@ from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? import xqueue_interface -from codejail.safe_exec import safe_exec +import safe_exec log = logging.getLogger(__name__) @@ -971,6 +971,11 @@ def sympy_check2(): if cfn: log.debug("cfn = %s" % cfn) + # This is a bit twisty. We used to grab the cfn function from + # the context, but now that we sandbox Python execution, we + # can't get functions from previous executions. So we make an + # actual function that will re-execute the original script, + # and invoke the function with the data needed. def make_check_function(script_code, cfn): def check_function(expect, ans): code = (script_code + "\n" + @@ -979,7 +984,7 @@ def sympy_check2(): 'expect': expect, 'ans': ans, } - safe_exec(code, globals_dict) + safe_exec.safe_exec(code, globals_dict) return globals_dict['cfn_return'] return check_function @@ -1924,7 +1929,7 @@ class SchematicResponse(LoncapaResponse): json.loads(student_answers[k]) for k in sorted(self.answer_ids) ] self.context.update({'submission': submission}) - safe_exec(self.code, {}, self.context) + safe_exec.safe_exec(self.code, {}, self.context) cmap = CorrectMap() cmap.set_dict(dict(zip(sorted( self.answer_ids), self.context['correct']))) diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py new file mode 100644 index 0000000000..0f57ece529 --- /dev/null +++ b/common/lib/capa/capa/safe_exec.py @@ -0,0 +1,20 @@ +"""Capa's specialized use of codejail.safe_exec.""" + +import codejail.safe_exec + +def safe_exec(code, globals_dict, locals_dict=None): + codejail.safe_exec.safe_exec( + code, globals_dict, locals_dict, future_division=True, + assumed_imports=[ + "random", + "numpy", + "math", + "scipy", + "calc", + "eia", + ("chemcalc", "chem.chemcalc"), + ("chemtools", "chem.chemtools"), + ("miller", "chem.miller"), + ("draganddrop", "verifiers.draganddrop"), + ], + ) From a6677aa0a8cf8507844b89f2c5f0fc7d933ba729 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 14 Feb 2013 13:15:29 -0500 Subject: [PATCH 018/120] Computed answers are run through safe_exec. --- common/lib/capa/capa/responsetypes.py | 3 +-- .../data/embedded_python/course/2013_Spring.xml | 14 ++++++++++++++ lms/djangoapps/courseware/tests/tests.py | 5 +++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 1b40742419..6c8af09946 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1087,8 +1087,7 @@ def sympy_check2(): # exec the check function if isinstance(self.code, basestring): try: - raise Exception("exec 1") - exec self.code in self.context['global_context'], self.context + safe_exec.safe_exec(self.code, self.context) correct = self.context['correct'] messages = self.context['messages'] overall_message = self.context['overall_message'] diff --git a/common/test/data/embedded_python/course/2013_Spring.xml b/common/test/data/embedded_python/course/2013_Spring.xml index 15e11befd1..fa6881c37b 100644 --- a/common/test/data/embedded_python/course/2013_Spring.xml +++ b/common/test/data/embedded_python/course/2013_Spring.xml @@ -92,6 +92,20 @@ print(num) + + + + + +if submission[0] == "Xyzzy": + correct = ['correct'] +else: + correct = ['incorrect'] + + + + +
diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index ba9799b0df..9d3538d19b 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1023,3 +1023,8 @@ class TestSchematicResponse(TestSubmittingProblems): resp = self.submit_question_answer('cfn_problem', {'2_1': "xyzzy!"}) respdata = json.loads(resp.content) self.assertEqual(respdata['success'], 'incorrect') + + def test_computed_answer(self): + resp = self.submit_question_answer('computed_answer', {'2_1': "Xyzzy"}) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'correct') From 248017b4ea4cf4304550d001b12abc26f7cca603 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 14 Feb 2013 13:46:48 -0500 Subject: [PATCH 019/120] No longer need to support without , so scrap the code. --- common/lib/capa/capa/responsetypes.py | 63 +-------------------------- 1 file changed, 2 insertions(+), 61 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 6c8af09946..1b06ce63db 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1318,10 +1318,8 @@ class CodeResponse(LoncapaResponse): # Check if XML uses the ExternalResponse format or the generic # CodeResponse format codeparam = self.xml.find('codeparam') - if codeparam is None: - self._parse_externalresponse_xml() - else: - self._parse_coderesponse_xml(codeparam) + assert codeparam is not None, "Unsupported old format! without " + self._parse_coderesponse_xml(codeparam) def _parse_coderesponse_xml(self, codeparam): ''' @@ -1341,63 +1339,6 @@ class CodeResponse(LoncapaResponse): self.answer = find_with_default(codeparam, 'answer_display', 'No answer provided.') - def _parse_externalresponse_xml(self): - ''' - VS[compat]: Suppport for old ExternalResponse XML format. When successful, sets: - self.initial_display - self.answer (an answer to display to the student in the LMS) - self.payload - ''' - answer = self.xml.find('answer') - - if answer is not None: - answer_src = answer.get('src') - if answer_src is not None: - code = self.system.filesystem.open('src/' + answer_src).read() - else: - code = answer.text - else: # no stanza; get code from ''' - snippets = [{'snippet': r""" - -
- Suppose that \(I(t)\) rises from \(0\) to \(I_S\) at a time \(t_0 \neq 0\) - In the space provided below write an algebraic expression for \(I(t)\). -
- -
- - correct=['correct'] - try: - r = str(submission[0]) - except ValueError: - correct[0] ='incorrect' - r = '0' - if not(r=="IS*u(t-t0)"): - correct[0] ='incorrect' - -
"""}, - {'snippet': """ - - - - - """}] response_tag = 'customresponse' @@ -1245,16 +1195,6 @@ class SymbolicResponse(CustomResponse): """ Symbolic math response checking, using symmath library. """ - snippets = [{'snippet': r''' - Compute \[ \exp\left(-i \frac{\theta}{2} \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) \] - and give the resulting \(2\times 2\) matrix:
- - - -
- Your input should be typed in as a list of lists, eg [[1,2],[3,4]]. -
-
'''}] response_tag = 'symbolicresponse' @@ -1518,44 +1458,6 @@ class ExternalResponse(LoncapaResponse): Typically used by coding problems. ''' - snippets = [{'snippet': ''' - - - - '''}] response_tag = 'externalresponse' allowed_inputfields = ['textline', 'textbox'] @@ -1701,23 +1603,6 @@ class FormulaResponse(LoncapaResponse): ''' Checking of symbolic math response using numerical sampling. ''' - snippets = [{'snippet': ''' - - - - -
- Give an equation for the relativistic energy of an object with mass m. -
- - - - - -
'''}] response_tag = 'formularesponse' hint_tag = 'formulahint' @@ -1908,19 +1793,6 @@ class ImageResponse(LoncapaResponse): Returns: True, if click is inside any region or rectangle. Otherwise False. """ - snippets = [{'snippet': ''' - - - - - - '''}] response_tag = 'imageresponse' allowed_inputfields = ['imageinput'] From 19e3a0ceb9faaabda80c82c06575af6947ebae06 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 14 Feb 2013 16:06:05 -0500 Subject: [PATCH 022/120] Implement safe_exec on top of jailpy (old unsafe safe_exec is still here); Remove some crazy stuff from the context; always pass globals and locals, locals are the things that can be changed. --- common/lib/capa/capa/capa_problem.py | 7 +- common/lib/capa/capa/responsetypes.py | 18 ++--- common/lib/capa/capa/safe_exec.py | 2 +- common/lib/codejail/codejail/safe_exec.py | 68 +++++++++++++++---- .../codejail/codejail/tests/test_jailpy.py | 7 ++ 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 5518da8318..202d197428 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -440,9 +440,6 @@ class LoncapaProblem(object): random.seed(self.seed) context = {} - - # pass instance of LoncapaProblem in - context['the_lcp'] = self context['script_code'] = '' self._execute_scripts(tree.findall('.//script'), context) @@ -473,7 +470,9 @@ class LoncapaProblem(object): context['script_code'] += code try: # use "context" for global context; thus defs in code are global within code - safe_exec.safe_exec(code, context) + locals_dict = {} + safe_exec.safe_exec(code, context, locals_dict) + context.update(locals_dict) except Exception as err: log.exception("Error while execing script code: " + code) msg = "Error while executing script code: %s" % str(err).replace('<', '<') diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 3aabc4adbd..1471093a98 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -934,8 +934,9 @@ class CustomResponse(LoncapaResponse): 'expect': expect, 'ans': ans, } - safe_exec.safe_exec(code, globals_dict) - return globals_dict['cfn_return'] + locals_dict = {} + safe_exec.safe_exec(code, globals_dict, locals_dict) + return locals_dict['cfn_return'] return check_function self.code = make_check_function(self.context['script_code'], cfn) @@ -995,9 +996,6 @@ class CustomResponse(LoncapaResponse): # put these in the context of the check function evaluator # note that this doesn't help the "cfn" version - only the exec version self.context.update({ - # our subtree - 'xml': self.xml, - # my ID 'response_id': self.myid, @@ -1037,7 +1035,9 @@ class CustomResponse(LoncapaResponse): # exec the check function if isinstance(self.code, basestring): try: - safe_exec.safe_exec(self.code, self.context) + locals_dict = {} + safe_exec.safe_exec(self.code, self.context, locals_dict) + self.context.update(locals_dict) correct = self.context['correct'] messages = self.context['messages'] overall_message = self.context['overall_message'] @@ -1754,10 +1754,10 @@ class SchematicResponse(LoncapaResponse): json.loads(student_answers[k]) for k in sorted(self.answer_ids) ] self.context.update({'submission': submission}) - safe_exec.safe_exec(self.code, {}, self.context) + locals_dict = {} + safe_exec.safe_exec(self.code, self.context, locals_dict) cmap = CorrectMap() - cmap.set_dict(dict(zip(sorted( - self.answer_ids), self.context['correct']))) + cmap.set_dict(dict(zip(sorted(self.answer_ids), locals_dict['correct']))) return cmap def get_answers(self): diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py index 0f57ece529..e6fa0e7bb5 100644 --- a/common/lib/capa/capa/safe_exec.py +++ b/common/lib/capa/capa/safe_exec.py @@ -2,7 +2,7 @@ import codejail.safe_exec -def safe_exec(code, globals_dict, locals_dict=None): +def safe_exec(code, globals_dict, locals_dict): codejail.safe_exec.safe_exec( code, globals_dict, locals_dict, future_division=True, assumed_imports=[ diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 92beba98cc..9de8398030 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -1,13 +1,15 @@ """Safe execution of untrusted Python code.""" import json +import textwrap -from .lazymod import LazyModule +import lazymod +import jailpy -def straw(v): - return json.loads(json.dumps(jsonable_dict(v))) - -def jsonable_dict(d): +# If we aren't running safe, then we need to artificially pass the values +# through a JSON straw to ensure we aren't passing something that won't +# be executable in the safe context. +def straw(d): jd = {} for k,v in d.iteritems(): try: @@ -16,9 +18,9 @@ def jsonable_dict(d): continue else: jd[k] = v - return jd + return json.loads(json.dumps(jd)) -def safe_exec(code, globals_dict, locals_dict=None, future_division=False, assumed_imports=None): +def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): """Execute code safely. Returns None. The code can modify globals in `global_dict`. @@ -28,21 +30,57 @@ def safe_exec(code, globals_dict, locals_dict=None, future_division=False, assum code = "from __future__ import division\n" + code g_dict = straw(globals_dict) - - if locals_dict is None: - l_dict = g_dict - else: - l_dict = straw(locals_dict) + l_dict = straw(locals_dict) for modname in assumed_imports or (): if isinstance(modname, tuple): name, modname = modname else: name = modname - g_dict[name] = LazyModule(modname) + g_dict[name] = lazymod.LazyModule(modname) exec code in g_dict, l_dict globals_dict.update(straw(g_dict)) - if locals_dict is not None: - locals_dict.update(straw(l_dict)) + locals_dict.update(straw(l_dict)) + +# We'll need the code from lazymod.py for use in jailpy, so read it now. +lazymod_py_file = lazymod.__file__ +if lazymod_py_file.endswith("c"): + lazymod_py_file = lazymod_py_file[:-1] + +lazymod_py = open(lazymod_py_file).read() + + +def xxxsafe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): + the_code = [] + + the_code.append(textwrap.dedent("""\ + import json + import sys + code, g_dict, l_dict = json.load(sys.stdin) + """)) + + if assumed_imports: + the_code.append(lazymod_py) + for modname in assumed_imports: + if isinstance(modname, tuple): + name, modname = modname + else: + name = modname + the_code.append("g_dict['{}'] = LazyModule('{}')\n".format(name, modname)) + + the_code.append(textwrap.dedent("""\ + exec code in g_dict, l_dict + print >>sys.stderr, l_dict.keys() + ok_types = (int, long, float, str, unicode, list, tuple, dict) + l_dict = {k:v for k,v in l_dict.iteritems() if isinstance(v, ok_types)} + json.dump(l_dict, sys.stdout) + """)) + + print "".join(the_code) + stdin = json.dumps([code, globals_dict, locals_dict]) + res = jailpy.jailpy("".join(the_code), stdin=stdin) + if res.status != 0: + raise Exception("Couldn't excecute jailed code: %s" % res.stderr) + locals_dict.update(json.loads(res.stdout)) diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index d1133d745d..a04bf3bf0f 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -33,6 +33,13 @@ class TestFeatures(unittest.TestCase): Exception: FAIL """)) + def test_stdin_is_provided(self): + res = jailpy( + "import json,sys; print sum(json.load(sys.stdin))", + stdin="[1, 2.5, 33]" + ) + self.assertEqual(res.stdout.strip(), "36.5") + class TestLimits(unittest.TestCase): def test_cant_use_too_much_memory(self): From 5db5426e05f443ab4e29f4750be23f01ae9fb8ef Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 15 Feb 2013 10:22:21 -0500 Subject: [PATCH 023/120] Use the real safe_exec; make the seed available in the context. --- common/lib/capa/capa/capa_problem.py | 1 + common/lib/capa/capa/responsetypes.py | 3 +-- common/lib/codejail/codejail/safe_exec.py | 14 +++++++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 202d197428..888501e8b5 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -440,6 +440,7 @@ class LoncapaProblem(object): random.seed(self.seed) context = {} + context['seed'] = self.seed context['script_code'] = '' self._execute_scripts(tree.findall('.//script'), context) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 1471093a98..7213a766ce 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -489,7 +489,7 @@ class JavascriptResponse(LoncapaResponse): output = self.call_node([generator_file, self.generator, json.dumps(self.generator_dependencies), - json.dumps(str(self.context['the_lcp'].seed)), + json.dumps(str(self.context['seed'])), json.dumps(self.params)]).strip() return json.loads(output) @@ -1201,7 +1201,6 @@ class SymbolicResponse(CustomResponse): def setup_response(self): self.xml.set('cfn', 'symmath_check') code = "from symmath import *" - raise Exception("exec 2") exec code in self.context, self.context CustomResponse.setup_response(self) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 9de8398030..ba5d3fb5bc 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -52,9 +52,12 @@ if lazymod_py_file.endswith("c"): lazymod_py = open(lazymod_py_file).read() -def xxxsafe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): +def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): the_code = [] + if future_division: + the_code.append("from __future__ import division\n") + the_code.append(textwrap.dedent("""\ import json import sys @@ -73,12 +76,17 @@ def xxxsafe_exec(code, globals_dict, locals_dict, future_division=False, assumed the_code.append(textwrap.dedent("""\ exec code in g_dict, l_dict print >>sys.stderr, l_dict.keys() - ok_types = (int, long, float, str, unicode, list, tuple, dict) + ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) l_dict = {k:v for k,v in l_dict.iteritems() if isinstance(v, ok_types)} json.dump(l_dict, sys.stdout) """)) - print "".join(the_code) + if 0: + print "-- {:-<40}".format("jailed ") + print "".join(the_code) + print "-- {:-<40}".format("exec ") + print code + stdin = json.dumps([code, globals_dict, locals_dict]) res = jailpy.jailpy("".join(the_code), stdin=stdin) if res.status != 0: From 70930c25c10438c45569aa3fa4c79191e8c3f3f6 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 15 Feb 2013 11:58:36 -0500 Subject: [PATCH 024/120] Remove the unsafe version of safe_exec, and document the safe one. --- common/lib/codejail/codejail/safe_exec.py | 54 +++++++---------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index ba5d3fb5bc..7a39a57690 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -6,44 +6,6 @@ import textwrap import lazymod import jailpy -# If we aren't running safe, then we need to artificially pass the values -# through a JSON straw to ensure we aren't passing something that won't -# be executable in the safe context. -def straw(d): - jd = {} - for k,v in d.iteritems(): - try: - json.dumps(v) - except TypeError: - continue - else: - jd[k] = v - return json.loads(json.dumps(jd)) - -def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): - """Execute code safely. - - Returns None. The code can modify globals in `global_dict`. - - """ - if future_division: - code = "from __future__ import division\n" + code - - g_dict = straw(globals_dict) - l_dict = straw(locals_dict) - - for modname in assumed_imports or (): - if isinstance(modname, tuple): - name, modname = modname - else: - name = modname - g_dict[name] = lazymod.LazyModule(modname) - - exec code in g_dict, l_dict - - globals_dict.update(straw(g_dict)) - locals_dict.update(straw(l_dict)) - # We'll need the code from lazymod.py for use in jailpy, so read it now. lazymod_py_file = lazymod.__file__ if lazymod_py_file.endswith("c"): @@ -53,6 +15,22 @@ lazymod_py = open(lazymod_py_file).read() def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): + """Execute code as "exec" does, but safely. + + `code` is a string of Python code. `globals_dict` and `locals_dict` are + dictionaries to use as the globals and locals. Modifications the code + makes to `locals_dict` are reflected in the dictionary on return. + + `future_division` determines whether Python-3-style division is used. + + `assumed_imports` is a list of module to make available as implicit + imports for the code. Entries are either a name, "mod", which makes + "import mod" part of the code, or a pair, ("fooey", "f"), which makes + "import fooey as f" part of the code. The module name can be dotted. + + Returns None, changes made by `code` are visible in `locals_dict`. + + """ the_code = [] if future_division: From 7c498be606daea6177ce17e29d90457c9d912e84 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 19 Feb 2013 11:07:11 -0500 Subject: [PATCH 025/120] Move packages around so we can install packages into the sandbox. --- common/djangoapps/util/views.py | 4 ++-- common/lib/{capa/capa => calc}/calc.py | 0 common/lib/calc/setup.py | 12 +++++++++++ common/lib/capa/setup.py | 2 +- .../lib/{capa/capa => chem}/chem/__init__.py | 0 .../lib/{capa/capa => chem}/chem/chemcalc.py | 0 .../lib/{capa/capa => chem}/chem/chemtools.py | 0 common/lib/{capa/capa => chem}/chem/miller.py | 0 common/lib/{capa/capa => chem}/chem/tests.py | 0 common/lib/chem/setup.py | 13 ++++++++++++ common/lib/codejail/README | 21 +++++++++++++++++++ common/lib/sandbox-packages/README | 1 + .../{capa/capa => sandbox-packages}/eia.py | 0 common/lib/sandbox-packages/setup.py | 17 +++++++++++++++ .../lib/sandbox-packages}/symmath/README.md | 0 .../lib/sandbox-packages}/symmath/__init__.py | 0 .../lib/sandbox-packages}/symmath/formula.py | 0 .../symmath/symmath_check.py | 0 .../verifiers/__init__.py | 0 .../verifiers/draganddrop.py | 0 .../verifiers/tests_draganddrop.py | 0 common/lib/xmodule/xmodule/tests/__init__.py | 2 +- local-requirements.txt | 4 +++- sandbox-requirements.txt | 6 ++++++ 24 files changed, 77 insertions(+), 5 deletions(-) rename common/lib/{capa/capa => calc}/calc.py (100%) create mode 100644 common/lib/calc/setup.py rename common/lib/{capa/capa => chem}/chem/__init__.py (100%) rename common/lib/{capa/capa => chem}/chem/chemcalc.py (100%) rename common/lib/{capa/capa => chem}/chem/chemtools.py (100%) rename common/lib/{capa/capa => chem}/chem/miller.py (100%) rename common/lib/{capa/capa => chem}/chem/tests.py (100%) create mode 100644 common/lib/chem/setup.py create mode 100644 common/lib/codejail/README create mode 100644 common/lib/sandbox-packages/README rename common/lib/{capa/capa => sandbox-packages}/eia.py (100%) create mode 100644 common/lib/sandbox-packages/setup.py rename {lms/lib => common/lib/sandbox-packages}/symmath/README.md (100%) rename {lms/lib => common/lib/sandbox-packages}/symmath/__init__.py (100%) rename {lms/lib => common/lib/sandbox-packages}/symmath/formula.py (100%) rename {lms/lib => common/lib/sandbox-packages}/symmath/symmath_check.py (100%) rename common/lib/{capa/capa => sandbox-packages}/verifiers/__init__.py (100%) rename common/lib/{capa/capa => sandbox-packages}/verifiers/draganddrop.py (100%) rename common/lib/{capa/capa => sandbox-packages}/verifiers/tests_draganddrop.py (100%) create mode 100644 sandbox-requirements.txt diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index cece37757b..75fdfac210 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -12,7 +12,7 @@ from django.http import HttpResponse from django.shortcuts import redirect from mitxmako.shortcuts import render_to_response, render_to_string -import capa.calc +import calc import track.views @@ -20,7 +20,7 @@ def calculate(request): ''' Calculator in footer of every page. ''' equation = request.GET['equation'] try: - result = capa.calc.evaluator({}, {}, equation) + result = calc.evaluator({}, {}, equation) except: event = {'error': map(str, sys.exc_info()), 'equation': equation} diff --git a/common/lib/capa/capa/calc.py b/common/lib/calc/calc.py similarity index 100% rename from common/lib/capa/capa/calc.py rename to common/lib/calc/calc.py diff --git a/common/lib/calc/setup.py b/common/lib/calc/setup.py new file mode 100644 index 0000000000..f7bb1708af --- /dev/null +++ b/common/lib/calc/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup + +setup( + name="calc", + version="0.1", + py_modules=["calc"], + install_requires=[ + "pyparsing==1.5.6", + "numpy", + "scipy" + ], +) diff --git a/common/lib/capa/setup.py b/common/lib/capa/setup.py index d9c813f55c..5f1731fdc0 100644 --- a/common/lib/capa/setup.py +++ b/common/lib/capa/setup.py @@ -4,5 +4,5 @@ setup( name="capa", version="0.1", packages=find_packages(exclude=["tests"]), - install_requires=['distribute==0.6.30', 'pyparsing==1.5.6'], + install_requires=["distribute==0.6.34"], ) diff --git a/common/lib/capa/capa/chem/__init__.py b/common/lib/chem/chem/__init__.py similarity index 100% rename from common/lib/capa/capa/chem/__init__.py rename to common/lib/chem/chem/__init__.py diff --git a/common/lib/capa/capa/chem/chemcalc.py b/common/lib/chem/chem/chemcalc.py similarity index 100% rename from common/lib/capa/capa/chem/chemcalc.py rename to common/lib/chem/chem/chemcalc.py diff --git a/common/lib/capa/capa/chem/chemtools.py b/common/lib/chem/chem/chemtools.py similarity index 100% rename from common/lib/capa/capa/chem/chemtools.py rename to common/lib/chem/chem/chemtools.py diff --git a/common/lib/capa/capa/chem/miller.py b/common/lib/chem/chem/miller.py similarity index 100% rename from common/lib/capa/capa/chem/miller.py rename to common/lib/chem/chem/miller.py diff --git a/common/lib/capa/capa/chem/tests.py b/common/lib/chem/chem/tests.py similarity index 100% rename from common/lib/capa/capa/chem/tests.py rename to common/lib/chem/chem/tests.py diff --git a/common/lib/chem/setup.py b/common/lib/chem/setup.py new file mode 100644 index 0000000000..4f2b24ddee --- /dev/null +++ b/common/lib/chem/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup + +setup( + name="chem", + version="0.1", + packages=["chem"], + install_requires=[ + "pyparsing==1.5.6", + "numpy", + "scipy", + "nltk==2.0.4", + ], +) diff --git a/common/lib/codejail/README b/common/lib/codejail/README new file mode 100644 index 0000000000..7b1849e18c --- /dev/null +++ b/common/lib/codejail/README @@ -0,0 +1,21 @@ +Choose a place for the virtualenv, call it + +Create a virtualenv: + + virtualenv + +Install the sandbox requirements + + +Edit an AppArmor profile: + + /bin/python { + ... + } + +Parse the profiles + + $ apparmor_parser + $ aaenforce /bin/python + + diff --git a/common/lib/sandbox-packages/README b/common/lib/sandbox-packages/README new file mode 100644 index 0000000000..706998b08e --- /dev/null +++ b/common/lib/sandbox-packages/README @@ -0,0 +1 @@ +This directory is in the Python path for sandboxed Python execution. diff --git a/common/lib/capa/capa/eia.py b/common/lib/sandbox-packages/eia.py similarity index 100% rename from common/lib/capa/capa/eia.py rename to common/lib/sandbox-packages/eia.py diff --git a/common/lib/sandbox-packages/setup.py b/common/lib/sandbox-packages/setup.py new file mode 100644 index 0000000000..f5cfa91b8b --- /dev/null +++ b/common/lib/sandbox-packages/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup + +setup( + name="sandbox-packages", + version="0.1", + packages=[ + "symmath", + "verifiers", + ], + py_modules=[ + "eia", + ], + install_requires=[ + # symmath needs: + "sympy", "requests", "lxml", + ], +) diff --git a/lms/lib/symmath/README.md b/common/lib/sandbox-packages/symmath/README.md similarity index 100% rename from lms/lib/symmath/README.md rename to common/lib/sandbox-packages/symmath/README.md diff --git a/lms/lib/symmath/__init__.py b/common/lib/sandbox-packages/symmath/__init__.py similarity index 100% rename from lms/lib/symmath/__init__.py rename to common/lib/sandbox-packages/symmath/__init__.py diff --git a/lms/lib/symmath/formula.py b/common/lib/sandbox-packages/symmath/formula.py similarity index 100% rename from lms/lib/symmath/formula.py rename to common/lib/sandbox-packages/symmath/formula.py diff --git a/lms/lib/symmath/symmath_check.py b/common/lib/sandbox-packages/symmath/symmath_check.py similarity index 100% rename from lms/lib/symmath/symmath_check.py rename to common/lib/sandbox-packages/symmath/symmath_check.py diff --git a/common/lib/capa/capa/verifiers/__init__.py b/common/lib/sandbox-packages/verifiers/__init__.py similarity index 100% rename from common/lib/capa/capa/verifiers/__init__.py rename to common/lib/sandbox-packages/verifiers/__init__.py diff --git a/common/lib/capa/capa/verifiers/draganddrop.py b/common/lib/sandbox-packages/verifiers/draganddrop.py similarity index 100% rename from common/lib/capa/capa/verifiers/draganddrop.py rename to common/lib/sandbox-packages/verifiers/draganddrop.py diff --git a/common/lib/capa/capa/verifiers/tests_draganddrop.py b/common/lib/sandbox-packages/verifiers/tests_draganddrop.py similarity index 100% rename from common/lib/capa/capa/verifiers/tests_draganddrop.py rename to common/lib/sandbox-packages/verifiers/tests_draganddrop.py diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 1a10654f6c..79e1fec0f0 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -14,7 +14,7 @@ import fs.osfs import numpy -import capa.calc as calc +import calc import xmodule from xmodule.x_module import ModuleSystem from mock import Mock diff --git a/local-requirements.txt b/local-requirements.txt index 053a223605..4ad1ef6636 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,5 +1,7 @@ # Python libraries to install that are local to the mitx repo +-e common/lib/calc -e common/lib/capa --e common/lib/xmodule +-e common/lib/chem -e common/lib/codejail +-e common/lib/xmodule -e . diff --git a/sandbox-requirements.txt b/sandbox-requirements.txt new file mode 100644 index 0000000000..9b18fba9e9 --- /dev/null +++ b/sandbox-requirements.txt @@ -0,0 +1,6 @@ +# Packages to install in the Python sandbox for secured execution. +numpy==1.6.2 +scipy==0.11.0 +-e common/lib/calc +-e common/lib/chem +-e common/lib/sandbox-packages From ebb2624719307b674ccbfff99a606dc87a4e083e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 19 Feb 2013 11:08:09 -0500 Subject: [PATCH 026/120] Make jailpy tests more convenient and informative. --- .../codejail/codejail/tests/test_jailpy.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index a04bf3bf0f..74c475b63d 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -8,10 +8,17 @@ from codejail.jailpy import jailpy dedent = textwrap.dedent -class TestFeatures(unittest.TestCase): +class JailPyHelpers(object): + """Assert helpers for jailpy tests.""" + def assertResultOk(self, res): + self.assertEqual(res.stderr, "") + self.assertEqual(res.status, 0) + + +class TestFeatures(JailPyHelpers, unittest.TestCase): def test_hello_world(self): res = jailpy("print 'Hello, world!'") - self.assertEqual(res.status, 0) + self.assertResultOk(res) self.assertEqual(res.stdout, 'Hello, world!\n') def test_argv(self): @@ -19,7 +26,7 @@ class TestFeatures(unittest.TestCase): "import sys; print ':'.join(sys.argv[1:])", argv=["Hello", "world", "-x"] ) - self.assertEqual(res.status, 0) + self.assertResultOk(res) self.assertEqual(res.stdout, "Hello:world:-x\n") def test_ends_with_exception(self): @@ -38,10 +45,11 @@ class TestFeatures(unittest.TestCase): "import json,sys; print sum(json.load(sys.stdin))", stdin="[1, 2.5, 33]" ) + self.assertResultOk(res) self.assertEqual(res.stdout.strip(), "36.5") -class TestLimits(unittest.TestCase): +class TestLimits(JailPyHelpers, unittest.TestCase): def test_cant_use_too_much_memory(self): res = jailpy("print sum(range(100000000))") self.assertNotEqual(res.status, 0) @@ -78,7 +86,7 @@ class TestLimits(unittest.TestCase): # TODO: read network # TODO: fork -class TestMalware(unittest.TestCase): +class TestMalware(JailPyHelpers, unittest.TestCase): def test_crash_cpython(self): # http://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html res = jailpy(dedent("""\ @@ -119,5 +127,5 @@ class TestMalware(unittest.TestCase): print "Files in %r: %r" % (place, files) print "Done." """)) - self.assertEqual(res.status, 0) + self.assertResultOk(res) self.assertEqual(res.stdout, "Done.\n") From 3316aeb0325a5981bef61dbec32c717276240c9e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 19 Feb 2013 11:48:50 -0500 Subject: [PATCH 027/120] Add back the not_safe_exec implementation, for debugging. --- common/lib/codejail/codejail/safe_exec.py | 58 ++++++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 7a39a57690..824a6c2d57 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -23,12 +23,12 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im `future_division` determines whether Python-3-style division is used. - `assumed_imports` is a list of module to make available as implicit + `assumed_imports` is a list of modules to make available as implicit imports for the code. Entries are either a name, "mod", which makes - "import mod" part of the code, or a pair, ("fooey", "f"), which makes + "import mod" part of the code, or a pair, ("f", "fooey"), which makes "import fooey as f" part of the code. The module name can be dotted. - Returns None, changes made by `code` are visible in `locals_dict`. + Returns None. Changes made by `code` are visible in `locals_dict`. """ the_code = [] @@ -53,16 +53,15 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im the_code.append(textwrap.dedent("""\ exec code in g_dict, l_dict - print >>sys.stderr, l_dict.keys() ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) l_dict = {k:v for k,v in l_dict.iteritems() if isinstance(v, ok_types)} json.dump(l_dict, sys.stdout) """)) - if 0: - print "-- {:-<40}".format("jailed ") + if 1: + print "--{:-<40}".format(" jailed ") print "".join(the_code) - print "-- {:-<40}".format("exec ") + print "--{:-<40}".format(" exec ") print code stdin = json.dumps([code, globals_dict, locals_dict]) @@ -70,3 +69,48 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im if res.status != 0: raise Exception("Couldn't excecute jailed code: %s" % res.stderr) locals_dict.update(json.loads(res.stdout)) + + +def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): + """Another implementation of `safe_exec`, but not safe. + + This can be swapped in for debugging problems in sandboxed Python code. + + """ + def straw(d): + """Return only the JSON-safe part of d. + + Used to emulate reading data through a serialization straw. + + """ + jd = {} + for k,v in d.iteritems(): + try: + json.dumps(v) + except TypeError: + continue + else: + jd[k] = v + return json.loads(json.dumps(jd)) + + if future_division: + code = "from __future__ import division\n" + code + + g_dict = straw(globals_dict) + l_dict = straw(locals_dict) + + for modname in assumed_imports or (): + if isinstance(modname, tuple): + name, modname = modname + else: + name = modname + g_dict[name] = lazymod.LazyModule(modname) + + exec code in g_dict, l_dict + + locals_dict.update(straw(l_dict)) + +# Running Python code in the sandbox makes it difficult to debug. +# Turn this on to run the code directly. +if 1: + safe_exec = not_safe_exec From e8da1b8f61e1ed40209e8a4c5088f92f9af17b39 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 19 Feb 2013 12:43:58 -0500 Subject: [PATCH 028/120] Turn off our debugging levers --- common/lib/codejail/codejail/safe_exec.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 824a6c2d57..89e04c17f3 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -58,7 +58,8 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im json.dump(l_dict, sys.stdout) """)) - if 1: + # Turn this on to see what's being executed. + if 0: print "--{:-<40}".format(" jailed ") print "".join(the_code) print "--{:-<40}".format(" exec ") @@ -112,5 +113,5 @@ def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assume # Running Python code in the sandbox makes it difficult to debug. # Turn this on to run the code directly. -if 1: +if 0: safe_exec = not_safe_exec From 716a97ea59dc2a12af3b03340fe6b25a6e461a37 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 19 Feb 2013 13:42:47 -0500 Subject: [PATCH 029/120] Symbolic response doesn't need to pre-import symmath any more, I think? --- common/lib/capa/capa/responsetypes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 7213a766ce..743edb358a 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1200,8 +1200,6 @@ class SymbolicResponse(CustomResponse): def setup_response(self): self.xml.set('cfn', 'symmath_check') - code = "from symmath import *" - exec code in self.context, self.context CustomResponse.setup_response(self) #----------------------------------------------------------------------------- From 30748a06ff9494621b2e04a2eac198293b48f539 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 19 Feb 2013 13:51:45 -0500 Subject: [PATCH 030/120] Try to find the sandbox in a few places. --- common/lib/codejail/codejail/jailpy.py | 27 +++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jailpy.py index 45842d715f..2a8fd61b98 100644 --- a/common/lib/codejail/codejail/jailpy.py +++ b/common/lib/codejail/codejail/jailpy.py @@ -14,24 +14,23 @@ from .util import temp_directory # TODO: limit too much stdout data? -DEBUG = False -STRICT = True - # Configure the Python command -SANDBOX_PYTHON = "/usr/bin/python-sandbox" +SANDBOX_POSSIBILITIES = [ + "~/mitx_all/python-sandbox/bin/python", + "/usr/bin/python-sandbox", +] -if os.path.exists(SANDBOX_PYTHON): - # Python -S inhibits loading site.py, which prevent Ubuntu from adding - # specialized traceback handlers that fail in the sandbox. - PYTHON_CMD = [ - 'sudo', '-u', 'sandbox', - SANDBOX_PYTHON, '-S' - ] -elif STRICT: - raise Exception("Couldn't find Python sandbox") +for sandbox_python in SANDBOX_POSSIBILITIES: + sandbox_python = os.path.expanduser(sandbox_python) + if os.path.exists(sandbox_python): + PYTHON_CMD = [ + 'sudo', '-u', 'sandbox', + sandbox_python, '-E', + ] + break else: - PYTHON_CMD = ['python', '-S'] + raise Exception("Couldn't find Python sandbox") class JailResult(object): From 5d4b61c7f588923663020da6856e529c126bc5a5 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 19 Feb 2013 15:47:46 -0500 Subject: [PATCH 031/120] Better configuration for codejail. --- .../codejail/codejail/django_integration.py | 16 +++++++++ common/lib/codejail/codejail/jailpy.py | 35 +++++++++++-------- .../codejail/codejail/tests/test_jailpy.py | 7 +++- lms/envs/common.py | 11 ++++++ 4 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 common/lib/codejail/codejail/django_integration.py diff --git a/common/lib/codejail/codejail/django_integration.py b/common/lib/codejail/codejail/django_integration.py new file mode 100644 index 0000000000..52306ba6f1 --- /dev/null +++ b/common/lib/codejail/codejail/django_integration.py @@ -0,0 +1,16 @@ +"""Django integration for codejail""" + +from django.core.exceptions import MiddlewareNotUsed +from django.conf import settings + +import codejail.jailpy + +class ConfigureCodeJailMiddleware(object): + """Middleware to configure codejail on startup.""" + + def __init__(self): + python_bin = settings.CODE_JAIL.get('python_bin') + if python_bin: + user = settings.CODE_JAIL['user'] + codejail.jailpy.configure(python_bin, user=user) + raise MiddlewareNotUsed diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jailpy.py index 2a8fd61b98..8f68ab33de 100644 --- a/common/lib/codejail/codejail/jailpy.py +++ b/common/lib/codejail/codejail/jailpy.py @@ -7,6 +7,7 @@ import os, os.path import resource import shutil import subprocess +import sys import threading import time @@ -16,21 +17,24 @@ from .util import temp_directory # Configure the Python command -SANDBOX_POSSIBILITIES = [ - "~/mitx_all/python-sandbox/bin/python", - "/usr/bin/python-sandbox", -] +PYTHON_CMD = None -for sandbox_python in SANDBOX_POSSIBILITIES: - sandbox_python = os.path.expanduser(sandbox_python) - if os.path.exists(sandbox_python): - PYTHON_CMD = [ - 'sudo', '-u', 'sandbox', - sandbox_python, '-E', - ] - break -else: - raise Exception("Couldn't find Python sandbox") +def configure(python_bin, user=None): + """Configure the jailpy module.""" + global PYTHON_CMD + PYTHON_CMD = [] + if user: + PYTHON_CMD.extend(['sudo', '-u', 'sandbox']) + PYTHON_CMD.extend([python_bin, '-E']) + +def is_configured(): + return bool(PYTHON_CMD) + +# By default, look where our current Python is, and maybe there's a +# python-sandbox alongside. Only do this if running in a virtualenv. +if hasattr(sys, 'real_prefix'): + if os.path.isdir(sys.prefix + "-sandbox"): + configure(sys.prefix + "-sandbox/bin/python", "sandbox") class JailResult(object): @@ -52,6 +56,9 @@ def jailpy(code, files=None, argv=None, stdin=None): .status: return status of the process: an int, 0 for successful """ + if not PYTHON_CMD: + raise Exception("jailpy needs to be configured") + with temp_directory(delete_when_done=True) as tmpdir: # All the supporting files are copied into our directory. diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index 74c475b63d..3c6d13b714 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -4,12 +4,17 @@ import textwrap import unittest from nose.plugins.skip import SkipTest -from codejail.jailpy import jailpy +from codejail.jailpy import jailpy, is_configured dedent = textwrap.dedent class JailPyHelpers(object): """Assert helpers for jailpy tests.""" + def setUp(self): + super(JailPyHelpers, self).setUp() + if not is_configured(): + raise SkipTest + def assertResultOk(self, res): self.assertEqual(res.stderr, "") self.assertEqual(res.status, 0) diff --git a/lms/envs/common.py b/lms/envs/common.py index 32a213f06e..b99efd60c3 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -242,6 +242,16 @@ MODULESTORE = { } CONTENTSTORE = None +#################### Python sandbox ############################################ + +CODE_JAIL = { + # Path to a sandboxed Python executable. None means don't bother. + 'python_bin': None, + # User to run as in the sandbox. + 'user': 'sandbox', +} + + ############################ SIGNAL HANDLERS ################################ # This is imported to register the exception signal handling that logs exceptions import monitoring.exceptions # noqa @@ -385,6 +395,7 @@ MIDDLEWARE_CLASSES = ( # 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django_comment_client.utils.ViewNameMiddleware', + 'codejail.django_integration.ConfigureCodeJailMiddleware', ) ############################### Pipeline ####################################### From eb8569634738ec93b8ca1cf9676863a10a44712e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 10:59:40 -0500 Subject: [PATCH 032/120] Tests for safe_exec --- .../codejail/codejail/tests/test_safe_exec.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 common/lib/codejail/codejail/tests/test_safe_exec.py diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py new file mode 100644 index 0000000000..685e2b8c39 --- /dev/null +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -0,0 +1,53 @@ +"""Test safe_exec.py""" + +import textwrap +import unittest +from nose.plugins.skip import SkipTest + +from codejail.safe_exec import safe_exec, not_safe_exec + +dedent = textwrap.dedent + +class SafeExecTests(object): + """The tests for `safe_exec`, will be mixed into specific test classes below.""" + def test_set_values(self): + g, l = {}, {} + self.safe_exec("a = 17", g, l) + self.assertEqual(l['a'], 17) + + def test_division(self): + g, l = {}, {} + # No future division: 1/2 is 0. + self.safe_exec("a = 1/2", g, l) + self.assertEqual(l['a'], 0) + # Future division: 1/2 is 0.5. + self.safe_exec("a = 1/2", g, l, future_division=True) + self.assertEqual(l['a'], 0.5) + + def test_assumed_imports(self): + g, l = {}, {} + # Using string without importing it is bad. + with self.assertRaises(Exception): + self.safe_exec("a = string.ascii_lowercase[0]", g, l) + # Using string with an assumed import is fine. + self.safe_exec("a = string.ascii_lowercase[0]", g, l, assumed_imports=["string"]) + self.assertEqual(l['a'], 'a') + # Can also import with a shorthand. + self.safe_exec("a = op.join('x', 'y')", g, l, assumed_imports=[("op", "os.path")]) + self.assertEqual(l['a'][0], 'x') + self.assertEqual(l['a'][-1], 'y') + + +class TestSafeExec(SafeExecTests, unittest.TestCase): + """Run SafeExecTests, with the real safe_exec.""" + def safe_exec(self, *args, **kwargs): + safe_exec(*args, **kwargs) + +class TestNotSafeExec(SafeExecTests, unittest.TestCase): + """Run SafeExecTests, with not_safe_exec.""" + def setUp(self): + if safe_exec is not_safe_exec: + raise SkipTest + + def safe_exec(self, *args, **kwargs): + not_safe_exec(*args, **kwargs) From d99eadc0a2059ca1852c49c9f39021150cdf18d9 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 11:00:10 -0500 Subject: [PATCH 033/120] Refactor the assumed_imports handling --- common/lib/codejail/codejail/safe_exec.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 89e04c17f3..b6469996ab 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -14,6 +14,15 @@ if lazymod_py_file.endswith("c"): lazymod_py = open(lazymod_py_file).read() +def names_and_modules(assumed_imports): + """Get uniform names and modules from assumed_imports.""" + for modname in assumed_imports: + if isinstance(modname, tuple): + yield modname + else: + yield modname, modname + + def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): """Execute code as "exec" does, but safely. @@ -44,11 +53,7 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im if assumed_imports: the_code.append(lazymod_py) - for modname in assumed_imports: - if isinstance(modname, tuple): - name, modname = modname - else: - name = modname + for name, modname in names_and_modules(assumed_imports): the_code.append("g_dict['{}'] = LazyModule('{}')\n".format(name, modname)) the_code.append(textwrap.dedent("""\ @@ -100,11 +105,7 @@ def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assume g_dict = straw(globals_dict) l_dict = straw(locals_dict) - for modname in assumed_imports or (): - if isinstance(modname, tuple): - name, modname = modname - else: - name = modname + for name, modname in names_and_modules(assumed_imports or ()): g_dict[name] = lazymod.LazyModule(modname) exec code in g_dict, l_dict From ab8a3050fd46dded98f5dc51fe54b6867f52f442 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 11:07:24 -0500 Subject: [PATCH 034/120] Don't use jailpy if it hasn't been configured. --- common/lib/codejail/codejail/safe_exec.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index b6469996ab..04045fb782 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -113,6 +113,6 @@ def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assume locals_dict.update(straw(l_dict)) # Running Python code in the sandbox makes it difficult to debug. -# Turn this on to run the code directly. -if 0: +# Change 0 to 1 to run the code directly. +if 0 or not jailpy.is_configured(): safe_exec = not_safe_exec From 12b6876753fc1fcac330b83839a1586091ec1033 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 12:41:40 -0500 Subject: [PATCH 035/120] safe_exec seeds the random module, and now we have tests for it. --- common/lib/capa/capa/safe_exec.py | 12 +++++-- common/lib/capa/capa/tests/test_safe_exec.py | 37 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 common/lib/capa/capa/tests/test_safe_exec.py diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py index e6fa0e7bb5..32f6049453 100644 --- a/common/lib/capa/capa/safe_exec.py +++ b/common/lib/capa/capa/safe_exec.py @@ -2,11 +2,17 @@ import codejail.safe_exec -def safe_exec(code, globals_dict, locals_dict): +CODE_PROLOG = """\ +import random as random_module +random = random_module.Random(%r) +random.Random = random_module.Random +""" + +def safe_exec(code, globals_dict, locals_dict, random_seed=None): + code_prolog = CODE_PROLOG % random_seed codejail.safe_exec.safe_exec( - code, globals_dict, locals_dict, future_division=True, + code_prolog + code, globals_dict, locals_dict, future_division=True, assumed_imports=[ - "random", "numpy", "math", "scipy", diff --git a/common/lib/capa/capa/tests/test_safe_exec.py b/common/lib/capa/capa/tests/test_safe_exec.py new file mode 100644 index 0000000000..035b15b24d --- /dev/null +++ b/common/lib/capa/capa/tests/test_safe_exec.py @@ -0,0 +1,37 @@ +"""Test safe_exec.py""" + +import random +import unittest + +from capa.safe_exec import safe_exec + +class TestSafeExec(unittest.TestCase): + def test_set_values(self): + g, l = {}, {} + safe_exec("a = 17", g, l) + self.assertEqual(l['a'], 17) + + def test_division(self): + g, l = {}, {} + # Future division: 1/2 is 0.5. + safe_exec("a = 1/2", g, l) + self.assertEqual(l['a'], 0.5) + + def test_assumed_imports(self): + g, l = {}, {} + # Math is always available. + safe_exec("a = int(math.pi)", g, l) + self.assertEqual(l['a'], 3) + + def test_random_seeding(self): + g, l = {}, {} + r = random.Random(17) + rnums = [r.randint(0, 999) for _ in xrange(100)] + + # Without a seed, the results are unpredictable + safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g, l) + self.assertNotEqual(l['rnums'], rnums) + + # With a seed, the results are predictable + safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g, l, random_seed=17) + self.assertEqual(l['rnums'], rnums) From abb91745596f97f1b497257d34042909e21c3c5c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 13:14:51 -0500 Subject: [PATCH 036/120] Refactor how script chunks are run. --- common/lib/capa/capa/capa_problem.py | 38 +++++++++++----------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 888501e8b5..2e23d38380 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -18,7 +18,6 @@ import logging import math import numpy import os -import random import re import struct import sys @@ -437,49 +436,42 @@ class LoncapaProblem(object): Problem XML goes to Python execution context. Runs everything in script tags. ''' - random.seed(self.seed) - context = {} context['seed'] = self.seed - context['script_code'] = '' + all_code = '' - self._execute_scripts(tree.findall('.//script'), context) + python_path = [] - return context - - def _execute_scripts(self, scripts, context): - ''' - Executes scripts in the given context. - ''' - original_path = sys.path - - for script in scripts: - sys.path = original_path + self._extract_system_path(script) + for script in tree.findall('.//script'): stype = script.get('type') - if stype: if 'javascript' in stype: continue # skip javascript if 'perl' in stype: continue # skip perl # TODO: evaluate only python + + python_path.extend(self._extract_system_path(script)) + code = script.text XMLESC = {"'": "'", """: '"'} code = unescape(code, XMLESC) - # store code source in context - context['script_code'] += code + all_code += code + + if all_code: try: - # use "context" for global context; thus defs in code are global within code locals_dict = {} - safe_exec.safe_exec(code, context, locals_dict) + safe_exec.safe_exec(all_code, context, locals_dict, random_seed=self.seed) context.update(locals_dict) except Exception as err: - log.exception("Error while execing script code: " + code) + log.exception("Error while execing script code: " + all_code) msg = "Error while executing script code: %s" % str(err).replace('<', '<') raise responsetypes.LoncapaProblemError(msg) - finally: - sys.path = original_path + + # store code source in context + context['script_code'] = all_code + return context From a04317b31d4b0e8894127c93fbb0ab230570381f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 15:16:43 -0500 Subject: [PATCH 037/120] Files are properly copied in both implementations of safe_exec, and a new python_path argument adds to the python path. --- common/lib/capa/capa/capa_problem.py | 3 +- common/lib/capa/capa/safe_exec.py | 9 +++- .../capa/tests/test_files/pylib/constant.py | 1 + common/lib/capa/capa/tests/test_safe_exec.py | 9 ++++ common/lib/codejail/codejail/jailpy.py | 7 ++- common/lib/codejail/codejail/safe_exec.py | 43 ++++++++++++++++--- common/lib/codejail/codejail/tests/hello.txt | 1 + .../codejail/codejail/tests/pylib/module.py | 1 + .../codejail/codejail/tests/test_jailpy.py | 9 ++++ .../codejail/codejail/tests/test_safe_exec.py | 20 +++++++-- common/lib/codejail/codejail/util.py | 20 +++++++++ 11 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 common/lib/capa/capa/tests/test_files/pylib/constant.py create mode 100644 common/lib/codejail/codejail/tests/hello.txt create mode 100644 common/lib/codejail/codejail/tests/pylib/module.py diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 2e23d38380..b8e146d773 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -454,9 +454,8 @@ class LoncapaProblem(object): python_path.extend(self._extract_system_path(script)) - code = script.text XMLESC = {"'": "'", """: '"'} - code = unescape(code, XMLESC) + code = unescape(script.text, XMLESC) all_code += code if all_code: diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py index 32f6049453..e10da30196 100644 --- a/common/lib/capa/capa/safe_exec.py +++ b/common/lib/capa/capa/safe_exec.py @@ -2,16 +2,23 @@ import codejail.safe_exec +# This will set up the name "random" as a properly-seeded stand-in for the +# random module. CODE_PROLOG = """\ import random as random_module random = random_module.Random(%r) random.Random = random_module.Random +del random_module """ -def safe_exec(code, globals_dict, locals_dict, random_seed=None): +def safe_exec(code, globals_dict, locals_dict, random_seed=None, python_path=None): + """Exec python code safely. + + """ code_prolog = CODE_PROLOG % random_seed codejail.safe_exec.safe_exec( code_prolog + code, globals_dict, locals_dict, future_division=True, + python_path=python_path, assumed_imports=[ "numpy", "math", diff --git a/common/lib/capa/capa/tests/test_files/pylib/constant.py b/common/lib/capa/capa/tests/test_files/pylib/constant.py new file mode 100644 index 0000000000..0769d528ba --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/pylib/constant.py @@ -0,0 +1 @@ +THE_CONST = 23 diff --git a/common/lib/capa/capa/tests/test_safe_exec.py b/common/lib/capa/capa/tests/test_safe_exec.py index 035b15b24d..3a436ac3ce 100644 --- a/common/lib/capa/capa/tests/test_safe_exec.py +++ b/common/lib/capa/capa/tests/test_safe_exec.py @@ -1,5 +1,6 @@ """Test safe_exec.py""" +import os.path import random import unittest @@ -35,3 +36,11 @@ class TestSafeExec(unittest.TestCase): # With a seed, the results are predictable safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g, l, random_seed=17) self.assertEqual(l['rnums'], rnums) + + def test_python_lib(self): + pylib = os.path.dirname(__file__) + "/test_files/pylib" + g, l = {}, {} + safe_exec( + "import constant; a = constant.THE_CONST", + g, l, python_path=[pylib] + ) diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jailpy.py index 8f68ab33de..ed13a0d3f0 100644 --- a/common/lib/codejail/codejail/jailpy.py +++ b/common/lib/codejail/codejail/jailpy.py @@ -63,8 +63,11 @@ def jailpy(code, files=None, argv=None, stdin=None): # All the supporting files are copied into our directory. for filename in files or (): - dest = os.path.join(tmpdir, os.path.basename(filename)) - shutil.copyfile(filename, dest) + if os.path.isfile(filename): + shutil.copy(filename, tmpdir) + else: + dest = os.path.join(tmpdir, os.path.basename(filename)) + shutil.copytree(filename, dest) # Create the main file. with open(os.path.join(tmpdir, "jailed_code.py"), "w") as jailed: diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 04045fb782..06bc4c60d3 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -1,11 +1,16 @@ """Safe execution of untrusted Python code.""" import json +import os.path +import shutil +import sys import textwrap import lazymod import jailpy +from util import temp_directory, change_directory, TempDirectory + # We'll need the code from lazymod.py for use in jailpy, so read it now. lazymod_py_file = lazymod.__file__ if lazymod_py_file.endswith("c"): @@ -23,7 +28,7 @@ def names_and_modules(assumed_imports): yield modname, modname -def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): +def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None, files=None, python_path=None): """Execute code as "exec" does, but safely. `code` is a string of Python code. `globals_dict` and `locals_dict` are @@ -41,6 +46,7 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im """ the_code = [] + files = list(files or ()) if future_division: the_code.append("from __future__ import division\n") @@ -51,6 +57,11 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im code, g_dict, l_dict = json.load(sys.stdin) """)) + for pydir in python_path or (): + pybase = os.path.basename(pydir) + the_code.append("sys.path.append(%r)\n" % pybase) + files.append(pydir) + if assumed_imports: the_code.append(lazymod_py) for name, modname in names_and_modules(assumed_imports): @@ -63,25 +74,30 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im json.dump(l_dict, sys.stdout) """)) + stdin = json.dumps([code, globals_dict, locals_dict]) + jailed_code = "".join(the_code) + # Turn this on to see what's being executed. - if 0: + if 1: print "--{:-<40}".format(" jailed ") - print "".join(the_code) + print jailed_code print "--{:-<40}".format(" exec ") print code - stdin = json.dumps([code, globals_dict, locals_dict]) - res = jailpy.jailpy("".join(the_code), stdin=stdin) + res = jailpy.jailpy(jailed_code, stdin=stdin, files=files) if res.status != 0: raise Exception("Couldn't excecute jailed code: %s" % res.stderr) locals_dict.update(json.loads(res.stdout)) -def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None): +def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None, files=None, python_path=None): """Another implementation of `safe_exec`, but not safe. This can be swapped in for debugging problems in sandboxed Python code. + This is not thread-safe, due to temporarily changing the current directory + and modifying sys.path. + """ def straw(d): """Return only the JSON-safe part of d. @@ -108,7 +124,20 @@ def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assume for name, modname in names_and_modules(assumed_imports or ()): g_dict[name] = lazymod.LazyModule(modname) - exec code in g_dict, l_dict + with temp_directory(delete_when_done=True) as tmpdir: + with change_directory(tmpdir): + # Copy the files here. + for filename in files or (): + dest = os.path.join(tmpdir, os.path.basename(filename)) + shutil.copyfile(filename, dest) + + original_path = sys.path + if python_path: + sys.path.extend(python_path) + try: + exec code in g_dict, l_dict + finally: + sys.path = original_path locals_dict.update(straw(l_dict)) diff --git a/common/lib/codejail/codejail/tests/hello.txt b/common/lib/codejail/codejail/tests/hello.txt new file mode 100644 index 0000000000..c12abce9f3 --- /dev/null +++ b/common/lib/codejail/codejail/tests/hello.txt @@ -0,0 +1 @@ +Hello there. diff --git a/common/lib/codejail/codejail/tests/pylib/module.py b/common/lib/codejail/codejail/tests/pylib/module.py new file mode 100644 index 0000000000..8cb5cded29 --- /dev/null +++ b/common/lib/codejail/codejail/tests/pylib/module.py @@ -0,0 +1 @@ +const = 42 diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index 3c6d13b714..fb59bac31d 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -1,5 +1,6 @@ """Test jailpy.py""" +import os.path import textwrap import unittest from nose.plugins.skip import SkipTest @@ -53,6 +54,14 @@ class TestFeatures(JailPyHelpers, unittest.TestCase): self.assertResultOk(res) self.assertEqual(res.stdout.strip(), "36.5") + def test_files_are_copied(self): + res = jailpy( + "print 'Look:', open('hello.txt').read()", + files=[os.path.dirname(__file__) + "/hello.txt"] + ) + self.assertResultOk(res) + self.assertEqual(res.stdout, 'Look: Hello there.\n\n') + class TestLimits(JailPyHelpers, unittest.TestCase): def test_cant_use_too_much_memory(self): diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index 685e2b8c39..00987f7787 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -1,13 +1,11 @@ """Test safe_exec.py""" -import textwrap +import os.path import unittest from nose.plugins.skip import SkipTest from codejail.safe_exec import safe_exec, not_safe_exec -dedent = textwrap.dedent - class SafeExecTests(object): """The tests for `safe_exec`, will be mixed into specific test classes below.""" def test_set_values(self): @@ -37,6 +35,22 @@ class SafeExecTests(object): self.assertEqual(l['a'][0], 'x') self.assertEqual(l['a'][-1], 'y') + def test_files_are_copied(self): + g, l = {}, {} + self.safe_exec( + "a = 'Look: ' + open('hello.txt').read()", g, l, + files=[os.path.dirname(__file__) + "/hello.txt"] + ) + self.assertEqual(l['a'], 'Look: Hello there.\n') + + def test_python_path(self): + g, l = {}, {} + self.safe_exec( + "import module; a = module.const", g, l, + python_path=[os.path.dirname(__file__) + "/pylib"] + ) + self.assertEqual(l['a'], 42) + class TestSafeExec(SafeExecTests, unittest.TestCase): """Run SafeExecTests, with the real safe_exec.""" diff --git a/common/lib/codejail/codejail/util.py b/common/lib/codejail/codejail/util.py index 88ecc1b319..e293ce052f 100644 --- a/common/lib/codejail/codejail/util.py +++ b/common/lib/codejail/codejail/util.py @@ -49,3 +49,23 @@ class ModuleIsolation(object): # and delete them all so another import will run code for real again. for m in new_mods: del sys.modules[m] + + +class ChangeDirectory(object): + def __init__(self, new_dir): + self.old_dir = os.getcwd() + os.chdir(new_dir) + + def clean_up(self): + os.chdir(self.old_dir) + +@contextlib.contextmanager +def change_directory(new_dir): + """ + A context manager to change the directory, and then change it back. + """ + cd = ChangeDirectory(new_dir) + try: + yield new_dir + finally: + cd.clean_up() From 7187b10f9ce786f9c56b65fa007ecb85ad65256f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 15:26:24 -0500 Subject: [PATCH 038/120] Use the python_path argument to safe_exec --- common/lib/capa/capa/capa_problem.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index b8e146d773..9665721293 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -17,7 +17,7 @@ from datetime import datetime import logging import math import numpy -import os +import os, os.path import re import struct import sys @@ -452,7 +452,9 @@ class LoncapaProblem(object): continue # skip perl # TODO: evaluate only python - python_path.extend(self._extract_system_path(script)) + for d in self._extract_system_path(script): + if d not in python_path and os.path.exists(d): + python_path.append(d) XMLESC = {"'": "'", """: '"'} code = unescape(script.text, XMLESC) @@ -461,7 +463,7 @@ class LoncapaProblem(object): if all_code: try: locals_dict = {} - safe_exec.safe_exec(all_code, context, locals_dict, random_seed=self.seed) + safe_exec.safe_exec(all_code, context, locals_dict, random_seed=self.seed, python_path=python_path) context.update(locals_dict) except Exception as err: log.exception("Error while execing script code: " + all_code) From 4b234a63a3bc3f35cda85b27ee009829f35361b6 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 15:29:52 -0500 Subject: [PATCH 039/120] Future division is really a capa concern, not a general-purpose codejail concern. Move it. --- common/lib/capa/capa/safe_exec.py | 5 +++-- common/lib/codejail/codejail/safe_exec.py | 12 ++---------- common/lib/codejail/codejail/tests/test_safe_exec.py | 9 --------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py index e10da30196..355350eb5d 100644 --- a/common/lib/capa/capa/safe_exec.py +++ b/common/lib/capa/capa/safe_exec.py @@ -3,8 +3,9 @@ import codejail.safe_exec # This will set up the name "random" as a properly-seeded stand-in for the -# random module. +# random module. Also, capa assumes float-friendly division always. CODE_PROLOG = """\ +from __future__ import division import random as random_module random = random_module.Random(%r) random.Random = random_module.Random @@ -17,7 +18,7 @@ def safe_exec(code, globals_dict, locals_dict, random_seed=None, python_path=Non """ code_prolog = CODE_PROLOG % random_seed codejail.safe_exec.safe_exec( - code_prolog + code, globals_dict, locals_dict, future_division=True, + code_prolog + code, globals_dict, locals_dict, python_path=python_path, assumed_imports=[ "numpy", diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 06bc4c60d3..aaffe8757f 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -28,15 +28,13 @@ def names_and_modules(assumed_imports): yield modname, modname -def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None, files=None, python_path=None): +def safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, python_path=None): """Execute code as "exec" does, but safely. `code` is a string of Python code. `globals_dict` and `locals_dict` are dictionaries to use as the globals and locals. Modifications the code makes to `locals_dict` are reflected in the dictionary on return. - `future_division` determines whether Python-3-style division is used. - `assumed_imports` is a list of modules to make available as implicit imports for the code. Entries are either a name, "mod", which makes "import mod" part of the code, or a pair, ("f", "fooey"), which makes @@ -48,9 +46,6 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im the_code = [] files = list(files or ()) - if future_division: - the_code.append("from __future__ import division\n") - the_code.append(textwrap.dedent("""\ import json import sys @@ -90,7 +85,7 @@ def safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_im locals_dict.update(json.loads(res.stdout)) -def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assumed_imports=None, files=None, python_path=None): +def not_safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, python_path=None): """Another implementation of `safe_exec`, but not safe. This can be swapped in for debugging problems in sandboxed Python code. @@ -115,9 +110,6 @@ def not_safe_exec(code, globals_dict, locals_dict, future_division=False, assume jd[k] = v return json.loads(json.dumps(jd)) - if future_division: - code = "from __future__ import division\n" + code - g_dict = straw(globals_dict) l_dict = straw(locals_dict) diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index 00987f7787..1bdcceaf44 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -13,15 +13,6 @@ class SafeExecTests(object): self.safe_exec("a = 17", g, l) self.assertEqual(l['a'], 17) - def test_division(self): - g, l = {}, {} - # No future division: 1/2 is 0. - self.safe_exec("a = 1/2", g, l) - self.assertEqual(l['a'], 0) - # Future division: 1/2 is 0.5. - self.safe_exec("a = 1/2", g, l, future_division=True) - self.assertEqual(l['a'], 0.5) - def test_assumed_imports(self): g, l = {}, {} # Using string without importing it is bad. From 4fb73248ba25818b7f0cfcb0c9fbcc5190b1f225 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 17:31:56 -0500 Subject: [PATCH 040/120] Try to get test running. (Not yet) --- common/lib/capa/capa/responsetypes.py | 2 ++ common/lib/xmodule/test_files/symbolicresponse.xml | 13 ++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 743edb358a..1a788a9f54 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1199,6 +1199,8 @@ class SymbolicResponse(CustomResponse): response_tag = 'symbolicresponse' def setup_response(self): + # No, this is not pretty. + self.context['script_code'] += "from symmath import symmath_check\n" self.xml.set('cfn', 'symmath_check') CustomResponse.setup_response(self) diff --git a/common/lib/xmodule/test_files/symbolicresponse.xml b/common/lib/xmodule/test_files/symbolicresponse.xml index 4dc2bc9d7b..7d5de7a101 100644 --- a/common/lib/xmodule/test_files/symbolicresponse.xml +++ b/common/lib/xmodule/test_files/symbolicresponse.xml @@ -13,16 +13,15 @@ real time, next to the input box.

This is a correct answer which may be entered below:

cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]

- Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax] and give the resulting \(2 \times 2\) matrix.
Your input should be typed in as a list of lists, eg [[1,2],[3,4]].
- [mathjax]U=[/mathjax] - - -
+ [mathjax]U=[/mathjax] + + + + +
From a40aed58d0345f9608e770c3cfd45dc0faabfe0e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 20 Feb 2013 17:32:07 -0500 Subject: [PATCH 041/120] Clean up --- common/lib/capa/capa/safe_exec.py | 6 ++++-- common/lib/codejail/codejail/safe_exec.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py index 355350eb5d..31f47b5c36 100644 --- a/common/lib/capa/capa/safe_exec.py +++ b/common/lib/capa/capa/safe_exec.py @@ -2,10 +2,12 @@ import codejail.safe_exec -# This will set up the name "random" as a properly-seeded stand-in for the -# random module. Also, capa assumes float-friendly division always. +# Establish the Python environment for Capa. +# Capa assumes float-friendly division always. +# The name "random" is a properly-seeded stand-in for the random module. CODE_PROLOG = """\ from __future__ import division + import random as random_module random = random_module.Random(%r) random.Random = random_module.Random diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index aaffe8757f..5a4e48d39e 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -73,7 +73,7 @@ def safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, jailed_code = "".join(the_code) # Turn this on to see what's being executed. - if 1: + if 0: print "--{:-<40}".format(" jailed ") print jailed_code print "--{:-<40}".format(" exec ") From 42eee48ec9b07e2c98018d59648c86f6813087fa Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 21 Feb 2013 13:24:32 -0500 Subject: [PATCH 042/120] A few places we used an option of 'imaginaryi', which isn't a real option. Doesn't change any behavior. --- common/lib/sandbox-packages/symmath/formula.py | 2 +- common/lib/xmodule/test_files/symbolicresponse.xml | 10 ++++------ .../data/full/problem/test_files/symbolicresponse.xml | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/common/lib/sandbox-packages/symmath/formula.py b/common/lib/sandbox-packages/symmath/formula.py index 604941ffdd..8369baa27c 100644 --- a/common/lib/sandbox-packages/symmath/formula.py +++ b/common/lib/sandbox-packages/symmath/formula.py @@ -736,4 +736,4 @@ def test6(): # imaginary numbers ''' - return formula(xmlstr, options='imaginaryi') + return formula(xmlstr, options='imaginary') diff --git a/common/lib/xmodule/test_files/symbolicresponse.xml b/common/lib/xmodule/test_files/symbolicresponse.xml index 7d5de7a101..f174bc986c 100644 --- a/common/lib/xmodule/test_files/symbolicresponse.xml +++ b/common/lib/xmodule/test_files/symbolicresponse.xml @@ -16,12 +16,10 @@ real time, next to the input box. Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax] and give the resulting \(2 \times 2\) matrix.
Your input should be typed in as a list of lists, eg [[1,2],[3,4]].
- [mathjax]U=[/mathjax] - - - - -
+ [mathjax]U=[/mathjax] + + +
diff --git a/common/test/data/full/problem/test_files/symbolicresponse.xml b/common/test/data/full/problem/test_files/symbolicresponse.xml index 4dc2bc9d7b..3573c34076 100644 --- a/common/test/data/full/problem/test_files/symbolicresponse.xml +++ b/common/test/data/full/problem/test_files/symbolicresponse.xml @@ -19,7 +19,7 @@ from symmath import * Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax] and give the resulting \(2 \times 2\) matrix.
Your input should be typed in as a list of lists, eg [[1,2],[3,4]].
- [mathjax]U=[/mathjax] + [mathjax]U=[/mathjax]
From 81c4e4f74ff0e9f5a835f40f5895bbff3e2d9059 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 21 Feb 2013 14:13:30 -0500 Subject: [PATCH 043/120] Make check_function more flexible so symbolicresponse can pass in more information. --- common/lib/capa/capa/responsetypes.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 1a788a9f54..ae6361ecd3 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -927,15 +927,19 @@ class CustomResponse(LoncapaResponse): # actual function that will re-execute the original script, # and invoke the function with the data needed. def make_check_function(script_code, cfn): - def check_function(expect, ans): - code = (script_code + "\n" + - "cfn_return = %s(expect, ans)\n" % cfn) + def check_function(expect, ans, **kwargs): + code = [script_code, "kwargs = {}"] + for name, val in kwargs.iteritems(): + if isinstance(val, (str, list, tuple, int, long, float, dict)): + code.append("kwargs[%r] = %r" % (name, val)) + code.append("cfn_return = %s(expect, ans, **kwargs)" % cfn) + code.append("") # a final newline globals_dict = { 'expect': expect, 'ans': ans, } locals_dict = {} - safe_exec.safe_exec(code, globals_dict, locals_dict) + safe_exec.safe_exec("\n".join(code), globals_dict, locals_dict) return locals_dict['cfn_return'] return check_function @@ -954,6 +958,8 @@ class CustomResponse(LoncapaResponse): else: self.code = answer.text + self.cfn_kwargs_keys = [] + def get_score(self, student_answers): ''' student_answers is a dict with everything from request.POST, but with the first part @@ -1054,7 +1060,8 @@ class CustomResponse(LoncapaResponse): log.debug(" submission = %s" % submission) try: answer_given = submission[0] if (len(idset) == 1) else submission - ret = fn(self.expect, answer_given) + kwargs = {n:self.context.get(n) for n in self.cfn_kwargs_keys} + ret = fn(self.expect, answer_given, **kwargs) except Exception as err: self._handle_exec_exception(err) @@ -1203,6 +1210,7 @@ class SymbolicResponse(CustomResponse): self.context['script_code'] += "from symmath import symmath_check\n" self.xml.set('cfn', 'symmath_check') CustomResponse.setup_response(self) + self.cfn_kwargs_keys.extend(['dynamath', 'options', 'debug']) #----------------------------------------------------------------------------- From 070f184ee0d848cc2eb42437487fa74c269ab00f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 21 Feb 2013 14:14:19 -0500 Subject: [PATCH 044/120] Not sure why these had capital-I's in them, since the text just above shows lowercase-i's, and uppercase doesn't work properly. --- common/lib/xmodule/test_files/symbolicresponse.xml | 2 +- common/test/data/full/problem/test_files/symbolicresponse.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/test_files/symbolicresponse.xml b/common/lib/xmodule/test_files/symbolicresponse.xml index f174bc986c..8443366ffe 100644 --- a/common/lib/xmodule/test_files/symbolicresponse.xml +++ b/common/lib/xmodule/test_files/symbolicresponse.xml @@ -16,7 +16,7 @@ real time, next to the input box. Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax] and give the resulting \(2 \times 2\) matrix.
Your input should be typed in as a list of lists, eg [[1,2],[3,4]].
- [mathjax]U=[/mathjax] + [mathjax]U=[/mathjax]
diff --git a/common/test/data/full/problem/test_files/symbolicresponse.xml b/common/test/data/full/problem/test_files/symbolicresponse.xml index 3573c34076..85945b1d8c 100644 --- a/common/test/data/full/problem/test_files/symbolicresponse.xml +++ b/common/test/data/full/problem/test_files/symbolicresponse.xml @@ -19,7 +19,7 @@ from symmath import * Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax] and give the resulting \(2 \times 2\) matrix.
Your input should be typed in as a list of lists, eg [[1,2],[3,4]].
- [mathjax]U=[/mathjax] + [mathjax]U=[/mathjax]
From 9dbfca129c384572726b3e5619c8198866f60922 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 21 Feb 2013 14:36:14 -0500 Subject: [PATCH 045/120] Check functions now can only return serializable data, and 'ex' and 'got' weren't used later anyway. --- common/lib/sandbox-packages/symmath/symmath_check.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/sandbox-packages/symmath/symmath_check.py b/common/lib/sandbox-packages/symmath/symmath_check.py index 151debee71..65a17883f5 100644 --- a/common/lib/sandbox-packages/symmath/symmath_check.py +++ b/common/lib/sandbox-packages/symmath/symmath_check.py @@ -324,4 +324,5 @@ def symmath_check(expect, ans, dynamath=None, options=None, debug=None, xml=None msg += "

Difference: %s

" % to_latex(diff) msg += '
' - return {'ok': False, 'msg': msg, 'ex': fexpect, 'got': fsym} + # Used to return more keys: 'ex': fexpect, 'got': fsym + return {'ok': False, 'msg': msg} From c04f3e09c09d830927487e1e045bee6294121b4e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 21 Feb 2013 15:52:37 -0500 Subject: [PATCH 046/120] Test that the sandbox can't get to the network. --- common/lib/codejail/codejail/tests/test_jailpy.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index fb59bac31d..15c548663b 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -96,6 +96,18 @@ class TestLimits(JailPyHelpers, unittest.TestCase): self.assertEqual(res.stdout, "Trying\n") self.assertIn("ermission denied", res.stderr) + def test_cant_use_network(self): + res = jailpy(dedent("""\ + import urllib + print "Reading google" + u = urllib.urlopen("http://google.com") + google = u.read() + print len(google) + """)) + self.assertNotEqual(res.status, 0) + self.assertEqual(res.stdout, "Reading google\n") + self.assertIn("IOError", res.stderr) + # TODO: write files # TODO: read network # TODO: fork From 94f6e685df4b3f069cdf5ea522bc9ca26f37f16f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 21 Feb 2013 17:29:44 -0500 Subject: [PATCH 047/120] Mock the response from the snuggletex server, and unskip the SymbolicResponse test. --- .../tests/test_files/snuggletex_correct.html | 480 ++++++++++++++++++ .../tests/test_files/snuggletex_wrong.html | 187 +++++++ .../lib/capa/capa/tests/test_responsetypes.py | 29 +- 3 files changed, 687 insertions(+), 9 deletions(-) create mode 100644 common/lib/capa/capa/tests/test_files/snuggletex_correct.html create mode 100644 common/lib/capa/capa/tests/test_files/snuggletex_wrong.html diff --git a/common/lib/capa/capa/tests/test_files/snuggletex_correct.html b/common/lib/capa/capa/tests/test_files/snuggletex_correct.html new file mode 100644 index 0000000000..0d10f7f56d --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/snuggletex_correct.html @@ -0,0 +1,480 @@ + + + + + + + + + + + + SnuggleTeX - ASCIIMathML Enrichment Demo + + + + + + + +

SnuggleTeX (1.2.2)

+
+ + +
+ +
+

ASCIIMathML Enrichment Demo

+

Input

+

+ This demo is similar to the + MathML Semantic Enrichnment Demo + but uses + ASCIIMathML as + an alternative input format, which provides real-time feedback as you + type but can often generate MathML with odd semantics in it. + SnuggleTeX includes some functionality that can to convert this raw MathML into + something equivalent to its own MathML output, thereby allowing you to + semantically enrich it in + certain simple cases, making ASCIIMathML a possibly viable input format + for simple semantic maths. + +

+

+ To try the demo, simply enter some some ASCIIMathML into the box below. + You should see a real time preview of this while you type. + Then hit Go! to use SnuggleTeX to semantically enrich your + input. + +

+
+
+ ASCIIMath Input: +
+
+

Live Preview

+

+ This is a MathML rendering of your input, generated by ASCIIMathML as you type. + +

+
+
+
+

+ This is the underlying MathML source generated by ASCIIMathML, again updated in real time. + +

+
 
+

Enhanced Presentation MathML

+

+ This shows the result of attempting to enrich the raw Presentation MathML + generated by ASCIIMathML: + +

<math xmlns="http://www.w3.org/1998/Math/MathML">
+   <mrow>
+      <mrow>
+         <mrow>
+            <mi>cos</mi>
+            <mo>&ApplyFunction;</mo>
+            <mfenced close=")" open="(">
+               <mi>theta</mi>
+            </mfenced>
+         </mrow>
+         <mo>&sdot;</mo>
+         <mfenced close="]" open="[">
+            <mtable>
+               <mtr>
+                  <mtd>
+                     <mn>1</mn>
+                  </mtd>
+                  <mtd>
+                     <mn>0</mn>
+                  </mtd>
+               </mtr>
+               <mtr>
+                  <mtd>
+                     <mn>0</mn>
+                  </mtd>
+                  <mtd>
+                     <mn>1</mn>
+                  </mtd>
+               </mtr>
+            </mtable>
+         </mfenced>
+      </mrow>
+      <mo>+</mo>
+      <mrow>
+         <mi>i</mi>
+         <mo>&sdot;</mo>
+         <mrow>
+            <mi>sin</mi>
+            <mo>&ApplyFunction;</mo>
+            <mfenced close=")" open="(">
+               <mi>theta</mi>
+            </mfenced>
+         </mrow>
+         <mo>&sdot;</mo>
+         <mfenced close="]" open="[">
+            <mtable>
+               <mtr>
+                  <mtd>
+                     <mn>0</mn>
+                  </mtd>
+                  <mtd>
+                     <mn>1</mn>
+                  </mtd>
+               </mtr>
+               <mtr>
+                  <mtd>
+                     <mn>1</mn>
+                  </mtd>
+                  <mtd>
+                     <mn>0</mn>
+                  </mtd>
+               </mtr>
+            </mtable>
+         </mfenced>
+      </mrow>
+   </mrow>
+</math>

Content MathML

+

+ This shows the result of an attempted + conversion to Content MathML: + +

<math xmlns="http://www.w3.org/1998/Math/MathML">
+   <apply>
+      <plus/>
+      <apply>
+         <times/>
+         <apply>
+            <cos/>
+            <ci>theta</ci>
+         </apply>
+         <list>
+            <matrix>
+               <vector>
+                  <cn>1</cn>
+                  <cn>0</cn>
+               </vector>
+               <vector>
+                  <cn>0</cn>
+                  <cn>1</cn>
+               </vector>
+            </matrix>
+         </list>
+      </apply>
+      <apply>
+         <times/>
+         <ci>i</ci>
+         <apply>
+            <sin/>
+            <ci>theta</ci>
+         </apply>
+         <list>
+            <matrix>
+               <vector>
+                  <cn>0</cn>
+                  <cn>1</cn>
+               </vector>
+               <vector>
+                  <cn>1</cn>
+                  <cn>0</cn>
+               </vector>
+            </matrix>
+         </list>
+      </apply>
+   </apply>
+</math>

Maxima Input Form

+

+ This shows the result of an attempted + conversion to Maxima Input syntax: + +

+

+ The conversion from Content MathML to Maxima Input was not successful for + this input. + +

+ + + + + + + + + + + + + + + + + + + + + + + +
Failure CodeMessageXPathContext
UMFG00Content MathML element matrix not supportedapply[1]/apply[1]/list[1]/matrix[1]
<matrix>
+   <vector>
+      <cn>1</cn>
+      <cn>0</cn>
+   </vector>
+   <vector>
+      <cn>0</cn>
+      <cn>1</cn>
+   </vector>
+</matrix>
UMFG00Content MathML element matrix not supportedapply[1]/apply[2]/list[1]/matrix[1]
<matrix>
+   <vector>
+      <cn>0</cn>
+      <cn>1</cn>
+   </vector>
+   <vector>
+      <cn>1</cn>
+      <cn>0</cn>
+   </vector>
+</matrix>
+

MathML Parallel Markup

+

+ This shows the enhanced Presentation MathML with other forms encapsulated + as annotations: + +

<math xmlns="http://www.w3.org/1998/Math/MathML">
+   <semantics>
+      <mrow>
+         <mrow>
+            <mrow>
+               <mi>cos</mi>
+               <mo>&ApplyFunction;</mo>
+               <mfenced close=")" open="(">
+                  <mi>theta</mi>
+               </mfenced>
+            </mrow>
+            <mo>&sdot;</mo>
+            <mfenced close="]" open="[">
+               <mtable>
+                  <mtr>
+                     <mtd>
+                        <mn>1</mn>
+                     </mtd>
+                     <mtd>
+                        <mn>0</mn>
+                     </mtd>
+                  </mtr>
+                  <mtr>
+                     <mtd>
+                        <mn>0</mn>
+                     </mtd>
+                     <mtd>
+                        <mn>1</mn>
+                     </mtd>
+                  </mtr>
+               </mtable>
+            </mfenced>
+         </mrow>
+         <mo>+</mo>
+         <mrow>
+            <mi>i</mi>
+            <mo>&sdot;</mo>
+            <mrow>
+               <mi>sin</mi>
+               <mo>&ApplyFunction;</mo>
+               <mfenced close=")" open="(">
+                  <mi>theta</mi>
+               </mfenced>
+            </mrow>
+            <mo>&sdot;</mo>
+            <mfenced close="]" open="[">
+               <mtable>
+                  <mtr>
+                     <mtd>
+                        <mn>0</mn>
+                     </mtd>
+                     <mtd>
+                        <mn>1</mn>
+                     </mtd>
+                  </mtr>
+                  <mtr>
+                     <mtd>
+                        <mn>1</mn>
+                     </mtd>
+                     <mtd>
+                        <mn>0</mn>
+                     </mtd>
+                  </mtr>
+               </mtable>
+            </mfenced>
+         </mrow>
+      </mrow>
+      <annotation-xml encoding="MathML-Content">
+         <apply>
+            <plus/>
+            <apply>
+               <times/>
+               <apply>
+                  <cos/>
+                  <ci>theta</ci>
+               </apply>
+               <list>
+                  <matrix>
+                     <vector>
+                        <cn>1</cn>
+                        <cn>0</cn>
+                     </vector>
+                     <vector>
+                        <cn>0</cn>
+                        <cn>1</cn>
+                     </vector>
+                  </matrix>
+               </list>
+            </apply>
+            <apply>
+               <times/>
+               <ci>i</ci>
+               <apply>
+                  <sin/>
+                  <ci>theta</ci>
+               </apply>
+               <list>
+                  <matrix>
+                     <vector>
+                        <cn>0</cn>
+                        <cn>1</cn>
+                     </vector>
+                     <vector>
+                        <cn>1</cn>
+                        <cn>0</cn>
+                     </vector>
+                  </matrix>
+               </list>
+            </apply>
+         </apply>
+      </annotation-xml>
+      <annotation encoding="ASCIIMathInput"/>
+      <annotation-xml encoding="Maxima-upconversion-failures">
+         <s:fail xmlns:s="http://www.ph.ed.ac.uk/snuggletex" code="UMFG00"
+                 message="Content MathML element matrix not supported">
+            <s:arg>matrix</s:arg>
+            <s:xpath>apply[1]/apply[1]/list[1]/matrix[1]</s:xpath>
+            <s:context>
+               <matrix>
+                  <vector>
+                     <cn>1</cn>
+                     <cn>0</cn>
+                  </vector>
+                  <vector>
+                     <cn>0</cn>
+                     <cn>1</cn>
+                  </vector>
+               </matrix>
+            </s:context>
+         </s:fail>
+         <s:fail xmlns:s="http://www.ph.ed.ac.uk/snuggletex" code="UMFG00"
+                 message="Content MathML element matrix not supported">
+            <s:arg>matrix</s:arg>
+            <s:xpath>apply[1]/apply[2]/list[1]/matrix[1]</s:xpath>
+            <s:context>
+               <matrix>
+                  <vector>
+                     <cn>0</cn>
+                     <cn>1</cn>
+                  </vector>
+                  <vector>
+                     <cn>1</cn>
+                     <cn>0</cn>
+                  </vector>
+               </matrix>
+            </s:context>
+         </s:fail>
+      </annotation-xml>
+   </semantics>
+</math>
+
+
+
+ + + \ No newline at end of file diff --git a/common/lib/capa/capa/tests/test_files/snuggletex_wrong.html b/common/lib/capa/capa/tests/test_files/snuggletex_wrong.html new file mode 100644 index 0000000000..abd62ca4d2 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/snuggletex_wrong.html @@ -0,0 +1,187 @@ + + + + + + + + + + + + SnuggleTeX - ASCIIMathML Enrichment Demo + + + + + + + +

SnuggleTeX (1.2.2)

+
+ + +
+ +
+

ASCIIMathML Enrichment Demo

+

Input

+

+ This demo is similar to the + MathML Semantic Enrichnment Demo + but uses + ASCIIMathML as + an alternative input format, which provides real-time feedback as you + type but can often generate MathML with odd semantics in it. + SnuggleTeX includes some functionality that can to convert this raw MathML into + something equivalent to its own MathML output, thereby allowing you to + semantically enrich it in + certain simple cases, making ASCIIMathML a possibly viable input format + for simple semantic maths. + +

+

+ To try the demo, simply enter some some ASCIIMathML into the box below. + You should see a real time preview of this while you type. + Then hit Go! to use SnuggleTeX to semantically enrich your + input. + +

+
+
+ ASCIIMath Input: +
+
+

Live Preview

+

+ This is a MathML rendering of your input, generated by ASCIIMathML as you type. + +

+
+
+
+

+ This is the underlying MathML source generated by ASCIIMathML, again updated in real time. + +

+
 
+

Enhanced Presentation MathML

+

+ This shows the result of attempting to enrich the raw Presentation MathML + generated by ASCIIMathML: + +

<math xmlns="http://www.w3.org/1998/Math/MathML">
+   <mn>2</mn>
+</math>

Content MathML

+

+ This shows the result of an attempted + conversion to Content MathML: + +

<math xmlns="http://www.w3.org/1998/Math/MathML">
+   <cn>2</cn>
+</math>

Maxima Input Form

+

+ This shows the result of an attempted + conversion to Maxima Input syntax: + +

2

MathML Parallel Markup

+

+ This shows the enhanced Presentation MathML with other forms encapsulated + as annotations: + +

<math xmlns="http://www.w3.org/1998/Math/MathML">
+   <semantics>
+      <mn>2</mn>
+      <annotation-xml encoding="MathML-Content">
+         <cn>2</cn>
+      </annotation-xml>
+      <annotation encoding="ASCIIMathInput"/>
+      <annotation encoding="Maxima">2</annotation>
+   </semantics>
+</math>
+
+
+
+ + + \ No newline at end of file diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 7a43fff4c9..3d93af0c75 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -10,6 +10,7 @@ import os import random import unittest import textwrap +import mock from . import test_system @@ -186,7 +187,6 @@ class ImageResponseTest(ResponseTest): class SymbolicResponseTest(unittest.TestCase): def test_sr_grade(self): - raise SkipTest() # This test fails due to dependencies on a local copy of snuggletex-webapp. Until we have figured that out, we'll just skip this test symbolicresponse_file = os.path.dirname(__file__) + "/test_files/symbolicresponse.xml" test_lcp = lcp.LoncapaProblem(open(symbolicresponse_file).read(), '1', system=test_system) correct_answers = {'1_2_1': 'cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]', @@ -264,14 +264,25 @@ class SymbolicResponseTest(unittest.TestCase): } wrong_answers = {'1_2_1': '2', '1_2_1_dynamath': ''' - - - 2 - - ''', - } - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect') + + + 2 + + ''', + } + + import requests + d = os.path.dirname(__file__) + correct_snuggletex_response = open(os.path.join(d, "test_files/snuggletex_correct.html")).read().decode('utf8') + wrong_snuggletex_response = open(os.path.join(d, "test_files/snuggletex_wrong.html")).read().decode('utf8') + + with mock.patch.object(requests, 'post') as mock_post: + mock_post.return_value.text = correct_snuggletex_response + self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') + + with mock.patch.object(requests, 'post') as mock_post: + mock_post.return_value.text = wrong_snuggletex_response + self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect') class OptionResponseTest(ResponseTest): From 67d0670b2e93e25678fbf2f0469cbfba720c5d4b Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 21 Feb 2013 17:30:08 -0500 Subject: [PATCH 048/120] Symbolic response no longer runs its checker in the Python sandbox. --- common/lib/capa/capa/responsetypes.py | 111 +++++++++++++------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index ae6361ecd3..8f1ca25fa8 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -927,19 +927,17 @@ class CustomResponse(LoncapaResponse): # actual function that will re-execute the original script, # and invoke the function with the data needed. def make_check_function(script_code, cfn): - def check_function(expect, ans, **kwargs): - code = [script_code, "kwargs = {}"] - for name, val in kwargs.iteritems(): - if isinstance(val, (str, list, tuple, int, long, float, dict)): - code.append("kwargs[%r] = %r" % (name, val)) - code.append("cfn_return = %s(expect, ans, **kwargs)" % cfn) - code.append("") # a final newline + def check_function(expect, ans): + code = ( + script_code + "\n" + + "cfn_return = %s(expect, ans)\n" % cfn + ) globals_dict = { 'expect': expect, 'ans': ans, } locals_dict = {} - safe_exec.safe_exec("\n".join(code), globals_dict, locals_dict) + safe_exec.safe_exec(code, globals_dict, locals_dict) return locals_dict['cfn_return'] return check_function @@ -958,8 +956,6 @@ class CustomResponse(LoncapaResponse): else: self.code = answer.text - self.cfn_kwargs_keys = [] - def get_score(self, student_answers): ''' student_answers is a dict with everything from request.POST, but with the first part @@ -1038,16 +1034,30 @@ class CustomResponse(LoncapaResponse): # pass self.system.debug to cfn self.context['debug'] = self.system.DEBUG + # Run the check function + self.execute_check_function(idset, submission) + + # build map giving "correct"ness of the answer(s) + correct = self.context['correct'] + messages = self.context['messages'] + correct_map = CorrectMap() + + overall_message = self.clean_message_html(self.context['overall_message'])) + correct_map.set_overall_message(overall_message) + + for k in range(len(idset)): + npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0 + correct_map.set(idset[k], correct[k], msg=messages[k], + npoints=npoints) + return correct_map + + def execute_check_function(self, idset, submission): # exec the check function if isinstance(self.code, basestring): try: locals_dict = {} safe_exec.safe_exec(self.code, self.context, locals_dict) self.context.update(locals_dict) - correct = self.context['correct'] - messages = self.context['messages'] - overall_message = self.context['overall_message'] - except Exception as err: self._handle_exec_exception(err) @@ -1056,33 +1066,27 @@ class CustomResponse(LoncapaResponse): # this is an interface to the Tutor2 check functions fn = self.code - ret = None log.debug(" submission = %s" % submission) try: answer_given = submission[0] if (len(idset) == 1) else submission - kwargs = {n:self.context.get(n) for n in self.cfn_kwargs_keys} - ret = fn(self.expect, answer_given, **kwargs) + ret = fn(self.expect, answer_given) except Exception as err: - self._handle_exec_exception(err) - - if type(ret) == dict: - + log.error("oops in customresponse (cfn) error %s" % err) + # print "context = ",self.context + log.error(traceback.format_exc()) + raise Exception("oops in customresponse (cfn) error %s" % err) + log.debug( + "[courseware.capa.responsetypes.customresponse.get_score] ret = %s", + ret + ) + if isinstance(ret, dict): # One kind of dictionary the check function can return has the # form {'ok': BOOLEAN, 'msg': STRING} # If there are multiple inputs, they all get marked # to the same correct/incorrect value if 'ok' in ret: - correct = ['correct'] * len(idset) if ret[ - 'ok'] else ['incorrect'] * len(idset) - msg = ret.get('msg', None) - msg = self.clean_message_html(msg) - - # If there is only one input, apply the message to that input - # Otherwise, apply the message to the whole problem - if len(idset) > 1: - overall_message = msg - else: - messages[0] = msg + correct = ['correct' if ret['ok'] else 'incorrect'] * len(idset) + self.context['messages'][0] = self.clean_message_html(ret['msg']) # Another kind of dictionary the check function can return has # the form: @@ -1104,6 +1108,7 @@ class CustomResponse(LoncapaResponse): msg = (self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None) messages.append(msg) + self.context['messages'] = messages # Otherwise, we do not recognize the dictionary # Raise an exception @@ -1112,25 +1117,10 @@ class CustomResponse(LoncapaResponse): raise ResponseError( "CustomResponse: check function returned an invalid dict") - # The check function can return a boolean value, - # indicating whether all inputs should be marked - # correct or incorrect else: - n = len(idset) - correct = ['correct'] * n if ret else ['incorrect'] * n + correct = ['correct' if ret else 'incorrect'] * len(idset) - # build map giving "correct"ness of the answer(s) - correct_map = CorrectMap() - - overall_message = self.clean_message_html(overall_message) - correct_map.set_overall_message(overall_message) - - for k in range(len(idset)): - npoints = (self.maxpoints[idset[k]] - if correct[k] == 'correct' else 0) - correct_map.set(idset[k], correct[k], msg=messages[k], - npoints=npoints) - return correct_map + self.context['correct'] = correct def clean_message_html(self, msg): @@ -1205,12 +1195,23 @@ class SymbolicResponse(CustomResponse): response_tag = 'symbolicresponse' - def setup_response(self): - # No, this is not pretty. - self.context['script_code'] += "from symmath import symmath_check\n" - self.xml.set('cfn', 'symmath_check') - CustomResponse.setup_response(self) - self.cfn_kwargs_keys.extend(['dynamath', 'options', 'debug']) + def execute_check_function(self, idset, submission): + from symmath import symmath_check + fn = self.code + try: + answer_given = submission[0] if (len(idset) == 1) else submission + ret = symmath_check( + self.expect, answer_given, + dynamath=self.context.get('dynamath'), + options=self.context.get('options'), + debug=self.context.get('debug'), + ) + except Exception as err: + log.error("oops in symbolicresponse (cfn) error %s" % err) + log.error(traceback.format_exc()) + raise Exception("oops in symbolicresponse (cfn) error %s" % err) + self.context['messages'][0] = self.clean_message_html(ret['msg']) + self.context['correct'] = ['correct' if ret['ok'] else 'incorrect'] * len(idset) #----------------------------------------------------------------------------- From df17c0c7dde48e46ecf403e0412c5cf09087f689 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 22 Feb 2013 06:27:36 -0500 Subject: [PATCH 049/120] Move symmath into capa so that it's available where needed --- common/lib/{sandbox-packages => capa}/symmath/README.md | 0 common/lib/{sandbox-packages => capa}/symmath/__init__.py | 0 common/lib/{sandbox-packages => capa}/symmath/formula.py | 0 common/lib/{sandbox-packages => capa}/symmath/symmath_check.py | 0 common/lib/sandbox-packages/setup.py | 3 --- 5 files changed, 3 deletions(-) rename common/lib/{sandbox-packages => capa}/symmath/README.md (100%) rename common/lib/{sandbox-packages => capa}/symmath/__init__.py (100%) rename common/lib/{sandbox-packages => capa}/symmath/formula.py (100%) rename common/lib/{sandbox-packages => capa}/symmath/symmath_check.py (100%) diff --git a/common/lib/sandbox-packages/symmath/README.md b/common/lib/capa/symmath/README.md similarity index 100% rename from common/lib/sandbox-packages/symmath/README.md rename to common/lib/capa/symmath/README.md diff --git a/common/lib/sandbox-packages/symmath/__init__.py b/common/lib/capa/symmath/__init__.py similarity index 100% rename from common/lib/sandbox-packages/symmath/__init__.py rename to common/lib/capa/symmath/__init__.py diff --git a/common/lib/sandbox-packages/symmath/formula.py b/common/lib/capa/symmath/formula.py similarity index 100% rename from common/lib/sandbox-packages/symmath/formula.py rename to common/lib/capa/symmath/formula.py diff --git a/common/lib/sandbox-packages/symmath/symmath_check.py b/common/lib/capa/symmath/symmath_check.py similarity index 100% rename from common/lib/sandbox-packages/symmath/symmath_check.py rename to common/lib/capa/symmath/symmath_check.py diff --git a/common/lib/sandbox-packages/setup.py b/common/lib/sandbox-packages/setup.py index f5cfa91b8b..1b99118aca 100644 --- a/common/lib/sandbox-packages/setup.py +++ b/common/lib/sandbox-packages/setup.py @@ -4,14 +4,11 @@ setup( name="sandbox-packages", version="0.1", packages=[ - "symmath", "verifiers", ], py_modules=[ "eia", ], install_requires=[ - # symmath needs: - "sympy", "requests", "lxml", ], ) From ec7a04fdb3cb26d8fc8b2de2b10b82e17167f94f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 22 Feb 2013 13:31:31 -0500 Subject: [PATCH 050/120] A /debug/run_python endpoint for staff to test the sandboxing of Python code. --- lms/djangoapps/debug/__init__.py | 0 lms/djangoapps/debug/models.py | 3 +++ lms/djangoapps/debug/views.py | 29 ++++++++++++++++++++++++ lms/envs/common.py | 1 + lms/templates/debug/run_python_form.html | 19 ++++++++++++++++ lms/urls.py | 4 ++++ 6 files changed, 56 insertions(+) create mode 100644 lms/djangoapps/debug/__init__.py create mode 100644 lms/djangoapps/debug/models.py create mode 100644 lms/djangoapps/debug/views.py create mode 100644 lms/templates/debug/run_python_form.html diff --git a/lms/djangoapps/debug/__init__.py b/lms/djangoapps/debug/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/debug/models.py b/lms/djangoapps/debug/models.py new file mode 100644 index 0000000000..71a8362390 --- /dev/null +++ b/lms/djangoapps/debug/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/lms/djangoapps/debug/views.py b/lms/djangoapps/debug/views.py new file mode 100644 index 0000000000..5d58436ed6 --- /dev/null +++ b/lms/djangoapps/debug/views.py @@ -0,0 +1,29 @@ +"""Views for debugging and diagnostics""" + +import pprint + +from django.http import Http404 +from django.contrib.auth.decorators import login_required +from django_future.csrf import ensure_csrf_cookie, csrf_exempt +from mitxmako.shortcuts import render_to_response + +from codejail.safe_exec import safe_exec + +@login_required +@ensure_csrf_cookie +def run_python(request): + if not request.user.is_staff: + raise Http404 + c = {} + c['code'] = '' + c['results'] = None + if request.method == 'POST': + py_code = c['code'] = request.POST.get('code') + g, l = {}, {} + try: + safe_exec(py_code, g, l) + except Exception as e: + c['results'] = str(e) + else: + c['results'] = pprint.pformat(l) + return render_to_response("debug/run_python_form.html", c) diff --git a/lms/envs/common.py b/lms/envs/common.py index b99efd60c3..1b492a3c56 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -589,6 +589,7 @@ INSTALLED_APPS = ( # For testing 'django.contrib.admin', # only used in DEBUG mode + 'debug', # Discussion forums 'django_comment_client', diff --git a/lms/templates/debug/run_python_form.html b/lms/templates/debug/run_python_form.html new file mode 100644 index 0000000000..daecdf2abd --- /dev/null +++ b/lms/templates/debug/run_python_form.html @@ -0,0 +1,19 @@ + +
+

Python:

+
+ +
+ +
+ +
+
+%if results: +
+

Results:

+
+${results|h}
+
+
+%endif diff --git a/lms/urls.py b/lms/urls.py index 126d68c73e..dc558d6a54 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -358,6 +358,10 @@ urlpatterns += ( url(r'^comm/foldit_ops', 'foldit.views.foldit_ops', name="foldit_ops"), ) +urlpatterns += ( + url(r'^debug/run_python', 'debug.views.run_python'), +) + urlpatterns = patterns(*urlpatterns) if settings.DEBUG: From 771de938c7ed6d4b01ca0150797e4f0d5e5651e0 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 25 Feb 2013 10:48:14 -0500 Subject: [PATCH 051/120] Update the instructions for setting up the sandbox. --- common/lib/codejail/README | 49 ++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/common/lib/codejail/README b/common/lib/codejail/README index 7b1849e18c..75862d69e3 100644 --- a/common/lib/codejail/README +++ b/common/lib/codejail/README @@ -1,21 +1,56 @@ -Choose a place for the virtualenv, call it +Choose a place for the virtualenv, call it . It will be automatically +detected and used if you put it right alongside your existing virtualenv, but +with -sandbox appended. So if your existing virtualenv is in ~/mitx_all/python, +make be ~/mitx_all/python-sandbox (but you'll need to spell out your +home directory instead of ~). + +Other details here that depend on your configuration: + + - Your mitx working tree is , for example, ~/mitx_all/mitx + + - The user running the LMS is , for example, you on a dev machine, + or www-data on a server. Create a virtualenv: - virtualenv + $ sudo virtualenv Install the sandbox requirements + $ source /bin/activate + $ sudo pip install -r sandbox-requirements.txt -Edit an AppArmor profile: +Add a sandbox user: + + $ sudo addgroup sandbox + $ sudo adduser --disabled-login sandbox --ingroup sandbox + +Let the web server run the sandboxed Python as sandbox. Create the file +/etc/sudoers.d/01-sandbox: + + $ visudo -f /etc/sudoers.d/01-sandbox + + ALL=(sandbox) NOPASSWD:/bin/python + ALL=(ALL) NOPASSWD:/bin/kill + +Edit an AppArmor profile. The file must be named for the python executable, +but with slashes changed to dots: + + #include /bin/python { - ... + #include + + /** mr, + /common/lib/sandbox-packages/** r, + /usr/local/lib/python2.7/** r, + /usr/lib/python2.7/** rix, + + /tmp/** rix, } Parse the profiles - $ apparmor_parser - $ aaenforce /bin/python - + $ sudo apparmor_parser +Reactivate your real virtualenv again From d9df65eef06aa8bd0db521e108d97741060503c3 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 25 Feb 2013 16:04:59 -0500 Subject: [PATCH 052/120] Add some logging to codejail --- common/lib/codejail/codejail/jailpy.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jailpy.py index ed13a0d3f0..c3e98a518a 100644 --- a/common/lib/codejail/codejail/jailpy.py +++ b/common/lib/codejail/codejail/jailpy.py @@ -3,6 +3,7 @@ # Instructions: # - AppArmor.md from xserver +import logging import os, os.path import resource import shutil @@ -13,6 +14,8 @@ import time from .util import temp_directory +log = logging.getLogger(__name__) + # TODO: limit too much stdout data? # Configure the Python command @@ -61,6 +64,8 @@ def jailpy(code, files=None, argv=None, stdin=None): with temp_directory(delete_when_done=True) as tmpdir: + log.debug("Executing jailed code: %r", code) + # All the supporting files are copied into our directory. for filename in files or (): if os.path.isfile(filename): From 1473fe377a8ef6a48940986810724ba375ef9b43 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 26 Feb 2013 13:19:19 -0500 Subject: [PATCH 053/120] A unit test that demonstrates the problem we're having with some sandboxed code. --- common/lib/codejail/codejail/tests/test_safe_exec.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index 1bdcceaf44..f9b496b8e9 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -1,6 +1,7 @@ """Test safe_exec.py""" import os.path +import textwrap import unittest from nose.plugins.skip import SkipTest @@ -42,6 +43,17 @@ class SafeExecTests(object): ) self.assertEqual(l['a'], 42) + def test_functions_calling_each_other(self): + g, l = {}, {} + self.safe_exec(textwrap.dedent("""\ + def f(): + return 1723 + def g(): + return f() + x = g() + """), g, l) + self.assertEqual(l['x'], 1723) + class TestSafeExec(SafeExecTests, unittest.TestCase): """Run SafeExecTests, with the real safe_exec.""" From 839c5684746391a8c2173f23662cf60050a32a1e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 26 Feb 2013 14:17:08 -0500 Subject: [PATCH 054/120] Hmm, turns out exec wants just one dict to properly simulate Python module execution. --- common/lib/capa/capa/capa_problem.py | 4 +- common/lib/capa/capa/responsetypes.py | 14 +++---- common/lib/capa/capa/safe_exec.py | 4 +- common/lib/capa/capa/tests/test_safe_exec.py | 32 ++++++++-------- common/lib/codejail/codejail/safe_exec.py | 30 +++++++-------- .../codejail/codejail/tests/test_safe_exec.py | 38 +++++++++---------- lms/djangoapps/debug/views.py | 6 +-- 7 files changed, 61 insertions(+), 67 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 9665721293..b5e773ebc7 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -462,9 +462,7 @@ class LoncapaProblem(object): if all_code: try: - locals_dict = {} - safe_exec.safe_exec(all_code, context, locals_dict, random_seed=self.seed, python_path=python_path) - context.update(locals_dict) + safe_exec.safe_exec(all_code, context, random_seed=self.seed, python_path=python_path) except Exception as err: log.exception("Error while execing script code: " + all_code) msg = "Error while executing script code: %s" % str(err).replace('<', '<') diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 8f1ca25fa8..c441539aee 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -936,9 +936,8 @@ class CustomResponse(LoncapaResponse): 'expect': expect, 'ans': ans, } - locals_dict = {} - safe_exec.safe_exec(code, globals_dict, locals_dict) - return locals_dict['cfn_return'] + safe_exec.safe_exec(code, globals_dict) + return globals_dict['cfn_return'] return check_function self.code = make_check_function(self.context['script_code'], cfn) @@ -1055,9 +1054,7 @@ class CustomResponse(LoncapaResponse): # exec the check function if isinstance(self.code, basestring): try: - locals_dict = {} - safe_exec.safe_exec(self.code, self.context, locals_dict) - self.context.update(locals_dict) + safe_exec.safe_exec(self.code, self.context) except Exception as err: self._handle_exec_exception(err) @@ -1762,10 +1759,9 @@ class SchematicResponse(LoncapaResponse): json.loads(student_answers[k]) for k in sorted(self.answer_ids) ] self.context.update({'submission': submission}) - locals_dict = {} - safe_exec.safe_exec(self.code, self.context, locals_dict) + safe_exec.safe_exec(self.code, self.context) cmap = CorrectMap() - cmap.set_dict(dict(zip(sorted(self.answer_ids), locals_dict['correct']))) + cmap.set_dict(dict(zip(sorted(self.answer_ids), self.context['correct']))) return cmap def get_answers(self): diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py index 31f47b5c36..dd52c3571a 100644 --- a/common/lib/capa/capa/safe_exec.py +++ b/common/lib/capa/capa/safe_exec.py @@ -14,13 +14,13 @@ random.Random = random_module.Random del random_module """ -def safe_exec(code, globals_dict, locals_dict, random_seed=None, python_path=None): +def safe_exec(code, globals_dict, random_seed=None, python_path=None): """Exec python code safely. """ code_prolog = CODE_PROLOG % random_seed codejail.safe_exec.safe_exec( - code_prolog + code, globals_dict, locals_dict, + code_prolog + code, globals_dict, python_path=python_path, assumed_imports=[ "numpy", diff --git a/common/lib/capa/capa/tests/test_safe_exec.py b/common/lib/capa/capa/tests/test_safe_exec.py index 3a436ac3ce..8a65cb6c38 100644 --- a/common/lib/capa/capa/tests/test_safe_exec.py +++ b/common/lib/capa/capa/tests/test_safe_exec.py @@ -8,39 +8,39 @@ from capa.safe_exec import safe_exec class TestSafeExec(unittest.TestCase): def test_set_values(self): - g, l = {}, {} - safe_exec("a = 17", g, l) - self.assertEqual(l['a'], 17) + g = {} + safe_exec("a = 17", g) + self.assertEqual(g['a'], 17) def test_division(self): - g, l = {}, {} + g = {} # Future division: 1/2 is 0.5. - safe_exec("a = 1/2", g, l) - self.assertEqual(l['a'], 0.5) + safe_exec("a = 1/2", g) + self.assertEqual(g['a'], 0.5) def test_assumed_imports(self): - g, l = {}, {} + g = {} # Math is always available. - safe_exec("a = int(math.pi)", g, l) - self.assertEqual(l['a'], 3) + safe_exec("a = int(math.pi)", g) + self.assertEqual(g['a'], 3) def test_random_seeding(self): - g, l = {}, {} + g = {} r = random.Random(17) rnums = [r.randint(0, 999) for _ in xrange(100)] # Without a seed, the results are unpredictable - safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g, l) - self.assertNotEqual(l['rnums'], rnums) + safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g) + self.assertNotEqual(g['rnums'], rnums) # With a seed, the results are predictable - safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g, l, random_seed=17) - self.assertEqual(l['rnums'], rnums) + safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g, random_seed=17) + self.assertEqual(g['rnums'], rnums) def test_python_lib(self): pylib = os.path.dirname(__file__) + "/test_files/pylib" - g, l = {}, {} + g = {} safe_exec( "import constant; a = constant.THE_CONST", - g, l, python_path=[pylib] + g, python_path=[pylib] ) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 5a4e48d39e..c4c6a8145b 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -28,19 +28,19 @@ def names_and_modules(assumed_imports): yield modname, modname -def safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, python_path=None): +def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path=None): """Execute code as "exec" does, but safely. - `code` is a string of Python code. `globals_dict` and `locals_dict` are - dictionaries to use as the globals and locals. Modifications the code - makes to `locals_dict` are reflected in the dictionary on return. + `code` is a string of Python code. `globals_dict` is used as the globals + during execution. Modifications the code makes to `globals_dict` are + reflected in the dictionary on return. `assumed_imports` is a list of modules to make available as implicit imports for the code. Entries are either a name, "mod", which makes "import mod" part of the code, or a pair, ("f", "fooey"), which makes "import fooey as f" part of the code. The module name can be dotted. - Returns None. Changes made by `code` are visible in `locals_dict`. + Returns None. Changes made by `code` are visible in `globals_dict`. """ the_code = [] @@ -49,7 +49,7 @@ def safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, the_code.append(textwrap.dedent("""\ import json import sys - code, g_dict, l_dict = json.load(sys.stdin) + code, g_dict = json.load(sys.stdin) """)) for pydir in python_path or (): @@ -63,13 +63,14 @@ def safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, the_code.append("g_dict['{}'] = LazyModule('{}')\n".format(name, modname)) the_code.append(textwrap.dedent("""\ - exec code in g_dict, l_dict + exec code in g_dict ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) - l_dict = {k:v for k,v in l_dict.iteritems() if isinstance(v, ok_types)} - json.dump(l_dict, sys.stdout) + bad_keys = ("__builtins__",) + g_dict = {k:v for k,v in g_dict.iteritems() if isinstance(v, ok_types) and k not in bad_keys} + json.dump(g_dict, sys.stdout) """)) - stdin = json.dumps([code, globals_dict, locals_dict]) + stdin = json.dumps([code, globals_dict]) jailed_code = "".join(the_code) # Turn this on to see what's being executed. @@ -82,10 +83,10 @@ def safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, res = jailpy.jailpy(jailed_code, stdin=stdin, files=files) if res.status != 0: raise Exception("Couldn't excecute jailed code: %s" % res.stderr) - locals_dict.update(json.loads(res.stdout)) + globals_dict.update(json.loads(res.stdout)) -def not_safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=None, python_path=None): +def not_safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path=None): """Another implementation of `safe_exec`, but not safe. This can be swapped in for debugging problems in sandboxed Python code. @@ -111,7 +112,6 @@ def not_safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=N return json.loads(json.dumps(jd)) g_dict = straw(globals_dict) - l_dict = straw(locals_dict) for name, modname in names_and_modules(assumed_imports or ()): g_dict[name] = lazymod.LazyModule(modname) @@ -127,11 +127,11 @@ def not_safe_exec(code, globals_dict, locals_dict, assumed_imports=None, files=N if python_path: sys.path.extend(python_path) try: - exec code in g_dict, l_dict + exec code in g_dict finally: sys.path = original_path - locals_dict.update(straw(l_dict)) + globals_dict.update(straw(g_dict)) # Running Python code in the sandbox makes it difficult to debug. # Change 0 to 1 to run the code directly. diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index f9b496b8e9..bf4a0408cd 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -10,49 +10,49 @@ from codejail.safe_exec import safe_exec, not_safe_exec class SafeExecTests(object): """The tests for `safe_exec`, will be mixed into specific test classes below.""" def test_set_values(self): - g, l = {}, {} - self.safe_exec("a = 17", g, l) - self.assertEqual(l['a'], 17) + g = {} + self.safe_exec("a = 17", g) + self.assertEqual(g['a'], 17) def test_assumed_imports(self): - g, l = {}, {} + g = {} # Using string without importing it is bad. with self.assertRaises(Exception): - self.safe_exec("a = string.ascii_lowercase[0]", g, l) + self.safe_exec("a = string.ascii_lowercase[0]", g) # Using string with an assumed import is fine. - self.safe_exec("a = string.ascii_lowercase[0]", g, l, assumed_imports=["string"]) - self.assertEqual(l['a'], 'a') + self.safe_exec("a = string.ascii_lowercase[0]", g, assumed_imports=["string"]) + self.assertEqual(g['a'], 'a') # Can also import with a shorthand. - self.safe_exec("a = op.join('x', 'y')", g, l, assumed_imports=[("op", "os.path")]) - self.assertEqual(l['a'][0], 'x') - self.assertEqual(l['a'][-1], 'y') + self.safe_exec("a = op.join('x', 'y')", g, assumed_imports=[("op", "os.path")]) + self.assertEqual(g['a'][0], 'x') + self.assertEqual(g['a'][-1], 'y') def test_files_are_copied(self): - g, l = {}, {} + g = {} self.safe_exec( - "a = 'Look: ' + open('hello.txt').read()", g, l, + "a = 'Look: ' + open('hello.txt').read()", g, files=[os.path.dirname(__file__) + "/hello.txt"] ) - self.assertEqual(l['a'], 'Look: Hello there.\n') + self.assertEqual(g['a'], 'Look: Hello there.\n') def test_python_path(self): - g, l = {}, {} + g = {} self.safe_exec( - "import module; a = module.const", g, l, + "import module; a = module.const", g, python_path=[os.path.dirname(__file__) + "/pylib"] ) - self.assertEqual(l['a'], 42) + self.assertEqual(g['a'], 42) def test_functions_calling_each_other(self): - g, l = {}, {} + g = {} self.safe_exec(textwrap.dedent("""\ def f(): return 1723 def g(): return f() x = g() - """), g, l) - self.assertEqual(l['x'], 1723) + """), g) + self.assertEqual(g['x'], 1723) class TestSafeExec(SafeExecTests, unittest.TestCase): diff --git a/lms/djangoapps/debug/views.py b/lms/djangoapps/debug/views.py index 5d58436ed6..3bf0c81240 100644 --- a/lms/djangoapps/debug/views.py +++ b/lms/djangoapps/debug/views.py @@ -19,11 +19,11 @@ def run_python(request): c['results'] = None if request.method == 'POST': py_code = c['code'] = request.POST.get('code') - g, l = {}, {} + g = {} try: - safe_exec(py_code, g, l) + safe_exec(py_code, g) except Exception as e: c['results'] = str(e) else: - c['results'] = pprint.pformat(l) + c['results'] = pprint.pformat(g) return render_to_response("debug/run_python_form.html", c) From 5acb2258164ce5f8d7c8c297b32534547e861450 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 26 Feb 2013 17:03:39 -0500 Subject: [PATCH 055/120] Print the full traceback when execution fails. --- lms/djangoapps/debug/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/debug/views.py b/lms/djangoapps/debug/views.py index 3bf0c81240..c1d4155fdd 100644 --- a/lms/djangoapps/debug/views.py +++ b/lms/djangoapps/debug/views.py @@ -1,6 +1,7 @@ """Views for debugging and diagnostics""" import pprint +import traceback from django.http import Http404 from django.contrib.auth.decorators import login_required @@ -12,6 +13,7 @@ from codejail.safe_exec import safe_exec @login_required @ensure_csrf_cookie def run_python(request): + """A page to allow testing the Python sandbox on a production server.""" if not request.user.is_staff: raise Http404 c = {} @@ -23,7 +25,7 @@ def run_python(request): try: safe_exec(py_code, g) except Exception as e: - c['results'] = str(e) + c['results'] = traceback.format_exc() else: c['results'] = pprint.pformat(g) return render_to_response("debug/run_python_form.html", c) From b95ea4422bdb6c161a8ff466403d48f92ed88ce6 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 26 Feb 2013 17:27:31 -0500 Subject: [PATCH 056/120] Prevent a print statement from accidentally borking the sandbox. --- common/lib/codejail/codejail/safe_exec.py | 31 ++++++++++++++++--- .../codejail/codejail/tests/test_safe_exec.py | 5 +++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index c4c6a8145b..7b4037a2a9 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -46,9 +46,24 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= the_code = [] files = list(files or ()) - the_code.append(textwrap.dedent("""\ + the_code.append(textwrap.dedent( + """ import json import sys + """ + # 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) """)) @@ -62,12 +77,20 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= for name, modname in names_and_modules(assumed_imports): the_code.append("g_dict['{}'] = LazyModule('{}')\n".format(name, modname)) - the_code.append(textwrap.dedent("""\ + 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__",) g_dict = {k:v for k,v in g_dict.iteritems() if isinstance(v, ok_types) and k not in bad_keys} - json.dump(g_dict, sys.stdout) + """ + # Write the globals back to the calling process. + """ + json.dump(g_dict, sys.__stdout__) """)) stdin = json.dumps([code, globals_dict]) @@ -82,7 +105,7 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= res = jailpy.jailpy(jailed_code, stdin=stdin, files=files) if res.status != 0: - raise Exception("Couldn't excecute jailed code: %s" % res.stderr) + raise Exception("Couldn't execute jailed code: %s" % res.stderr) globals_dict.update(json.loads(res.stdout)) diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index bf4a0408cd..b4f3627ad6 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -54,6 +54,11 @@ class SafeExecTests(object): """), g) self.assertEqual(g['x'], 1723) + def test_printing_stuff_when_you_shouldnt(self): + g = {} + self.safe_exec("a = 17; print 'hi!'", g) + self.assertEqual(g['a'], 17) + class TestSafeExec(SafeExecTests, unittest.TestCase): """Run SafeExecTests, with the real safe_exec.""" From 478f967af46b9808354d8b8b69242bead2fc5f35 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 27 Feb 2013 14:01:31 -0500 Subject: [PATCH 057/120] We would fail if a global was defined with a non-jsonable value inside a jsonable one. Now we don't/ --- common/lib/codejail/codejail/safe_exec.py | 20 ++++++++++++++++++- .../codejail/codejail/tests/test_safe_exec.py | 8 ++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 7b4037a2a9..b77081a2a2 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -43,6 +43,10 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= Returns None. Changes made by `code` are visible in `globals_dict`. """ + print "--- Executing: -------" + print code + print + the_code = [] files = list(files or ()) @@ -86,7 +90,15 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= """ ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) bad_keys = ("__builtins__",) - g_dict = {k:v for k,v in g_dict.iteritems() if isinstance(v, ok_types) and k not in bad_keys} + 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. """ @@ -124,8 +136,14 @@ def not_safe_exec(code, globals_dict, assumed_imports=None, files=None, python_p Used to emulate reading data through a serialization straw. """ + ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) + bad_keys = ("__builtins__",) jd = {} for k,v in d.iteritems(): + if not isinstance(v, ok_types): + continue + if k in bad_keys: + continue try: json.dumps(v) except TypeError: diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index b4f3627ad6..8a565f8cfb 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -59,6 +59,14 @@ class SafeExecTests(object): self.safe_exec("a = 17; print 'hi!'", g) self.assertEqual(g['a'], 17) + def test_importing_lots_of_crap(self): + g = {} + self.safe_exec(textwrap.dedent("""\ + from numpy import * + a = 1723 + """), g) + self.assertEqual(g['a'], 1723) + class TestSafeExec(SafeExecTests, unittest.TestCase): """Run SafeExecTests, with the real safe_exec.""" From f3e8d5bb7abb6862b293e0ee5a6f555c5f8d635b Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 27 Feb 2013 14:04:18 -0500 Subject: [PATCH 058/120] Didn't mean to put this in --- common/lib/codejail/codejail/safe_exec.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index b77081a2a2..f2ffa41523 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -43,10 +43,6 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= Returns None. Changes made by `code` are visible in `globals_dict`. """ - print "--- Executing: -------" - print code - print - the_code = [] files = list(files or ()) From 283fc47a95f2f0be95b5ba38e6dc2056b1621230 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 27 Feb 2013 14:13:45 -0500 Subject: [PATCH 059/120] Jailed code importing random explicitly would get the wrong seed. --- common/lib/capa/capa/safe_exec.py | 2 ++ common/lib/capa/capa/tests/test_safe_exec.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec.py index dd52c3571a..3d481495d4 100644 --- a/common/lib/capa/capa/safe_exec.py +++ b/common/lib/capa/capa/safe_exec.py @@ -9,9 +9,11 @@ CODE_PROLOG = """\ from __future__ import division import random as random_module +import sys random = random_module.Random(%r) random.Random = random_module.Random del random_module +sys.modules['random'] = random """ def safe_exec(code, globals_dict, random_seed=None, python_path=None): diff --git a/common/lib/capa/capa/tests/test_safe_exec.py b/common/lib/capa/capa/tests/test_safe_exec.py index 8a65cb6c38..7ed44a69a1 100644 --- a/common/lib/capa/capa/tests/test_safe_exec.py +++ b/common/lib/capa/capa/tests/test_safe_exec.py @@ -37,6 +37,18 @@ class TestSafeExec(unittest.TestCase): safe_exec("rnums = [random.randint(0, 999) for _ in xrange(100)]", g, random_seed=17) self.assertEqual(g['rnums'], rnums) + def test_random_is_still_importable(self): + g = {} + r = random.Random(17) + rnums = [r.randint(0, 999) for _ in xrange(100)] + + # With a seed, the results are predictable even from the random module + safe_exec( + "import random\n" + "rnums = [random.randint(0, 999) for _ in xrange(100)]\n", + g, random_seed=17) + self.assertEqual(g['rnums'], rnums) + def test_python_lib(self): pylib = os.path.dirname(__file__) + "/test_files/pylib" g = {} From 7aa493ec85f68f48004d49d32f2ed9a50ec1201e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 1 Mar 2013 10:36:20 -0500 Subject: [PATCH 060/120] A start on getting these tests to run again. --- .../capa/capa/tests/response_xml_factory.py | 21 +++++++++++++++++++ .../lib/capa/capa/tests/test_responsetypes.py | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index aa401b70cd..1d04b7897d 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -241,6 +241,27 @@ class CustomResponseXMLFactory(ResponseXMLFactory): return ResponseXMLFactory.textline_input_xml(**kwargs) +class SymbolicResponseXMLFactory(ResponseXMLFactory): + """ Factory for creating XML trees """ + + def create_response_element(self, **kwargs): + cfn = kwargs.get('cfn', None) + answer = kwargs.get('answer', None) + options = kwargs.get('options', None) + + response_element = etree.Element("symbolicresponse") + if cfn: + response_element.set('cfn', str(cfn)) + if answer: + response_element.set('answer', str(answer)) + if options: + response_element.set('options', str(options)) + return response_element + + def create_input_element(self, **kwargs): + return ResponseXMLFactory.textline_input_xml(**kwargs) + + class SchematicResponseXMLFactory(ResponseXMLFactory): """ Factory for creating XML trees """ diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 3d93af0c75..018d9240d7 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -186,7 +186,10 @@ class ImageResponseTest(ResponseTest): class SymbolicResponseTest(unittest.TestCase): - def test_sr_grade(self): + from response_xml_factory import SymbolicResponseXMLFactory + xml_factory_class = SymbolicResponseXMLFactory + + def test_symbolic_response_grade(self): symbolicresponse_file = os.path.dirname(__file__) + "/test_files/symbolicresponse.xml" test_lcp = lcp.LoncapaProblem(open(symbolicresponse_file).read(), '1', system=test_system) correct_answers = {'1_2_1': 'cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]', From c49b0c50270c93d9c771f296538dbf9d753527bf Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 1 Mar 2013 11:16:50 -0500 Subject: [PATCH 061/120] Have to make the globals json-safe before sending them to the sandbox. --- common/lib/codejail/codejail/safe_exec.py | 51 ++++++++++++----------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index f2ffa41523..b11ef60616 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -101,7 +101,7 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= json.dump(g_dict, sys.__stdout__) """)) - stdin = json.dumps([code, globals_dict]) + stdin = json.dumps([code, json_safe(globals_dict)]) jailed_code = "".join(the_code) # Turn this on to see what's being executed. @@ -117,6 +117,29 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= 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. + + """ + ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) + bad_keys = ("__builtins__",) + jd = {} + for k,v in d.iteritems(): + if not isinstance(v, ok_types): + continue + if k in bad_keys: + continue + try: + json.dumps(v) + except TypeError: + continue + else: + jd[k] = v + return json.loads(json.dumps(jd)) + + def not_safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path=None): """Another implementation of `safe_exec`, but not safe. @@ -126,29 +149,7 @@ def not_safe_exec(code, globals_dict, assumed_imports=None, files=None, python_p and modifying sys.path. """ - def straw(d): - """Return only the JSON-safe part of d. - - Used to emulate reading data through a serialization straw. - - """ - ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) - bad_keys = ("__builtins__",) - jd = {} - for k,v in d.iteritems(): - if not isinstance(v, ok_types): - continue - if k in bad_keys: - continue - try: - json.dumps(v) - except TypeError: - continue - else: - jd[k] = v - return json.loads(json.dumps(jd)) - - g_dict = straw(globals_dict) + g_dict = json_safe(globals_dict) for name, modname in names_and_modules(assumed_imports or ()): g_dict[name] = lazymod.LazyModule(modname) @@ -168,7 +169,7 @@ def not_safe_exec(code, globals_dict, assumed_imports=None, files=None, python_p finally: sys.path = original_path - globals_dict.update(straw(g_dict)) + globals_dict.update(json_safe(g_dict)) # Running Python code in the sandbox makes it difficult to debug. # Change 0 to 1 to run the code directly. From e61a6fe787e6213b2dd8e64821ca5ede7f71de6c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 1 Mar 2013 15:08:49 -0500 Subject: [PATCH 062/120] Make it possible for customresponse check functions to get extra arguments, though they need to be declared in the XML. --- common/lib/capa/capa/responsetypes.py | 12 ++++--- .../capa/capa/tests/response_xml_factory.py | 8 +++++ .../lib/capa/capa/tests/test_responsetypes.py | 35 ++++++++++++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index c441539aee..9dd1e74409 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -927,15 +927,17 @@ class CustomResponse(LoncapaResponse): # actual function that will re-execute the original script, # and invoke the function with the data needed. def make_check_function(script_code, cfn): - def check_function(expect, ans): + def check_function(expect, ans, **kwargs): + extra_args = "".join(", {0}={0}".format(k) for k in kwargs) code = ( script_code + "\n" + - "cfn_return = %s(expect, ans)\n" % cfn + "cfn_return = %s(expect, ans%s)\n" % (cfn, extra_args) ) globals_dict = { 'expect': expect, 'ans': ans, } + globals_dict.update(kwargs) safe_exec.safe_exec(code, globals_dict) return globals_dict['cfn_return'] return check_function @@ -1063,10 +1065,12 @@ class CustomResponse(LoncapaResponse): # this is an interface to the Tutor2 check functions fn = self.code + answer_given = submission[0] if (len(idset) == 1) else submission + kwnames = self.xml.get("cfn_extra_args", "").split() + kwargs = {n:self.context.get(n) for n in kwnames} log.debug(" submission = %s" % submission) try: - answer_given = submission[0] if (len(idset) == 1) else submission - ret = fn(self.expect, answer_given) + ret = fn(self.expect, answer_given, **kwargs) except Exception as err: log.error("oops in customresponse (cfn) error %s" % err) # print "context = ",self.context diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index 1d04b7897d..ee1fdfafc9 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -221,6 +221,8 @@ class CustomResponseXMLFactory(ResponseXMLFactory): cfn = kwargs.get('cfn', None) expect = kwargs.get('expect', None) answer = kwargs.get('answer', None) + options = kwargs.get('options', None) + cfn_extra_args = kwargs.get('cfn_extra_args', None) # Create the response element response_element = etree.Element("customresponse") @@ -235,6 +237,12 @@ class CustomResponseXMLFactory(ResponseXMLFactory): answer_element = etree.SubElement(response_element, "answer") answer_element.text = str(answer) + if options: + response_element.set('options', str(options)) + + if cfn_extra_args: + response_element.set('cfn_extra_args', str(cfn_extra_args)) + return response_element def create_input_element(self, **kwargs): diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 018d9240d7..c2ee62ed7a 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -2,7 +2,6 @@ Tests of responsetypes """ - from datetime import datetime import json from nose.plugins.skip import SkipTest @@ -806,9 +805,8 @@ class CustomResponseTest(ResponseTest): # # 'answer_given' is the answer the student gave (if there is just one input) # or an ordered list of answers (if there are multiple inputs) - # - # - # The function should return a dict of the form + # + # The function should return a dict of the form # { 'ok': BOOL, 'msg': STRING } # script = textwrap.dedent(""" @@ -917,6 +915,35 @@ class CustomResponseTest(ResponseTest): self.assertEqual(correct_map.get_msg('1_2_2'), 'Feedback 2') self.assertEqual(correct_map.get_msg('1_2_3'), 'Feedback 3') + def test_function_code_with_extra_args(self): + script = textwrap.dedent("""\ + def check_func(expect, answer_given, options, dynamath): + assert options == "xyzzy", "Options was %r" % options + return {'ok': answer_given == expect, 'msg': 'Message text'} + """) + + problem = self.build_problem(script=script, cfn="check_func", expect="42", options="xyzzy", cfn_extra_args="options dynamath") + + # Correct answer + input_dict = {'1_2_1': '42'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + msg = correct_map.get_msg('1_2_1') + + self.assertEqual(correctness, 'correct') + self.assertEqual(msg, "Message text\n") + + # Incorrect answer + input_dict = {'1_2_1': '0'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + msg = correct_map.get_msg('1_2_1') + + self.assertEqual(correctness, 'incorrect') + self.assertEqual(msg, "Message text\n") + def test_multiple_inputs_return_one_status(self): # When given multiple inputs, the 'answer_given' argument # to the check_func() is a list of inputs From f62dad2f5710a41453bb9c71f6bef2313b58820e Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 16:31:02 -0500 Subject: [PATCH 063/120] Added symbolic response tests --- common/lib/capa/capa/inputtypes.py | 2 +- common/lib/capa/capa/responsetypes.py | 20 +- .../capa/capa/tests/response_xml_factory.py | 35 +++ .../lib/capa/capa/tests/test_responsetypes.py | 225 +++++++++++------- common/lib/capa/capa/util.py | 2 +- 5 files changed, 188 insertions(+), 96 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index e253b61948..65280d6d29 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -46,7 +46,7 @@ import sys import pyparsing from .registry import TagRegistry -from capa.chem import chemcalc +from chem import chemcalc import xqueue_interface from datetime import datetime diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 9dd1e74409..ddf184c9be 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -29,7 +29,7 @@ from collections import namedtuple from shapely.geometry import Point, MultiPoint # specific library imports -from .calc import evaluator, UndefinedVariable +from calc import evaluator, UndefinedVariable from .correctmap import CorrectMap from datetime import datetime from .util import * @@ -1043,7 +1043,7 @@ class CustomResponse(LoncapaResponse): messages = self.context['messages'] correct_map = CorrectMap() - overall_message = self.clean_message_html(self.context['overall_message'])) + overall_message = self.clean_message_html(self.context['overall_message']) correct_map.set_overall_message(overall_message) for k in range(len(idset)): @@ -1195,12 +1195,24 @@ class SymbolicResponse(CustomResponse): """ response_tag = 'symbolicresponse' + max_inputfields = 1 + + def setup_response(self): + # Symbolic response always uses symmath_check() + # If the XML did not specify this, then set it now + # Otherwise, we get an error from the superclass + self.xml.set('cfn', 'symmath_check') + + # Let CustomResponse do its setup + super(SymbolicResponse, self).setup_response() def execute_check_function(self, idset, submission): from symmath import symmath_check - fn = self.code try: - answer_given = submission[0] if (len(idset) == 1) else submission + # Since we have limited max_inputfields to 1, + # we can assume that there is only one submission + answer_given = submission[0] + ret = symmath_check( self.expect, answer_given, dynamath=self.context.get('dynamath'), diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index ee1fdfafc9..ee9a7e6530 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -734,3 +734,38 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory): option_element.text = description return input_element + + +class SymbolicResponseXMLFactory(ResponseXMLFactory): + """ Factory for producing xml """ + + def create_response_element(self, **kwargs): + """ Build the XML element. + + Uses **kwargs: + + *expect*: The correct answer (a sympy string) + + *options*: list of option strings to pass to symmath_check + (e.g. 'matrix', 'qbit', 'imaginary', 'numerical')""" + + # Retrieve **kwargs + expect = kwargs.get('expect', '') + options = kwargs.get('options', []) + + # Symmath check expects a string of options + options_str = ",".join(options) + + # Construct the element + response_element = etree.Element('symbolicresponse') + + if expect: + response_element.set('expect', str(expect)) + + if options_str: + response_element.set('options', str(options_str)) + + return response_element + + def create_input_element(self, **kwargs): + return ResponseXMLFactory.textline_input_xml(**kwargs) diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index c2ee62ed7a..b76854c744 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -10,6 +10,7 @@ import random import unittest import textwrap import mock +import textwrap from . import test_system @@ -184,107 +185,151 @@ class ImageResponseTest(ResponseTest): self.assert_answer_format(problem) -class SymbolicResponseTest(unittest.TestCase): +class SymbolicResponseTest(ResponseTest): from response_xml_factory import SymbolicResponseXMLFactory xml_factory_class = SymbolicResponseXMLFactory - def test_symbolic_response_grade(self): - symbolicresponse_file = os.path.dirname(__file__) + "/test_files/symbolicresponse.xml" - test_lcp = lcp.LoncapaProblem(open(symbolicresponse_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]', - '1_2_1_dynamath': ''' - - - - cos - - ( - θ - ) - - - - - [ - - - - 1 - - - 0 - - - - - 0 - - - 1 - - - - ] - - + - i - - - sin - - ( - θ - ) - - - - - [ - - - - 0 - - - 1 - - - - - 1 - - - 0 - - - - ] - - - - ''', - } - wrong_answers = {'1_2_1': '2', - '1_2_1_dynamath': ''' - - - 2 - - ''', - } + def test_grade_single_input(self): + problem = self.build_problem(math_display=True, + expect="2*x+3*y") + # Correct answers + correct_inputs = [ + ('2x+3y', textwrap.dedent(""" + + + 2*x+3*y + """)), + + ('x+x+3y', textwrap.dedent(""" + + + x+x+3*y + """)), + ] + + for (input_str, input_mathml) in correct_inputs: + self._assert_symbolic_grade(problem, input_str, input_mathml, 'correct') + + # Incorrect answers + incorrect_inputs = [ + ('0', ''), + ('4x+3y', textwrap.dedent(""" + + + 4*x+3*y + """)), + ] + + for (input_str, input_mathml) in incorrect_inputs: + self._assert_symbolic_grade(problem, input_str, input_mathml, 'incorrect') + + + def test_complex_number_grade(self): + problem = self.build_problem(math_display=True, + expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]", + options=["matrix", "imaginary"]) + + # For LaTeX-style inputs, symmath_check() will try to contact + # a server to convert the input to MathML. + # We mock out the server, simulating the response that it would give + # for this input. import requests - d = os.path.dirname(__file__) - correct_snuggletex_response = open(os.path.join(d, "test_files/snuggletex_correct.html")).read().decode('utf8') - wrong_snuggletex_response = open(os.path.join(d, "test_files/snuggletex_wrong.html")).read().decode('utf8') + dirpath = os.path.dirname(__file__) + correct_snuggletex_response = open(os.path.join(dirpath, "test_files/snuggletex_correct.html")).read().decode('utf8') + wrong_snuggletex_response = open(os.path.join(dirpath, "test_files/snuggletex_wrong.html")).read().decode('utf8') + # Correct answer with mock.patch.object(requests, 'post') as mock_post: + + # Simulate what the LaTeX-to-MathML server would + # send for the correct response input mock_post.return_value.text = correct_snuggletex_response - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') + self._assert_symbolic_grade(problem, + "cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]", + textwrap.dedent(""" + + + + cos + (θ) + + + + [ + + + 10 + + + 01 + + + ] + + + + i + + + sin + + (θ) + + + + + [ + + + 01 + + + 10 + + + ] + + + + """), + 'correct') + + # Incorrect answer with mock.patch.object(requests, 'post') as mock_post: + + # Simulate what the LaTeX-to-MathML server would + # send for the incorrect response input mock_post.return_value.text = wrong_snuggletex_response - self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect') + + self._assert_symbolic_grade(problem, "2", + textwrap.dedent(""" + + 2 + + """), + 'incorrect') + + def test_multiple_inputs_exception(self): + + # Should not allow multiple inputs, since we specify + # only one "expect" value + with self.assertRaises(Exception): + problem = self.build_problem(math_display=True, + expect="2*x+3*y", + num_inputs=3) + + def _assert_symbolic_grade(self, problem, + student_input, + dynamath_input, + expected_correctness): + input_dict = {'1_2_1': str(student_input), + '1_2_1_dynamath': str(dynamath_input) } + + correct_map = problem.grade_answers(input_dict) + + self.assertEqual(correct_map.get_correctness('1_2_1'), + expected_correctness) class OptionResponseTest(ResponseTest): diff --git a/common/lib/capa/capa/util.py b/common/lib/capa/capa/util.py index 9f3e8bd3a0..cd694f1137 100644 --- a/common/lib/capa/capa/util.py +++ b/common/lib/capa/capa/util.py @@ -1,4 +1,4 @@ -from .calc import evaluator, UndefinedVariable +from calc import evaluator #----------------------------------------------------------------------------- # From be79810ff68bdeba2e0b4b79e6cb109e09e413e8 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 6 Mar 2013 10:54:09 -0500 Subject: [PATCH 064/120] Fix one problem from the merges --- common/lib/capa/capa/responsetypes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index ddf184c9be..b9ea3944c0 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1041,9 +1041,8 @@ class CustomResponse(LoncapaResponse): # build map giving "correct"ness of the answer(s) correct = self.context['correct'] messages = self.context['messages'] - correct_map = CorrectMap() - overall_message = self.clean_message_html(self.context['overall_message']) + correct_map = CorrectMap() correct_map.set_overall_message(overall_message) for k in range(len(idset)): @@ -1110,6 +1109,7 @@ class CustomResponse(LoncapaResponse): if 'msg' in input_dict else None) messages.append(msg) self.context['messages'] = messages + self.context['overall_message'] = overall_message # Otherwise, we do not recognize the dictionary # Raise an exception From efaa0eea037d80f63fd811523dac6a8e8593a599 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 6 Mar 2013 11:15:02 -0500 Subject: [PATCH 065/120] More fixes to the merge, now all tests pass. --- common/lib/capa/capa/responsetypes.py | 10 +++++++++- common/lib/capa/capa/tests/test_responsetypes.py | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index b9ea3944c0..caefbe0fd0 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1086,7 +1086,15 @@ class CustomResponse(LoncapaResponse): # to the same correct/incorrect value if 'ok' in ret: correct = ['correct' if ret['ok'] else 'incorrect'] * len(idset) - self.context['messages'][0] = self.clean_message_html(ret['msg']) + msg = ret.get('msg', None) + msg = self.clean_message_html(msg) + + # If there is only one input, apply the message to that input + # Otherwise, apply the message to the whole problem + if len(idset) > 1: + self.context['overall_message'] = msg + else: + self.context['messages'][0] = msg # Another kind of dictionary the check function can return has # the form: diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index b76854c744..3f88734884 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -977,7 +977,7 @@ class CustomResponseTest(ResponseTest): msg = correct_map.get_msg('1_2_1') self.assertEqual(correctness, 'correct') - self.assertEqual(msg, "Message text\n") + self.assertEqual(msg, "Message text") # Incorrect answer input_dict = {'1_2_1': '0'} @@ -987,7 +987,7 @@ class CustomResponseTest(ResponseTest): msg = correct_map.get_msg('1_2_1') self.assertEqual(correctness, 'incorrect') - self.assertEqual(msg, "Message text\n") + self.assertEqual(msg, "Message text") def test_multiple_inputs_return_one_status(self): # When given multiple inputs, the 'answer_given' argument From d925604113b78d1926cc5713d46d25610a17210e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 26 Mar 2013 13:49:45 -0400 Subject: [PATCH 066/120] Clarify provenance --- common/lib/codejail/codejail/lazymod.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/codejail/codejail/lazymod.py b/common/lib/codejail/codejail/lazymod.py index 936a8d263a..cdd8410f2c 100644 --- a/common/lib/codejail/codejail/lazymod.py +++ b/common/lib/codejail/codejail/lazymod.py @@ -1,6 +1,7 @@ """A module proxy for delayed importing of modules. -Lifted from http://barnesc.blogspot.com/2006/06/automatic-python-imports-with-autoimp.html +From http://barnesc.blogspot.com/2006/06/automatic-python-imports-with-autoimp.html, +in the public domain. """ From 89f6ef840710727a55b4e7129256484b6278431e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 27 Mar 2013 17:24:56 -0400 Subject: [PATCH 067/120] Move capa/safe_exec into its own directory, in prep for moving code here. --- common/lib/capa/capa/safe_exec/__init__.py | 3 +++ common/lib/capa/capa/{ => safe_exec}/safe_exec.py | 0 .../capa/{ => safe_exec}/tests/test_files/pylib/constant.py | 0 common/lib/capa/capa/{ => safe_exec}/tests/test_safe_exec.py | 0 4 files changed, 3 insertions(+) create mode 100644 common/lib/capa/capa/safe_exec/__init__.py rename common/lib/capa/capa/{ => safe_exec}/safe_exec.py (100%) rename common/lib/capa/capa/{ => safe_exec}/tests/test_files/pylib/constant.py (100%) rename common/lib/capa/capa/{ => safe_exec}/tests/test_safe_exec.py (100%) diff --git a/common/lib/capa/capa/safe_exec/__init__.py b/common/lib/capa/capa/safe_exec/__init__.py new file mode 100644 index 0000000000..bbfea1c138 --- /dev/null +++ b/common/lib/capa/capa/safe_exec/__init__.py @@ -0,0 +1,3 @@ +"""Capa's specialized use of codejail.safe_exec.""" + +from .safe_exec import safe_exec diff --git a/common/lib/capa/capa/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py similarity index 100% rename from common/lib/capa/capa/safe_exec.py rename to common/lib/capa/capa/safe_exec/safe_exec.py diff --git a/common/lib/capa/capa/tests/test_files/pylib/constant.py b/common/lib/capa/capa/safe_exec/tests/test_files/pylib/constant.py similarity index 100% rename from common/lib/capa/capa/tests/test_files/pylib/constant.py rename to common/lib/capa/capa/safe_exec/tests/test_files/pylib/constant.py diff --git a/common/lib/capa/capa/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py similarity index 100% rename from common/lib/capa/capa/tests/test_safe_exec.py rename to common/lib/capa/capa/safe_exec/tests/test_safe_exec.py From 0021b0acb32ccdeaf77cd954f6475346e808ec6f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 28 Mar 2013 10:03:33 -0400 Subject: [PATCH 068/120] Refactor to move assumed_imports into capa, so that code_jail is more pure. --- .../capa/safe_exec}/lazymod.py | 0 common/lib/capa/capa/safe_exec/safe_exec.py | 41 ++++++++++++----- .../capa/capa/safe_exec/tests/test_lazymod.py | 44 +++++++++++++++++++ common/lib/codejail/codejail/safe_exec.py | 37 ++-------------- .../codejail/codejail/tests/test_lazymod.py | 26 ----------- .../codejail/codejail/tests/test_safe_exec.py | 13 ------ common/lib/codejail/codejail/util.py | 19 -------- 7 files changed, 76 insertions(+), 104 deletions(-) rename common/lib/{codejail/codejail => capa/capa/safe_exec}/lazymod.py (100%) create mode 100644 common/lib/capa/capa/safe_exec/tests/test_lazymod.py delete mode 100644 common/lib/codejail/codejail/tests/test_lazymod.py diff --git a/common/lib/codejail/codejail/lazymod.py b/common/lib/capa/capa/safe_exec/lazymod.py similarity index 100% rename from common/lib/codejail/codejail/lazymod.py rename to common/lib/capa/capa/safe_exec/lazymod.py diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 3d481495d4..3ae9567fa9 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -1,6 +1,7 @@ """Capa's specialized use of codejail.safe_exec.""" import codejail.safe_exec +from . import lazymod # Establish the Python environment for Capa. # Capa assumes float-friendly division always. @@ -16,23 +17,39 @@ del random_module sys.modules['random'] = random """ +ASSUMED_IMPORTS=[ + ("numpy", "numpy"), + ("math", "math"), + ("scipy", "scipy"), + ("calc", "calc"), + ("eia", "eia"), + ("chemcalc", "chem.chemcalc"), + ("chemtools", "chem.chemtools"), + ("miller", "chem.miller"), + ("draganddrop", "verifiers.draganddrop"), +] + +# We'll need the code from lazymod.py for use in jailpy, so read it now. +lazymod_py_file = lazymod.__file__ +if lazymod_py_file.endswith("c"): + lazymod_py_file = lazymod_py_file[:-1] + +lazymod_py = open(lazymod_py_file).read() + +LAZY_IMPORTS = [lazymod_py] +for name, modname in ASSUMED_IMPORTS: + LAZY_IMPORTS.append("{} = LazyModule('{}')\n".format(name, modname)) + +LAZY_IMPORTS = "".join(LAZY_IMPORTS) + + def safe_exec(code, globals_dict, random_seed=None, python_path=None): """Exec python code safely. """ code_prolog = CODE_PROLOG % random_seed + codejail.safe_exec.safe_exec( - code_prolog + code, globals_dict, + code_prolog + LAZY_IMPORTS + code, globals_dict, python_path=python_path, - assumed_imports=[ - "numpy", - "math", - "scipy", - "calc", - "eia", - ("chemcalc", "chem.chemcalc"), - ("chemtools", "chem.chemtools"), - ("miller", "chem.miller"), - ("draganddrop", "verifiers.draganddrop"), - ], ) diff --git a/common/lib/capa/capa/safe_exec/tests/test_lazymod.py b/common/lib/capa/capa/safe_exec/tests/test_lazymod.py new file mode 100644 index 0000000000..68dcd81ea7 --- /dev/null +++ b/common/lib/capa/capa/safe_exec/tests/test_lazymod.py @@ -0,0 +1,44 @@ +"""Test lazymod.py""" + +import sys +import unittest + +from capa.safe_exec.lazymod import LazyModule + + +class ModuleIsolation(object): + """ + Manage changes to sys.modules so that we can roll back imported modules. + + Create this object, it will snapshot the currently imported modules. When + you call `clean_up()`, it will delete any module imported since its creation. + """ + def __init__(self): + # Save all the names of all the imported modules. + self.mods = set(sys.modules) + + def clean_up(self): + # Get a list of modules that didn't exist when we were created + new_mods = [m for m in sys.modules if m not in self.mods] + # and delete them all so another import will run code for real again. + for m in new_mods: + del sys.modules[m] + + +class TestLazyMod(unittest.TestCase): + + def setUp(self): + # Each test will remove modules that it imported. + self.addCleanup(ModuleIsolation().clean_up) + + def test_simple(self): + # Import some stdlib module that has not been imported before + self.assertNotIn("colorsys", sys.modules) + colorsys = LazyModule("colorsys") + hsv = colorsys.rgb_to_hsv(.3, .4, .2) + self.assertEqual(hsv[0], 0.25) + + def test_dotted(self): + self.assertNotIn("email.utils", sys.modules) + email_utils = LazyModule("email.utils") + self.assertEqual(email_utils.quote('"hi"'), r'\"hi\"') diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index b11ef60616..07b1ad378c 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -6,40 +6,17 @@ import shutil import sys import textwrap -import lazymod import jailpy -from util import temp_directory, change_directory, TempDirectory +from util import temp_directory, change_directory -# We'll need the code from lazymod.py for use in jailpy, so read it now. -lazymod_py_file = lazymod.__file__ -if lazymod_py_file.endswith("c"): - lazymod_py_file = lazymod_py_file[:-1] - -lazymod_py = open(lazymod_py_file).read() - - -def names_and_modules(assumed_imports): - """Get uniform names and modules from assumed_imports.""" - for modname in assumed_imports: - if isinstance(modname, tuple): - yield modname - else: - yield modname, modname - - -def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path=None): +def safe_exec(code, globals_dict, files=None, python_path=None): """Execute code as "exec" does, but safely. `code` is a string of Python code. `globals_dict` is used as the globals during execution. Modifications the code makes to `globals_dict` are reflected in the dictionary on return. - `assumed_imports` is a list of modules to make available as implicit - imports for the code. Entries are either a name, "mod", which makes - "import mod" part of the code, or a pair, ("f", "fooey"), which makes - "import fooey as f" part of the code. The module name can be dotted. - Returns None. Changes made by `code` are visible in `globals_dict`. """ @@ -72,11 +49,6 @@ def safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path= the_code.append("sys.path.append(%r)\n" % pybase) files.append(pydir) - if assumed_imports: - the_code.append(lazymod_py) - for name, modname in names_and_modules(assumed_imports): - the_code.append("g_dict['{}'] = LazyModule('{}')\n".format(name, modname)) - the_code.append(textwrap.dedent( # Execute the sandboxed code. """ @@ -140,7 +112,7 @@ def json_safe(d): return json.loads(json.dumps(jd)) -def not_safe_exec(code, globals_dict, assumed_imports=None, files=None, python_path=None): +def not_safe_exec(code, globals_dict, files=None, python_path=None): """Another implementation of `safe_exec`, but not safe. This can be swapped in for debugging problems in sandboxed Python code. @@ -151,9 +123,6 @@ def not_safe_exec(code, globals_dict, assumed_imports=None, files=None, python_p """ g_dict = json_safe(globals_dict) - for name, modname in names_and_modules(assumed_imports or ()): - g_dict[name] = lazymod.LazyModule(modname) - with temp_directory(delete_when_done=True) as tmpdir: with change_directory(tmpdir): # Copy the files here. diff --git a/common/lib/codejail/codejail/tests/test_lazymod.py b/common/lib/codejail/codejail/tests/test_lazymod.py deleted file mode 100644 index eb853060d0..0000000000 --- a/common/lib/codejail/codejail/tests/test_lazymod.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Test lazymod.py""" - -import sys -import unittest - -from codejail.lazymod import LazyModule -from codejail.util import ModuleIsolation - - -class TestLazyMod(unittest.TestCase): - - def setUp(self): - # Each test will remove modules that it imported. - self.addCleanup(ModuleIsolation().clean_up) - - def test_simple(self): - # Import some stdlib module that has not been imported before - self.assertNotIn("colorsys", sys.modules) - colorsys = LazyModule("colorsys") - hsv = colorsys.rgb_to_hsv(.3, .4, .2) - self.assertEqual(hsv[0], 0.25) - - def test_dotted(self): - self.assertNotIn("email.utils", sys.modules) - email_utils = LazyModule("email.utils") - self.assertEqual(email_utils.quote('"hi"'), r'\"hi\"') diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index 8a565f8cfb..05a27020b3 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -14,19 +14,6 @@ class SafeExecTests(object): self.safe_exec("a = 17", g) self.assertEqual(g['a'], 17) - def test_assumed_imports(self): - g = {} - # Using string without importing it is bad. - with self.assertRaises(Exception): - self.safe_exec("a = string.ascii_lowercase[0]", g) - # Using string with an assumed import is fine. - self.safe_exec("a = string.ascii_lowercase[0]", g, assumed_imports=["string"]) - self.assertEqual(g['a'], 'a') - # Can also import with a shorthand. - self.safe_exec("a = op.join('x', 'y')", g, assumed_imports=[("op", "os.path")]) - self.assertEqual(g['a'][0], 'x') - self.assertEqual(g['a'][-1], 'y') - def test_files_are_copied(self): g = {} self.safe_exec( diff --git a/common/lib/codejail/codejail/util.py b/common/lib/codejail/codejail/util.py index e293ce052f..ce41f9d5d4 100644 --- a/common/lib/codejail/codejail/util.py +++ b/common/lib/codejail/codejail/util.py @@ -32,25 +32,6 @@ def temp_directory(delete_when_done=True): tmp.clean_up() -class ModuleIsolation(object): - """ - Manage changes to sys.modules so that we can roll back imported modules. - - Create this object, it will snapshot the currently imported modules. When - you call `clean_up()`, it will delete any module imported since its creation. - """ - def __init__(self): - # Save all the names of all the imported modules. - self.mods = set(sys.modules) - - def clean_up(self): - # Get a list of modules that didn't exist when we were created - new_mods = [m for m in sys.modules if m not in self.mods] - # and delete them all so another import will run code for real again. - for m in new_mods: - del sys.modules[m] - - class ChangeDirectory(object): def __init__(self, new_dir): self.old_dir = os.getcwd() From 182a1a189938234a92b1bbb07c7a92a4b181c3ba Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 28 Mar 2013 14:47:12 -0400 Subject: [PATCH 069/120] Cleanups --- common/lib/codejail/codejail/django_integration.py | 1 + common/lib/codejail/codejail/jailpy.py | 11 ++++++++--- common/lib/codejail/codejail/safe_exec.py | 6 +++--- common/lib/codejail/codejail/tests/test_jailpy.py | 2 ++ common/lib/codejail/codejail/tests/test_safe_exec.py | 2 ++ common/lib/codejail/codejail/util.py | 3 ++- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/common/lib/codejail/codejail/django_integration.py b/common/lib/codejail/codejail/django_integration.py index 52306ba6f1..b720ffcff1 100644 --- a/common/lib/codejail/codejail/django_integration.py +++ b/common/lib/codejail/codejail/django_integration.py @@ -5,6 +5,7 @@ from django.conf import settings import codejail.jailpy + class ConfigureCodeJailMiddleware(object): """Middleware to configure codejail on startup.""" diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jailpy.py index c3e98a518a..df1bd8df12 100644 --- a/common/lib/codejail/codejail/jailpy.py +++ b/common/lib/codejail/codejail/jailpy.py @@ -4,7 +4,8 @@ # - AppArmor.md from xserver import logging -import os, os.path +import os +import os.path import resource import shutil import subprocess @@ -22,6 +23,7 @@ log = logging.getLogger(__name__) PYTHON_CMD = None + def configure(python_bin, user=None): """Configure the jailpy module.""" global PYTHON_CMD @@ -30,6 +32,7 @@ def configure(python_bin, user=None): PYTHON_CMD.extend(['sudo', '-u', 'sandbox']) PYTHON_CMD.extend([python_bin, '-E']) + def is_configured(): return bool(PYTHON_CMD) @@ -42,7 +45,9 @@ if hasattr(sys, 'real_prefix'): class JailResult(object): """A passive object for us to return from jailpy.""" - pass + def __init__(self): + self.stdout = self.stderr = self.status = None + def jailpy(code, files=None, argv=None, stdin=None): """ @@ -104,7 +109,7 @@ def set_process_limits(): resource.setrlimit(resource.RLIMIT_NPROC, (0, 0)) # no subprocesses resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)) # no files - mem = 32 * 2**20 # 32 MB should be enough for anyone, right? :) + mem = 32 * (2 ** 20) # 32 MB should be enough for anyone, right? :) resource.setrlimit(resource.RLIMIT_STACK, (mem, mem)) resource.setrlimit(resource.RLIMIT_RSS, (mem, mem)) resource.setrlimit(resource.RLIMIT_DATA, (mem, mem)) diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 07b1ad378c..5db9651438 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -6,9 +6,9 @@ import shutil import sys import textwrap -import jailpy +from codejail import jailpy +from codejail.util import temp_directory, change_directory -from util import temp_directory, change_directory def safe_exec(code, globals_dict, files=None, python_path=None): """Execute code as "exec" does, but safely. @@ -98,7 +98,7 @@ def json_safe(d): ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) bad_keys = ("__builtins__",) jd = {} - for k,v in d.iteritems(): + for k, v in d.iteritems(): if not isinstance(v, ok_types): continue if k in bad_keys: diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index 15c548663b..c0d51ba684 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -9,6 +9,7 @@ from codejail.jailpy import jailpy, is_configured dedent = textwrap.dedent + class JailPyHelpers(object): """Assert helpers for jailpy tests.""" def setUp(self): @@ -112,6 +113,7 @@ class TestLimits(JailPyHelpers, unittest.TestCase): # TODO: read network # TODO: fork + class TestMalware(JailPyHelpers, unittest.TestCase): def test_crash_cpython(self): # http://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index 05a27020b3..46d6a59a98 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -7,6 +7,7 @@ from nose.plugins.skip import SkipTest from codejail.safe_exec import safe_exec, not_safe_exec + class SafeExecTests(object): """The tests for `safe_exec`, will be mixed into specific test classes below.""" def test_set_values(self): @@ -60,6 +61,7 @@ class TestSafeExec(SafeExecTests, unittest.TestCase): def safe_exec(self, *args, **kwargs): safe_exec(*args, **kwargs) + class TestNotSafeExec(SafeExecTests, unittest.TestCase): """Run SafeExecTests, with not_safe_exec.""" def setUp(self): diff --git a/common/lib/codejail/codejail/util.py b/common/lib/codejail/codejail/util.py index ce41f9d5d4..d88a05b4dd 100644 --- a/common/lib/codejail/codejail/util.py +++ b/common/lib/codejail/codejail/util.py @@ -3,7 +3,6 @@ import contextlib import os import shutil -import sys import tempfile @@ -19,6 +18,7 @@ class TempDirectory(object): # if this errors, something is genuinely wrong, so don't ignore errors. shutil.rmtree(self.temp_dir) + @contextlib.contextmanager def temp_directory(delete_when_done=True): """ @@ -40,6 +40,7 @@ class ChangeDirectory(object): def clean_up(self): os.chdir(self.old_dir) + @contextlib.contextmanager def change_directory(new_dir): """ From ceb6cedaaee1a59bd4f0e4bbd3d154e8a69b8a19 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 29 Mar 2013 16:41:01 -0400 Subject: [PATCH 070/120] Fix merge --- lms/djangoapps/courseware/tests/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 2d3db03074..df141fe284 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -897,7 +897,7 @@ class TestCourseGrader(TestSubmittingProblems): progress_summary = grades.progress_summary(self.student_user, fake_request, - self.graded_course, + self.course, model_data_cache) return progress_summary From 5e8e31b2d14622033a214406ad0f40dde5a5d176 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 1 Apr 2013 12:34:11 -0400 Subject: [PATCH 071/120] Add a cache attribute to ModuleSystem --- common/lib/xmodule/xmodule/x_module.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 1fd0b8e138..31a32eb6dc 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -700,7 +700,9 @@ class ModuleSystem(object): anonymous_student_id='', course_id=None, open_ended_grading_interface=None, - s3_interface=None): + s3_interface=None, + cache=None, + ): ''' Create a closure around the system environment. @@ -742,6 +744,11 @@ class ModuleSystem(object): xblock_model_data - A dict-like object containing the all data available to this xblock + + cache - A cache object with two methods: + .get(key) returns an object from the cache or None. + .set(key, value, timeout_secs=None) stores a value in the cache with a timeout. + ''' self.ajax_url = ajax_url self.xqueue = xqueue @@ -766,6 +773,8 @@ class ModuleSystem(object): self.open_ended_grading_interface = open_ended_grading_interface self.s3_interface = s3_interface + self.cache = cache or DoNothingCache() + def get(self, attr): ''' provide uniform access to attributes (like etree).''' return self.__dict__.get(attr) @@ -779,3 +788,12 @@ class ModuleSystem(object): def __str__(self): return str(self.__dict__) + + +class DoNothingCache(object): + """A duck-compatible object to use in ModuleSystem when there's no cache.""" + def get(self, key): + return None + + def set(self, key, value, timeout=None): + pass From c8b908a24461ce7603cb9e57a6d401503e675fc9 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 1 Apr 2013 13:54:25 -0400 Subject: [PATCH 072/120] capa.safe_exec can use a cache. --- common/lib/capa/capa/safe_exec/safe_exec.py | 26 ++++++++++++-- .../capa/safe_exec/tests/test_safe_exec.py | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 3ae9567fa9..da74f3aaf5 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -1,6 +1,7 @@ """Capa's specialized use of codejail.safe_exec.""" -import codejail.safe_exec +from codejail.safe_exec import safe_exec as codejail_safe_exec +from codejail.safe_exec import json_safe from . import lazymod # Establish the Python environment for Capa. @@ -43,13 +44,32 @@ for name, modname in ASSUMED_IMPORTS: LAZY_IMPORTS = "".join(LAZY_IMPORTS) -def safe_exec(code, globals_dict, random_seed=None, python_path=None): +def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None): """Exec python code safely. + `cache` is an object with .get(key) and .set(key, value) methods. + """ + # Check the cache for a previous result. + if cache: + canonical_globals = sorted(json_safe(globals_dict).iteritems()) + key = "safe_exec %r %s %r" % (random_seed, code, canonical_globals) + cached = cache.get(key) + if cached is not None: + globals_dict.update(cached) + return + + # Create the complete code we'll run. code_prolog = CODE_PROLOG % random_seed - codejail.safe_exec.safe_exec( + # Run the code! Results are side effects in globals_dict. + codejail_safe_exec( code_prolog + LAZY_IMPORTS + code, globals_dict, python_path=python_path, ) + + # Put the result back in the cache. This is complicated by the fact that + # the globals dict might not be entirely serializable. + if cache: + cleaned_results = json_safe(globals_dict) + cache.set(key, cleaned_results) diff --git a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py index 7ed44a69a1..37f86383c2 100644 --- a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py +++ b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py @@ -56,3 +56,37 @@ class TestSafeExec(unittest.TestCase): "import constant; a = constant.THE_CONST", g, python_path=[pylib] ) + + +class DictCache(object): + """A cache implementation over a simple dict, for testing.""" + + def __init__(self, d): + self.cache = d + + def get(self, key): + return self.cache.get(key) + + def set(self, key, value): + self.cache[key] = value + + +class TestSafeExecCaching(unittest.TestCase): + """Test that caching works on safe_exec.""" + + def test_cache_miss_then_hit(self): + g = {} + cache = {} + + # Cache miss + safe_exec("a = int(math.pi)", g, cache=DictCache(cache)) + self.assertEqual(g['a'], 3) + # A result has been cached + self.assertEqual(cache.values(), [{'a': 3}]) + + # Fiddle with the cache, then try it again. + cache[cache.keys()[0]] = {'a': 17} + + g = {} + safe_exec("a = int(math.pi)", g, cache=DictCache(cache)) + self.assertEqual(g['a'], 17) From 5e7d328e7f2581247fc86d7617fa6fdbbeefb3db Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 1 Apr 2013 16:33:59 -0400 Subject: [PATCH 073/120] Use the Django cache for sandboxed code execution. --- common/lib/capa/capa/capa_problem.py | 8 +++++++- common/lib/capa/capa/responsetypes.py | 6 +++--- common/lib/capa/capa/tests/__init__.py | 3 ++- lms/djangoapps/courseware/module_render.py | 2 ++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index b5e773ebc7..45200b8607 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -462,7 +462,13 @@ class LoncapaProblem(object): if all_code: try: - safe_exec.safe_exec(all_code, context, random_seed=self.seed, python_path=python_path) + safe_exec.safe_exec( + all_code, + context, + random_seed=self.seed, + python_path=python_path, + cache=self.system.cache, + ) except Exception as err: log.exception("Error while execing script code: " + all_code) msg = "Error while executing script code: %s" % str(err).replace('<', '<') diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index caefbe0fd0..829a554b4d 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -938,7 +938,7 @@ class CustomResponse(LoncapaResponse): 'ans': ans, } globals_dict.update(kwargs) - safe_exec.safe_exec(code, globals_dict) + safe_exec.safe_exec(code, globals_dict, cache=self.system.cache) return globals_dict['cfn_return'] return check_function @@ -1055,7 +1055,7 @@ class CustomResponse(LoncapaResponse): # exec the check function if isinstance(self.code, basestring): try: - safe_exec.safe_exec(self.code, self.context) + safe_exec.safe_exec(self.code, self.context, cache=self.system.cache) except Exception as err: self._handle_exec_exception(err) @@ -1783,7 +1783,7 @@ class SchematicResponse(LoncapaResponse): json.loads(student_answers[k]) for k in sorted(self.answer_ids) ] self.context.update({'submission': submission}) - safe_exec.safe_exec(self.code, self.context) + safe_exec.safe_exec(self.code, self.context, cache=self.system.cache) cmap = CorrectMap() cmap.set_dict(dict(zip(sorted(self.answer_ids), self.context['correct']))) return cmap diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index 72d82c683b..59c87780b5 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -33,5 +33,6 @@ test_system = Mock( debug=True, xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10}, node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), - anonymous_student_id='student' + anonymous_student_id='student', + cache=None, ) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 6f05b32778..dc71216adb 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -8,6 +8,7 @@ from functools import partial from django.conf import settings from django.contrib.auth.models import User +from django.core.cache import cache from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import Http404 @@ -299,6 +300,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours course_id=course_id, open_ended_grading_interface=open_ended_grading_interface, s3_interface=s3_interface, + cache=cache, ) # pass position specified in URL to module through ModuleSystem system.set('position', position) From ed13f0a0f164d67154b7ad5493008416abdbec5f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 3 Apr 2013 09:31:57 -0400 Subject: [PATCH 074/120] Catch up to new exception handling in responses. --- common/lib/capa/capa/responsetypes.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 829a554b4d..345716a236 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1071,10 +1071,7 @@ class CustomResponse(LoncapaResponse): try: ret = fn(self.expect, answer_given, **kwargs) except Exception as err: - log.error("oops in customresponse (cfn) error %s" % err) - # print "context = ",self.context - log.error(traceback.format_exc()) - raise Exception("oops in customresponse (cfn) error %s" % err) + self._handle_exec_exception(err) log.debug( "[courseware.capa.responsetypes.customresponse.get_score] ret = %s", ret @@ -1783,7 +1780,11 @@ class SchematicResponse(LoncapaResponse): json.loads(student_answers[k]) for k in sorted(self.answer_ids) ] self.context.update({'submission': submission}) - safe_exec.safe_exec(self.code, self.context, cache=self.system.cache) + try: + safe_exec.safe_exec(self.code, self.context, cache=self.system.cache) + except Exception as err: + msg = 'Error %s in evaluating SchematicResponse' % err + raise ResponseError(msg) cmap = CorrectMap() cmap.set_dict(dict(zip(sorted(self.answer_ids), self.context['correct']))) return cmap From bcdc11c3a5271ef1dde7ab2297dae2f6bb2de775 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 5 Apr 2013 16:14:11 -0400 Subject: [PATCH 075/120] Hint functions are now run in the sandbox. --- common/lib/capa/capa/responsetypes.py | 42 +++++++++++++++---- .../capa/capa/tests/response_xml_factory.py | 30 ++++++++----- .../lib/capa/capa/tests/test_responsetypes.py | 16 +++++++ common/lib/codejail/codejail/safe_exec.py | 10 +++-- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 345716a236..de73dcda30 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -22,6 +22,7 @@ import random import re import requests import subprocess +import textwrap import traceback import xml.sax.saxutils as saxutils @@ -30,7 +31,7 @@ from shapely.geometry import Point, MultiPoint # specific library imports from calc import evaluator, UndefinedVariable -from .correctmap import CorrectMap +from . import correctmap from datetime import datetime from .util import * from lxml import etree @@ -42,6 +43,10 @@ import safe_exec log = logging.getLogger(__name__) +CorrectMap = correctmap.CorrectMap +CORRECTMAP_PY = None + + #----------------------------------------------------------------------------- # Exceptions @@ -253,20 +258,41 @@ class LoncapaResponse(object): # We may extend this in the future to add another argument which provides a # callback procedure to a social hint generation system. - if not hintfn in self.context: - msg = 'missing specified hint function %s in script context' % hintfn - msg += "\nSee XML source line %s" % getattr( - self.xml, 'sourceline', '') - raise LoncapaProblemError(msg) + + global CORRECTMAP_PY + if CORRECTMAP_PY is None: + # We need the CorrectMap code for hint functions. No, this is not great. + CORRECTMAP_PY = inspect.getsource(correctmap) + + code = ( + CORRECTMAP_PY + "\n" + + self.context['script_code'] + "\n" + + textwrap.dedent(""" + new_cmap = CorrectMap() + new_cmap.set_dict(new_cmap_dict) + old_cmap = CorrectMap() + old_cmap.set_dict(old_cmap_dict) + {hintfn}(answer_ids, student_answers, new_cmap, old_cmap) + new_cmap_dict.update(new_cmap.get_dict()) + old_cmap_dict.update(old_cmap.get_dict()) + """).format(hintfn=hintfn) + ) + globals_dict = { + 'answer_ids': self.answer_ids, + 'student_answers': student_answers, + 'new_cmap_dict': new_cmap.get_dict(), + 'old_cmap_dict': old_cmap.get_dict(), + } try: - self.context[hintfn]( - self.answer_ids, student_answers, new_cmap, old_cmap) + safe_exec.safe_exec(code, globals_dict) except Exception as err: msg = 'Error %s in evaluating hint function %s' % (err, hintfn) msg += "\nSee XML source line %s" % getattr( self.xml, 'sourceline', '') raise ResponseError(msg) + + new_cmap.set_dict(globals_dict['new_cmap_dict']) return # hint specified by conditions and text dependent on conditions (a-la Loncapa design) diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index ee9a7e6530..35c12800ae 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -667,12 +667,16 @@ class StringResponseXMLFactory(ResponseXMLFactory): Where *hint_prompt* is the string for which we show the hint, *hint_name* is an internal identifier for the hint, and *hint_text* is the text we show for the hint. + + *hintfn*: The name of a function in the script to use for hints. + """ # Retrieve the **kwargs answer = kwargs.get("answer", None) case_sensitive = kwargs.get("case_sensitive", True) hint_list = kwargs.get('hints', None) - assert(answer) + hint_fn = kwargs.get('hintfn', None) + assert answer # Create the element response_element = etree.Element("stringresponse") @@ -684,18 +688,24 @@ class StringResponseXMLFactory(ResponseXMLFactory): response_element.set("type", "cs" if case_sensitive else "ci") # Add the hints if specified - if hint_list: + if hint_list or hint_fn: hintgroup_element = etree.SubElement(response_element, "hintgroup") - for (hint_prompt, hint_name, hint_text) in hint_list: - stringhint_element = etree.SubElement(hintgroup_element, "stringhint") - stringhint_element.set("answer", str(hint_prompt)) - stringhint_element.set("name", str(hint_name)) + if hint_list: + assert not hint_fn + for (hint_prompt, hint_name, hint_text) in hint_list: + stringhint_element = etree.SubElement(hintgroup_element, "stringhint") + stringhint_element.set("answer", str(hint_prompt)) + stringhint_element.set("name", str(hint_name)) - hintpart_element = etree.SubElement(hintgroup_element, "hintpart") - hintpart_element.set("on", str(hint_name)) + hintpart_element = etree.SubElement(hintgroup_element, "hintpart") + hintpart_element.set("on", str(hint_name)) - hint_text_element = etree.SubElement(hintpart_element, "text") - hint_text_element.text = str(hint_text) + hint_text_element = etree.SubElement(hintpart_element, "text") + hint_text_element.text = str(hint_text) + + if hint_fn: + assert not hint_list + hintgroup_element.set("hintfn", hint_fn) return response_element diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 3f88734884..8e41ff39de 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -552,6 +552,22 @@ class StringResponseTest(ResponseTest): correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_hint('1_2_1'), "") + def test_computed_hints(self): + problem = self.build_problem( + answer="Michigan", + hintfn="gimme_a_hint", + script = textwrap.dedent(""" + def gimme_a_hint(answer_ids, student_answers, new_cmap, old_cmap): + aid = answer_ids[0] + answer = student_answers[aid] + new_cmap.set_hint_and_mode(aid, answer+"??", "always") + """) + ) + + input_dict = {'1_2_1': 'Hello'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), "Hello??") + class CodeResponseTest(ResponseTest): from response_xml_factory import CodeResponseXMLFactory diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 5db9651438..682ae809af 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -1,6 +1,7 @@ """Safe execution of untrusted Python code.""" import json +import logging import os.path import shutil import sys @@ -9,6 +10,8 @@ import textwrap from codejail import jailpy from codejail.util import temp_directory, change_directory +log = logging.getLogger(__name__) + def safe_exec(code, globals_dict, files=None, python_path=None): """Execute code as "exec" does, but safely. @@ -78,10 +81,9 @@ def safe_exec(code, globals_dict, files=None, python_path=None): # Turn this on to see what's being executed. if 0: - print "--{:-<40}".format(" jailed ") - print jailed_code - print "--{:-<40}".format(" exec ") - print code + log.debug("Jailed code: %s", jailed_code) + log.debug("Exec: %s", code) + log.debug("Stdin: %s", stdin) res = jailpy.jailpy(jailed_code, stdin=stdin, files=files) if res.status != 0: From 55e910aafc04a4be704f933e72fbff14b895b3ea Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 9 Apr 2013 14:15:50 -0400 Subject: [PATCH 076/120] Not sure why my branch was ahead of master for the version of distribute. Make them the same. --- common/lib/capa/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/capa/setup.py b/common/lib/capa/setup.py index 5f1731fdc0..d0a174a15e 100644 --- a/common/lib/capa/setup.py +++ b/common/lib/capa/setup.py @@ -4,5 +4,5 @@ setup( name="capa", version="0.1", packages=find_packages(exclude=["tests"]), - install_requires=["distribute==0.6.34"], + install_requires=["distribute==0.6.30"], ) From bde976dad21757451398ddeba4e42a9a2b2f053f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 9 Apr 2013 17:07:57 -0400 Subject: [PATCH 077/120] Refactor code_jail to accommodate non-Python code. --- common/lib/capa/capa/safe_exec/safe_exec.py | 2 +- .../codejail/codejail/django_integration.py | 4 +- .../codejail/{jailpy.py => jail_code.py} | 46 +++++++++++-------- common/lib/codejail/codejail/safe_exec.py | 6 +-- .../codejail/codejail/tests/test_jailpy.py | 22 +++++---- 5 files changed, 47 insertions(+), 33 deletions(-) rename common/lib/codejail/codejail/{jailpy.py => jail_code.py} (74%) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index da74f3aaf5..a07fe359fd 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -30,7 +30,7 @@ ASSUMED_IMPORTS=[ ("draganddrop", "verifiers.draganddrop"), ] -# We'll need the code from lazymod.py for use in jailpy, so read it now. +# We'll need the code from lazymod.py for use in safe_exec, so read it now. lazymod_py_file = lazymod.__file__ if lazymod_py_file.endswith("c"): lazymod_py_file = lazymod_py_file[:-1] diff --git a/common/lib/codejail/codejail/django_integration.py b/common/lib/codejail/codejail/django_integration.py index b720ffcff1..56c47f88d9 100644 --- a/common/lib/codejail/codejail/django_integration.py +++ b/common/lib/codejail/codejail/django_integration.py @@ -3,7 +3,7 @@ from django.core.exceptions import MiddlewareNotUsed from django.conf import settings -import codejail.jailpy +import codejail.jail_code class ConfigureCodeJailMiddleware(object): @@ -13,5 +13,5 @@ class ConfigureCodeJailMiddleware(object): python_bin = settings.CODE_JAIL.get('python_bin') if python_bin: user = settings.CODE_JAIL['user'] - codejail.jailpy.configure(python_bin, user=user) + codejail.jail_code.configure("python", python_bin, user=user) raise MiddlewareNotUsed diff --git a/common/lib/codejail/codejail/jailpy.py b/common/lib/codejail/codejail/jail_code.py similarity index 74% rename from common/lib/codejail/codejail/jailpy.py rename to common/lib/codejail/codejail/jail_code.py index df1bd8df12..a44004d585 100644 --- a/common/lib/codejail/codejail/jailpy.py +++ b/common/lib/codejail/codejail/jail_code.py @@ -19,39 +19,49 @@ log = logging.getLogger(__name__) # TODO: limit too much stdout data? -# Configure the Python command +# Configure the commands -PYTHON_CMD = None +# COMMANDS is a map from an abstract command name to a list of command-line +# pieces, such as subprocess.Popen wants. +COMMANDS = {} -def configure(python_bin, user=None): - """Configure the jailpy module.""" - global PYTHON_CMD - PYTHON_CMD = [] +def configure(command, bin_path, user=None): + """Configure a command for jail_code to use. + + `command` is the abstract command you're configuring, such as "python" or + "node". `bin_path` is the path to the binary. `user`, if provided, is + the user name to run the command under. + + """ + cmd_argv = [] if user: - PYTHON_CMD.extend(['sudo', '-u', 'sandbox']) - PYTHON_CMD.extend([python_bin, '-E']) + cmd_argv.extend(['sudo', '-u', 'sandbox']) + cmd_argv.extend([bin_path, '-E']) + COMMANDS[command] = cmd_argv -def is_configured(): - return bool(PYTHON_CMD) +def is_configured(command): + return command in COMMANDS # By default, look where our current Python is, and maybe there's a # python-sandbox alongside. Only do this if running in a virtualenv. if hasattr(sys, 'real_prefix'): if os.path.isdir(sys.prefix + "-sandbox"): - configure(sys.prefix + "-sandbox/bin/python", "sandbox") + configure("python", sys.prefix + "-sandbox/bin/python", "sandbox") class JailResult(object): - """A passive object for us to return from jailpy.""" + """A passive object for us to return from jail_code.""" def __init__(self): self.stdout = self.stderr = self.status = None -def jailpy(code, files=None, argv=None, stdin=None): - """ - Run Python code in a jailed subprocess. +def jail_code(command, code, files=None, argv=None, stdin=None): + """Run code in a jailed subprocess. + + `command` is an abstract command ("python", "node", ...) that must have + been configured using `configure`. `code` is a string containing the Python code to run. @@ -64,8 +74,8 @@ def jailpy(code, files=None, argv=None, stdin=None): .status: return status of the process: an int, 0 for successful """ - if not PYTHON_CMD: - raise Exception("jailpy needs to be configured") + if not is_configured(command): + raise Exception("jail_code needs to be configured for %r" % command) with temp_directory(delete_when_done=True) as tmpdir: @@ -83,7 +93,7 @@ def jailpy(code, files=None, argv=None, stdin=None): with open(os.path.join(tmpdir, "jailed_code.py"), "w") as jailed: jailed.write(code) - cmd = PYTHON_CMD + ['jailed_code.py'] + (argv or []) + cmd = COMMANDS[command] + ['jailed_code.py'] + (argv or []) subproc = subprocess.Popen( cmd, preexec_fn=set_process_limits, cwd=tmpdir, diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 682ae809af..5379052ce0 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -7,7 +7,7 @@ import shutil import sys import textwrap -from codejail import jailpy +from codejail import jail_code from codejail.util import temp_directory, change_directory log = logging.getLogger(__name__) @@ -85,7 +85,7 @@ def safe_exec(code, globals_dict, files=None, python_path=None): log.debug("Exec: %s", code) log.debug("Stdin: %s", stdin) - res = jailpy.jailpy(jailed_code, stdin=stdin, files=files) + res = jail_code.jail_code("python", jailed_code, stdin=stdin, files=files) if res.status != 0: raise Exception("Couldn't execute jailed code: %s" % res.stderr) globals_dict.update(json.loads(res.stdout)) @@ -144,5 +144,5 @@ def not_safe_exec(code, globals_dict, files=None, python_path=None): # Running Python code in the sandbox makes it difficult to debug. # Change 0 to 1 to run the code directly. -if 0 or not jailpy.is_configured(): +if 0 or not jail_code.is_configured("python"): safe_exec = not_safe_exec diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index c0d51ba684..395cfc4d53 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -1,20 +1,24 @@ -"""Test jailpy.py""" +"""Test jail_code.py""" import os.path import textwrap import unittest from nose.plugins.skip import SkipTest -from codejail.jailpy import jailpy, is_configured +from codejail.jail_code import jail_code, is_configured dedent = textwrap.dedent -class JailPyHelpers(object): - """Assert helpers for jailpy tests.""" +def jailpy(*args, **kwargs): + return jail_code("python", *args, **kwargs) + + +class JailCodeHelpers(object): + """Assert helpers for jail_code tests.""" def setUp(self): - super(JailPyHelpers, self).setUp() - if not is_configured(): + super(JailCodeHelpers, self).setUp() + if not is_configured("python"): raise SkipTest def assertResultOk(self, res): @@ -22,7 +26,7 @@ class JailPyHelpers(object): self.assertEqual(res.status, 0) -class TestFeatures(JailPyHelpers, unittest.TestCase): +class TestFeatures(JailCodeHelpers, unittest.TestCase): def test_hello_world(self): res = jailpy("print 'Hello, world!'") self.assertResultOk(res) @@ -64,7 +68,7 @@ class TestFeatures(JailPyHelpers, unittest.TestCase): self.assertEqual(res.stdout, 'Look: Hello there.\n\n') -class TestLimits(JailPyHelpers, unittest.TestCase): +class TestLimits(JailCodeHelpers, unittest.TestCase): def test_cant_use_too_much_memory(self): res = jailpy("print sum(range(100000000))") self.assertNotEqual(res.status, 0) @@ -114,7 +118,7 @@ class TestLimits(JailPyHelpers, unittest.TestCase): # TODO: fork -class TestMalware(JailPyHelpers, unittest.TestCase): +class TestMalware(JailCodeHelpers, unittest.TestCase): def test_crash_cpython(self): # http://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html res = jailpy(dedent("""\ From 9683098f3da394150fe0d163145af316487e8bcc Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 10 Apr 2013 17:53:05 -0400 Subject: [PATCH 078/120] Python should have -E, not sure of a clean way to do it, but this at least only applies it to python. --- common/lib/codejail/codejail/jail_code.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/common/lib/codejail/codejail/jail_code.py b/common/lib/codejail/codejail/jail_code.py index a44004d585..9b48568bd8 100644 --- a/common/lib/codejail/codejail/jail_code.py +++ b/common/lib/codejail/codejail/jail_code.py @@ -27,7 +27,7 @@ COMMANDS = {} def configure(command, bin_path, user=None): - """Configure a command for jail_code to use. + """Configure a command for `jail_code` to use. `command` is the abstract command you're configuring, such as "python" or "node". `bin_path` is the path to the binary. `user`, if provided, is @@ -37,11 +37,22 @@ def configure(command, bin_path, user=None): cmd_argv = [] if user: cmd_argv.extend(['sudo', '-u', 'sandbox']) - cmd_argv.extend([bin_path, '-E']) + cmd_argv.append(bin_path) + + # Command-specific arguments + if command == "python": + cmd_argv.append('-E') + COMMANDS[command] = cmd_argv def is_configured(command): + """Has `jail_code` been configured for `command`? + + Returns true if the abstract command `command` has been configured for use + in the `jail_code` function. + + """ return command in COMMANDS # By default, look where our current Python is, and maybe there's a From fb5343237a4729f79d9c449f1b24202dc76745bd Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 12 Apr 2013 14:57:37 -0400 Subject: [PATCH 079/120] jail_code can execute a provided file also. --- common/lib/codejail/codejail/jail_code.py | 22 +++++++--- common/lib/codejail/codejail/safe_exec.py | 2 +- common/lib/codejail/codejail/tests/doit.py | 4 ++ .../codejail/codejail/tests/test_jailpy.py | 44 ++++++++++++------- 4 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 common/lib/codejail/codejail/tests/doit.py diff --git a/common/lib/codejail/codejail/jail_code.py b/common/lib/codejail/codejail/jail_code.py index 9b48568bd8..2e1b78fe0a 100644 --- a/common/lib/codejail/codejail/jail_code.py +++ b/common/lib/codejail/codejail/jail_code.py @@ -68,15 +68,20 @@ class JailResult(object): self.stdout = self.stderr = self.status = None -def jail_code(command, code, files=None, argv=None, stdin=None): +def jail_code(command, code=None, files=None, argv=None, stdin=None): """Run code in a jailed subprocess. `command` is an abstract command ("python", "node", ...) that must have been configured using `configure`. - `code` is a string containing the Python code to run. + `code` is a string containing the code to run. If no code is supplied, + then the code to run must be in one of the `files` copied, and must be + named in the `argv` list. - `files` is a list of file paths. + `files` is a list of file paths, they are all copied to the jailed + directory. + + `argv` is the command-line arguments to supply. Return an object with: @@ -92,6 +97,8 @@ def jail_code(command, code, files=None, argv=None, stdin=None): log.debug("Executing jailed code: %r", code) + argv = argv or [] + # All the supporting files are copied into our directory. for filename in files or (): if os.path.isfile(filename): @@ -101,10 +108,13 @@ def jail_code(command, code, files=None, argv=None, stdin=None): shutil.copytree(filename, dest) # Create the main file. - with open(os.path.join(tmpdir, "jailed_code.py"), "w") as jailed: - jailed.write(code) + if code: + with open(os.path.join(tmpdir, "jailed_code"), "w") as jailed: + jailed.write(code) - cmd = COMMANDS[command] + ['jailed_code.py'] + (argv or []) + argv = ["jailed_code"] + argv + + cmd = COMMANDS[command] + argv subproc = subprocess.Popen( cmd, preexec_fn=set_process_limits, cwd=tmpdir, diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py index 5379052ce0..79729565f7 100644 --- a/common/lib/codejail/codejail/safe_exec.py +++ b/common/lib/codejail/codejail/safe_exec.py @@ -85,7 +85,7 @@ def safe_exec(code, globals_dict, files=None, python_path=None): log.debug("Exec: %s", code) log.debug("Stdin: %s", stdin) - res = jail_code.jail_code("python", jailed_code, stdin=stdin, files=files) + res = jail_code.jail_code("python", code=jailed_code, stdin=stdin, files=files) if res.status != 0: raise Exception("Couldn't execute jailed code: %s" % res.stderr) globals_dict.update(json.loads(res.stdout)) diff --git a/common/lib/codejail/codejail/tests/doit.py b/common/lib/codejail/codejail/tests/doit.py new file mode 100644 index 0000000000..5786635067 --- /dev/null +++ b/common/lib/codejail/codejail/tests/doit.py @@ -0,0 +1,4 @@ +import sys + +print "This is doit.py!" +print "My args are %r" % (sys.argv,) diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index 395cfc4d53..21cb780946 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -11,9 +11,15 @@ dedent = textwrap.dedent def jailpy(*args, **kwargs): + """Run `jail_code` on Python.""" return jail_code("python", *args, **kwargs) +def file_here(fname): + """Return the full path to a file alongside this code.""" + return os.path.join(os.path.dirname(__file__), fname) + + class JailCodeHelpers(object): """Assert helpers for jail_code tests.""" def setUp(self): @@ -28,32 +34,32 @@ class JailCodeHelpers(object): class TestFeatures(JailCodeHelpers, unittest.TestCase): def test_hello_world(self): - res = jailpy("print 'Hello, world!'") + res = jailpy(code="print 'Hello, world!'") self.assertResultOk(res) self.assertEqual(res.stdout, 'Hello, world!\n') def test_argv(self): res = jailpy( - "import sys; print ':'.join(sys.argv[1:])", + code="import sys; print ':'.join(sys.argv[1:])", argv=["Hello", "world", "-x"] ) self.assertResultOk(res) self.assertEqual(res.stdout, "Hello:world:-x\n") def test_ends_with_exception(self): - res = jailpy("""raise Exception('FAIL')""") + res = jailpy(code="""raise Exception('FAIL')""") self.assertNotEqual(res.status, 0) self.assertEqual(res.stdout, "") self.assertEqual(res.stderr, dedent("""\ Traceback (most recent call last): - File "jailed_code.py", line 1, in + File "jailed_code", line 1, in raise Exception('FAIL') Exception: FAIL """)) def test_stdin_is_provided(self): res = jailpy( - "import json,sys; print sum(json.load(sys.stdin))", + code="import json,sys; print sum(json.load(sys.stdin))", stdin="[1, 2.5, 33]" ) self.assertResultOk(res) @@ -61,27 +67,35 @@ class TestFeatures(JailCodeHelpers, unittest.TestCase): def test_files_are_copied(self): res = jailpy( - "print 'Look:', open('hello.txt').read()", - files=[os.path.dirname(__file__) + "/hello.txt"] + code="print 'Look:', open('hello.txt').read()", + files=[file_here("hello.txt")] ) self.assertResultOk(res) self.assertEqual(res.stdout, 'Look: Hello there.\n\n') + def test_executing_a_copied_file(self): + res = jailpy( + files=[file_here("doit.py")], + argv=["doit.py", "1", "2", "3"] + ) + self.assertResultOk(res) + self.assertEqual(res.stdout, "This is doit.py!\nMy args are ['doit.py', '1', '2', '3']\n") + class TestLimits(JailCodeHelpers, unittest.TestCase): def test_cant_use_too_much_memory(self): - res = jailpy("print sum(range(100000000))") + res = jailpy(code="print sum(range(100000000))") self.assertNotEqual(res.status, 0) self.assertEqual(res.stdout, "") def test_cant_use_too_much_cpu(self): - res = jailpy("print sum(xrange(100000000))") + res = jailpy(code="print sum(xrange(100000000))") self.assertNotEqual(res.status, 0) self.assertEqual(res.stdout, "") def test_cant_use_too_much_time(self): raise SkipTest # TODO: test this once we can kill sleeping processes. - res = jailpy(dedent("""\ + res = jailpy(code=dedent("""\ import time time.sleep(5) print 'Done!' @@ -90,7 +104,7 @@ class TestLimits(JailCodeHelpers, unittest.TestCase): self.assertEqual(res.stdout, "") def test_cant_write_files(self): - res = jailpy(dedent("""\ + res = jailpy(code=dedent("""\ print "Trying" with open("mydata.txt", "w") as f: f.write("hello") @@ -102,7 +116,7 @@ class TestLimits(JailCodeHelpers, unittest.TestCase): self.assertIn("ermission denied", res.stderr) def test_cant_use_network(self): - res = jailpy(dedent("""\ + res = jailpy(code=dedent("""\ import urllib print "Reading google" u = urllib.urlopen("http://google.com") @@ -121,7 +135,7 @@ class TestLimits(JailCodeHelpers, unittest.TestCase): class TestMalware(JailCodeHelpers, unittest.TestCase): def test_crash_cpython(self): # http://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html - res = jailpy(dedent("""\ + res = jailpy(code=dedent("""\ import new, sys crash_me = new.function(new.code(0,0,0,0,"KABOOM",(),(),(),"","",0,""), {}) print "Here we go..." @@ -134,7 +148,7 @@ class TestMalware(JailCodeHelpers, unittest.TestCase): self.assertEqual(res.stderr, "") def test_read_etc_passwd(self): - res = jailpy(dedent("""\ + res = jailpy(code=dedent("""\ bytes = len(open('/etc/passwd').read()) print 'Gotcha', bytes """)) @@ -143,7 +157,7 @@ class TestMalware(JailCodeHelpers, unittest.TestCase): self.assertIn("ermission denied", res.stderr) def test_find_other_sandboxes(self): - res = jailpy(dedent(""" + res = jailpy(code=dedent(""" import os; places = [ "..", "/tmp", "/", "/home", "/etc", From 726e8db13e9b248c7a07d5d74e9f36b08886dff2 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 24 Apr 2013 15:14:07 -0400 Subject: [PATCH 080/120] Add more docs --- common/lib/codejail/README | 75 ++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/common/lib/codejail/README b/common/lib/codejail/README index 75862d69e3..889534a0c3 100644 --- a/common/lib/codejail/README +++ b/common/lib/codejail/README @@ -1,40 +1,75 @@ -Choose a place for the virtualenv, call it . It will be automatically -detected and used if you put it right alongside your existing virtualenv, but -with -sandbox appended. So if your existing virtualenv is in ~/mitx_all/python, -make be ~/mitx_all/python-sandbox (but you'll need to spell out your -home directory instead of ~). +CodeJail +======== + +CodeJail manages execution of untrusted code in secure sandboxes. It is +designed primarily for Python execution, but can be used for other languages as +well. + +Security is enforced with AppArmor. If your operating system doesn't support +AppArmor, then CodeJail won't protect the execution. + +CodeJail is designed to be configurable, and will auto-configure itself for +Python execution if you install it properly. The configuration is designed to +be flexible: it can run in safe more or unsafe mode. This helps support large +development groups where only some of the developers are involved enough with +secure execution to configure AppArmor on their development machines. + +If CodeJail is not configured for safe execution, it will execution Python +using the same API, but will not guard against malicious code. This allows the +same code to be used on safe-configured or non-safe-configured developer's +machines. + + +Installation +------------ + +These instructions detail how to configure your operating system so that +CodeJail can execute Python code safely. You can run CodeJail without these +steps, and you will have an unsafe CodeJail. This is fine for developers' +machines who are unconcerned with security, and simplifies the integration of +CodeJail into your project. + +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 +sandboxed Python code. + +Choose a place for the new virtualenv, call it . It will be +automatically detected and used if you put it right alongside your existing +virtualenv, but with -sandbox appended. So if your existing virtualenv is in +~/ve/myproj, make be ~/ve/myproj-sandbox (but you'll need to spell +out your home directory instead of ~). Other details here that depend on your configuration: - Your mitx working tree is , for example, ~/mitx_all/mitx - - The user running the LMS is , for example, you on a dev machine, + - The user running the LMS is , for example, you on your dev machine, or www-data on a server. -Create a virtualenv: +1. Create the new virtualenv:: $ sudo virtualenv -Install the sandbox requirements +2. Install the sandbox requirements:: $ source /bin/activate $ sudo pip install -r sandbox-requirements.txt -Add a sandbox user: +3. Add a sandbox user:: $ sudo addgroup sandbox $ sudo adduser --disabled-login sandbox --ingroup sandbox -Let the web server run the sandboxed Python as sandbox. Create the file -/etc/sudoers.d/01-sandbox: +4. Let the web server run the sandboxed Python as sandbox. Create the file +/etc/sudoers.d/01-sandbox:: $ visudo -f /etc/sudoers.d/01-sandbox ALL=(sandbox) NOPASSWD:/bin/python ALL=(ALL) NOPASSWD:/bin/kill -Edit an AppArmor profile. The file must be named for the python executable, -but with slashes changed to dots: +5. Edit an AppArmor profile. The file must be named for the python executable, +but with slashes changed to dots:: #include @@ -49,8 +84,18 @@ but with slashes changed to dots: /tmp/** rix, } -Parse the profiles +6. Parse the profiles:: $ sudo apparmor_parser -Reactivate your real virtualenv again +7. Reactivate your project's main virtualenv again. + + +Tests +===== + +The tests run under nose in the standard fashion. + +If CodeJail is running unsafely, many of the tests will be automatically +skipped, or will fail, depending on whether CodeJail thinks it should be in +safe mode or not. From adde93983149444fe47899a995a794b65ab81e79 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 24 Apr 2013 15:14:24 -0400 Subject: [PATCH 081/120] Clarify some comments in tests. --- common/lib/codejail/codejail/tests/test_jailpy.py | 2 -- common/lib/codejail/codejail/tests/test_safe_exec.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py index 21cb780946..76bf2b1c1d 100644 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ b/common/lib/codejail/codejail/tests/test_jailpy.py @@ -127,8 +127,6 @@ class TestLimits(JailCodeHelpers, unittest.TestCase): self.assertEqual(res.stdout, "Reading google\n") self.assertIn("IOError", res.stderr) - # TODO: write files - # TODO: read network # TODO: fork diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py index 46d6a59a98..f7dac45b03 100644 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ b/common/lib/codejail/codejail/tests/test_safe_exec.py @@ -65,6 +65,8 @@ class TestSafeExec(SafeExecTests, unittest.TestCase): class TestNotSafeExec(SafeExecTests, unittest.TestCase): """Run SafeExecTests, with not_safe_exec.""" 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 is not_safe_exec: raise SkipTest From 09fbbe7bfa2c16a33074b72de9e5fcf2eeb02de1 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 29 Apr 2013 17:31:19 -0400 Subject: [PATCH 082/120] Codejail is in its own repo now. --- common/lib/codejail/README | 101 ---------- common/lib/codejail/codejail/__init__.py | 0 .../codejail/codejail/django_integration.py | 17 -- common/lib/codejail/codejail/jail_code.py | 168 ----------------- common/lib/codejail/codejail/safe_exec.py | 148 --------------- .../lib/codejail/codejail/tests/__init__.py | 0 common/lib/codejail/codejail/tests/doit.py | 4 - common/lib/codejail/codejail/tests/hello.txt | 1 - .../codejail/codejail/tests/pylib/module.py | 1 - .../codejail/codejail/tests/test_jailpy.py | 175 ------------------ .../codejail/codejail/tests/test_safe_exec.py | 74 -------- common/lib/codejail/codejail/util.py | 53 ------ common/lib/codejail/setup.py | 7 - local-requirements.txt | 2 +- 14 files changed, 1 insertion(+), 750 deletions(-) delete mode 100644 common/lib/codejail/README delete mode 100644 common/lib/codejail/codejail/__init__.py delete mode 100644 common/lib/codejail/codejail/django_integration.py delete mode 100644 common/lib/codejail/codejail/jail_code.py delete mode 100644 common/lib/codejail/codejail/safe_exec.py delete mode 100644 common/lib/codejail/codejail/tests/__init__.py delete mode 100644 common/lib/codejail/codejail/tests/doit.py delete mode 100644 common/lib/codejail/codejail/tests/hello.txt delete mode 100644 common/lib/codejail/codejail/tests/pylib/module.py delete mode 100644 common/lib/codejail/codejail/tests/test_jailpy.py delete mode 100644 common/lib/codejail/codejail/tests/test_safe_exec.py delete mode 100644 common/lib/codejail/codejail/util.py delete mode 100644 common/lib/codejail/setup.py diff --git a/common/lib/codejail/README b/common/lib/codejail/README deleted file mode 100644 index 889534a0c3..0000000000 --- a/common/lib/codejail/README +++ /dev/null @@ -1,101 +0,0 @@ -CodeJail -======== - -CodeJail manages execution of untrusted code in secure sandboxes. It is -designed primarily for Python execution, but can be used for other languages as -well. - -Security is enforced with AppArmor. If your operating system doesn't support -AppArmor, then CodeJail won't protect the execution. - -CodeJail is designed to be configurable, and will auto-configure itself for -Python execution if you install it properly. The configuration is designed to -be flexible: it can run in safe more or unsafe mode. This helps support large -development groups where only some of the developers are involved enough with -secure execution to configure AppArmor on their development machines. - -If CodeJail is not configured for safe execution, it will execution Python -using the same API, but will not guard against malicious code. This allows the -same code to be used on safe-configured or non-safe-configured developer's -machines. - - -Installation ------------- - -These instructions detail how to configure your operating system so that -CodeJail can execute Python code safely. You can run CodeJail without these -steps, and you will have an unsafe CodeJail. This is fine for developers' -machines who are unconcerned with security, and simplifies the integration of -CodeJail into your project. - -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 -sandboxed Python code. - -Choose a place for the new virtualenv, call it . It will be -automatically detected and used if you put it right alongside your existing -virtualenv, but with -sandbox appended. So if your existing virtualenv is in -~/ve/myproj, make be ~/ve/myproj-sandbox (but you'll need to spell -out your home directory instead of ~). - -Other details here that depend on your configuration: - - - Your mitx working tree is , for example, ~/mitx_all/mitx - - - The user running the LMS is , for example, you on your dev machine, - or www-data on a server. - -1. Create the new virtualenv:: - - $ sudo virtualenv - -2. Install the sandbox requirements:: - - $ source /bin/activate - $ sudo pip install -r sandbox-requirements.txt - -3. Add a sandbox user:: - - $ sudo addgroup sandbox - $ sudo adduser --disabled-login sandbox --ingroup sandbox - -4. Let the web server run the sandboxed Python as sandbox. Create the file -/etc/sudoers.d/01-sandbox:: - - $ visudo -f /etc/sudoers.d/01-sandbox - - ALL=(sandbox) NOPASSWD:/bin/python - ALL=(ALL) NOPASSWD:/bin/kill - -5. Edit an AppArmor profile. The file must be named for the python executable, -but with slashes changed to dots:: - - #include - - /bin/python { - #include - - /** mr, - /common/lib/sandbox-packages/** r, - /usr/local/lib/python2.7/** r, - /usr/lib/python2.7/** rix, - - /tmp/** rix, - } - -6. Parse the profiles:: - - $ sudo apparmor_parser - -7. Reactivate your project's main virtualenv again. - - -Tests -===== - -The tests run under nose in the standard fashion. - -If CodeJail is running unsafely, many of the tests will be automatically -skipped, or will fail, depending on whether CodeJail thinks it should be in -safe mode or not. diff --git a/common/lib/codejail/codejail/__init__.py b/common/lib/codejail/codejail/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/lib/codejail/codejail/django_integration.py b/common/lib/codejail/codejail/django_integration.py deleted file mode 100644 index 56c47f88d9..0000000000 --- a/common/lib/codejail/codejail/django_integration.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Django integration for codejail""" - -from django.core.exceptions import MiddlewareNotUsed -from django.conf import settings - -import codejail.jail_code - - -class ConfigureCodeJailMiddleware(object): - """Middleware to configure codejail on startup.""" - - def __init__(self): - python_bin = settings.CODE_JAIL.get('python_bin') - if python_bin: - user = settings.CODE_JAIL['user'] - codejail.jail_code.configure("python", python_bin, user=user) - raise MiddlewareNotUsed diff --git a/common/lib/codejail/codejail/jail_code.py b/common/lib/codejail/codejail/jail_code.py deleted file mode 100644 index 2e1b78fe0a..0000000000 --- a/common/lib/codejail/codejail/jail_code.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Run a python process in a jail.""" - -# Instructions: -# - AppArmor.md from xserver - -import logging -import os -import os.path -import resource -import shutil -import subprocess -import sys -import threading -import time - -from .util import temp_directory - -log = logging.getLogger(__name__) - -# TODO: limit too much stdout data? - -# Configure the commands - -# COMMANDS is a map from an abstract command name to a list of command-line -# pieces, such as subprocess.Popen wants. -COMMANDS = {} - - -def configure(command, bin_path, user=None): - """Configure a command for `jail_code` to use. - - `command` is the abstract command you're configuring, such as "python" or - "node". `bin_path` is the path to the binary. `user`, if provided, is - the user name to run the command under. - - """ - cmd_argv = [] - if user: - cmd_argv.extend(['sudo', '-u', 'sandbox']) - cmd_argv.append(bin_path) - - # Command-specific arguments - if command == "python": - cmd_argv.append('-E') - - COMMANDS[command] = cmd_argv - - -def is_configured(command): - """Has `jail_code` been configured for `command`? - - Returns true if the abstract command `command` has been configured for use - in the `jail_code` function. - - """ - return command in COMMANDS - -# By default, look where our current Python is, and maybe there's a -# python-sandbox alongside. Only do this if running in a virtualenv. -if hasattr(sys, 'real_prefix'): - if os.path.isdir(sys.prefix + "-sandbox"): - configure("python", sys.prefix + "-sandbox/bin/python", "sandbox") - - -class JailResult(object): - """A passive object for us to return from jail_code.""" - def __init__(self): - self.stdout = self.stderr = self.status = None - - -def jail_code(command, code=None, files=None, argv=None, stdin=None): - """Run code in a jailed subprocess. - - `command` is an abstract command ("python", "node", ...) that must have - been configured using `configure`. - - `code` is a string containing the code to run. If no code is supplied, - then the code to run must be in one of the `files` copied, and must be - named in the `argv` list. - - `files` is a list of file paths, they are all copied to the jailed - directory. - - `argv` is the command-line arguments to supply. - - Return an object with: - - .stdout: stdout of the program, a string - .stderr: stderr of the program, a string - .status: return status of the process: an int, 0 for successful - - """ - if not is_configured(command): - raise Exception("jail_code needs to be configured for %r" % command) - - with temp_directory(delete_when_done=True) as tmpdir: - - log.debug("Executing jailed code: %r", code) - - argv = argv or [] - - # All the supporting files are copied into our directory. - for filename in files or (): - if os.path.isfile(filename): - shutil.copy(filename, tmpdir) - else: - dest = os.path.join(tmpdir, os.path.basename(filename)) - shutil.copytree(filename, dest) - - # Create the main file. - if code: - with open(os.path.join(tmpdir, "jailed_code"), "w") as jailed: - jailed.write(code) - - argv = ["jailed_code"] + argv - - cmd = COMMANDS[command] + argv - - subproc = subprocess.Popen( - cmd, preexec_fn=set_process_limits, cwd=tmpdir, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - - # TODO: time limiting - - killer = ProcessKillerThread(subproc) - killer.start() - result = JailResult() - result.stdout, result.stderr = subproc.communicate(stdin) - result.status = subproc.returncode - - return result - - -def set_process_limits(): - """ - Set limits on this processs, to be used first in a child process. - """ - resource.setrlimit(resource.RLIMIT_CPU, (1, 1)) # 1 second of CPU--not wall clock time - resource.setrlimit(resource.RLIMIT_NPROC, (0, 0)) # no subprocesses - resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)) # no files - - mem = 32 * (2 ** 20) # 32 MB should be enough for anyone, right? :) - resource.setrlimit(resource.RLIMIT_STACK, (mem, mem)) - resource.setrlimit(resource.RLIMIT_RSS, (mem, mem)) - resource.setrlimit(resource.RLIMIT_DATA, (mem, mem)) - - -class ProcessKillerThread(threading.Thread): - def __init__(self, subproc, limit=1): - super(ProcessKillerThread, self).__init__() - self.subproc = subproc - self.limit = limit - - def run(self): - start = time.time() - while (time.time() - start) < self.limit: - time.sleep(.1) - if self.subproc.poll() is not None: - # Process ended, no need for us any more. - return - - if self.subproc.poll() is None: - # Can't use subproc.kill because we launched the subproc with sudo. - killargs = ["sudo", "kill", "-9", str(self.subproc.pid)] - kill = subprocess.Popen(killargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - out, err = kill.communicate() - # TODO: This doesn't actually kill the process.... :( diff --git a/common/lib/codejail/codejail/safe_exec.py b/common/lib/codejail/codejail/safe_exec.py deleted file mode 100644 index 79729565f7..0000000000 --- a/common/lib/codejail/codejail/safe_exec.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Safe execution of untrusted Python code.""" - -import json -import logging -import os.path -import shutil -import sys -import textwrap - -from codejail import jail_code -from codejail.util import temp_directory, change_directory - -log = logging.getLogger(__name__) - - -def safe_exec(code, globals_dict, files=None, python_path=None): - """Execute code as "exec" does, but safely. - - `code` is a string of Python code. `globals_dict` is used as the globals - during execution. Modifications the code makes to `globals_dict` are - reflected in the dictionary on return. - - Returns None. Changes made by `code` are visible in `globals_dict`. - - """ - the_code = [] - files = list(files or ()) - - the_code.append(textwrap.dedent( - """ - import json - import sys - """ - # 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 or (): - pybase = os.path.basename(pydir) - the_code.append("sys.path.append(%r)\n" % pybase) - 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 0: - 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) - if res.status != 0: - raise Exception("Couldn't execute jailed code: %s" % res.stderr) - 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. - - """ - ok_types = (type(None), int, long, float, str, unicode, list, tuple, dict) - bad_keys = ("__builtins__",) - jd = {} - for k, v in d.iteritems(): - if not isinstance(v, ok_types): - continue - if k in bad_keys: - continue - try: - json.dumps(v) - except TypeError: - continue - else: - jd[k] = v - return json.loads(json.dumps(jd)) - - -def not_safe_exec(code, globals_dict, files=None, python_path=None): - """Another implementation of `safe_exec`, but not safe. - - This can be swapped in for debugging problems in sandboxed Python code. - - This is not thread-safe, due to temporarily changing the current directory - and modifying sys.path. - - """ - g_dict = json_safe(globals_dict) - - with temp_directory(delete_when_done=True) as tmpdir: - with change_directory(tmpdir): - # Copy the files here. - for filename in files or (): - dest = os.path.join(tmpdir, os.path.basename(filename)) - shutil.copyfile(filename, dest) - - original_path = sys.path - if python_path: - sys.path.extend(python_path) - try: - exec code in g_dict - finally: - sys.path = original_path - - globals_dict.update(json_safe(g_dict)) - -# Running Python code in the sandbox makes it difficult to debug. -# Change 0 to 1 to run the code directly. -if 0 or not jail_code.is_configured("python"): - safe_exec = not_safe_exec diff --git a/common/lib/codejail/codejail/tests/__init__.py b/common/lib/codejail/codejail/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/lib/codejail/codejail/tests/doit.py b/common/lib/codejail/codejail/tests/doit.py deleted file mode 100644 index 5786635067..0000000000 --- a/common/lib/codejail/codejail/tests/doit.py +++ /dev/null @@ -1,4 +0,0 @@ -import sys - -print "This is doit.py!" -print "My args are %r" % (sys.argv,) diff --git a/common/lib/codejail/codejail/tests/hello.txt b/common/lib/codejail/codejail/tests/hello.txt deleted file mode 100644 index c12abce9f3..0000000000 --- a/common/lib/codejail/codejail/tests/hello.txt +++ /dev/null @@ -1 +0,0 @@ -Hello there. diff --git a/common/lib/codejail/codejail/tests/pylib/module.py b/common/lib/codejail/codejail/tests/pylib/module.py deleted file mode 100644 index 8cb5cded29..0000000000 --- a/common/lib/codejail/codejail/tests/pylib/module.py +++ /dev/null @@ -1 +0,0 @@ -const = 42 diff --git a/common/lib/codejail/codejail/tests/test_jailpy.py b/common/lib/codejail/codejail/tests/test_jailpy.py deleted file mode 100644 index 76bf2b1c1d..0000000000 --- a/common/lib/codejail/codejail/tests/test_jailpy.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Test jail_code.py""" - -import os.path -import textwrap -import unittest -from nose.plugins.skip import SkipTest - -from codejail.jail_code import jail_code, is_configured - -dedent = textwrap.dedent - - -def jailpy(*args, **kwargs): - """Run `jail_code` on Python.""" - return jail_code("python", *args, **kwargs) - - -def file_here(fname): - """Return the full path to a file alongside this code.""" - return os.path.join(os.path.dirname(__file__), fname) - - -class JailCodeHelpers(object): - """Assert helpers for jail_code tests.""" - def setUp(self): - super(JailCodeHelpers, self).setUp() - if not is_configured("python"): - raise SkipTest - - def assertResultOk(self, res): - self.assertEqual(res.stderr, "") - self.assertEqual(res.status, 0) - - -class TestFeatures(JailCodeHelpers, unittest.TestCase): - def test_hello_world(self): - res = jailpy(code="print 'Hello, world!'") - self.assertResultOk(res) - self.assertEqual(res.stdout, 'Hello, world!\n') - - def test_argv(self): - res = jailpy( - code="import sys; print ':'.join(sys.argv[1:])", - argv=["Hello", "world", "-x"] - ) - self.assertResultOk(res) - self.assertEqual(res.stdout, "Hello:world:-x\n") - - def test_ends_with_exception(self): - res = jailpy(code="""raise Exception('FAIL')""") - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "") - self.assertEqual(res.stderr, dedent("""\ - Traceback (most recent call last): - File "jailed_code", line 1, in - raise Exception('FAIL') - Exception: FAIL - """)) - - def test_stdin_is_provided(self): - res = jailpy( - code="import json,sys; print sum(json.load(sys.stdin))", - stdin="[1, 2.5, 33]" - ) - self.assertResultOk(res) - self.assertEqual(res.stdout.strip(), "36.5") - - def test_files_are_copied(self): - res = jailpy( - code="print 'Look:', open('hello.txt').read()", - files=[file_here("hello.txt")] - ) - self.assertResultOk(res) - self.assertEqual(res.stdout, 'Look: Hello there.\n\n') - - def test_executing_a_copied_file(self): - res = jailpy( - files=[file_here("doit.py")], - argv=["doit.py", "1", "2", "3"] - ) - self.assertResultOk(res) - self.assertEqual(res.stdout, "This is doit.py!\nMy args are ['doit.py', '1', '2', '3']\n") - - -class TestLimits(JailCodeHelpers, unittest.TestCase): - def test_cant_use_too_much_memory(self): - res = jailpy(code="print sum(range(100000000))") - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "") - - def test_cant_use_too_much_cpu(self): - res = jailpy(code="print sum(xrange(100000000))") - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "") - - def test_cant_use_too_much_time(self): - raise SkipTest # TODO: test this once we can kill sleeping processes. - res = jailpy(code=dedent("""\ - import time - time.sleep(5) - print 'Done!' - """)) - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "") - - def test_cant_write_files(self): - res = jailpy(code=dedent("""\ - print "Trying" - with open("mydata.txt", "w") as f: - f.write("hello") - with open("mydata.txt") as f2: - print "Got this:", f2.read() - """)) - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "Trying\n") - self.assertIn("ermission denied", res.stderr) - - def test_cant_use_network(self): - res = jailpy(code=dedent("""\ - import urllib - print "Reading google" - u = urllib.urlopen("http://google.com") - google = u.read() - print len(google) - """)) - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "Reading google\n") - self.assertIn("IOError", res.stderr) - - # TODO: fork - - -class TestMalware(JailCodeHelpers, unittest.TestCase): - def test_crash_cpython(self): - # http://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html - res = jailpy(code=dedent("""\ - import new, sys - crash_me = new.function(new.code(0,0,0,0,"KABOOM",(),(),(),"","",0,""), {}) - print "Here we go..." - sys.stdout.flush() - crash_me() - print "The afterlife!" - """)) - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "Here we go...\n") - self.assertEqual(res.stderr, "") - - def test_read_etc_passwd(self): - res = jailpy(code=dedent("""\ - bytes = len(open('/etc/passwd').read()) - print 'Gotcha', bytes - """)) - self.assertNotEqual(res.status, 0) - self.assertEqual(res.stdout, "") - self.assertIn("ermission denied", res.stderr) - - def test_find_other_sandboxes(self): - res = jailpy(code=dedent(""" - import os; - places = [ - "..", "/tmp", "/", "/home", "/etc", - "/var" - ] - for place in places: - try: - files = os.listdir(place) - except Exception: - # darn - pass - else: - print "Files in %r: %r" % (place, files) - print "Done." - """)) - self.assertResultOk(res) - self.assertEqual(res.stdout, "Done.\n") diff --git a/common/lib/codejail/codejail/tests/test_safe_exec.py b/common/lib/codejail/codejail/tests/test_safe_exec.py deleted file mode 100644 index f7dac45b03..0000000000 --- a/common/lib/codejail/codejail/tests/test_safe_exec.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Test safe_exec.py""" - -import os.path -import textwrap -import unittest -from nose.plugins.skip import SkipTest - -from codejail.safe_exec import safe_exec, not_safe_exec - - -class SafeExecTests(object): - """The tests for `safe_exec`, will be mixed into specific test classes below.""" - def test_set_values(self): - g = {} - self.safe_exec("a = 17", g) - self.assertEqual(g['a'], 17) - - def test_files_are_copied(self): - g = {} - self.safe_exec( - "a = 'Look: ' + open('hello.txt').read()", g, - files=[os.path.dirname(__file__) + "/hello.txt"] - ) - self.assertEqual(g['a'], 'Look: Hello there.\n') - - def test_python_path(self): - g = {} - self.safe_exec( - "import module; a = module.const", g, - python_path=[os.path.dirname(__file__) + "/pylib"] - ) - self.assertEqual(g['a'], 42) - - def test_functions_calling_each_other(self): - g = {} - self.safe_exec(textwrap.dedent("""\ - def f(): - return 1723 - def g(): - return f() - x = g() - """), g) - self.assertEqual(g['x'], 1723) - - def test_printing_stuff_when_you_shouldnt(self): - g = {} - self.safe_exec("a = 17; print 'hi!'", g) - self.assertEqual(g['a'], 17) - - def test_importing_lots_of_crap(self): - g = {} - self.safe_exec(textwrap.dedent("""\ - from numpy import * - a = 1723 - """), g) - self.assertEqual(g['a'], 1723) - - -class TestSafeExec(SafeExecTests, unittest.TestCase): - """Run SafeExecTests, with the real safe_exec.""" - def safe_exec(self, *args, **kwargs): - safe_exec(*args, **kwargs) - - -class TestNotSafeExec(SafeExecTests, unittest.TestCase): - """Run SafeExecTests, with not_safe_exec.""" - 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 is not_safe_exec: - raise SkipTest - - def safe_exec(self, *args, **kwargs): - not_safe_exec(*args, **kwargs) diff --git a/common/lib/codejail/codejail/util.py b/common/lib/codejail/codejail/util.py deleted file mode 100644 index d88a05b4dd..0000000000 --- a/common/lib/codejail/codejail/util.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Helpers for codejail.""" - -import contextlib -import os -import shutil -import tempfile - - -class TempDirectory(object): - def __init__(self, delete_when_done=True): - self.delete_when_done = delete_when_done - self.temp_dir = tempfile.mkdtemp(prefix="codejail-") - # Make directory readable by other users ('sandbox' user needs to be able to read it) - os.chmod(self.temp_dir, 0775) - - def clean_up(self): - if self.delete_when_done: - # if this errors, something is genuinely wrong, so don't ignore errors. - shutil.rmtree(self.temp_dir) - - -@contextlib.contextmanager -def temp_directory(delete_when_done=True): - """ - A context manager to make and use a temp directory. If `delete_when_done` - is true (the default), the directory will be removed when done. - """ - tmp = TempDirectory(delete_when_done) - try: - yield tmp.temp_dir - finally: - tmp.clean_up() - - -class ChangeDirectory(object): - def __init__(self, new_dir): - self.old_dir = os.getcwd() - os.chdir(new_dir) - - def clean_up(self): - os.chdir(self.old_dir) - - -@contextlib.contextmanager -def change_directory(new_dir): - """ - A context manager to change the directory, and then change it back. - """ - cd = ChangeDirectory(new_dir) - try: - yield new_dir - finally: - cd.clean_up() diff --git a/common/lib/codejail/setup.py b/common/lib/codejail/setup.py deleted file mode 100644 index c4dcf2b0f7..0000000000 --- a/common/lib/codejail/setup.py +++ /dev/null @@ -1,7 +0,0 @@ -from setuptools import setup - -setup( - name="codejail", - version="0.1", - packages=['codejail'], -) diff --git a/local-requirements.txt b/local-requirements.txt index 4ad1ef6636..5407398a75 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -2,6 +2,6 @@ -e common/lib/calc -e common/lib/capa -e common/lib/chem --e common/lib/codejail +#-e common/lib/codejail -e common/lib/xmodule -e . From baa6b4e3e4f9e69b3c63706b996c02661ffe6aff Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 30 Apr 2013 11:23:11 -0400 Subject: [PATCH 083/120] The cache key for safe_exec has to be hashed to keep it a reasonable size. --- common/lib/capa/capa/safe_exec/safe_exec.py | 7 ++++++- .../capa/capa/safe_exec/tests/test_safe_exec.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index a07fe359fd..3fd6c215b7 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -4,6 +4,8 @@ from codejail.safe_exec import safe_exec as codejail_safe_exec from codejail.safe_exec import json_safe from . import lazymod +import hashlib + # Establish the Python environment for Capa. # Capa assumes float-friendly division always. # The name "random" is a properly-seeded stand-in for the random module. @@ -53,7 +55,10 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None # Check the cache for a previous result. if cache: canonical_globals = sorted(json_safe(globals_dict).iteritems()) - key = "safe_exec %r %s %r" % (random_seed, code, canonical_globals) + md5er = hashlib.md5() + md5er.update(code) + md5er.update(repr(canonical_globals)) + key = "safe_exec %r %s" % (random_seed, md5er.hexdigest()) cached = cache.get(key) if cached is not None: globals_dict.update(cached) diff --git a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py index 37f86383c2..bd960f331c 100644 --- a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py +++ b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py @@ -65,9 +65,13 @@ class DictCache(object): self.cache = d def get(self, key): + # Actual cache implementations have limits on key length + assert len(key) <= 250 return self.cache.get(key) def set(self, key, value): + # Actual cache implementations have limits on key length + assert len(key) <= 250 self.cache[key] = value @@ -90,3 +94,13 @@ class TestSafeExecCaching(unittest.TestCase): g = {} safe_exec("a = int(math.pi)", g, cache=DictCache(cache)) self.assertEqual(g['a'], 17) + + def test_cache_large_code_chunk(self): + # Caching used to die on memcache with more than 250 bytes of code. + # Check that it doesn't any more. + code = "a = 0\n" + ("a += 1\n" * 12345) + + g = {} + cache = {} + safe_exec(code, g, cache=DictCache(cache)) + self.assertEqual(g['a'], 12345) From 05021377d481417ac12431ecbfa7b396856d9358 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 30 Apr 2013 11:51:52 -0400 Subject: [PATCH 084/120] Make the correct link to the codejail repo --- github-requirements.txt | 1 + local-requirements.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/github-requirements.txt b/github-requirements.txt index 3b71d228e7..2111d3f597 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -8,3 +8,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@5ce6f70a#egg=XBlock +-e git+https://github.com/edx/codejail.git#egg=codejail diff --git a/local-requirements.txt b/local-requirements.txt index 5407398a75..a72f1f6dea 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -2,6 +2,5 @@ -e common/lib/calc -e common/lib/capa -e common/lib/chem -#-e common/lib/codejail -e common/lib/xmodule -e . From 0b2aedb4fea8d689bfe343cd8f15b426fbc66233 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 29 Apr 2013 10:47:26 -0400 Subject: [PATCH 085/120] Added datadog monitoring of safe_exec() time --- common/lib/capa/capa/safe_exec/safe_exec.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 3fd6c215b7..d8b7f561cc 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -3,6 +3,7 @@ from codejail.safe_exec import safe_exec as codejail_safe_exec from codejail.safe_exec import json_safe from . import lazymod +from statsd import statsd import hashlib @@ -46,6 +47,7 @@ for name, modname in ASSUMED_IMPORTS: LAZY_IMPORTS = "".join(LAZY_IMPORTS) +@statsd.timed('capa.safe_exec.time') def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None): """Exec python code safely. From ac660ead3e2400899e3a9680a494d52c38211158 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 26 Apr 2013 11:11:34 -0400 Subject: [PATCH 086/120] Added load test of CustomResponse --- .../courseware/tests/load_tests/README.md | 4 + .../load_tests/custom_response/README.md | 47 +++++++ .../load_tests/custom_response/config.cfg | 22 ++++ .../custom_response/test_scripts/v_user.py | 115 ++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 lms/djangoapps/courseware/tests/load_tests/README.md create mode 100644 lms/djangoapps/courseware/tests/load_tests/custom_response/README.md create mode 100644 lms/djangoapps/courseware/tests/load_tests/custom_response/config.cfg create mode 100644 lms/djangoapps/courseware/tests/load_tests/custom_response/test_scripts/v_user.py diff --git a/lms/djangoapps/courseware/tests/load_tests/README.md b/lms/djangoapps/courseware/tests/load_tests/README.md new file mode 100644 index 0000000000..09d8797947 --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/README.md @@ -0,0 +1,4 @@ +# Load Testing + +Scripts for load testing the courseware app, +mostly using [multimechanize](http://testutils.org/multi-mechanize/) diff --git a/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md b/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md new file mode 100644 index 0000000000..2120b06b7e --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md @@ -0,0 +1,47 @@ +# Custom Response Load Test + +## Optional Installations + +* [memcached](http://pypi.python.org/pypi/python-memcached/): Install this +and make sure it is running, or the Capa problem will not cache results. + +* [AppArmor](http://wiki.apparmor.net): Follow the instructions in +`common/lib/codejail/README` to set up the Python sandbox environment. +If you do not set up the sandbox, the tests will still execute code in the CustomResponse, +so you can still run the tests. + +* [matplotlib](http://matplotlib.org): Multi-mechanize uses this to create graphs. + + +## Running the Tests + +This test simulates student submissions for a custom response problem. + +You can run the test using: + + multimech-run custom_response + +You can configure the parameters in `customresponse/config.cfg`, +and you can change the CustomResponse script and student submissions +in `customresponse/test_scripts/v_user.py`. + +## Components Under Test + +Components under test: + +* Python sandbox (see `common/lib/codejail`), which uses `AppArmor` +* Caching (see `common/lib/capa/capa/safe_exec/`), which uses `memcache` in production + +Components NOT under test: + +* Django views +* `XModule` +* gunicorn + +This allows us to avoid creating courses in mongo, logging in, using CSRF tokens, +and other inconveniences. Instead, we create a capa problem (from the capa package), +pass it Django's memcache backend, and pass the problem student submissions. + +Even though the test uses `capa.capa_problem.LoncapaProblem` directly, +the `capa` should not depend on Django. For this reason, we put the +test in the `courseware` Django app. diff --git a/lms/djangoapps/courseware/tests/load_tests/custom_response/config.cfg b/lms/djangoapps/courseware/tests/load_tests/custom_response/config.cfg new file mode 100644 index 0000000000..c75f02a669 --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/custom_response/config.cfg @@ -0,0 +1,22 @@ + +[global] +run_time = 240 +rampup = 30 +results_ts_interval = 10 +progress_bar = on +console_logging = off +xml_report = off + + +[user_group-1] +threads = 10 +script = v_user.py + +[user_group-2] +threads = 10 +script = v_user.py + +[user_group-3] +threads = 10 +script = v_user.py + diff --git a/lms/djangoapps/courseware/tests/load_tests/custom_response/test_scripts/v_user.py b/lms/djangoapps/courseware/tests/load_tests/custom_response/test_scripts/v_user.py new file mode 100644 index 0000000000..9bfc39e55b --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/custom_response/test_scripts/v_user.py @@ -0,0 +1,115 @@ +""" User script for load testing CustomResponse """ + +from capa.tests.response_xml_factory import CustomResponseXMLFactory +import capa.capa_problem as lcp +from xmodule.x_module import ModuleSystem +import mock +import fs.osfs +import random +import textwrap + +# Use memcache running locally +CACHE_SETTINGS = { + 'default': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': '127.0.0.1:11211' + }, +} + +# Configure settings so Django will let us import its cache wrapper +# Caching is the only part of Django being tested +from django.conf import settings +settings.configure(CACHES=CACHE_SETTINGS) + +from django.core.cache import cache + +# Script to install as the checker for the CustomResponse +TEST_SCRIPT = textwrap.dedent(""" + def check_func(expect, answer_given): + return {'ok': answer_given == expect, 'msg': 'Message text'} +""") + +# Submissions submitted by the student +TEST_SUBMISSIONS = [random.randint(-100, 100) for i in range(100)] + +class TestContext(object): + """ One-time set up for the test that is shared across transactions. + Uses a Singleton design pattern.""" + + SINGLETON = None + NUM_UNIQUE_SEEDS = 20 + + @classmethod + def singleton(cls): + """ Return the singleton, creating one if it does not already exist.""" + + # If we haven't created the singleton yet, create it now + if cls.SINGLETON is None: + + # Create a mock ModuleSystem, installing our cache + system = mock.MagicMock(ModuleSystem) + system.render_template = lambda template, context: "
%s
" % template + system.cache = cache + system.filestore = mock.MagicMock(fs.osfs.OSFS) + system.filestore.root_path = "" + system.DEBUG = True + + # Create a custom response problem + xml_factory = CustomResponseXMLFactory() + xml = xml_factory.build_xml(script=TEST_SCRIPT, cfn="check_func", expect="42") + + # Create and store the context + cls.SINGLETON = cls(system, xml) + + else: + pass + + # Return the singleton + return cls.SINGLETON + + def __init__(self, system, xml): + """ Store context needed for the test across transactions """ + self.system = system + self.xml = xml + + # Construct a small pool of unique seeds + # To keep our implementation in line with the one capa actually uses, + # construct the problems, then use the seeds they generate + self.seeds = [lcp.LoncapaProblem(self.xml, 'problem_id', system=self.system).seed + for i in range(self.NUM_UNIQUE_SEEDS)] + + def random_seed(self): + """ Return one of a small number of unique random seeds """ + return random.choice(self.seeds) + + def student_submission(self): + """ Return one of a small number of student submissions """ + return random.choice(TEST_SUBMISSIONS) + +class Transaction(object): + """ User script that submits a response to a CustomResponse problem """ + + def __init__(self): + """ Create the problem """ + + # Get the context (re-used across transactions) + self.context = TestContext.singleton() + + # Create a new custom response problem + # using one of a small number of unique seeds + # We're assuming that the capa module is limiting the number + # of seeds (currently not the case for certain settings) + self.problem = lcp.LoncapaProblem(self.context.xml, + '1', + state=None, + seed=self.context.random_seed(), + system=self.context.system) + + def run(self): + """ Submit a response to the CustomResponse problem """ + answers = {'1_2_1': self.context.student_submission()} + self.problem.grade_answers(answers) + +if __name__ == '__main__': + trans = Transaction() + trans.run() From 7b26c50e3208a4a711d6a5bce47b2f21e49f86c1 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 26 Apr 2013 11:14:19 -0400 Subject: [PATCH 087/120] Added instructions for clearing the cache before running tests --- .../courseware/tests/load_tests/custom_response/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md b/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md index 2120b06b7e..e3fae8c817 100644 --- a/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md +++ b/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md @@ -17,7 +17,11 @@ so you can still run the tests. This test simulates student submissions for a custom response problem. -You can run the test using: +First, clear the cache: + + /etc/init.d/memcached restart + +Then, run the test: multimech-run custom_response From 477fe670dd1b30ae97de3258cc8710255941c4a4 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 1 May 2013 10:08:37 -0400 Subject: [PATCH 088/120] All re-randomization has to be bucketed to get a reasonable cache hit rate. --- common/lib/capa/capa/capa_problem.py | 14 +++---- common/lib/capa/capa/tests/__init__.py | 8 +++- .../lib/capa/capa/tests/test_html_render.py | 19 +++++----- .../lib/capa/capa/tests/test_responsetypes.py | 5 +-- common/lib/xmodule/xmodule/capa_module.py | 38 +++++++++++++------ .../xmodule/xmodule/tests/test_capa_module.py | 14 ++++++- 6 files changed, 61 insertions(+), 37 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 45200b8607..1c0189d9aa 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -17,9 +17,8 @@ from datetime import datetime import logging import math import numpy -import os, os.path +import os.path import re -import struct import sys from lxml import etree @@ -73,7 +72,7 @@ class LoncapaProblem(object): - problem_text (string): xml defining the problem - id (string): identifier for this problem; often a filename (no spaces) - - seed (int): random number generator seed (int) + - seed (int): random number generator seed (int) - state (dict): containing the following keys: - 'seed' - (int) random number generator seed - 'student_answers' - (dict) maps input id to the stored answer for that input @@ -92,23 +91,20 @@ class LoncapaProblem(object): if self.system is None: raise Exception() - state = state if state else {} + state = state or {} # Set seed according to the following priority: # 1. Contained in problem's state # 2. Passed into capa_problem via constructor - # 3. Assign from the OS's random number generator self.seed = state.get('seed', seed) - if self.seed is None: - self.seed = struct.unpack('i', os.urandom(4))[0] + assert self.seed is not None, "Seed must be provided for LoncapaProblem." + self.student_answers = state.get('student_answers', {}) if 'correct_map' in state: self.correct_map.set_dict(state['correct_map']) self.done = state.get('done', False) self.input_state = state.get('input_state', {}) - - # Convert startouttext and endouttext to proper problem_text = re.sub("startouttext\s*/", "text", problem_text) problem_text = re.sub("endouttext\s*/", "/text", problem_text) diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index 59c87780b5..1997e4d055 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -1,7 +1,7 @@ -import fs import fs.osfs -import os +import os, os.path +from capa.capa_problem import LoncapaProblem from mock import Mock, MagicMock import xml.sax.saxutils as saxutils @@ -36,3 +36,7 @@ test_system = Mock( anonymous_student_id='student', cache=None, ) + +def new_loncapa_problem(xml): + """Construct a `LoncapaProblem` suitable for unit tests.""" + return LoncapaProblem(xml, id='1', seed=723, system=test_system) diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 492fcb2743..e0edf344f2 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -6,9 +6,8 @@ import json import mock -from capa.capa_problem import LoncapaProblem from .response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory -from . import test_system +from . import test_system, new_loncapa_problem class CapaHtmlRenderTest(unittest.TestCase): @@ -20,7 +19,7 @@ class CapaHtmlRenderTest(unittest.TestCase): xml_str = " " # Create the problem - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -39,7 +38,7 @@ class CapaHtmlRenderTest(unittest.TestCase): """) # Create the problem - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -61,7 +60,7 @@ class CapaHtmlRenderTest(unittest.TestCase): """) # Create the problem - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -80,7 +79,7 @@ class CapaHtmlRenderTest(unittest.TestCase): """) # Create the problem - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -98,7 +97,7 @@ class CapaHtmlRenderTest(unittest.TestCase): """) # Create the problem - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -121,7 +120,7 @@ class CapaHtmlRenderTest(unittest.TestCase): test_system.render_template.return_value = "
Input Template Render
" # Create the problem and render the HTML - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) rendered_html = etree.XML(problem.get_html()) # Expect problem has been turned into a
@@ -184,7 +183,7 @@ class CapaHtmlRenderTest(unittest.TestCase): xml_str = CustomResponseXMLFactory().build_xml(**kwargs) # Create the problem and render the html - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) # Grade the problem correctmap = problem.grade_answers({'1_2_1': 'test'}) @@ -219,7 +218,7 @@ class CapaHtmlRenderTest(unittest.TestCase): """) # Create the problem and render the HTML - problem = LoncapaProblem(xml_str, '1', system=test_system) + problem = new_loncapa_problem(xml_str) rendered_html = etree.XML(problem.get_html()) # Expect that the variable $test has been replaced with its value diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 8e41ff39de..9cafef23d6 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -12,9 +12,8 @@ import textwrap import mock import textwrap -from . import test_system +from . import new_loncapa_problem -import capa.capa_problem as lcp from capa.responsetypes import LoncapaProblemError, \ StudentInputError, ResponseError from capa.correctmap import CorrectMap @@ -33,7 +32,7 @@ class ResponseTest(unittest.TestCase): def build_problem(self, **kwargs): xml = self.xml_factory.build_xml(**kwargs) - return lcp.LoncapaProblem(xml, '1', system=test_system) + return new_loncapa_problem(xml) def assert_grade(self, problem, submission, expected_correctness, msg=None): input_dict = {'1_2_1': submission} diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 4143345196..ec0c3c8619 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -3,7 +3,9 @@ import datetime import hashlib import json import logging +import os import traceback +import struct import sys from pkg_resources import resource_string @@ -23,8 +25,10 @@ from xmodule.util.date_utils import time_to_datetime log = logging.getLogger("mitx.courseware") -# Generated this many different variants of problems with rerandomize=per_student +# Generate this many different variants of problems with rerandomize=per_student NUM_RANDOMIZATION_BINS = 20 +# Never produce more than this many different seeds, no matter what. +MAX_RANDOMIZATION_BINS = 1000 def randomization_bin(seed, problem_id): @@ -108,11 +112,7 @@ class CapaModule(CapaFields, XModule): self.close_date = due_date if self.seed is None: - if self.rerandomize == 'never': - self.seed = 1 - elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'): - # see comment on randomization_bin - self.seed = randomization_bin(system.seed, self.location.url) + self.choose_new_seed() # Need the problem location in openendedresponse to send out. Adding # it to the system here seems like the least clunky way to get it @@ -156,6 +156,22 @@ class CapaModule(CapaFields, XModule): self.set_state_from_lcp() + assert self.seed is not None + + def choose_new_seed(self): + """Choose a new seed.""" + if self.rerandomize == 'never': + self.seed = 1 + elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'): + # see comment on randomization_bin + self.seed = randomization_bin(self.system.seed, self.location.url) + else: + self.seed = struct.unpack('i', os.urandom(4))[0] + + # So that sandboxed code execution can be cached, but still have an interesting + # number of possibilities, cap the number of different random seeds. + self.seed %= MAX_RANDOMIZATION_BINS + def new_lcp(self, state, text=None): if text is None: text = self.data @@ -164,6 +180,7 @@ class CapaModule(CapaFields, XModule): problem_text=text, id=self.location.html_id(), state=state, + seed=self.seed, system=self.system, ) @@ -831,14 +848,11 @@ class CapaModule(CapaFields, XModule): 'error': "Refresh the page and make an attempt before resetting."} if self.rerandomize in ["always", "onreset"]: - # reset random number generator seed (note the self.lcp.get_state() - # in next line) - seed = None - else: - seed = self.lcp.seed + # Reset random number generator seed. + self.choose_new_seed() # Generate a new problem with either the previous seed or a new seed - self.lcp = self.new_lcp({'seed': seed}) + self.lcp = self.new_lcp(None) # Pull in the new problem seed self.set_state_from_lcp() diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index f948f5bdfe..61de21b129 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -550,6 +550,7 @@ class CapaModuleTest(unittest.TestCase): def test_reset_problem(self): module = CapaFactory.create(done=True) module.new_lcp = Mock(wraps=module.new_lcp) + module.choose_new_seed = Mock(wraps=module.choose_new_seed) # Stub out HTML rendering with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html: @@ -567,7 +568,8 @@ class CapaModuleTest(unittest.TestCase): self.assertEqual(result['html'], "
Test HTML
") # Expect that the problem was reset - module.new_lcp.assert_called_once_with({'seed': None}) + module.new_lcp.assert_called_once_with(None) + module.choose_new_seed.assert_called_once_with() def test_reset_problem_closed(self): module = CapaFactory.create() @@ -1033,3 +1035,13 @@ class CapaModuleTest(unittest.TestCase): self.assertTrue(module.seed is not None) msg = 'Could not get a new seed from reset after 5 tries' self.assertTrue(success, msg) + + def test_random_seed_bins(self): + # Assert that we are limiting the number of possible seeds. + + # Check the conditions that generate random seeds + for rerandomize in ['always', 'per_student', 'true', 'onreset']: + # Get a bunch of seeds, they should all be in 0-999. + for i in range(200): + module = CapaFactory.create(rerandomize=rerandomize) + assert 0 <= module.seed < 1000 From 0ba4b680f9ece6e9a8986ca5837176079b2cf14c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 1 May 2013 16:03:28 -0400 Subject: [PATCH 089/120] Minor fixes of test_system in xmodule tests --- common/lib/xmodule/xmodule/tests/test_progress.py | 2 +- common/lib/xmodule/xmodule/tests/test_randomize_module.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_progress.py b/common/lib/xmodule/xmodule/tests/test_progress.py index 0114ba4ad3..4bb663ad85 100644 --- a/common/lib/xmodule/xmodule/tests/test_progress.py +++ b/common/lib/xmodule/xmodule/tests/test_progress.py @@ -134,6 +134,6 @@ class ModuleProgressTest(unittest.TestCase): ''' def test_xmodule_default(self): '''Make sure default get_progress exists, returns None''' - xm = x_module.XModule(test_system, 'a://b/c/d/e', None, {}) + xm = x_module.XModule(test_system(), 'a://b/c/d/e', None, {}) p = xm.get_progress() self.assertEqual(p, None) diff --git a/common/lib/xmodule/xmodule/tests/test_randomize_module.py b/common/lib/xmodule/xmodule/tests/test_randomize_module.py index 59cf5a59f3..81935c4013 100644 --- a/common/lib/xmodule/xmodule/tests/test_randomize_module.py +++ b/common/lib/xmodule/xmodule/tests/test_randomize_module.py @@ -14,7 +14,6 @@ START = '2013-01-01T01:00:00' from .test_course_module import DummySystem as DummyImportSystem -from . import test_system class RandomizeModuleTestCase(unittest.TestCase): From f4d84e67e1ab153e4238d648ffc6f57ba9c75487 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 2 May 2013 10:15:23 -0400 Subject: [PATCH 090/120] Build the XModuleSystem anew for each test so we can fiddle with it safely. --- common/lib/capa/capa/tests/__init__.py | 38 ++++++++------- .../lib/capa/capa/tests/test_customrender.py | 8 ++-- .../lib/capa/capa/tests/test_html_render.py | 20 ++++---- common/lib/capa/capa/tests/test_inputtypes.py | 47 +++++++++---------- common/lib/xmodule/xmodule/tests/__init__.py | 13 +++-- 5 files changed, 66 insertions(+), 60 deletions(-) diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index 1997e4d055..d989405951 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -22,21 +22,27 @@ def calledback_url(dispatch = 'score_update'): xqueue_interface = MagicMock() xqueue_interface.send_to_queue.return_value = (0, 'Success!') -test_system = Mock( - ajax_url='courses/course_id/modx/a_location', - track_function=Mock(), - get_module=Mock(), - render_template=tst_render_template, - replace_urls=Mock(), - user=Mock(), - filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), - debug=True, - xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10}, - node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), - anonymous_student_id='student', - cache=None, -) +def test_system(): + """ + Construct a mock ModuleSystem instance. -def new_loncapa_problem(xml): + """ + the_system = Mock( + ajax_url='courses/course_id/modx/a_location', + track_function=Mock(), + get_module=Mock(), + render_template=tst_render_template, + replace_urls=Mock(), + user=Mock(), + filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), + debug=True, + xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10}, + node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), + anonymous_student_id='student', + cache=None, + ) + return the_system + +def new_loncapa_problem(xml, system=None): """Construct a `LoncapaProblem` suitable for unit tests.""" - return LoncapaProblem(xml, id='1', seed=723, system=test_system) + return LoncapaProblem(xml, id='1', seed=723, system=system or test_system()) diff --git a/common/lib/capa/capa/tests/test_customrender.py b/common/lib/capa/capa/tests/test_customrender.py index eece275b05..8012804a40 100644 --- a/common/lib/capa/capa/tests/test_customrender.py +++ b/common/lib/capa/capa/tests/test_customrender.py @@ -26,7 +26,7 @@ class HelperTest(unittest.TestCase): Make sure that our helper function works! ''' def check(self, d): - xml = etree.XML(test_system.render_template('blah', d)) + xml = etree.XML(test_system().render_template('blah', d)) self.assertEqual(d, extract_context(xml)) def test_extract_context(self): @@ -46,11 +46,11 @@ class SolutionRenderTest(unittest.TestCase): xml_str = """{s}""".format(s=solution) element = etree.fromstring(xml_str) - renderer = lookup_tag('solution')(test_system, element) + renderer = lookup_tag('solution')(test_system(), element) self.assertEqual(renderer.id, 'solution_12') - # our test_system "renders" templates to a div with the repr of the context + # Our test_system "renders" templates to a div with the repr of the context. xml = renderer.get_html() context = extract_context(xml) self.assertEqual(context, {'id': 'solution_12'}) @@ -65,7 +65,7 @@ class MathRenderTest(unittest.TestCase): xml_str = """{tex}""".format(tex=latex_in) element = etree.fromstring(xml_str) - renderer = lookup_tag('math')(test_system, element) + renderer = lookup_tag('math')(test_system(), element) self.assertEqual(renderer.mathstr, mathjax_out) diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index e0edf344f2..62605b48f5 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -11,6 +11,10 @@ from . import test_system, new_loncapa_problem class CapaHtmlRenderTest(unittest.TestCase): + def setUp(self): + super(CapaHtmlRenderTest, self).setUp() + self.system = test_system() + def test_blank_problem(self): """ It's important that blank problems don't break, since that's @@ -38,7 +42,7 @@ class CapaHtmlRenderTest(unittest.TestCase): """) # Create the problem - problem = new_loncapa_problem(xml_str) + problem = new_loncapa_problem(xml_str, system=self.system) # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -48,9 +52,6 @@ class CapaHtmlRenderTest(unittest.TestCase): self.assertEqual(test_element.tag, "test") self.assertEqual(test_element.text, "Test include") - - - def test_process_outtext(self): # Generate some XML with and xml_str = textwrap.dedent(""" @@ -116,11 +117,12 @@ class CapaHtmlRenderTest(unittest.TestCase): xml_str = StringResponseXMLFactory().build_xml(**kwargs) # Mock out the template renderer - test_system.render_template = mock.Mock() - test_system.render_template.return_value = "
Input Template Render
" + the_system = test_system() + the_system.render_template = mock.Mock() + the_system.render_template.return_value = "
Input Template Render
" # Create the problem and render the HTML - problem = new_loncapa_problem(xml_str) + problem = new_loncapa_problem(xml_str, system=the_system) rendered_html = etree.XML(problem.get_html()) # Expect problem has been turned into a
@@ -165,7 +167,7 @@ class CapaHtmlRenderTest(unittest.TestCase): mock.call('textline.html', expected_textline_context), mock.call('solutionspan.html', expected_solution_context)] - self.assertEqual(test_system.render_template.call_args_list, + self.assertEqual(the_system.render_template.call_args_list, expected_calls) @@ -226,7 +228,7 @@ class CapaHtmlRenderTest(unittest.TestCase): self.assertEqual(span_element.get('attr'), "TEST") def _create_test_file(self, path, content_str): - test_fp = test_system.filestore.open(path, "w") + test_fp = self.system.filestore.open(path, "w") test_fp.write(content_str) test_fp.close() diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 54edb5bf9f..313eb28249 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -45,7 +45,7 @@ class OptionInputTest(unittest.TestCase): state = {'value': 'Down', 'id': 'sky_input', 'status': 'answered'} - option_input = lookup_tag('optioninput')(test_system, element, state) + option_input = lookup_tag('optioninput')(test_system(), element, state) context = option_input._get_render_context() @@ -92,7 +92,7 @@ class ChoiceGroupTest(unittest.TestCase): 'id': 'sky_input', 'status': 'answered'} - the_input = lookup_tag(tag)(test_system, element, state) + the_input = lookup_tag(tag)(test_system(), element, state) context = the_input._get_render_context() @@ -142,7 +142,7 @@ class JavascriptInputTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': '3', } - the_input = lookup_tag('javascriptinput')(test_system, element, state) + the_input = lookup_tag('javascriptinput')(test_system(), element, state) context = the_input._get_render_context() @@ -170,7 +170,7 @@ class TextLineTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': 'BumbleBee', } - the_input = lookup_tag('textline')(test_system, element, state) + the_input = lookup_tag('textline')(test_system(), element, state) context = the_input._get_render_context() @@ -198,7 +198,7 @@ class TextLineTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': 'BumbleBee', } - the_input = lookup_tag('textline')(test_system, element, state) + the_input = lookup_tag('textline')(test_system(), element, state) context = the_input._get_render_context() @@ -236,7 +236,7 @@ class TextLineTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': 'BumbleBee', } - the_input = lookup_tag('textline')(test_system, element, state) + the_input = lookup_tag('textline')(test_system(), element, state) context = the_input._get_render_context() @@ -274,7 +274,7 @@ class FileSubmissionTest(unittest.TestCase): 'status': 'incomplete', 'feedback': {'message': '3'}, } input_class = lookup_tag('filesubmission') - the_input = input_class(test_system, element, state) + the_input = input_class(test_system(), element, state) context = the_input._get_render_context() @@ -319,7 +319,7 @@ class CodeInputTest(unittest.TestCase): 'feedback': {'message': '3'}, } input_class = lookup_tag('codeinput') - the_input = input_class(test_system, element, state) + the_input = input_class(test_system(), element, state) context = the_input._get_render_context() @@ -368,7 +368,7 @@ class MatlabTest(unittest.TestCase): 'feedback': {'message': '3'}, } self.input_class = lookup_tag('matlabinput') - self.the_input = self.input_class(test_system, elt, state) + self.the_input = self.input_class(test_system(), elt, state) def test_rendering(self): context = self.the_input._get_render_context() @@ -396,7 +396,7 @@ class MatlabTest(unittest.TestCase): 'feedback': {'message': '3'}, } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_system, elt, state) + the_input = self.input_class(test_system(), elt, state) context = the_input._get_render_context() expected = {'id': 'prob_1_2', @@ -423,7 +423,7 @@ class MatlabTest(unittest.TestCase): } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_system, elt, state) + the_input = self.input_class(test_system(), elt, state) context = the_input._get_render_context() expected = {'id': 'prob_1_2', 'value': 'print "good evening"', @@ -448,7 +448,7 @@ class MatlabTest(unittest.TestCase): } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_system, elt, state) + the_input = self.input_class(test_system(), elt, state) context = the_input._get_render_context() expected = {'id': 'prob_1_2', 'value': 'print "good evening"', @@ -470,7 +470,7 @@ class MatlabTest(unittest.TestCase): get = {'submission': 'x = 1234;'} response = self.the_input.handle_ajax("plot", get) - test_system.xqueue['interface'].send_to_queue.assert_called_with(header=ANY, body=ANY) + test_system().xqueue['interface'].send_to_queue.assert_called_with(header=ANY, body=ANY) self.assertTrue(response['success']) self.assertTrue(self.the_input.input_state['queuekey'] is not None) @@ -479,13 +479,12 @@ class MatlabTest(unittest.TestCase): def test_plot_data_failure(self): get = {'submission': 'x = 1234;'} error_message = 'Error message!' - test_system.xqueue['interface'].send_to_queue.return_value = (1, error_message) + test_system().xqueue['interface'].send_to_queue.return_value = (1, error_message) response = self.the_input.handle_ajax("plot", get) self.assertFalse(response['success']) self.assertEqual(response['message'], error_message) self.assertTrue('queuekey' not in self.the_input.input_state) self.assertTrue('queuestate' not in self.the_input.input_state) - test_system.xqueue['interface'].send_to_queue.return_value = (0, 'Success!') def test_ungraded_response_success(self): queuekey = 'abcd' @@ -496,7 +495,7 @@ class MatlabTest(unittest.TestCase): 'feedback': {'message': '3'}, } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_system, elt, state) + the_input = self.input_class(test_system(), elt, state) inner_msg = 'hello!' queue_msg = json.dumps({'msg': inner_msg}) @@ -514,7 +513,7 @@ class MatlabTest(unittest.TestCase): 'feedback': {'message': '3'}, } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_system, elt, state) + the_input = self.input_class(test_system(), elt, state) inner_msg = 'hello!' queue_msg = json.dumps({'msg': inner_msg}) @@ -553,7 +552,7 @@ class SchematicTest(unittest.TestCase): state = {'value': value, 'status': 'unsubmitted'} - the_input = lookup_tag('schematic')(test_system, element, state) + the_input = lookup_tag('schematic')(test_system(), element, state) context = the_input._get_render_context() @@ -592,7 +591,7 @@ class ImageInputTest(unittest.TestCase): state = {'value': value, 'status': 'unsubmitted'} - the_input = lookup_tag('imageinput')(test_system, element, state) + the_input = lookup_tag('imageinput')(test_system(), element, state) context = the_input._get_render_context() @@ -643,7 +642,7 @@ class CrystallographyTest(unittest.TestCase): state = {'value': value, 'status': 'unsubmitted'} - the_input = lookup_tag('crystallography')(test_system, element, state) + the_input = lookup_tag('crystallography')(test_system(), element, state) context = the_input._get_render_context() @@ -681,7 +680,7 @@ class VseprTest(unittest.TestCase): state = {'value': value, 'status': 'unsubmitted'} - the_input = lookup_tag('vsepr_input')(test_system, element, state) + the_input = lookup_tag('vsepr_input')(test_system(), element, state) context = the_input._get_render_context() @@ -708,7 +707,7 @@ class ChemicalEquationTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': 'H2OYeah', } - self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state) + self.the_input = lookup_tag('chemicalequationinput')(test_system(), element, state) def test_rendering(self): ''' Verify that the render context matches the expected render context''' @@ -783,7 +782,7 @@ class DragAndDropTest(unittest.TestCase): ] } - the_input = lookup_tag('drag_and_drop_input')(test_system, element, state) + the_input = lookup_tag('drag_and_drop_input')(test_system(), element, state) context = the_input._get_render_context() expected = {'id': 'prob_1_2', @@ -832,7 +831,7 @@ class AnnotationInputTest(unittest.TestCase): tag = 'annotationinput' - the_input = lookup_tag(tag)(test_system, element, state) + the_input = lookup_tag(tag)(test_system(), element, state) context = the_input._get_render_context() diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 79e1fec0f0..bcf333d7d9 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -33,15 +33,14 @@ def test_system(): """ Construct a test ModuleSystem instance. - By default, the render_template() method simply returns - the context it is passed as a string. - You can override this behavior by monkey patching: + By default, the render_template() method simply returns the context it is + passed as a string. You can override this behavior by monkey patching:: - system = test_system() - system.render_template = my_render_func + system = test_system() + system.render_template = my_render_func + + where `my_render_func` is a function of the form my_render_func(template, context). - where my_render_func is a function of the form - my_render_func(template, context) """ return ModuleSystem( ajax_url='courses/course_id/modx/a_location', From f1fac732cf6399484a24b63068e65d86ac2c086c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 2 May 2013 10:50:56 -0400 Subject: [PATCH 091/120] A new boolean on XModuleSystem that determines whether to allow execution of untrusted unsandboxed code. --- common/lib/capa/capa/responsetypes.py | 4 ++ common/lib/capa/capa/tests/__init__.py | 1 + .../lib/capa/capa/tests/test_responsetypes.py | 37 +++++++++++++++---- common/lib/xmodule/xmodule/x_module.py | 5 +++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index de73dcda30..f7d285887e 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -502,6 +502,10 @@ class JavascriptResponse(LoncapaResponse): return tmp_env def call_node(self, args): + # Node.js code is un-sandboxed. If the XModuleSystem says we aren't + # allowed to run unsafe code, then stop now. + if not self.system.can_execute_unsafe_code: + raise LoncapaProblemError("Execution of unsafe Javascript code is not allowed.") subprocess_args = ["node"] subprocess_args.extend(args) diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index d989405951..da3a3524f4 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -40,6 +40,7 @@ def test_system(): node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), anonymous_student_id='student', cache=None, + can_execute_unsafe_code=False, ) return the_system diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 9cafef23d6..af6bdf823c 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -12,7 +12,7 @@ import textwrap import mock import textwrap -from . import new_loncapa_problem +from . import new_loncapa_problem, test_system from capa.responsetypes import LoncapaProblemError, \ StudentInputError, ResponseError @@ -30,9 +30,9 @@ class ResponseTest(unittest.TestCase): if self.xml_factory_class: self.xml_factory = self.xml_factory_class() - def build_problem(self, **kwargs): + def build_problem(self, system=None, **kwargs): xml = self.xml_factory.build_xml(**kwargs) - return new_loncapa_problem(xml) + return new_loncapa_problem(xml, system=system) def assert_grade(self, problem, submission, expected_correctness, msg=None): input_dict = {'1_2_1': submission} @@ -746,16 +746,37 @@ class JavascriptResponseTest(ResponseTest): coffee_file_path = os.path.dirname(__file__) + "/test_files/js/*.coffee" os.system("coffee -c %s" % (coffee_file_path)) - problem = self.build_problem(generator_src="test_problem_generator.js", - grader_src="test_problem_grader.js", - display_class="TestProblemDisplay", - display_src="test_problem_display.js", - param_dict={'value': '4'}) + system = test_system() + system.can_execute_unsafe_code = True + problem = self.build_problem( + system=system, + generator_src="test_problem_generator.js", + grader_src="test_problem_grader.js", + display_class="TestProblemDisplay", + display_src="test_problem_display.js", + param_dict={'value': '4'}, + ) # Test that we get graded correctly self.assert_grade(problem, json.dumps({0: 4}), "correct") self.assert_grade(problem, json.dumps({0: 5}), "incorrect") + def test_cant_execute_javascript(self): + # If the system says to disallow unsafe code execution, then making + # this problem will raise an exception. + system = test_system() + system.can_execute_unsafe_code = False + + with self.assertRaises(LoncapaProblemError): + problem = self.build_problem( + system=system, + generator_src="test_problem_generator.js", + grader_src="test_problem_grader.js", + display_class="TestProblemDisplay", + display_src="test_problem_display.js", + param_dict={'value': '4'}, + ) + class NumericalResponseTest(ResponseTest): from response_xml_factory import NumericalResponseXMLFactory diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 31a32eb6dc..268eb4b40d 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -702,6 +702,7 @@ class ModuleSystem(object): open_ended_grading_interface=None, s3_interface=None, cache=None, + can_execute_unsafe_code=False, ): ''' Create a closure around the system environment. @@ -749,6 +750,9 @@ class ModuleSystem(object): .get(key) returns an object from the cache or None. .set(key, value, timeout_secs=None) stores a value in the cache with a timeout. + can_execute_unsafe_code - A boolean, whether or not to allow the execution + of unsafe, unsandboxed code. + ''' self.ajax_url = ajax_url self.xqueue = xqueue @@ -774,6 +778,7 @@ class ModuleSystem(object): self.s3_interface = s3_interface self.cache = cache or DoNothingCache() + self.can_execute_unsafe_code = can_execute_unsafe_code def get(self, attr): ''' provide uniform access to attributes (like etree).''' From d7ea1dafe8a7fbea4d0e0dd0e2a90d5b4e242b2e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 2 May 2013 10:58:38 -0400 Subject: [PATCH 092/120] On second thought, make can_execute_unsafe_code a function returning a boolean. --- common/lib/capa/capa/responsetypes.py | 2 +- common/lib/capa/capa/tests/__init__.py | 2 +- common/lib/capa/capa/tests/test_responsetypes.py | 4 ++-- common/lib/xmodule/xmodule/x_module.py | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index f7d285887e..1f71c828c3 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -504,7 +504,7 @@ class JavascriptResponse(LoncapaResponse): def call_node(self, args): # Node.js code is un-sandboxed. If the XModuleSystem says we aren't # allowed to run unsafe code, then stop now. - if not self.system.can_execute_unsafe_code: + if not self.system.can_execute_unsafe_code(): raise LoncapaProblemError("Execution of unsafe Javascript code is not allowed.") subprocess_args = ["node"] diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index da3a3524f4..ac81ff66c4 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -40,7 +40,7 @@ def test_system(): node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), anonymous_student_id='student', cache=None, - can_execute_unsafe_code=False, + can_execute_unsafe_code=lambda: False, ) return the_system diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index af6bdf823c..b9c2bfa3ae 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -747,7 +747,7 @@ class JavascriptResponseTest(ResponseTest): os.system("coffee -c %s" % (coffee_file_path)) system = test_system() - system.can_execute_unsafe_code = True + system.can_execute_unsafe_code = lambda: True problem = self.build_problem( system=system, generator_src="test_problem_generator.js", @@ -765,7 +765,7 @@ class JavascriptResponseTest(ResponseTest): # If the system says to disallow unsafe code execution, then making # this problem will raise an exception. system = test_system() - system.can_execute_unsafe_code = False + system.can_execute_unsafe_code = lambda: False with self.assertRaises(LoncapaProblemError): problem = self.build_problem( diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 268eb4b40d..3a2bdb798e 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -702,7 +702,7 @@ class ModuleSystem(object): open_ended_grading_interface=None, s3_interface=None, cache=None, - can_execute_unsafe_code=False, + can_execute_unsafe_code=None, ): ''' Create a closure around the system environment. @@ -750,8 +750,8 @@ class ModuleSystem(object): .get(key) returns an object from the cache or None. .set(key, value, timeout_secs=None) stores a value in the cache with a timeout. - can_execute_unsafe_code - A boolean, whether or not to allow the execution - of unsafe, unsandboxed code. + can_execute_unsafe_code - A function returning a boolean, whether or + not to allow the execution of unsafe, unsandboxed code. ''' self.ajax_url = ajax_url @@ -778,7 +778,7 @@ class ModuleSystem(object): self.s3_interface = s3_interface self.cache = cache or DoNothingCache() - self.can_execute_unsafe_code = can_execute_unsafe_code + self.can_execute_unsafe_code = can_execute_unsafe_code or (lambda: False) def get(self, attr): ''' provide uniform access to attributes (like etree).''' From d8c22dbeb3895a22eef9aa1b9694d34558e9f897 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 2 May 2013 11:21:38 -0400 Subject: [PATCH 093/120] Add a Django setting for course allowed to run unsafe code. --- lms/djangoapps/courseware/module_render.py | 9 +++++++++ lms/envs/common.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index dc71216adb..622800382e 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -274,6 +274,14 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours statsd.increment("lms.courseware.question_answered", tags=tags) + def can_execute_unsafe_code(): + # To decide if we can run unsafe code, we check the course id against + # a list of regexes configured on the server. + for regex in settings.COURSES_WITH_UNSAFE_CODE: + if re.match(regex, course_id): + return True + return False + # TODO (cpennington): When modules are shared between courses, the static # prefix is going to have to be specific to the module, not the directory # that the xml was loaded from @@ -301,6 +309,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours open_ended_grading_interface=open_ended_grading_interface, s3_interface=s3_interface, cache=cache, + can_execute_unsafe_code=can_execute_unsafe_code, ) # pass position specified in URL to module through ModuleSystem system.set('position', position) diff --git a/lms/envs/common.py b/lms/envs/common.py index 1b492a3c56..12f50233a2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -251,6 +251,15 @@ CODE_JAIL = { 'user': 'sandbox', } +# Some courses are allowed to run unsafe code. This is a list of regexes, one +# of them must match the course id for that course to run unsafe code. +# +# For example: +# +# COURSES_WITH_UNSAFE_CODE = [ +# r"Harvard/XY123.1/.*" +# ] +COURSES_WITH_UNSAFE_CODE = [] ############################ SIGNAL HANDLERS ################################ # This is imported to register the exception signal handling that logs exceptions From 001ef7b0fe8e505a3b374a66b8e09948466d180d Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 2 May 2013 11:42:06 -0400 Subject: [PATCH 094/120] Use only safe characters for the cache key --- common/lib/capa/capa/safe_exec/safe_exec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index d8b7f561cc..745704373b 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -60,7 +60,7 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None md5er = hashlib.md5() md5er.update(code) md5er.update(repr(canonical_globals)) - key = "safe_exec %r %s" % (random_seed, md5er.hexdigest()) + key = "safe_exec.%r.%s" % (random_seed, md5er.hexdigest()) cached = cache.get(key) if cached is not None: globals_dict.update(cached) From 403218ec6b5e5eb46b0a134f7641b6385df3bdb5 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 2 May 2013 12:46:35 -0400 Subject: [PATCH 095/120] If sandboxed code raises an exception, the exception will be cached. --- common/lib/capa/capa/safe_exec/safe_exec.py | 28 +++++++++---- .../capa/safe_exec/tests/test_safe_exec.py | 41 ++++++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 745704373b..34cca0593e 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -1,7 +1,7 @@ """Capa's specialized use of codejail.safe_exec.""" from codejail.safe_exec import safe_exec as codejail_safe_exec -from codejail.safe_exec import json_safe +from codejail.safe_exec import json_safe, SafeExecException from . import lazymod from statsd import statsd @@ -63,20 +63,34 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None key = "safe_exec.%r.%s" % (random_seed, md5er.hexdigest()) cached = cache.get(key) if cached is not None: - globals_dict.update(cached) + # We have a cached result. The result is a pair: the exception + # message, if any, else None; and the resulting globals dictionary. + emsg, cleaned_results = cached + globals_dict.update(cleaned_results) + if emsg: + raise SafeExecException(emsg) return # Create the complete code we'll run. code_prolog = CODE_PROLOG % random_seed # Run the code! Results are side effects in globals_dict. - codejail_safe_exec( - code_prolog + LAZY_IMPORTS + code, globals_dict, - python_path=python_path, - ) + try: + codejail_safe_exec( + code_prolog + LAZY_IMPORTS + code, globals_dict, + python_path=python_path, + ) + except SafeExecException as e: + emsg = e.message + else: + emsg = None # Put the result back in the cache. This is complicated by the fact that # the globals dict might not be entirely serializable. if cache: cleaned_results = json_safe(globals_dict) - cache.set(key, cleaned_results) + cache.set(key, (emsg, cleaned_results)) + + # If an exception happened, raise it now. + if emsg: + raise e diff --git a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py index bd960f331c..b648672daf 100644 --- a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py +++ b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py @@ -5,6 +5,8 @@ import random import unittest from capa.safe_exec import safe_exec +from codejail.safe_exec import SafeExecException + class TestSafeExec(unittest.TestCase): def test_set_values(self): @@ -57,6 +59,12 @@ class TestSafeExec(unittest.TestCase): g, python_path=[pylib] ) + def test_raising_exceptions(self): + g = {} + with self.assertRaises(SafeExecException) as cm: + safe_exec("1/0", g) + self.assertIn("ZeroDivisionError", cm.exception.message) + class DictCache(object): """A cache implementation over a simple dict, for testing.""" @@ -86,10 +94,10 @@ class TestSafeExecCaching(unittest.TestCase): safe_exec("a = int(math.pi)", g, cache=DictCache(cache)) self.assertEqual(g['a'], 3) # A result has been cached - self.assertEqual(cache.values(), [{'a': 3}]) + self.assertEqual(cache.values()[0], (None, {'a': 3})) # Fiddle with the cache, then try it again. - cache[cache.keys()[0]] = {'a': 17} + cache[cache.keys()[0]] = (None, {'a': 17}) g = {} safe_exec("a = int(math.pi)", g, cache=DictCache(cache)) @@ -104,3 +112,32 @@ class TestSafeExecCaching(unittest.TestCase): cache = {} safe_exec(code, g, cache=DictCache(cache)) self.assertEqual(g['a'], 12345) + + def test_cache_exceptions(self): + # Used to be that running code that raised an exception didn't cache + # the result. Check that now it does. + code = "1/0" + g = {} + cache = {} + with self.assertRaises(SafeExecException): + safe_exec(code, g, cache=DictCache(cache)) + + # The exception should be in the cache now. + self.assertEqual(len(cache), 1) + cache_exc_msg, cache_globals = cache.values()[0] + self.assertIn("ZeroDivisionError", cache_exc_msg) + + # Change the value stored in the cache, the result should change. + cache[cache.keys()[0]] = ("Hey there!", {}) + + with self.assertRaises(SafeExecException): + safe_exec(code, g, cache=DictCache(cache)) + + self.assertEqual(len(cache), 1) + cache_exc_msg, cache_globals = cache.values()[0] + self.assertEqual("Hey there!", cache_exc_msg) + + # Change it again, now no exception! + cache[cache.keys()[0]] = (None, {'a': 17}) + safe_exec(code, g, cache=DictCache(cache)) + self.assertEqual(g['a'], 17) From 686eb64949b84d16a91e562206723f18431d0478 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 2 May 2013 17:39:29 -0400 Subject: [PATCH 096/120] An example of how to set the CPU limit for codejail. --- lms/envs/common.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lms/envs/common.py b/lms/envs/common.py index 12f50233a2..324bb82216 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -249,6 +249,12 @@ CODE_JAIL = { 'python_bin': None, # User to run as in the sandbox. 'user': 'sandbox', + + # Configurable limits. + 'limits': { + # How many CPU seconds can jailed code use? + 'CPU': 1, + }, } # Some courses are allowed to run unsafe code. This is a list of regexes, one From 7cb3987f949b4391c88e4831de5f6f4964f07a92 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 3 May 2013 14:10:31 -0400 Subject: [PATCH 097/120] Ugh, missing import. --- lms/djangoapps/courseware/module_render.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 622800382e..d6c104a83c 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -1,6 +1,7 @@ import json import logging import pyparsing +import re import sys import static_replace From e3de0dc847d8756ffc23a019d80608b619797fc3 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 6 May 2013 10:15:27 -0400 Subject: [PATCH 098/120] A fuller unit test with a real 8.02x problem. --- .../capa/safe_exec/tests/test_safe_exec.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py index b648672daf..b8a70a09c6 100644 --- a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py +++ b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py @@ -2,6 +2,7 @@ import os.path import random +import textwrap import unittest from capa.safe_exec import safe_exec @@ -141,3 +142,71 @@ class TestSafeExecCaching(unittest.TestCase): cache[cache.keys()[0]] = (None, {'a': 17}) safe_exec(code, g, cache=DictCache(cache)) self.assertEqual(g['a'], 17) + + +class TestRealProblems(unittest.TestCase): + def test_802x(self): + code = textwrap.dedent("""\ + import math + import random + import numpy + e=1.602e-19 #C + me=9.1e-31 #kg + mp=1.672e-27 #kg + eps0=8.854e-12 #SI units + mu0=4e-7*math.pi #SI units + + Rd1=random.randrange(1,30,1) + Rd2=random.randrange(30,50,1) + Rd3=random.randrange(50,70,1) + Rd4=random.randrange(70,100,1) + Rd5=random.randrange(100,120,1) + + Vd1=random.randrange(1,20,1) + Vd2=random.randrange(20,40,1) + Vd3=random.randrange(40,60,1) + + #R=[0,10,30,50,70,100] #Ohm + #V=[0,12,24,36] # Volt + + R=[0,Rd1,Rd2,Rd3,Rd4,Rd5] #Ohms + V=[0,Vd1,Vd2,Vd3] #Volts + #here the currents IL and IR are defined as in figure ps3_p3_fig2 + a=numpy.array([ [ R[1]+R[4]+R[5],R[4] ],[R[4], R[2]+R[3]+R[4] ] ]) + b=numpy.array([V[1]-V[2],-V[3]-V[2]]) + x=numpy.linalg.solve(a,b) + IL='%.2e' % x[0] + IR='%.2e' % x[1] + ILR='%.2e' % (x[0]+x[1]) + def sign(x): + return abs(x)/x + + RW="Rightwards" + LW="Leftwards" + UW="Upwards" + DW="Downwards" + I1='%.2e' % abs(x[0]) + I1d=LW if sign(x[0])==1 else RW + I1not=LW if I1d==RW else RW + I2='%.2e' % abs(x[1]) + I2d=RW if sign(x[1])==1 else LW + I2not=LW if I2d==RW else RW + I3='%.2e' % abs(x[1]) + I3d=DW if sign(x[1])==1 else UW + I3not=DW if I3d==UW else UW + I4='%.2e' % abs(x[0]+x[1]) + I4d=UW if sign(x[1]+x[0])==1 else DW + I4not=DW if I4d==UW else UW + I5='%.2e' % abs(x[0]) + I5d=RW if sign(x[0])==1 else LW + I5not=LW if I5d==RW else RW + VAP=-x[0]*R[1]-(x[0]+x[1])*R[4] + VPN=-V[2] + VGD=+V[1]-x[0]*R[1]+V[3]+x[1]*R[2] + aVAP='%.2e' % VAP + aVPN='%.2e' % VPN + aVGD='%.2e' % VGD + """) + g = {} + safe_exec(code, g) + self.assertIn("aVAP", g) From 4f33b8e0c07e9b72813d53a63cf5ab84546751f0 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 6 May 2013 14:20:43 -0400 Subject: [PATCH 099/120] Fixed a UnicodeEncodeError that occurred when generating cache keys from non-ASCII unicode code submissions. --- common/lib/capa/capa/safe_exec/safe_exec.py | 2 +- .../lib/capa/capa/safe_exec/tests/test_safe_exec.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 34cca0593e..07e1430688 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -58,7 +58,7 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None if cache: canonical_globals = sorted(json_safe(globals_dict).iteritems()) md5er = hashlib.md5() - md5er.update(code) + md5er.update(repr(code)) md5er.update(repr(canonical_globals)) key = "safe_exec.%r.%s" % (random_seed, md5er.hexdigest()) cached = cache.get(key) diff --git a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py index b8a70a09c6..d79140f33c 100644 --- a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py +++ b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py @@ -143,6 +143,19 @@ class TestSafeExecCaching(unittest.TestCase): safe_exec(code, g, cache=DictCache(cache)) self.assertEqual(g['a'], 17) + def test_unicode_submission(self): + # Check that using non-ASCII unicode does not raise an encoding error. + + # Try several non-ASCII unicode characters + for code in [129, 500, 2**8 - 1, 2**16 - 1]: + + code_with_unichr = unicode("# ") + unichr(code) + + try: + safe_exec(code_with_unichr, {}, cache=DictCache({})) + except UnicodeEncodeError: + self.fail("Tried executing code with non-ASCII unicode: {0}".format(code)) + class TestRealProblems(unittest.TestCase): def test_802x(self): From 3da841ea2be46f019050a7637f8e663c02323a87 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 6 May 2013 17:16:35 -0400 Subject: [PATCH 100/120] Pin codejail to a specific commit so it will get installed properly on deploying. --- github-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github-requirements.txt b/github-requirements.txt index 2111d3f597..7c06f679ad 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -8,4 +8,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@5ce6f70a#egg=XBlock --e git+https://github.com/edx/codejail.git#egg=codejail +-e git+https://github.com/edx/codejail.git@eb0803a#egg=codejail From 91db1eed4cbdcce097ff5e8851cae4470f3757cc Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 6 May 2013 18:06:08 -0400 Subject: [PATCH 101/120] The new settings will only be read from .json files if aws.py handles them. --- lms/envs/aws.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index df81c1e3a9..c7e6c0e50f 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -89,6 +89,16 @@ COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') +for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items(): + oldvalue = CODE_JAIL.get(name) + if isinstance(oldvalue, dict): + for subname, subvalue in value.items(): + oldvalue[subname] = subvalue + else: + CODE_JAIL[name] = value + +COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) + ############################## SECURE AUTH ITEMS ############### # Secret things: passwords, access keys, etc. with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: From 6264d41f9244fe5531adf7d1e9510b91ebffa43b Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 8 May 2013 14:48:46 -0400 Subject: [PATCH 102/120] Very minor docstring tweak :) --- common/lib/capa/capa/safe_exec/safe_exec.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 07e1430688..2424fd4dc4 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -49,7 +49,8 @@ LAZY_IMPORTS = "".join(LAZY_IMPORTS) @statsd.timed('capa.safe_exec.time') def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None): - """Exec python code safely. + """ + Exec python code safely. `cache` is an object with .get(key) and .set(key, value) methods. From ce3e733d1f0e828dcc399322eeb97c3ca95ffc85 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 8 May 2013 14:49:02 -0400 Subject: [PATCH 103/120] Problems in 7.02x need lxml for their sandbox. --- sandbox-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/sandbox-requirements.txt b/sandbox-requirements.txt index 9b18fba9e9..d7c703e08f 100644 --- a/sandbox-requirements.txt +++ b/sandbox-requirements.txt @@ -1,6 +1,7 @@ # Packages to install in the Python sandbox for secured execution. numpy==1.6.2 scipy==0.11.0 +lxml==3.0.1 -e common/lib/calc -e common/lib/chem -e common/lib/sandbox-packages From c98ab686e63c65993fe248a075db9817d3aa28c4 Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Mon, 13 May 2013 14:11:28 -0400 Subject: [PATCH 104/120] adding pre-requirements for sandbox-requirements.txt --- pre-sandbox-requirements.txt | 1 + sandbox-requirements.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 pre-sandbox-requirements.txt diff --git a/pre-sandbox-requirements.txt b/pre-sandbox-requirements.txt new file mode 100644 index 0000000000..d801f46c8e --- /dev/null +++ b/pre-sandbox-requirements.txt @@ -0,0 +1 @@ +numpy==1.6.2 diff --git a/sandbox-requirements.txt b/sandbox-requirements.txt index d7c703e08f..f99e8a8c4b 100644 --- a/sandbox-requirements.txt +++ b/sandbox-requirements.txt @@ -1,5 +1,4 @@ # Packages to install in the Python sandbox for secured execution. -numpy==1.6.2 scipy==0.11.0 lxml==3.0.1 -e common/lib/calc From b682e1c0d35a4b66c2257185d3521950619bfcf4 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 13 May 2013 17:07:13 -0400 Subject: [PATCH 105/120] Put a feature flag around the staff-only debugging page. --- lms/envs/common.py | 6 +++++- lms/urls.py | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 324bb82216..c9c53fd034 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -90,7 +90,11 @@ MITX_FEATURES = { # Give a UI to show a student's submission history in a problem by the # Staff Debug tool. - 'ENABLE_STUDENT_HISTORY_VIEW': True + 'ENABLE_STUDENT_HISTORY_VIEW': True, + + # Turn on a page that lets staff enter Python code to be run in the + # sandbox, for testing whether it's enabled properly. + 'ENABLE_DEBUG_RUN_PYTHON': False, } # Used for A/B testing diff --git a/lms/urls.py b/lms/urls.py index dc558d6a54..dc0d820278 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -358,9 +358,10 @@ urlpatterns += ( url(r'^comm/foldit_ops', 'foldit.views.foldit_ops', name="foldit_ops"), ) -urlpatterns += ( - url(r'^debug/run_python', 'debug.views.run_python'), -) +if settings.MITX_FEATURES.get('ENABLE_DEBUG_RUN_PYTHON'): + urlpatterns += ( + url(r'^debug/run_python', 'debug.views.run_python'), + ) urlpatterns = patterns(*urlpatterns) From 29f4566a0032308dd879e3a710aa77f6de29d3b4 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 13 May 2013 17:27:21 -0400 Subject: [PATCH 106/120] Change the XBlock target hash, not because we need new code, but because the old one was tangled up in a eponymous branch that was causing trouble. --- github-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github-requirements.txt b/github-requirements.txt index 7c06f679ad..9838f5d089 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -7,5 +7,5 @@ -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev # Our libraries: --e git+https://github.com/edx/XBlock.git@5ce6f70a#egg=XBlock +-e git+https://github.com/edx/XBlock.git@c33a0ce#egg=XBlock -e git+https://github.com/edx/codejail.git@eb0803a#egg=codejail From 768665067f3d7465e2b59c94bb89de042c5b20bf Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 14 May 2013 14:52:30 -0400 Subject: [PATCH 107/120] Update the CodeJail we want. --- github-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github-requirements.txt b/github-requirements.txt index 9838f5d089..c4cd7bd532 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -8,4 +8,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@c33a0ce#egg=XBlock --e git+https://github.com/edx/codejail.git@eb0803a#egg=codejail +-e git+https://github.com/edx/codejail.git@de65688#egg=codejail From 4ea76076481d1af79087bd97a8fb3d089ad81392 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 14 May 2013 14:59:19 -0400 Subject: [PATCH 108/120] Check that the directory being added to the Python path is really inside the course. --- common/lib/capa/capa/capa_problem.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 1c0189d9aa..7ead599d67 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -413,12 +413,17 @@ class LoncapaProblem(object): path = [] for dir in raw_path: - if not dir: continue # path is an absolute path or a path relative to the data dir dir = os.path.join(self.system.filestore.root_path, dir) + # Check that we are within the filestore tree. + reldir = os.path.relpath(dir, self.system.filestore.root_path) + if ".." in reldir: + log.warning("Ignoring Python directory outside of course: %r" % dir) + continue + abs_dir = os.path.normpath(dir) path.append(abs_dir) From b32878e6c781d5b862c9de2e01bfb64781774899 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 15 May 2013 12:59:56 -0400 Subject: [PATCH 109/120] Added instructions for getting capa sandboxed. --- common/lib/capa/capa/safe_exec/README.rst | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 common/lib/capa/capa/safe_exec/README.rst diff --git a/common/lib/capa/capa/safe_exec/README.rst b/common/lib/capa/capa/safe_exec/README.rst new file mode 100644 index 0000000000..57eac6b9fd --- /dev/null +++ b/common/lib/capa/capa/safe_exec/README.rst @@ -0,0 +1,32 @@ +Configuring Capa sandboxed execution +==================================== + +Capa problems can contain code authored by the course author. We need to +execute that code in a sandbox. We use CodeJail as the sandboxing facility, +but it needs to be configured specifically for Capa's use. + +As a developer, you don't have to do anything to configure sandboxing if you +don't want to, and everything will operate properly, you just won't have +protection on that code. + +If you want to configure sandboxing, you're going to use the `README from +CodeJail`__, with a few customized tweaks. + +__ https://github.com/edx/codejail/blob/master/README.rst + + +1. At the instruction to install packages into the sandboxed code, you'll + need to install both `pre-sandbox-requirements.txt` and + `sandbox-requirements.txt`:: + + $ sudo pip install -r pre-sandbox-requirements.txt + $ sudo pip install -r sandbox-requirements.txt + +2. At the instruction to create the AppArmor profile, you'll need a line in + the profile for the sandbox packages. is the full path to + your edx_platform repo:: + + /common/lib/sandbox-packages/** r, + +That's it. Once you've finished the CodeJail configuration instructions, +your course-hosted Python code should be run securely. From 8f3c1db1ef8cc52ab6e79ecdb873380bc6750c5a Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 15 May 2013 17:00:39 -0400 Subject: [PATCH 110/120] Add some notes for the future maintainer. --- common/lib/xmodule/xmodule/tests/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index bcf333d7d9..d26352bdae 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -85,10 +85,12 @@ class ModelsTest(unittest.TestCase): self.assertTrue(abs(calc.evaluator(variables, functions, "e^(j*pi)") + 1) < 0.00001) self.assertTrue(abs(calc.evaluator(variables, functions, "j||1") - 0.5 - 0.5j) < 0.00001) variables['t'] = 1.0 + # Use self.assertAlmostEqual here... self.assertTrue(abs(calc.evaluator(variables, functions, "t") - 1.0) < 0.00001) self.assertTrue(abs(calc.evaluator(variables, functions, "T") - 1.0) < 0.00001) self.assertTrue(abs(calc.evaluator(variables, functions, "t", cs=True) - 1.0) < 0.00001) self.assertTrue(abs(calc.evaluator(variables, functions, "T", cs=True) - 298) < 0.2) + # Use self.assertRaises here... exception_happened = False try: calc.evaluator({}, {}, "5+7 QWSEKO") From 1c36564f453a25fe72ec36384ccc7f060df27ab3 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 16 May 2013 07:47:16 -0400 Subject: [PATCH 111/120] Use newer CodeJail. --- github-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github-requirements.txt b/github-requirements.txt index c4cd7bd532..154a172534 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -8,4 +8,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@c33a0ce#egg=XBlock --e git+https://github.com/edx/codejail.git@de65688#egg=codejail +-e git+https://github.com/edx/codejail.git@94dc7ce#egg=codejail From f05b25d17a2e7d395fc66b2feb6a9b9e8d04d098 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 16 May 2013 13:15:28 -0400 Subject: [PATCH 112/120] Added more to the safe_exec docstring. --- common/lib/capa/capa/safe_exec/safe_exec.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 2424fd4dc4..1a33c88bca 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -50,9 +50,19 @@ LAZY_IMPORTS = "".join(LAZY_IMPORTS) @statsd.timed('capa.safe_exec.time') def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None): """ - Exec python code safely. + Execute python code safely. - `cache` is an object with .get(key) and .set(key, value) methods. + `code` is the Python code to execute. It has access to the globals in `globals_dict`, + and any changes it makes to those globals are visible in `globals_dict` when this + function returns. + + `random_seed` will be used to see the `random` module available to the code. + + `python_path` is a list of directories to add to the Python path before execution. + + `cache` is an object with .get(key) and .set(key, value) methods. It will be used + to cache the execution, taking into account the code, the values of the globals, + and the random seed. """ # Check the cache for a previous result. From bbd1d8d09ed1a9f39d657d19b485d9a56d39836d Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 16 May 2013 13:58:05 -0400 Subject: [PATCH 113/120] From code review: the hash was shallow, so nested objects could have hashed differently when they didn't need to. --- common/lib/capa/capa/safe_exec/__init__.py | 2 +- common/lib/capa/capa/safe_exec/safe_exec.py | 27 +++++++- .../capa/safe_exec/tests/test_safe_exec.py | 64 +++++++++++++++++-- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/common/lib/capa/capa/safe_exec/__init__.py b/common/lib/capa/capa/safe_exec/__init__.py index bbfea1c138..ffbe8f2320 100644 --- a/common/lib/capa/capa/safe_exec/__init__.py +++ b/common/lib/capa/capa/safe_exec/__init__.py @@ -1,3 +1,3 @@ """Capa's specialized use of codejail.safe_exec.""" -from .safe_exec import safe_exec +from .safe_exec import safe_exec, update_hash diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 1a33c88bca..b9cdf236bd 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -47,6 +47,29 @@ for name, modname in ASSUMED_IMPORTS: LAZY_IMPORTS = "".join(LAZY_IMPORTS) +def update_hash(hasher, obj): + """ + Update a `hashlib` hasher with a nested object. + + To properly cache nested structures, we need to compute a hash from the + entire structure, canonicalizing at every level. + + `hasher`'s `.update()` method is called a number of times, touching all of + `obj` in the process. Only primitive JSON-safe types are supported. + + """ + hasher.update(str(type(obj))) + if isinstance(obj, (tuple, list)): + for e in obj: + update_hash(hasher, e) + elif isinstance(obj, dict): + for k in sorted(obj): + update_hash(hasher, k) + update_hash(hasher, obj[k]) + else: + hasher.update(repr(obj)) + + @statsd.timed('capa.safe_exec.time') def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None): """ @@ -67,10 +90,10 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None """ # Check the cache for a previous result. if cache: - canonical_globals = sorted(json_safe(globals_dict).iteritems()) + safe_globals = json_safe(globals_dict) md5er = hashlib.md5() md5er.update(repr(code)) - md5er.update(repr(canonical_globals)) + update_hash(md5er, safe_globals) key = "safe_exec.%r.%s" % (random_seed, md5er.hexdigest()) cached = cache.get(key) if cached is not None: diff --git a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py index d79140f33c..4592af8305 100644 --- a/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py +++ b/common/lib/capa/capa/safe_exec/tests/test_safe_exec.py @@ -1,11 +1,12 @@ """Test safe_exec.py""" +import hashlib import os.path import random import textwrap import unittest -from capa.safe_exec import safe_exec +from capa.safe_exec import safe_exec, update_hash from codejail.safe_exec import SafeExecException @@ -145,18 +146,73 @@ class TestSafeExecCaching(unittest.TestCase): def test_unicode_submission(self): # Check that using non-ASCII unicode does not raise an encoding error. - # Try several non-ASCII unicode characters for code in [129, 500, 2**8 - 1, 2**16 - 1]: - code_with_unichr = unicode("# ") + unichr(code) - try: safe_exec(code_with_unichr, {}, cache=DictCache({})) except UnicodeEncodeError: self.fail("Tried executing code with non-ASCII unicode: {0}".format(code)) +class TestUpdateHash(unittest.TestCase): + """Test the safe_exec.update_hash function to be sure it canonicalizes properly.""" + + def hash_obj(self, obj): + """Return the md5 hash that `update_hash` makes us.""" + md5er = hashlib.md5() + update_hash(md5er, obj) + return md5er.hexdigest() + + def equal_but_different_dicts(self): + """ + Make two equal dicts with different key order. + + Simple literals won't do it. Filling one and then shrinking it will + make them different. + + """ + d1 = {k:1 for k in "abcdefghijklmnopqrstuvwxyz"} + d2 = dict(d1) + for i in xrange(10000): + d2[i] = 1 + for i in xrange(10000): + del d2[i] + + # Check that our dicts are equal, but with different key order. + self.assertEqual(d1, d2) + self.assertNotEqual(d1.keys(), d2.keys()) + + return d1, d2 + + def test_simple_cases(self): + h1 = self.hash_obj(1) + h10 = self.hash_obj(10) + hs1 = self.hash_obj("1") + + self.assertNotEqual(h1, h10) + self.assertNotEqual(h1, hs1) + + def test_list_ordering(self): + h1 = self.hash_obj({'a': [1,2,3]}) + h2 = self.hash_obj({'a': [3,2,1]}) + self.assertNotEqual(h1, h2) + + def test_dict_ordering(self): + d1, d2 = self.equal_but_different_dicts() + h1 = self.hash_obj(d1) + h2 = self.hash_obj(d2) + self.assertEqual(h1, h2) + + def test_deep_ordering(self): + d1, d2 = self.equal_but_different_dicts() + o1 = {'a':[1, 2, [d1], 3, 4]} + o2 = {'a':[1, 2, [d2], 3, 4]} + h1 = self.hash_obj(o1) + h2 = self.hash_obj(o2) + self.assertEqual(h1, h2) + + class TestRealProblems(unittest.TestCase): def test_802x(self): code = textwrap.dedent("""\ From 913830210881ce3326d5da2a6e9490db7367d8f3 Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Thu, 16 May 2013 12:16:21 -0400 Subject: [PATCH 114/120] rakefile updates --- rakefiles/django.rake | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rakefiles/django.rake b/rakefiles/django.rake index 594c7d6ec3..1a021c71b8 100644 --- a/rakefiles/django.rake +++ b/rakefiles/django.rake @@ -5,7 +5,7 @@ default_options = { task :predjango => :install_python_prereqs do sh("find . -type f -name *.pyc -delete") - sh('pip install -q --no-index -r requirements/local.txt') + sh('pip install -q --no-index -r requirements/edx/local.txt') end @@ -122,4 +122,4 @@ namespace :cms do "Example: \`rake cms:export COURSE_ID=MITx/12345/name OUTPUT_PATH=foo.tar.gz\`" end end -end \ No newline at end of file +end From e1120d56249909e58f17cb9e87f98652bca453c0 Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Thu, 16 May 2013 12:13:40 -0400 Subject: [PATCH 115/120] new location for requirement files --- README.md | 4 ++-- rakefiles/prereqs.rake | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 90b82ff07a..ed52c21fb2 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ environment), and Node has a library installer called Once you've got your languages and virtual environments set up, install the libraries like so: - $ pip install -r requirements/base.txt - $ pip install -r requirements/post.txt + $ pip install -r requirements/edx/base.txt + $ pip install -r requirements/edx/post.txt $ bundle install $ npm install diff --git a/rakefiles/prereqs.rake b/rakefiles/prereqs.rake index 430e650127..9a2d7ccd17 100644 --- a/rakefiles/prereqs.rake +++ b/rakefiles/prereqs.rake @@ -28,12 +28,12 @@ desc "Install all python prerequisites for the lms and cms" task :install_python_prereqs => "ws:migrate" do when_changed('requirements/**') do ENV['PIP_DOWNLOAD_CACHE'] ||= '.pip_download_cache' - sh('pip install --exists-action w -r requirements/base.txt') - sh('pip install --exists-action w -r requirements/post.txt') + sh('pip install --exists-action w -r requirements/edx/base.txt') + sh('pip install --exists-action w -r requirements/edx/post.txt') # Check for private-requirements.txt: used to install our libs as working dirs, # or personal-use tools. if File.file?("requirements/private.txt") sh('pip install -r requirements/private.txt') end end unless ENV['NO_PREREQ_INSTALL'] -end \ No newline at end of file +end From 653dd4240cd4cbff9813245b1eaec7eb8621b8ff Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Thu, 16 May 2013 12:06:31 -0400 Subject: [PATCH 116/120] moving requirements files to a directory that corresponds with the virtualenv name --- requirements/{ => edx}/base.txt | 0 requirements/{ => edx}/github.txt | 0 requirements/{ => edx}/local.txt | 0 requirements/{ => edx}/post.txt | 0 requirements/{ => edx}/repo.txt | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename requirements/{ => edx}/base.txt (100%) rename requirements/{ => edx}/github.txt (100%) rename requirements/{ => edx}/local.txt (100%) rename requirements/{ => edx}/post.txt (100%) rename requirements/{ => edx}/repo.txt (100%) diff --git a/requirements/base.txt b/requirements/edx/base.txt similarity index 100% rename from requirements/base.txt rename to requirements/edx/base.txt diff --git a/requirements/github.txt b/requirements/edx/github.txt similarity index 100% rename from requirements/github.txt rename to requirements/edx/github.txt diff --git a/requirements/local.txt b/requirements/edx/local.txt similarity index 100% rename from requirements/local.txt rename to requirements/edx/local.txt diff --git a/requirements/post.txt b/requirements/edx/post.txt similarity index 100% rename from requirements/post.txt rename to requirements/edx/post.txt diff --git a/requirements/repo.txt b/requirements/edx/repo.txt similarity index 100% rename from requirements/repo.txt rename to requirements/edx/repo.txt From b8acb39ce165f4ec3cc44d69a8235284906d6b34 Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Thu, 16 May 2013 14:32:27 -0400 Subject: [PATCH 117/120] moving sandbox requirements --- pre-sandbox-requirements.txt => requirements/edx-sandbox/base.txt | 0 sandbox-requirements.txt => requirements/edx-sandbox/post.txt | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename pre-sandbox-requirements.txt => requirements/edx-sandbox/base.txt (100%) rename sandbox-requirements.txt => requirements/edx-sandbox/post.txt (100%) diff --git a/pre-sandbox-requirements.txt b/requirements/edx-sandbox/base.txt similarity index 100% rename from pre-sandbox-requirements.txt rename to requirements/edx-sandbox/base.txt diff --git a/sandbox-requirements.txt b/requirements/edx-sandbox/post.txt similarity index 100% rename from sandbox-requirements.txt rename to requirements/edx-sandbox/post.txt From 522c2a12d2191850bcf68bfe5ded06082f87e124 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 16 May 2013 14:37:55 -0400 Subject: [PATCH 118/120] private-requirements.txt moved, ignore it in the new place. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 76cc1efa95..9c82bb8ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ :2e# .AppleDouble database.sqlite -private-requirements.txt +requirements/private.txt courseware/static/js/mathjax/* flushdb.sh build From d8fed59314bbb5ffbdadd0e350610b914eda105c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 16 May 2013 14:54:13 -0400 Subject: [PATCH 119/120] Use the latest CodeJail. --- requirements/edx/github.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 55bed92f98..d3f90d5abc 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -9,4 +9,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@483e0cb1#egg=XBlock --e git+https://github.com/edx/codejail.git@94dc7ce#egg=codejail +-e git+https://github.com/edx/codejail.git@07494f1#egg=codejail From d0c4afb32f3cdcec319563686f6cdf6073fcbb9a Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 16 May 2013 17:02:45 -0400 Subject: [PATCH 120/120] More info in the capa/safe_exec/README --- common/lib/capa/capa/safe_exec/README.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/common/lib/capa/capa/safe_exec/README.rst b/common/lib/capa/capa/safe_exec/README.rst index 57eac6b9fd..c61100f709 100644 --- a/common/lib/capa/capa/safe_exec/README.rst +++ b/common/lib/capa/capa/safe_exec/README.rst @@ -28,5 +28,24 @@ __ https://github.com/edx/codejail/blob/master/README.rst /common/lib/sandbox-packages/** r, +3. You can configure resource limits in settings.py. A CODE_JAIL setting is + available, a dictionary. The "limits" key lets you adjust the limits for + CPU time, real time, and memory use. Setting any of them to zero disables + that limit:: + + # in settings.py... + CODE_JAIL = { + # Configurable limits. + 'limits': { + # How many CPU seconds can jailed code use? + 'CPU': 1, + # How many real-time seconds will a sandbox survive? + 'REALTIME': 1, + # How much memory (in bytes) can a sandbox use? + 'VMEM': 30000000, + }, + } + + That's it. Once you've finished the CodeJail configuration instructions, your course-hosted Python code should be run securely.