From 2657852ee3e97ee2dc233c932bd7c88bceec94b1 Mon Sep 17 00:00:00 2001 From: Scragly <29337040+scragly@users.noreply.github.com> Date: Sun, 20 Jan 2019 20:42:34 +1000 Subject: Remove RMQ, Add API POST request method. --- tests/test_snekbox.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) (limited to 'tests') diff --git a/tests/test_snekbox.py b/tests/test_snekbox.py index e2505d6..cc79a2a 100644 --- a/tests/test_snekbox.py +++ b/tests/test_snekbox.py @@ -1,12 +1,6 @@ import unittest -import pytest -import os -import json from snekbox import Snekbox -from rmq import Rmq - -r = Rmq() snek = Snekbox() @@ -24,12 +18,14 @@ class SnekTests(unittest.TestCase): # self.assertEquals(result.strip(), 'timed out or memory limit exceeded') def test_timeout(self): - code = ('x = "*"\n' - 'while True:\n' - ' try:\n' - ' x = x * 99\n' - ' except:\n' - ' continue\n') + code = ( + 'x = "*"\n' + 'while True:\n' + ' try:\n' + ' x = x * 99\n' + ' except:\n' + ' continue\n' + ) result = snek.python3(code) self.assertEquals(result.strip(), 'timed out or memory limit exceeded') -- cgit v1.2.3 From 0e09d10281798dd365364a12af4487fc150844c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 Mar 2019 12:36:33 -0700 Subject: Restructure project layout * Move all code into a "snekbox" package * Use logging code as __init__.py * Rename Snekbox class to NsJail * Create "site" sub-package * Move templates into this sub-package * Move Flask code into a new snekapp module --- .flake8 | 2 +- Pipfile | 2 +- logs.py | 10 --- snekbox.py | 133 ------------------------------------- snekbox/__init__.py | 10 +++ snekbox/nsjail.py | 95 ++++++++++++++++++++++++++ snekbox/site/snekapp.py | 38 +++++++++++ snekbox/site/templates/index.html | 14 ++++ snekbox/site/templates/result.html | 9 +++ templates/index.html | 14 ---- templates/result.html | 9 --- tests/test_snekbox.py | 16 ++--- 12 files changed, 176 insertions(+), 176 deletions(-) delete mode 100644 logs.py delete mode 100644 snekbox.py create mode 100644 snekbox/__init__.py create mode 100644 snekbox/nsjail.py create mode 100644 snekbox/site/snekapp.py create mode 100644 snekbox/site/templates/index.html create mode 100644 snekbox/site/templates/result.html delete mode 100644 templates/index.html delete mode 100644 templates/result.html (limited to 'tests') diff --git a/.flake8 b/.flake8 index cc5f423..c897cb6 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length=100 -application_import_names=snekbox,config,logs +application_import_names=snekbox ignore= P102,B311,W503,E226,S311, # Missing Docstrings diff --git a/Pipfile b/Pipfile index 221263d..3f67b54 100644 --- a/Pipfile +++ b/Pipfile @@ -29,7 +29,7 @@ lint = "flake8" precommit = "pre-commit install" test = "pytest tests --cov . --cov-report term-missing -v" report = "pytest tests --cov . --cov-report=html" -snekbox = "gunicorn -w 2 -b 0.0.0.0:8060 snekbox:app" +snekbox = "gunicorn -w 2 -b 0.0.0.0:8060 snekbox.site.snekapp:app" buildbox = "docker build -t pythondiscord/snekbox:latest -f docker/Dockerfile ." pushbox = "docker push pythondiscord/snekbox:latest" buildboxbase = "docker build -t pythondiscord/snekbox-base:latest -f docker/base.Dockerfile ." diff --git a/logs.py b/logs.py deleted file mode 100644 index fc6070e..0000000 --- a/logs.py +++ /dev/null @@ -1,10 +0,0 @@ -import logging -import sys - -logformat = logging.Formatter(fmt='[%(asctime)s] [%(process)s] [%(levelname)s] %(message)s', - datefmt='%Y-%m-%d %H:%M:%S %z') -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) -console = logging.StreamHandler(sys.stdout) -console.setFormatter(logformat) -log.addHandler(console) diff --git a/snekbox.py b/snekbox.py deleted file mode 100644 index 65fc4b3..0000000 --- a/snekbox.py +++ /dev/null @@ -1,133 +0,0 @@ -import os -import subprocess -import sys - -from flask import Flask, jsonify, render_template, request - - -class Snekbox: - """Core snekbox functionality, providing safe execution of Python code.""" - - def __init__(self, - nsjail_binary='nsjail', - python_binary=os.path.dirname(sys.executable) + os.sep + 'python3.6'): - self.nsjail_binary = nsjail_binary - self.python_binary = python_binary - self._nsjail_workaround() - - env = { - 'PATH': ( - '/snekbox/.venv/bin:/usr/local/bin:/usr/local/' - 'sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' - ), - 'LANG': 'en_US.UTF-8', - 'PYTHON_VERSION': '3.6.5', - 'PYTHON_PIP_VERSION': '10.0.1', - 'PYTHONDONTWRITEBYTECODE': '1', - } - - def _nsjail_workaround(self): - dirs = ['/sys/fs/cgroup/pids/NSJAIL', '/sys/fs/cgroup/memory/NSJAIL'] - for d in dirs: - if not os.path.exists(d): - os.makedirs(d) - - def python3(self, cmd): - """ - Execute Python 3 code in a isolated environment. - - The value of ``cmd`` is passed using '-c' to a Python - interpreter that is started in a ``nsjail``, isolating it - from the rest of the system. - - Returns the output of executing the command (stdout) if - successful, or a error message if the execution failed. - """ - - args = [self.nsjail_binary, '-Mo', - '--rlimit_as', '700', - '--chroot', '/', - '-E', 'LANG=en_US.UTF-8', - '-R/usr', '-R/lib', '-R/lib64', - '--user', 'nobody', - '--group', 'nogroup', - '--time_limit', '2', - '--disable_proc', - '--iface_no_lo', - '--cgroup_pids_max=1', - '--cgroup_mem_max=52428800', - '--quiet', '--', - self.python_binary, '-ISq', '-c', cmd] - try: - proc = subprocess.Popen(args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=self.env, - universal_newlines=True) - except ValueError: - return 'ValueError: embedded null byte' - - stdout, stderr = proc.communicate() - if proc.returncode == 0: - output = stdout - - elif proc.returncode == 1: - try: - filtered = [] - for line in stderr.split('\n'): - if not line.startswith('['): - filtered.append(line) - output = '\n'.join(filtered) - except IndexError: - output = '' - - elif proc.returncode == 109: - return 'timed out or memory limit exceeded' - - elif proc.returncode == 255: - return 'permission denied (root required)' - - elif proc.returncode: - return f'unknown error, code: {proc.returncode}' - - else: - return 'unknown error, no error code' - - return output - - -snekbox = Snekbox() - -# Load app -app = Flask(__name__) -app.use_reloader = False - -# Logging -log = app.logger - - -@app.route('/') -def index(): - """Return a page with a form for inputting code to be executed.""" - - return render_template('index.html') - - -@app.route('/result', methods=["POST", "GET"]) -def result(): - """Execute code and return a page displaying the results.""" - - if request.method == "POST": - code = request.form["Code"] - output = snekbox.python3(code) - return render_template('result.html', code=code, result=output) - - -@app.route('/input', methods=["POST"]) -def code_input(): - """Execute code and return the results.""" - - body = request.get_json() - output = snekbox.python3(body["code"]) - return jsonify(input=body["code"], output=output) diff --git a/snekbox/__init__.py b/snekbox/__init__.py new file mode 100644 index 0000000..fc6070e --- /dev/null +++ b/snekbox/__init__.py @@ -0,0 +1,10 @@ +import logging +import sys + +logformat = logging.Formatter(fmt='[%(asctime)s] [%(process)s] [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S %z') +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) +console = logging.StreamHandler(sys.stdout) +console.setFormatter(logformat) +log.addHandler(console) diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py new file mode 100644 index 0000000..458a94e --- /dev/null +++ b/snekbox/nsjail.py @@ -0,0 +1,95 @@ +import os +import subprocess +import sys + + +class NsJail: + """Core Snekbox functionality, providing safe execution of Python code.""" + + def __init__(self, + nsjail_binary='nsjail', + python_binary=os.path.dirname(sys.executable) + os.sep + 'python3.6'): + self.nsjail_binary = nsjail_binary + self.python_binary = python_binary + self._nsjail_workaround() + + env = { + 'PATH': ( + '/snekbox/.venv/bin:/usr/local/bin:/usr/local/' + 'sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + ), + 'LANG': 'en_US.UTF-8', + 'PYTHON_VERSION': '3.6.5', + 'PYTHON_PIP_VERSION': '10.0.1', + 'PYTHONDONTWRITEBYTECODE': '1', + } + + def _nsjail_workaround(self): + dirs = ['/sys/fs/cgroup/pids/NSJAIL', '/sys/fs/cgroup/memory/NSJAIL'] + for d in dirs: + if not os.path.exists(d): + os.makedirs(d) + + def python3(self, cmd): + """ + Execute Python 3 code in a isolated environment. + + The value of ``cmd`` is passed using '-c' to a Python + interpreter that is started in a ``nsjail``, isolating it + from the rest of the system. + + Returns the output of executing the command (stdout) if + successful, or a error message if the execution failed. + """ + + args = [self.nsjail_binary, '-Mo', + '--rlimit_as', '700', + '--chroot', '/', + '-E', 'LANG=en_US.UTF-8', + '-R/usr', '-R/lib', '-R/lib64', + '--user', 'nobody', + '--group', 'nogroup', + '--time_limit', '2', + '--disable_proc', + '--iface_no_lo', + '--cgroup_pids_max=1', + '--cgroup_mem_max=52428800', + '--quiet', '--', + self.python_binary, '-ISq', '-c', cmd] + try: + proc = subprocess.Popen(args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self.env, + universal_newlines=True) + except ValueError: + return 'ValueError: embedded null byte' + + stdout, stderr = proc.communicate() + if proc.returncode == 0: + output = stdout + + elif proc.returncode == 1: + try: + filtered = [] + for line in stderr.split('\n'): + if not line.startswith('['): + filtered.append(line) + output = '\n'.join(filtered) + except IndexError: + output = '' + + elif proc.returncode == 109: + return 'timed out or memory limit exceeded' + + elif proc.returncode == 255: + return 'permission denied (root required)' + + elif proc.returncode: + return f'unknown error, code: {proc.returncode}' + + else: + return 'unknown error, no error code' + + return output diff --git a/snekbox/site/snekapp.py b/snekbox/site/snekapp.py new file mode 100644 index 0000000..492d703 --- /dev/null +++ b/snekbox/site/snekapp.py @@ -0,0 +1,38 @@ +from flask import Flask, jsonify, render_template, request + +from snekbox.nsjail import NsJail + +nsjail = NsJail() + +# Load app +app = Flask(__name__) +app.use_reloader = False + +# Logging +log = app.logger + + +@app.route('/') +def index(): + """Return a page with a form for inputting code to be executed.""" + + return render_template('index.html') + + +@app.route('/result', methods=["POST", "GET"]) +def result(): + """Execute code and return a page displaying the results.""" + + if request.method == "POST": + code = request.form["Code"] + output = nsjail.python3(code) + return render_template('result.html', code=code, result=output) + + +@app.route('/input', methods=["POST"]) +def code_input(): + """Execute code and return the results.""" + + body = request.get_json() + output = nsjail.python3(body["code"]) + return jsonify(input=body["code"], output=output) diff --git a/snekbox/site/templates/index.html b/snekbox/site/templates/index.html new file mode 100644 index 0000000..41980d1 --- /dev/null +++ b/snekbox/site/templates/index.html @@ -0,0 +1,14 @@ + + + + snekboxweb + +
+

Code:

+

+
+ + diff --git a/snekbox/site/templates/result.html b/snekbox/site/templates/result.html new file mode 100644 index 0000000..e339605 --- /dev/null +++ b/snekbox/site/templates/result.html @@ -0,0 +1,9 @@ + + + + snekboxweb + +

Code Evaluated:

{{ code }}

+

Results:

{{ result }}

+ + diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 41980d1..0000000 --- a/templates/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - snekboxweb - -
-

Code:

-

-
- - diff --git a/templates/result.html b/templates/result.html deleted file mode 100644 index e339605..0000000 --- a/templates/result.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - snekboxweb - -

Code Evaluated:

{{ code }}

-

Results:

{{ result }}

- - diff --git a/tests/test_snekbox.py b/tests/test_snekbox.py index cc79a2a..c08178f 100644 --- a/tests/test_snekbox.py +++ b/tests/test_snekbox.py @@ -1,20 +1,20 @@ import unittest -from snekbox import Snekbox +from snekbox.nsjail import NsJail -snek = Snekbox() +nsjail = NsJail() class SnekTests(unittest.TestCase): def test_nsjail(self): - result = snek.python3('print("test")') + result = nsjail.python3('print("test")') self.assertEquals(result.strip(), 'test') # def test_memory_error(self): # code = ('x = "*"\n' # 'while True:\n' # ' x = x * 99\n') - # result = snek.python3(code) + # result = nsjail.python3(code) # self.assertEquals(result.strip(), 'timed out or memory limit exceeded') def test_timeout(self): @@ -27,13 +27,13 @@ class SnekTests(unittest.TestCase): ' continue\n' ) - result = snek.python3(code) + result = nsjail.python3(code) self.assertEquals(result.strip(), 'timed out or memory limit exceeded') def test_kill(self): code = ('import subprocess\n' 'print(subprocess.check_output("kill -9 6", shell=True).decode())') - result = snek.python3(code) + result = nsjail.python3(code) if 'ModuleNotFoundError' in result.strip(): self.assertIn('ModuleNotFoundError', result.strip()) else: @@ -43,7 +43,7 @@ class SnekTests(unittest.TestCase): code = ('import os\n' 'while 1:\n' ' os.fork()') - result = snek.python3(code) + result = nsjail.python3(code) self.assertIn('Resource temporarily unavailable', result.strip()) def test_juan_golf(self): # in honour of Juan @@ -52,5 +52,5 @@ class SnekTests(unittest.TestCase): "bytecode = CodeType(0,1,0,0,0,b'',(),(),(),'','',1,b'')\n" "exec(bytecode)") - result = snek.python3(code) + result = nsjail.python3(code) self.assertEquals('unknown error, code: 111', result.strip()) -- cgit v1.2.3 From 8d9a0029bd3c8a8629fbf8db3903b412bef538e1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 May 2019 03:12:47 -0700 Subject: Add API tests for eval resource --- tests/__init__.py | 1 - tests/api/__init__.py | 16 ++++++++++++++++ tests/api/test_eval.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_eval.py (limited to 'tests') diff --git a/tests/__init__.py b/tests/__init__.py index 792d600..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -# diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..745519d --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1,16 @@ +from unittest import mock + +from falcon import testing + + +class SnekAPITestCase(testing.TestCase): + def setUp(self): + super().setUp() + + self.patcher = mock.patch("snekbox.api.resources.eval.NsJail", autospec=True) + self.mock_nsjail = self.patcher.start() + self.mock_nsjail.return_value.python3.return_value = "test output" + self.addCleanup(self.patcher.stop) + + from snekbox.api import SnekAPI + self.app = SnekAPI() diff --git a/tests/api/test_eval.py b/tests/api/test_eval.py new file mode 100644 index 0000000..a5b83fd --- /dev/null +++ b/tests/api/test_eval.py @@ -0,0 +1,49 @@ +from tests.api import SnekAPITestCase + + +class TestEvalResource(SnekAPITestCase): + PATH = "/eval" + + def test_post_valid_200(self): + body = {"input": "foo"} + result = self.simulate_post(self.PATH, json=body) + + self.assertEqual(result.status_code, 200) + self.assertEqual(body["input"], result.json["input"]) + self.assertEqual("test output", result.json["output"]) + + def test_post_invalid_schema_400(self): + body = {"stuff": "foo"} + result = self.simulate_post(self.PATH, json=body) + + self.assertEqual(result.status_code, 400) + + expected = { + "title": "Request data failed validation", + "description": "'input' is a required property" + } + + self.assertEqual(expected, result.json) + + def test_post_invalid_content_type_415(self): + body = "{\"input\": \"foo\"}" + headers = {"Content-Type": "application/xml"} + result = self.simulate_post(self.PATH, body=body, headers=headers) + + self.assertEqual(result.status_code, 415) + + expected = { + "title": "Unsupported media type", + "description": "application/xml is an unsupported media type." + } + + self.assertEqual(expected, result.json) + + def test_disallowed_method_405(self): + result = self.simulate_get(self.PATH) + self.assertEqual(result.status_code, 405) + + def test_options_allow_post_only(self): + result = self.simulate_options(self.PATH) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.headers.get("Allow"), "POST") -- cgit v1.2.3 From feaae8cab07b3db7d0baf3de7b7bb5b515e9acf8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 May 2019 16:22:30 -0700 Subject: Move SnekAPI import back to top of module --- tests/api/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/api/__init__.py b/tests/api/__init__.py index 745519d..fd4679a 100644 --- a/tests/api/__init__.py +++ b/tests/api/__init__.py @@ -2,6 +2,8 @@ from unittest import mock from falcon import testing +from snekbox.api import SnekAPI + class SnekAPITestCase(testing.TestCase): def setUp(self): @@ -12,5 +14,4 @@ class SnekAPITestCase(testing.TestCase): self.mock_nsjail.return_value.python3.return_value = "test output" self.addCleanup(self.patcher.stop) - from snekbox.api import SnekAPI self.app = SnekAPI() -- cgit v1.2.3 From 78757589a2cc6a76b83041dbb75b02896308da69 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 May 2019 22:47:31 -0700 Subject: Add flake8 plugin to only allow double quotes --- .flake8 | 1 + Pipfile | 1 + Pipfile.lock | 94 ++++++++++++++++++++++++++++--------------------- snekbox/__init__.py | 4 +-- snekbox/nsjail.py | 66 +++++++++++++++++----------------- snekbox/site/snekapp.py | 10 +++--- tests/test_snekbox.py | 46 ++++++++++++------------ 7 files changed, 119 insertions(+), 103 deletions(-) (limited to 'tests') diff --git a/.flake8 b/.flake8 index f8eec98..347bdb0 100644 --- a/.flake8 +++ b/.flake8 @@ -16,3 +16,4 @@ exclude= venv,.venv, tests import-order-style=pycharm +inline-quotes = " diff --git a/Pipfile b/Pipfile index 788e900..69bb0df 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ flake8-tidy-imports = "*" flake8-todo = "*" flake8-string-format = "*" flake8-formatter-junit-xml = "*" +flake8-quotes = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 466a42b..b73b997 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "814185e2e1b964ab58af9a9df416ace7b5b416475d828ec9b31a9dfecb5693e1" + "sha256": "c1f4c3df791d8c4758f72cb8fb148a34d5c1ca02298b0d660844899f15f6ba85" }, "pipfile-spec": 6, "requires": { @@ -25,11 +25,11 @@ }, "flask": { "hashes": [ - "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", - "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" + "sha256:ad7c6d841e64296b962296c2c2dabc6543752985727af86a975072dea984b6f3", + "sha256:e7d32475d1de5facaa55e3958bc4ec66d3762076b074296aa50ef8fdc5b9df61" ], "index": "pypi", - "version": "==1.0.2" + "version": "==1.0.3" }, "gunicorn": { "hashes": [ @@ -48,10 +48,10 @@ }, "jinja2": { "hashes": [ - "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", - "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" + "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", + "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" ], - "version": "==2.10" + "version": "==2.10.1" }, "markupsafe": { "hashes": [ @@ -88,19 +88,19 @@ }, "werkzeug": { "hashes": [ - "sha256:96da23fa8ccecbc3ae832a83df5c722c11547d021637faacb0bec4dd2f4666c8", - "sha256:ca5c2dcd367d6c0df87185b9082929d255358f5391923269335782b213d52655" + "sha256:865856ebb55c4dcd0630cdd8f3331a1847a819dda7e8c750d3db6f2aa6c0209c", + "sha256:a0b915f0815982fb2a09161cb8f31708052d0951c3ba433ccc5e1aa276507ca6" ], - "version": "==0.15.1" + "version": "==0.15.4" } }, "develop": { "aspy.yaml": { "hashes": [ - "sha256:ae249074803e8b957c83fdd82a99160d0d6d26dff9ba81ba608b42eebd7d8cd3", - "sha256:c7390d79f58eb9157406966201abf26da0d56c07e0ff0deadc39c8f4dbc13482" + "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc", + "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45" ], - "version": "==1.2.0" + "version": "==1.3.0" }, "atomicwrites": { "hashes": [ @@ -118,10 +118,10 @@ }, "cfgv": { "hashes": [ - "sha256:39f8475d8eca48639f900daffa3f8bd2f60a31d989df41a9f81c5ad1779a66eb", - "sha256:a6a4366d32799a6bfb6f577ebe113b27ba8d1bae43cb57133b1472c1c3dae227" + "sha256:32edbe09de6f4521224b87822103a8c16a614d31a894735f7a5b3bcf0eb3c37e", + "sha256:3bd31385cd2bebddbba8012200aaf15aa208539f1b33973759b4d02fc2148da5" ], - "version": "==1.5.0" + "version": "==2.0.0" }, "coverage": { "hashes": [ @@ -213,6 +213,13 @@ ], "version": "==1.0.2" }, + "flake8-quotes": { + "hashes": [ + "sha256:10c9af6b472d4302a8e721c5260856c3f985c5c082b04841aefd2f808ac02038" + ], + "index": "pypi", + "version": "==2.0.1" + }, "flake8-string-format": { "hashes": [ "sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2", @@ -238,17 +245,17 @@ }, "identify": { "hashes": [ - "sha256:244e7864ef59f0c7c50c6db73f58564151d91345cd9b76ed793458953578cadd", - "sha256:8ff062f90ad4b09cfe79b5dfb7a12e40f19d2e68a5c9598a49be45f16aba7171" + "sha256:432c548d6138cb57a3d8f62f079a025a29b8ae34a50dd3b496bbf661818f2bc0", + "sha256:d4401d60bf1938aa3074a352a5cc9044107edf11a6fedd3a1db172c141619b81" ], - "version": "==1.4.1" + "version": "==1.4.3" }, "importlib-metadata": { "hashes": [ - "sha256:a17ce1a8c7bff1e8674cb12c992375d8d0800c9190177ecf0ad93e0097224095", - "sha256:b50191ead8c70adfa12495fba19ce6d75f2e0275c14c5a7beb653d6799b512bd" + "sha256:a9f185022cfa69e9ca5f7eabfd5a58b689894cb78a11e3c8c89398a8ccbb8e7f", + "sha256:df1403cd3aebeb2b1dcd3515ca062eecb5bd3ea7611f18cba81130c68707e879" ], - "version": "==0.8" + "version": "==0.17" }, "junit-xml": { "hashes": [ @@ -279,18 +286,18 @@ }, "pluggy": { "hashes": [ - "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", - "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" + "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", + "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c" ], - "version": "==0.9.0" + "version": "==0.12.0" }, "pre-commit": { "hashes": [ - "sha256:d3d69c63ae7b7584c4b51446b0b583d454548f9df92575b2fe93a68ec800c4d3", - "sha256:fc512f129b9526e35e80d656a16a31c198f584c4fce3a5c739045b5140584917" + "sha256:6ca409d1f22d444af427fb023a33ca8b69625d508a50e1b7eaabd59247c93043", + "sha256:94dd519597f5bff06a4b0df194a79c524b78f4b1534c1ce63241a9d4fb23b926" ], "index": "pypi", - "version": "==1.14.4" + "version": "==1.16.1" }, "py": { "hashes": [ @@ -323,19 +330,19 @@ }, "pytest": { "hashes": [ - "sha256:592eaa2c33fae68c7d75aacf042efc9f77b27c08a6224a4f59beab8d9a420523", - "sha256:ad3ad5c450284819ecde191a654c09b0ec72257a2c711b9633d677c71c9850c4" + "sha256:1a8aa4fa958f8f451ac5441f3ac130d9fc86ea38780dd2715e6d5c5882700b24", + "sha256:b8bf138592384bd4e87338cb0f256bf5f615398a649d4bd83915f0e4047a5ca6" ], "index": "pypi", - "version": "==4.3.1" + "version": "==4.5.0" }, "pytest-cov": { "hashes": [ - "sha256:0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33", - "sha256:230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f" + "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", + "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" ], "index": "pypi", - "version": "==2.6.1" + "version": "==2.7.1" }, "pytest-dependency": { "hashes": [ @@ -383,17 +390,24 @@ }, "virtualenv": { "hashes": [ - "sha256:6aebaf4dd2568a0094225ebbca987859e369e3e5c22dc7d52e5406d504890417", - "sha256:984d7e607b0a5d1329425dd8845bd971b957424b5ba664729fab51ab8c11bc39" + "sha256:99acaf1e35c7ccf9763db9ba2accbca2f4254d61d1912c5ee364f9cc4a8942a0", + "sha256:fe51cdbf04e5d8152af06c075404745a7419de27495a83f0d72518ad50be3ce8" + ], + "version": "==16.6.0" + }, + "wcwidth": { + "hashes": [ + "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", + "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" ], - "version": "==16.4.3" + "version": "==0.1.7" }, "zipp": { "hashes": [ - "sha256:55ca87266c38af6658b84db8cfb7343cdb0bf275f93c7afaea0d8e7a209c7478", - "sha256:682b3e1c62b7026afe24eadf6be579fb45fec54c07ea218bded8092af07a68c4" + "sha256:8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d", + "sha256:ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3" ], - "version": "==0.3.3" + "version": "==0.5.1" } } } diff --git a/snekbox/__init__.py b/snekbox/__init__.py index fc6070e..f14fc89 100644 --- a/snekbox/__init__.py +++ b/snekbox/__init__.py @@ -1,8 +1,8 @@ import logging import sys -logformat = logging.Formatter(fmt='[%(asctime)s] [%(process)s] [%(levelname)s] %(message)s', - datefmt='%Y-%m-%d %H:%M:%S %z') +logformat = logging.Formatter(fmt="[%(asctime)s] [%(process)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S %z") log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) console = logging.StreamHandler(sys.stdout) diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index 5c7d0f0..c807b9e 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -7,25 +7,25 @@ class NsJail: """Core Snekbox functionality, providing safe execution of Python code.""" def __init__(self, - nsjail_binary='nsjail', - python_binary=os.path.dirname(sys.executable) + os.sep + 'python3.7'): + nsjail_binary="nsjail", + python_binary=os.path.dirname(sys.executable) + os.sep + "python3.7"): self.nsjail_binary = nsjail_binary self.python_binary = python_binary self._nsjail_workaround() env = { - 'PATH': ( - '/snekbox/.venv/bin:/usr/local/bin:/usr/local/' - 'sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + "PATH": ( + "/snekbox/.venv/bin:/usr/local/bin:/usr/local/" + "sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ), - 'LANG': 'en_US.UTF-8', - 'PYTHON_VERSION': '3.7.3', - 'PYTHON_PIP_VERSION': '19.0.3', - 'PYTHONDONTWRITEBYTECODE': '1', + "LANG": "en_US.UTF-8", + "PYTHON_VERSION": "3.7.3", + "PYTHON_PIP_VERSION": "19.0.3", + "PYTHONDONTWRITEBYTECODE": "1", } def _nsjail_workaround(self): - dirs = ['/sys/fs/cgroup/pids/NSJAIL', '/sys/fs/cgroup/memory/NSJAIL'] + dirs = ["/sys/fs/cgroup/pids/NSJAIL", "/sys/fs/cgroup/memory/NSJAIL"] for d in dirs: if not os.path.exists(d): os.makedirs(d) @@ -41,20 +41,20 @@ class NsJail: Returns the output of executing the command (stdout) if successful, or a error message if the execution failed. """ - args = [self.nsjail_binary, '-Mo', - '--rlimit_as', '700', - '--chroot', '/', - '-E', 'LANG=en_US.UTF-8', - '-R/usr', '-R/lib', '-R/lib64', - '--user', 'nobody', - '--group', 'nogroup', - '--time_limit', '2', - '--disable_proc', - '--iface_no_lo', - '--cgroup_pids_max=1', - '--cgroup_mem_max=52428800', - '--quiet', '--', - self.python_binary, '-ISq', '-c', cmd] + args = [self.nsjail_binary, "-Mo", + "--rlimit_as", "700", + "--chroot", "/", + "-E", "LANG=en_US.UTF-8", + "-R/usr", "-R/lib", "-R/lib64", + "--user", "nobody", + "--group", "nogroup", + "--time_limit", "2", + "--disable_proc", + "--iface_no_lo", + "--cgroup_pids_max=1", + "--cgroup_mem_max=52428800", + "--quiet", "--", + self.python_binary, "-ISq", "-c", cmd] try: proc = subprocess.Popen(args, stdin=subprocess.PIPE, @@ -63,7 +63,7 @@ class NsJail: env=self.env, universal_newlines=True) except ValueError: - return 'ValueError: embedded null byte' + return "ValueError: embedded null byte" stdout, stderr = proc.communicate() if proc.returncode == 0: @@ -72,23 +72,23 @@ class NsJail: elif proc.returncode == 1: try: filtered = [] - for line in stderr.split('\n'): - if not line.startswith('['): + for line in stderr.split("\n"): + if not line.startswith("["): filtered.append(line) - output = '\n'.join(filtered) + output = "\n".join(filtered) except IndexError: - output = '' + output = "" elif proc.returncode == 109: - return 'timed out or memory limit exceeded' + return "timed out or memory limit exceeded" elif proc.returncode == 255: - return 'permission denied (root required)' + return "permission denied (root required)" elif proc.returncode: - return f'unknown error, code: {proc.returncode}' + return f"unknown error, code: {proc.returncode}" else: - return 'unknown error, no error code' + return "unknown error, no error code" return output diff --git a/snekbox/site/snekapp.py b/snekbox/site/snekapp.py index ef96148..3954238 100644 --- a/snekbox/site/snekapp.py +++ b/snekbox/site/snekapp.py @@ -12,22 +12,22 @@ app.use_reloader = False log = app.logger -@app.route('/') +@app.route("/") def index(): """Return a page with a form for inputting code to be executed.""" - return render_template('index.html') + return render_template("index.html") -@app.route('/result', methods=["POST", "GET"]) +@app.route("/result", methods=["POST", "GET"]) def result(): """Execute code and return a page displaying the results.""" if request.method == "POST": code = request.form["Code"] output = nsjail.python3(code) - return render_template('result.html', code=code, result=output) + return render_template("result.html", code=code, result=output) -@app.route('/input', methods=["POST"]) +@app.route("/input", methods=["POST"]) def code_input(): """Execute code and return the results.""" body = request.get_json() diff --git a/tests/test_snekbox.py b/tests/test_snekbox.py index c08178f..46319d6 100644 --- a/tests/test_snekbox.py +++ b/tests/test_snekbox.py @@ -7,44 +7,44 @@ nsjail = NsJail() class SnekTests(unittest.TestCase): def test_nsjail(self): - result = nsjail.python3('print("test")') - self.assertEquals(result.strip(), 'test') + result = nsjail.python3("print('test')") + self.assertEquals(result.strip(), "test") # def test_memory_error(self): - # code = ('x = "*"\n' - # 'while True:\n' - # ' x = x * 99\n') + # code = ("x = "*"\n" + # "while True:\n" + # " x = x * 99\n") # result = nsjail.python3(code) - # self.assertEquals(result.strip(), 'timed out or memory limit exceeded') + # self.assertEquals(result.strip(), "timed out or memory limit exceeded") def test_timeout(self): code = ( - 'x = "*"\n' - 'while True:\n' - ' try:\n' - ' x = x * 99\n' - ' except:\n' - ' continue\n' + "x = '*'\n" + "while True:\n" + " try:\n" + " x = x * 99\n" + " except:\n" + " continue\n" ) result = nsjail.python3(code) - self.assertEquals(result.strip(), 'timed out or memory limit exceeded') + self.assertEquals(result.strip(), "timed out or memory limit exceeded") def test_kill(self): - code = ('import subprocess\n' - 'print(subprocess.check_output("kill -9 6", shell=True).decode())') + code = ("import subprocess\n" + "print(subprocess.check_output('kill -9 6', shell=True).decode())") result = nsjail.python3(code) - if 'ModuleNotFoundError' in result.strip(): - self.assertIn('ModuleNotFoundError', result.strip()) + if "ModuleNotFoundError" in result.strip(): + self.assertIn("ModuleNotFoundError", result.strip()) else: - self.assertIn('(PIDs left: 0)', result.strip()) + self.assertIn("(PIDs left: 0)", result.strip()) def test_forkbomb(self): - code = ('import os\n' - 'while 1:\n' - ' os.fork()') + code = ("import os\n" + "while 1:\n" + " os.fork()") result = nsjail.python3(code) - self.assertIn('Resource temporarily unavailable', result.strip()) + self.assertIn("Resource temporarily unavailable", result.strip()) def test_juan_golf(self): # in honour of Juan code = ("func = lambda: None\n" @@ -53,4 +53,4 @@ class SnekTests(unittest.TestCase): "exec(bytecode)") result = nsjail.python3(code) - self.assertEquals('unknown error, code: 111', result.strip()) + self.assertEquals("unknown error, code: 111", result.strip()) -- cgit v1.2.3 From e75c764f693c3688a59af0d679e0d3e94f003503 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 30 May 2019 03:19:34 -0700 Subject: Lint tests Tests ignore all D1xx warnings because tests shouldn't require docstrings. --- .pre-commit-config.yaml | 16 +++++++++++++++- tests/.flake8 | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 tests/.flake8 (limited to 'tests') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d2d40a..4f97db9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: v2.0.0 hooks: - id: flake8 + name: Flake8 (snekbox) args: [--config=.flake8] exclude: ^tests/ additional_dependencies: [ @@ -15,4 +16,17 @@ repos: flake8-formatter-junit-xml, flake8-quotes ] - + - id: flake8 + name: Flake8 (tests) + args: [--config=tests/.flake8] + exclude: ^(?!tests/) + additional_dependencies: [ + flake8-docstrings, + flake8-bugbear, + flake8-import-order, + flake8-tidy-imports, + flake8-todo, + flake8-string-format, + flake8-formatter-junit-xml, + flake8-quotes + ] diff --git a/tests/.flake8 b/tests/.flake8 new file mode 100644 index 0000000..c1c5031 --- /dev/null +++ b/tests/.flake8 @@ -0,0 +1,15 @@ +[flake8] +max-line-length=100 +application_import_names=snekbox,tests +ignore= + P102,B311,W503,E226,S311, + # Missing Docstrings + D1, + # Docstring Whitespace + D203,D212,D214,D215, + # Docstring Quotes + D301,D302, + # Docstring Content + D400,D401,D402,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414 +import-order-style=pycharm +inline-quotes = " -- cgit v1.2.3 From 6eb7afc321d7e6d2aeebcb90e4bb18cd5e43a6e2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 30 May 2019 18:26:38 -0700 Subject: Revert "Lint tests" This reverts commit e75c764f693c3688a59af0d679e0d3e94f003503. --- .pre-commit-config.yaml | 16 +--------------- tests/.flake8 | 15 --------------- 2 files changed, 1 insertion(+), 30 deletions(-) delete mode 100644 tests/.flake8 (limited to 'tests') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f97db9..5d2d40a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,6 @@ repos: rev: v2.0.0 hooks: - id: flake8 - name: Flake8 (snekbox) args: [--config=.flake8] exclude: ^tests/ additional_dependencies: [ @@ -16,17 +15,4 @@ repos: flake8-formatter-junit-xml, flake8-quotes ] - - id: flake8 - name: Flake8 (tests) - args: [--config=tests/.flake8] - exclude: ^(?!tests/) - additional_dependencies: [ - flake8-docstrings, - flake8-bugbear, - flake8-import-order, - flake8-tidy-imports, - flake8-todo, - flake8-string-format, - flake8-formatter-junit-xml, - flake8-quotes - ] + diff --git a/tests/.flake8 b/tests/.flake8 deleted file mode 100644 index c1c5031..0000000 --- a/tests/.flake8 +++ /dev/null @@ -1,15 +0,0 @@ -[flake8] -max-line-length=100 -application_import_names=snekbox,tests -ignore= - P102,B311,W503,E226,S311, - # Missing Docstrings - D1, - # Docstring Whitespace - D203,D212,D214,D215, - # Docstring Quotes - D301,D302, - # Docstring Content - D400,D401,D402,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414 -import-order-style=pycharm -inline-quotes = " -- cgit v1.2.3 From 7916804de8176fa34e4dfc56c0543157e72985f1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Jun 2019 22:29:08 -0700 Subject: Add logging for NsJail NsJail's is configured to log to a temporary file rather than stderr. The contents of the file are parsed using regex after the process exits. When not debugging, some blacklisted messages and most info-level messages are skipped. * Add a snekbox logger * Log the Python code being executed if debugging * Use nested single quotes in a test to fix a linter error --- snekbox/__init__.py | 10 +++++ snekbox/nsjail.py | 106 ++++++++++++++++++++++++++++++++++++------------- tests/api/test_eval.py | 2 +- 3 files changed, 90 insertions(+), 28 deletions(-) (limited to 'tests') diff --git a/snekbox/__init__.py b/snekbox/__init__.py index af8429b..a48abd5 100644 --- a/snekbox/__init__.py +++ b/snekbox/__init__.py @@ -1,5 +1,6 @@ import logging import os +import sys from gunicorn import glogging @@ -22,3 +23,12 @@ class GunicornLogger(glogging.Logger): self.loglevel = self.LOG_LEVELS.get(cfg.loglevel.lower(), logging.INFO) self.error_log.setLevel(self.loglevel) + + +log = logging.getLogger("snekbox") +log.setLevel(logging.DEBUG if DEBUG else logging.INFO) +log.propagate = True +formatter = logging.Formatter(GunicornLogger.error_fmt, GunicornLogger.datefmt) +handler = logging.StreamHandler(sys.stdout) +handler.setFormatter(formatter) +log.addHandler(handler) diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index 2484ba2..0ebfe0c 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -1,6 +1,20 @@ +import logging +import re import subprocess import sys +import textwrap from pathlib import Path +from tempfile import NamedTemporaryFile + +from snekbox import DEBUG + +log = logging.getLogger(__name__) + +# [level][timestamp][PID]? function_signature:line_no? message +LOG_PATTERN = re.compile( + r"\[(?P(I)|[WEF])\]\[.+?\](?(2)|(?P\[\d+\] .+?:\d+ )) ?(?P.+)" +) +LOG_BLACKLIST = ("Process will be ",) # Explicitly define constants for NsJail's default values. CGROUP_PIDS_PARENT = Path("/sys/fs/cgroup/pids/NSJAIL") @@ -56,39 +70,77 @@ class NsJail: pids.mkdir(parents=True, exist_ok=True) mem.mkdir(parents=True, exist_ok=True) + @staticmethod + def _parse_log(log_file): + """Parse and log NsJail's log messages.""" + for line in log_file.read().decode("UTF-8").splitlines(): + match = LOG_PATTERN.fullmatch(line) + if match is None: + log.warning(f"Failed to parse log line '{line}'") + continue + + msg = match["msg"] + if not DEBUG and any(msg.startswith(s) for s in LOG_BLACKLIST): + # Skip blacklisted messages if not debugging. + continue + + if DEBUG and match["func"]: + # Prepend PID, function signature, and line number if debugging. + msg = f"{match['func']}{msg}" + + if match["level"] == "D": + log.debug(msg) + elif match["level"] == "I": + if DEBUG or msg.startswith("pid="): + # Skip messages unrelated to process exit if not debugging. + log.info(msg) + elif match["level"] == "W": + log.warning(msg) + else: + # Treat fatal as error. + log.error(msg) + def python3(self, code: str) -> str: """Execute Python 3 code in an isolated environment and return stdout or an error.""" - args = ( - self.nsjail_binary, "-Mo", - "--rlimit_as", "700", - "--chroot", "/", - "-E", "LANG=en_US.UTF-8", - "-R/usr", "-R/lib", "-R/lib64", - "--user", "nobody", - "--group", "nogroup", - "--time_limit", "2", - "--disable_proc", - "--iface_no_lo", - "--cgroup_mem_max=52428800", - "--cgroup_mem_mount", str(CGROUP_MEMORY_PARENT.parent), - "--cgroup_mem_parent", CGROUP_MEMORY_PARENT.name, - "--cgroup_pids_max=1", - "--cgroup_pids_mount", str(CGROUP_PIDS_PARENT.parent), - "--cgroup_pids_parent", CGROUP_PIDS_PARENT.name, - "--quiet", "--", - self.python_binary, "-ISq", "-c", code - ) - - try: - proc = subprocess.run(args, capture_output=True, env=ENV, text=True) - except ValueError: - return "ValueError: embedded null byte" + with NamedTemporaryFile() as nsj_log: + args = ( + self.nsjail_binary, "-Mo", + "--rlimit_as", "700", + "--chroot", "/", + "-E", "LANG=en_US.UTF-8", + "-R/usr", "-R/lib", "-R/lib64", + "--user", "nobody", + "--group", "nogroup", + "--time_limit", "2", + "--disable_proc", + "--iface_no_lo", + "--log", nsj_log.name, + "--cgroup_mem_max=52428800", + "--cgroup_mem_mount", str(CGROUP_MEMORY_PARENT.parent), + "--cgroup_mem_parent", CGROUP_MEMORY_PARENT.name, + "--cgroup_pids_max=1", + "--cgroup_pids_mount", str(CGROUP_PIDS_PARENT.parent), + "--cgroup_pids_parent", CGROUP_PIDS_PARENT.name, + "--", + self.python_binary, "-ISq", "-c", code + ) + + try: + msg = "Executing code..." + if DEBUG: + msg = f"{msg[:-3]}:\n{textwrap.indent(code, ' ')}" + log.info(msg) + + proc = subprocess.run(args, capture_output=True, env=ENV, text=True) + except ValueError: + return "ValueError: embedded null byte" + + self._parse_log(nsj_log) if proc.returncode == 0: output = proc.stdout elif proc.returncode == 1: - filtered = (line for line in proc.stderr.split("\n") if not line.startswith("[")) - output = "\n".join(filtered) + output = proc.stderr elif proc.returncode == 109: return "timed out or memory limit exceeded" elif proc.returncode == 255: diff --git a/tests/api/test_eval.py b/tests/api/test_eval.py index a5b83fd..bcd0ec4 100644 --- a/tests/api/test_eval.py +++ b/tests/api/test_eval.py @@ -26,7 +26,7 @@ class TestEvalResource(SnekAPITestCase): self.assertEqual(expected, result.json) def test_post_invalid_content_type_415(self): - body = "{\"input\": \"foo\"}" + body = "{'input': 'foo'}" headers = {"Content-Type": "application/xml"} result = self.simulate_post(self.PATH, body=body, headers=headers) -- cgit v1.2.3 From 66d3836dd27f3b0e9f1cb780c7c37c8c0f081c70 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Jun 2019 00:46:04 -0700 Subject: Respond to eval with stdout, stderr, and the return code The previous implementation limited the client's flexibility in presenting the results of the process. A process can write to both stdout and stderr and do so even when the return code is not 0 or 1. * Return a CompletedProcess from NsJail * Don't check the return code; this should be done client-side now --- snekbox/api/resources/eval.py | 23 +++++++++++++++++------ snekbox/nsjail.py | 33 ++++++++++----------------------- tests/api/__init__.py | 8 +++++++- tests/api/test_eval.py | 5 +++-- 4 files changed, 37 insertions(+), 32 deletions(-) (limited to 'tests') diff --git a/snekbox/api/resources/eval.py b/snekbox/api/resources/eval.py index b2f4260..4779557 100644 --- a/snekbox/api/resources/eval.py +++ b/snekbox/api/resources/eval.py @@ -36,7 +36,16 @@ class EvalResource: @validate(REQ_SCHEMA) def on_post(self, req, resp): """ - Evaluate Python code and return the result. + Evaluate Python code and return stdout, stderr, and the return code. + + The return codes mostly resemble those of a Unix shell. Some noteworthy cases: + + - None + The NsJail process failed to launch + - 137 (SIGKILL) + Typically because NsJail killed the Python process due to time or memory constraints + - 255 + NsJail encountered a fatal error Request body: @@ -47,8 +56,9 @@ class EvalResource: Response format: >>> { - ... "input": "print(1 + 1)", - ... "output": "2\\n" + ... "stdout": "2\\n", + ... "stderr": "", + ... "returncode": 0 ... } Status codes: @@ -63,12 +73,13 @@ class EvalResource: code = req.media["input"] try: - output = self.nsjail.python3(code) + result = self.nsjail.python3(code) except Exception: log.exception("An exception occurred while trying to process the request") raise falcon.HTTPInternalServerError resp.media = { - "input": code, - "output": output + "stdout": result.stdout, + "stderr": result.stderr, + "returncode": result.returncode } diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index 0ebfe0c..ff12ec4 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -100,8 +100,8 @@ class NsJail: # Treat fatal as error. log.error(msg) - def python3(self, code: str) -> str: - """Execute Python 3 code in an isolated environment and return stdout or an error.""" + def python3(self, code: str) -> subprocess.CompletedProcess: + """Execute Python 3 code in an isolated environment and return the completed process.""" with NamedTemporaryFile() as nsj_log: args = ( self.nsjail_binary, "-Mo", @@ -125,29 +125,16 @@ class NsJail: self.python_binary, "-ISq", "-c", code ) - try: - msg = "Executing code..." - if DEBUG: - msg = f"{msg[:-3]}:\n{textwrap.indent(code, ' ')}" - log.info(msg) + msg = "Executing code..." + if DEBUG: + msg = f"{msg[:-3]}:\n{textwrap.indent(code, ' ')}" + log.info(msg) - proc = subprocess.run(args, capture_output=True, env=ENV, text=True) + try: + result = subprocess.run(args, capture_output=True, env=ENV, text=True) except ValueError: - return "ValueError: embedded null byte" + return subprocess.CompletedProcess(args, None, "", "ValueError: embedded null byte") self._parse_log(nsj_log) - if proc.returncode == 0: - output = proc.stdout - elif proc.returncode == 1: - output = proc.stderr - elif proc.returncode == 109: - return "timed out or memory limit exceeded" - elif proc.returncode == 255: - return "permission denied (root required)" - elif proc.returncode: - return f"unknown error, code: {proc.returncode}" - else: - return "unknown error, no error code" - - return output + return result diff --git a/tests/api/__init__.py b/tests/api/__init__.py index fd4679a..dcee5b5 100644 --- a/tests/api/__init__.py +++ b/tests/api/__init__.py @@ -1,3 +1,4 @@ +from subprocess import CompletedProcess from unittest import mock from falcon import testing @@ -11,7 +12,12 @@ class SnekAPITestCase(testing.TestCase): self.patcher = mock.patch("snekbox.api.resources.eval.NsJail", autospec=True) self.mock_nsjail = self.patcher.start() - self.mock_nsjail.return_value.python3.return_value = "test output" + self.mock_nsjail.return_value.python3.return_value = CompletedProcess( + args=[], + returncode=0, + stdout="output", + stderr="error" + ) self.addCleanup(self.patcher.stop) self.app = SnekAPI() diff --git a/tests/api/test_eval.py b/tests/api/test_eval.py index bcd0ec4..03f0e39 100644 --- a/tests/api/test_eval.py +++ b/tests/api/test_eval.py @@ -9,8 +9,9 @@ class TestEvalResource(SnekAPITestCase): result = self.simulate_post(self.PATH, json=body) self.assertEqual(result.status_code, 200) - self.assertEqual(body["input"], result.json["input"]) - self.assertEqual("test output", result.json["output"]) + self.assertEqual("output", result.json["stdout"]) + self.assertEqual("error", result.json["stderr"]) + self.assertEqual(0, result.json["returncode"]) def test_post_invalid_schema_400(self): body = {"stuff": "foo"} -- cgit v1.2.3 From bc130a4d44f38824b6173c0babff4eefe18ac1db Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Jun 2019 18:32:32 -0700 Subject: Merge stdout and stderr Removes the need for redirecting stderr using contextlib in the input. Furthermore, it captures errors which don't directly come from the input, such as SyntaxErrors. --- snekbox/api/resources/eval.py | 2 -- snekbox/nsjail.py | 10 ++++++++-- tests/api/test_eval.py | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/snekbox/api/resources/eval.py b/snekbox/api/resources/eval.py index 4779557..c4bd666 100644 --- a/snekbox/api/resources/eval.py +++ b/snekbox/api/resources/eval.py @@ -57,7 +57,6 @@ class EvalResource: >>> { ... "stdout": "2\\n", - ... "stderr": "", ... "returncode": 0 ... } @@ -80,6 +79,5 @@ class EvalResource: resp.media = { "stdout": result.stdout, - "stderr": result.stderr, "returncode": result.returncode } diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index ff12ec4..1675b3e 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -131,9 +131,15 @@ class NsJail: log.info(msg) try: - result = subprocess.run(args, capture_output=True, env=ENV, text=True) + result = subprocess.run( + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=ENV, + text=True + ) except ValueError: - return subprocess.CompletedProcess(args, None, "", "ValueError: embedded null byte") + return subprocess.CompletedProcess(args, None, "ValueError: embedded null byte", "") self._parse_log(nsj_log) diff --git a/tests/api/test_eval.py b/tests/api/test_eval.py index 03f0e39..3350763 100644 --- a/tests/api/test_eval.py +++ b/tests/api/test_eval.py @@ -10,7 +10,6 @@ class TestEvalResource(SnekAPITestCase): self.assertEqual(result.status_code, 200) self.assertEqual("output", result.json["stdout"]) - self.assertEqual("error", result.json["stderr"]) self.assertEqual(0, result.json["returncode"]) def test_post_invalid_schema_400(self): -- cgit v1.2.3 From 281df48622199c7a38a74da49782699184876492 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Jun 2019 12:59:32 -0700 Subject: Rewrite NsJail tests * Fix SIGSEGV test * Add embedded null byte test * Return None for stderr when there's a ValueError --- snekbox/nsjail.py | 5 ++-- tests/test_nsjail.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_snekbox.py | 56 ------------------------------------- 3 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 tests/test_nsjail.py delete mode 100644 tests/test_snekbox.py (limited to 'tests') diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index 3bcc0a1..f82dcf0 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -5,6 +5,7 @@ import subprocess import sys import textwrap from pathlib import Path +from subprocess import CompletedProcess from tempfile import NamedTemporaryFile from typing import List @@ -92,7 +93,7 @@ class NsJail: # Treat fatal as error. log.error(msg) - def python3(self, code: str) -> subprocess.CompletedProcess: + def python3(self, code: str) -> CompletedProcess: """Execute Python 3 code in an isolated environment and return the completed process.""" with NamedTemporaryFile() as nsj_log: args = ( @@ -130,7 +131,7 @@ class NsJail: text=True ) except ValueError: - return subprocess.CompletedProcess(args, None, "ValueError: embedded null byte", "") + return CompletedProcess(args, None, "ValueError: embedded null byte", None) log_lines = nsj_log.read().decode("UTF-8").splitlines() if not log_lines and result.returncode == 255: diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py new file mode 100644 index 0000000..1184b87 --- /dev/null +++ b/tests/test_nsjail.py @@ -0,0 +1,77 @@ +import logging +import unittest +from textwrap import dedent + +from snekbox.nsjail import NsJail + + +class NsJailTests(unittest.TestCase): + def setUp(self): + super().setUp() + + self.nsjail = NsJail() + self.logger = logging.getLogger("snekbox.nsjail") + + def test_print_returns_0(self): + result = self.nsjail.python3("print('test')") + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, "test\n") + self.assertEqual(result.stderr, None) + + def test_timeout_returns_137(self): + code = dedent(""" + x = '*' + while True: + try: + x = x * 99 + except: + continue + """).strip() + + with self.assertLogs(self.logger) as log: + result = self.nsjail.python3(code) + + self.assertEqual(result.returncode, 137) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, None) + self.assertIn("run time >= time limit", "\n".join(log.output)) + + def test_subprocess_resource_unavailable(self): + code = dedent(""" + import subprocess + print(subprocess.check_output('kill -9 6', shell=True).decode()) + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 1) + self.assertIn("Resource temporarily unavailable", result.stdout) + self.assertEqual(result.stderr, None) + + def test_forkbomb_resource_unavailable(self): + code = dedent(""" + import os + while 1: + os.fork() + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 1) + self.assertIn("Resource temporarily unavailable", result.stdout) + self.assertEqual(result.stderr, None) + + def test_sigsegv_returns_139(self): # In honour of Juan. + code = dedent(""" + import ctypes + ctypes.string_at(0) + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 139) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, None) + + def test_null_byte_value_error(self): + result = self.nsjail.python3("\0") + self.assertEqual(result.returncode, None) + self.assertEqual(result.stdout, "ValueError: embedded null byte") + self.assertEqual(result.stderr, None) diff --git a/tests/test_snekbox.py b/tests/test_snekbox.py deleted file mode 100644 index 46319d6..0000000 --- a/tests/test_snekbox.py +++ /dev/null @@ -1,56 +0,0 @@ -import unittest - -from snekbox.nsjail import NsJail - -nsjail = NsJail() - - -class SnekTests(unittest.TestCase): - def test_nsjail(self): - result = nsjail.python3("print('test')") - self.assertEquals(result.strip(), "test") - - # def test_memory_error(self): - # code = ("x = "*"\n" - # "while True:\n" - # " x = x * 99\n") - # result = nsjail.python3(code) - # self.assertEquals(result.strip(), "timed out or memory limit exceeded") - - def test_timeout(self): - code = ( - "x = '*'\n" - "while True:\n" - " try:\n" - " x = x * 99\n" - " except:\n" - " continue\n" - ) - - result = nsjail.python3(code) - self.assertEquals(result.strip(), "timed out or memory limit exceeded") - - def test_kill(self): - code = ("import subprocess\n" - "print(subprocess.check_output('kill -9 6', shell=True).decode())") - result = nsjail.python3(code) - if "ModuleNotFoundError" in result.strip(): - self.assertIn("ModuleNotFoundError", result.strip()) - else: - self.assertIn("(PIDs left: 0)", result.strip()) - - def test_forkbomb(self): - code = ("import os\n" - "while 1:\n" - " os.fork()") - result = nsjail.python3(code) - self.assertIn("Resource temporarily unavailable", result.strip()) - - def test_juan_golf(self): # in honour of Juan - code = ("func = lambda: None\n" - "CodeType = type(func.__code__)\n" - "bytecode = CodeType(0,1,0,0,0,b'',(),(),(),'','',1,b'')\n" - "exec(bytecode)") - - result = nsjail.python3(code) - self.assertEquals("unknown error, code: 111", result.strip()) -- cgit v1.2.3 From 81e2a92019a184b2a558658777ceded99b360731 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Jun 2019 13:27:47 -0700 Subject: Add a NsJail log parser test * Add support for debug level to log regex * Change type annotation of log_parse to Iterable --- snekbox/nsjail.py | 6 +++--- tests/test_nsjail.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index f82dcf0..b68b0b9 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -7,7 +7,7 @@ import textwrap from pathlib import Path from subprocess import CompletedProcess from tempfile import NamedTemporaryFile -from typing import List +from typing import Iterable from snekbox import DEBUG @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) # [level][timestamp][PID]? function_signature:line_no? message LOG_PATTERN = re.compile( - r"\[(?P(I)|[WEF])\]\[.+?\](?(2)|(?P\[\d+\] .+?:\d+ )) ?(?P.+)" + r"\[(?P(I)|[DWEF])\]\[.+?\](?(2)|(?P\[\d+\] .+?:\d+ )) ?(?P.+)" ) LOG_BLACKLIST = ("Process will be ",) @@ -64,7 +64,7 @@ class NsJail: mem.mkdir(parents=True, exist_ok=True) @staticmethod - def _parse_log(log_lines: List[str]): + def _parse_log(log_lines: Iterable[str]): """Parse and log NsJail's log messages.""" for line in log_lines: match = LOG_PATTERN.fullmatch(line) diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py index 1184b87..e3b8eb3 100644 --- a/tests/test_nsjail.py +++ b/tests/test_nsjail.py @@ -10,6 +10,7 @@ class NsJailTests(unittest.TestCase): super().setUp() self.nsjail = NsJail() + self.nsjail.DEBUG = False self.logger = logging.getLogger("snekbox.nsjail") def test_print_returns_0(self): @@ -75,3 +76,32 @@ class NsJailTests(unittest.TestCase): self.assertEqual(result.returncode, None) self.assertEqual(result.stdout, "ValueError: embedded null byte") self.assertEqual(result.stderr, None) + + def test_log_parser(self): + log_lines = ( + "[D][2019-06-22T20:07:00+0000][16] void foo::bar()():100 This is a debug message.", + "[I][2019-06-22T20:07:48+0000] pid=20 ([STANDALONE MODE]) " + "exited with status: 2, (PIDs left: 0)", + "[W][2019-06-22T20:06:04+0000][14] void cmdline::logParams(nsjconf_t*)():250 " + "Process will be UID/EUID=0 in the global user namespace, and will have user " + "root-level access to files", + "[W][2019-06-22T20:07:00+0000][16] void foo::bar()():100 This is a warning!", + "[E][2019-06-22T20:07:00+0000][16] bool " + "cmdline::setupArgv(nsjconf_t*, int, char**, int)():316 No command-line provided", + "[F][2019-06-22T20:07:00+0000][16] int main(int, char**)():204 " + "Couldn't parse cmdline options", + "Invalid Line" + ) + + with self.assertLogs(self.logger, logging.DEBUG) as log: + self.nsjail._parse_log(log_lines) + + self.assertIn("DEBUG:snekbox.nsjail:This is a debug message.", log.output) + self.assertIn("ERROR:snekbox.nsjail:Couldn't parse cmdline options", log.output) + self.assertIn("ERROR:snekbox.nsjail:No command-line provided", log.output) + self.assertIn("WARNING:snekbox.nsjail:Failed to parse log line 'Invalid Line'", log.output) + self.assertIn("WARNING:snekbox.nsjail:This is a warning!", log.output) + self.assertIn( + "INFO:snekbox.nsjail:pid=20 ([STANDALONE MODE]) exited with status: 2, (PIDs left: 0)", + log.output + ) -- cgit v1.2.3 From 158915a953879639722ab3bc1074fec7276117ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 26 Jun 2019 23:00:39 -0700 Subject: Disable memory swapping and add a memory limit test If memory swapping was enabled locally, the memory test would fail. Explicitly disabling swapping also removes reliance on the assumption that it'll be disabled in production. * Add a constant for the maximum memory * Simplify the timeout test; it'd otherwise first run out of memory now --- scripts/.profile | 9 ++++++++- snekbox/nsjail.py | 14 +++++++++++++- tests/test_nsjail.py | 19 +++++++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/scripts/.profile b/scripts/.profile index 415e4f6..bff260d 100644 --- a/scripts/.profile +++ b/scripts/.profile @@ -1,12 +1,19 @@ nsjpy() { + local MEM_MAX=52428800 + + # All arguments except the last are considered to be for NsJail, not Python. local nsj_args="" while [ "$#" -gt 1 ]; do nsj_args="${nsj_args:+${nsj_args} }$1" shift done + # Set up cgroups and disable memory swapping. mkdir -p /sys/fs/cgroup/pids/NSJAIL mkdir -p /sys/fs/cgroup/memory/NSJAIL + echo "${MEM_MAX}" > /sys/fs/cgroup/memory/NSJAIL/memory.limit_in_bytes + echo "${MEM_MAX}" > /sys/fs/cgroup/memory/NSJAIL/memory.memsw.limit_in_bytes + nsjail \ -Mo \ --rlimit_as 700 \ @@ -19,7 +26,7 @@ nsjpy() { --disable_proc \ --iface_no_lo \ --cgroup_pids_max=1 \ - --cgroup_mem_max=52428800 \ + --cgroup_mem_max="${MEM_MAX}" \ $nsj_args -- \ /snekbox/.venv/bin/python3 -Iq -c "$@" } diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index b68b0b9..b9c4fc7 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -24,6 +24,7 @@ CGROUP_PIDS_PARENT = Path("/sys/fs/cgroup/pids/NSJAIL") CGROUP_MEMORY_PARENT = Path("/sys/fs/cgroup/memory/NSJAIL") NSJAIL_PATH = os.getenv("NSJAIL_PATH", "/usr/sbin/nsjail") +MEM_MAX = 52428800 class NsJail: @@ -59,10 +60,21 @@ class NsJail: NsJail doesn't do this automatically because it requires privileges NsJail usually doesn't have. + + Disables memory swapping. """ pids.mkdir(parents=True, exist_ok=True) mem.mkdir(parents=True, exist_ok=True) + # Swap limit cannot be set to a value lower than memory.limit_in_bytes. + # Therefore, this must be set first. + with (mem / "memory.limit_in_bytes").open("w", encoding="utf=8") as f: + f.write(str(MEM_MAX)) + + # Swap limit is specified as the sum of the memory and swap limits. + with (mem / "memory.memsw.limit_in_bytes").open("w", encoding="utf=8") as f: + f.write(str(MEM_MAX)) + @staticmethod def _parse_log(log_lines: Iterable[str]): """Parse and log NsJail's log messages.""" @@ -108,7 +120,7 @@ class NsJail: "--disable_proc", "--iface_no_lo", "--log", nsj_log.name, - "--cgroup_mem_max=52428800", + f"--cgroup_mem_max={MEM_MAX}", "--cgroup_mem_mount", str(CGROUP_MEMORY_PARENT.parent), "--cgroup_mem_parent", CGROUP_MEMORY_PARENT.name, "--cgroup_pids_max=1", diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py index e3b8eb3..f1a60e6 100644 --- a/tests/test_nsjail.py +++ b/tests/test_nsjail.py @@ -2,7 +2,7 @@ import logging import unittest from textwrap import dedent -from snekbox.nsjail import NsJail +from snekbox.nsjail import MEM_MAX, NsJail class NsJailTests(unittest.TestCase): @@ -21,12 +21,8 @@ class NsJailTests(unittest.TestCase): def test_timeout_returns_137(self): code = dedent(""" - x = '*' while True: - try: - x = x * 99 - except: - continue + pass """).strip() with self.assertLogs(self.logger) as log: @@ -37,6 +33,17 @@ class NsJailTests(unittest.TestCase): self.assertEqual(result.stderr, None) self.assertIn("run time >= time limit", "\n".join(log.output)) + def test_memory_returns_137(self): + # Add a kilobyte just to be safe. + code = dedent(f""" + x = ' ' * {MEM_MAX + 1000} + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 137) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, None) + def test_subprocess_resource_unavailable(self): code = dedent(""" import subprocess -- cgit v1.2.3 From 905cdf11b3b76ebee672e54b4062e3904ed47072 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Jun 2019 17:53:09 -0700 Subject: Test that the file system is mounted as read only --- tests/test_nsjail.py | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'tests') diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py index f1a60e6..bb176d9 100644 --- a/tests/test_nsjail.py +++ b/tests/test_nsjail.py @@ -55,6 +55,16 @@ class NsJailTests(unittest.TestCase): self.assertIn("Resource temporarily unavailable", result.stdout) self.assertEqual(result.stderr, None) + def test_read_only_file_system(self): + code = dedent(""" + open('hello', 'w').write('world') + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 1) + self.assertIn("Read-only file system", result.stdout) + self.assertEqual(result.stderr, None) + def test_forkbomb_resource_unavailable(self): code = dedent(""" import os -- cgit v1.2.3