-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Configure codejail and run safety check at startup #10
base: main
Are you sure you want to change the base?
Conversation
edd7062
to
6479eca
Compare
92f7b43
to
a0de742
Compare
- 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
2123d53
to
e1ae885
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks.
codejail_service/apps/api/apps.py
Outdated
else: # pragma: no cover | ||
# Codejail needs this at startup | ||
apply_django_settings(settings.CODE_JAIL) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What would happen if this were broken? Would some unit test fail, or is there a reason why we don't want coverage for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it broke, the service would fail to start. There isn't currently any unit test coverage, because we can't really configure codejail outside of a container.
...but it looks like I can set CODE_JAIL = {}
in the settings/test.py, and that still allows tests to pass. I thought I had previously seen a failure with an empty settings block, but I can't reproduce that now. I'll add it in.
from django.test import TestCase | ||
from django.urls import reverse | ||
|
||
|
||
class HealthTests(TestCase): | ||
"""Tests of the health endpoint.""" | ||
|
||
def test_healthcheck(self): | ||
@patch('codejail_service.startup_check.STARTUP_SAFETY_CHECK_OK', None) | ||
def test_unhealthy(self): | ||
"""Test that the endpoint reports when all services are healthy.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docstring for test_unhealthy
and test_healthy
need to be swapped.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, will fix. Also going to add a couple more cases.
(responses(math=Exception("Divide by zero")), False), | ||
) | ||
@ddt.unpack | ||
@patch('codejail_service.startup_check.STARTUP_SAFETY_CHECK_OK', None) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought this ends up as a parameter on the test? I'm confused about how this works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That surprised me too. But if you set the new
parameter here, you don't get an additional argument to the decorated function.
If patch() is used as a decorator and new is omitted, the created mock is passed in as an extra argument to the decorated function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Which is the new
parameter? The None
? Maybe I'm just used to patched functions, and that is what is throwing me off.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this sets codejail_service.startup_check.STARTUP_SAFETY_CHECK_OK
to None
for the duration of the test and then sets it back to the original value afterwards.
mock_log_info.assert_has_calls([ | ||
call("Startup test 'Basic code execution' passed"), | ||
]) | ||
assert ( | ||
"Startup test 'Block sandbox escape by disk access' failed with: " | ||
"\"Expected error, but code ran successfully. Globals: {'ret': ['" | ||
) in mock_log_error.call_args_list[0][0][0] | ||
assert ( | ||
"Startup test 'Block sandbox escape by child process' failed with: " | ||
r'''"Expected error, but code ran successfully. Globals: {'ret': '42\\n'}"''' | ||
) == mock_log_error.call_args_list[1][0][0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you intentionally not want to check the count of the various log messages, to ensure there is nothing else?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not specifically, no. I mostly wanted to ensure that all of the useful information was present. I could add an additional check if you think it makes sense to do so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason for it is that it might ensure you add proper coverage if a new validation test and log message were ever added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose so. I mostly care whether there's at least one failure message and at least one success message, but no harm in adding some count assertions.
STARTUP_SAFETY_CHECK_OK = not any_failed | ||
|
||
|
||
def _test_basic_function(): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any non-mocked test for this function, and is there any way to? Same for the other calls.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes -- test_logging
and test_unsafe_tests_default
both expect these to actually run.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. Although maybe this should have been obvious, maybe in test_logging
you could explain that because we don't actually have any protections enabled via AppArmor in the unit test, we can assume that all safety tests should fail.
Can you confirm that we don't have any tests that pass the safety tests by mocking at the safe_exec
level? If not, would this be useful?
UPDATE: I guess that is what the first test in test_failure_modes
is, maybe, which isn't a failure? I'm not following that test well. UPDATE 2: Maybe call it test_success_and_failure_modes
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I can add that note to the test.
Having trouble understanding the second para.
- Set CODE_JAIL to empty dict during unit tests, which allows us to always call `apply_django_settings` instead of having an uncovered branch. - Fix docstrings for healthcheck unit tests Also: - Cover additional cases in healthcheck tests
CODE_JAIL
is setIf 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:
Supporting changes:
ready()
mechanismsafe_exec
to prevent codejail eagerly settingUNSAFE=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 Codejail safe_exec makes "unsafe=true" decision at startup 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:
Part of edx/edx-arch-experiments#927
Manual testing performed with changes to the Dockerfile and to devstack (PRs pending), and mostly entailed calling the healthcheck endpoint.
When passing, the startup logs look like this:
When codejail is misconfigured:
Merge checklist:
Check off if complete or not applicable: