forked from openedx/codejail-service
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Configure codejail and run safety check at startup
- Initialize codejail at startup, if `CODE_JAIL` is set - Run safety checks at startup, locking out the API if the checks fail If codejail isn't properly configured, it defaults to running code unsafely. To prevent this from affecting the service, we run a smoke test at startup to check if there's anything just *drastically* wrong. If this check does not pass, two things happen: - The healthcheck endpoint will never return a 200 OK - The code-exec endpoint will refuse with a 500 error Supporting changes: - Define an explicit AppConfig for the api subpackage so that we can hook into the `ready()` mechanism - Wrap `safe_exec` to prevent codejail eagerly setting `UNSAFE=True` at module load time. (Not clear why this doesn't affect edx-platform; maybe something to do with app vs. middleware load order.) Filed openedx/codejail#225 for possibly fixing this. - `safe_exec` wrapper also performs a deepcopy to allow callers to reason about the globals dict more easily. Other changes: - Clean up healthcheck docstring (mostly just trim it down) - Lint cleanup Part of edx/edx-arch-experiments#927
- Loading branch information
Showing
8 changed files
with
230 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
""" | ||
codejail-service module. | ||
""" | ||
__version__ = '0.3.0' | ||
__version__ = '0.3.1' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
""" | ||
Config for main API. | ||
""" | ||
|
||
import logging | ||
|
||
from codejail.django_integration_utils import apply_django_settings | ||
from django.apps import AppConfig | ||
from django.conf import settings | ||
|
||
from codejail_service.startup_check import run_startup_safety_check | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
class CodejailApiConfig(AppConfig): | ||
""" | ||
AppConfig for API views. | ||
The only reason we need this to be an app is so that we can hook into the | ||
ready() callback at startup. Any other mechanism would be fine too. | ||
""" | ||
name = "codejail_service.apps.api" | ||
|
||
def ready(self): | ||
lib_settings = getattr(settings, 'CODE_JAIL', None) | ||
if lib_settings is None: | ||
# Should only happen when tests are being run | ||
log.warning("Missing CODE_JAIL settings") | ||
else: | ||
# Codejail needs this at startup | ||
apply_django_settings(settings.CODE_JAIL) | ||
|
||
# Perform self-check and initialize status for healthcheck and | ||
# code-exec views to consult. | ||
run_startup_safety_check() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
""" | ||
Wrappers and utilities for codejail library. | ||
""" | ||
|
||
from copy import deepcopy | ||
|
||
|
||
def safe_exec(code, input_globals, **kwargs): | ||
""" | ||
Call safe_exec and work around several of its problems. | ||
Returns new globals dictionary that results from execution. | ||
(Does not mutate input.) | ||
""" | ||
# This needs to be a lazy import because as soon as codejail's | ||
# safe_exec module loads, it immediately makes a decision about | ||
# whether to run in always-unsafe mode. | ||
# | ||
# See https://github.com/openedx/codejail/issues/225 for maybe | ||
# fixing this. | ||
|
||
# pylint: disable=import-outside-toplevel | ||
from codejail.safe_exec import safe_exec as real_safe_exec | ||
|
||
# Prevent mutation of input | ||
output_globals = deepcopy(input_globals) | ||
real_safe_exec(code, output_globals, **kwargs) | ||
return output_globals |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
""" | ||
State and accessors for a safety check that is run at startup. | ||
""" | ||
|
||
import logging | ||
|
||
from codejail_service.codejail import safe_exec | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
# Results of the safety check that was performed at startup. | ||
# | ||
# Expected values: | ||
# | ||
# - None: The check has not yet been performed | ||
# - True: No issues were detected | ||
# - False: A fundamental safety or function issue was detected | ||
# | ||
# Any other value indicates that the safety check failed in an | ||
# unexpected way, perhaps raising an exception. | ||
# | ||
# The *only value* that indicates that it is safe to receive code-exec | ||
# calls is `True`. | ||
STARTUP_SAFETY_CHECK_OK = None | ||
|
||
|
||
def is_exec_safe(): | ||
""" | ||
Return True if and only if it is safe to accept code-exec calls. | ||
""" | ||
return STARTUP_SAFETY_CHECK_OK is True | ||
|
||
|
||
def run_startup_safety_check(): | ||
""" | ||
Perform a sandboxing safety check. | ||
Determines if the service is running with an acceptable configuration. | ||
This is *not* a full test suite, just a basic check that codejail | ||
is actually configured in sandboxing mode. | ||
This just initializes state. Afterwards, is_exec_safe can be called. | ||
""" | ||
global STARTUP_SAFETY_CHECK_OK | ||
|
||
# App initialization can happen multiple times; just run checks once. | ||
if STARTUP_SAFETY_CHECK_OK is not None: | ||
return | ||
|
||
tests = [ | ||
{ | ||
"name": "Basic code execution", | ||
"fn": _test_basic_function, | ||
}, | ||
{ | ||
"name": "Sandbox escape by disk access", | ||
"fn": _test_escape_disk, | ||
}, | ||
{ | ||
"name": "Sandbox escape by child process", | ||
"fn": _test_escape_subprocess, | ||
}, | ||
] | ||
|
||
any_failed = False | ||
for test in tests: | ||
try: | ||
result = test['fn']() | ||
except BaseException as e: | ||
result = f"Uncaught exception from test: {e!r}" | ||
|
||
if result is True: | ||
log.info(f"Startup test {test['name']!r} passed") | ||
else: | ||
any_failed = True | ||
log.error(f"Startup test {test['name']!r} failed with: {result!r}") | ||
|
||
STARTUP_SAFETY_CHECK_OK = not any_failed | ||
|
||
|
||
def _test_basic_function(): | ||
""" | ||
Test for basic code execution (math). | ||
""" | ||
globals_out = safe_exec("x = x + 1", {'x': 16}) | ||
|
||
if 'x' not in globals_out: | ||
return "x not in returned globals" | ||
if globals_out['x'] != 17: | ||
return f"returned global x != 17 (was {globals_out['x']})" | ||
|
||
return True | ||
|
||
|
||
def _test_escape_disk(): | ||
""" | ||
Test for sandbox escape by reading from files outside of sandbox. | ||
""" | ||
try: | ||
globals_out = safe_exec("import os; ret = os.listdir('/')", {}) | ||
return f"Expected error, but code ran successfully. Globals: {globals_out!r}" | ||
except BaseException as e: | ||
if "Permission denied" in repr(e): | ||
return True | ||
else: | ||
return f"Expected permission error, but got: {e!r}" | ||
|
||
|
||
def _test_escape_subprocess(): | ||
""" | ||
Test for sandbox escape by creating a child process. | ||
""" | ||
try: | ||
globals_out = safe_exec( | ||
"import subprocess;" | ||
"ret = subprocess.check_output('echo $((6 * 7))', shell=True)", | ||
{}, | ||
) | ||
return f"Expected error, but code ran successfully. Globals: {globals_out!r}" | ||
except BaseException as e: | ||
if "Permission denied" in repr(e): | ||
return True | ||
else: | ||
return f"Expected permission error, but got: {e!r}" |