diff --git a/docker_enforcer.py b/docker_enforcer.py index f604191..e3e70dd 100644 --- a/docker_enforcer.py +++ b/docker_enforcer.py @@ -8,6 +8,7 @@ from logging import StreamHandler from docker import APIClient +from docker.errors import NotFound from flask import Flask, Response, request from pygments import highlight from pygments.formatters.html import HtmlFormatter @@ -23,6 +24,7 @@ from dockerenforcer.killer import Killer, Judge, TriggerHandler from rules.rules import rules from request_rules.request_rules import request_rules +from request_rules.start_request_rules import start_request_rules from whitelist_rules.whitelist_rules import whitelist_rules config = Config() @@ -35,6 +37,9 @@ jurek = Killer(docker_helper, config.mode) trigger_handler = TriggerHandler() containers_regex = re.compile("^(/v.+?)?/containers/.+?$") +containers_start_regexp = re.compile("^(/v.+?)?/containers/(.+?)/rename\\?name=(.+?)$", re.IGNORECASE) + +start_requests_judge = Judge(start_request_rules, "request", config, run_whitelists=True) def create_app(): @@ -146,6 +151,10 @@ def show_rules(): def show_request_rules(): return python_file_to_json(request, "request_rules/request_rules.py") +@app.route('/start_request_rules') +def show_start_request_rules(): + return python_file_to_json(request, "request_rules/start_request_rules.py") + @app.route('/triggers') def show_triggers(): @@ -229,15 +238,27 @@ def authz_request(): return process_positive_verdict(verdict, json_data, register=False) operation = url.path.split("/")[-1] - if containers_regex.match(url.path) and operation == "create" and "requestbody" in json_data: - int_bytes = b64decode(json_data["requestbody"]) - int_json = json.loads(int_bytes.decode(request.charset)) + if containers_regex.match(url.path): tls_user = json_data["user"] if "user" in json_data and json_data["user"] != '' else "[unknown]" - int_json = docker_helper.rename_keys_to_lower(int_json) - container = make_container_periodic_check_compatible(int_json, url, tls_user) - verdict = judge.should_be_killed(container) - if verdict.verdict: - return process_positive_verdict(verdict, json_data) + if operation == "create" and "requestbody" in json_data: + int_bytes = b64decode(json_data["requestbody"]) + int_json = json.loads(int_bytes.decode(request.charset)) + int_json = docker_helper.rename_keys_to_lower(int_json) + container = make_container_periodic_check_compatible(int_json, url, tls_user) + + if config.merge_image_labels_when_check_authz_create_requests: + container.params["config"]["labels"] = docker_image_helper.merge_container_and_image_labels(container) + + verdict = judge.should_be_killed(container) + if verdict.verdict: + return process_positive_verdict(verdict, json_data) + elif operation == "start" and request.method == "POST" and config.check_authz_start_requests: + container_id = url.path.split("/")[-2] + container = docker_helper.check_container(container_id, CheckSource.AuthzPlugin, True) + verdict = start_requests_judge.should_be_killed(container) + if verdict.verdict: + return process_positive_verdict(verdict, json_data) + return to_formatted_json(json.dumps({"Allow": True})) diff --git a/dockerenforcer/config.py b/dockerenforcer/config.py index b1dd5c7..f388af8 100644 --- a/dockerenforcer/config.py +++ b/dockerenforcer/config.py @@ -4,7 +4,7 @@ import copy from typing import Dict -version = "0.8.15" +version = "0.8.16-aurea-dev-1" class Mode(Enum): @@ -32,6 +32,8 @@ def __init__(self) -> None: self.immediate_periodical_start: bool = bool(os.getenv('IMMEDIATE_PERIODICAL_START', 'False') == 'True') self.stop_on_first_violation: bool = bool(os.getenv('STOP_ON_FIRST_VIOLATION', 'True') == 'True') self.log_authz_requests: bool = bool(os.getenv('LOG_AUTHZ_REQUESTS', 'False') == 'True') + self.check_authz_start_requests: bool = bool(os.getenv('CHECK_AUTHZ_START_REQUESTS', 'False') == 'True') + self.merge_image_labels_when_check_authz_create_requests: bool = bool(os.getenv('MERGE_IMAGE_LABELS_WHEN_CHECK_AUTHZ_CREATE_REQUESTS', 'False') == 'True') self.default_allow: bool = bool(os.getenv('DEFAULT_ACTION_ALLOW', 'True') == 'True') self.version: str = version self.white_list_separator: str = "|" diff --git a/dockerenforcer/docker_image_helper.py b/dockerenforcer/docker_image_helper.py index 6676609..8e36adf 100644 --- a/dockerenforcer/docker_image_helper.py +++ b/dockerenforcer/docker_image_helper.py @@ -9,6 +9,7 @@ logger = logging.getLogger("docker_enforcer") + class DockerImageHelper: def __init__(self, config: Config, client: APIClient) -> None: super().__init__() @@ -27,3 +28,28 @@ def get_image_uniq_tag_by_id(self, image_id): return image_inspect_data['RepoTags'][0] else: return image_id + + def merge_container_and_image_labels(self, container): + image = container.params['image'] + image_params = {} + try: + image_params = self._client.inspect_image(image) + except NotFound as e: + logger.debug("Image {0} not found - {1}.".format(image, e)) + image_labels = {} + if 'Config' in image_params and 'Labels' in image_params['Config']: + image_labels = image_params['Config']['Labels'] + final_labels = self.merge_dicts(image_labels, container.params["config"]["labels"]) + return final_labels + + def merge_dicts(self, image_labels, container_labels): + if image_labels is None and container_labels is None: + return None + + if not image_labels: + return container_labels + + if not container_labels: + return image_labels + + return {**image_labels, **container_labels} diff --git a/request_rules/start_request_rules.py b/request_rules/start_request_rules.py new file mode 100644 index 0000000..cc249bb --- /dev/null +++ b/request_rules/start_request_rules.py @@ -0,0 +1,6 @@ +start_request_rules = [ + { + "name": "always false", + "rule": lambda container: False + } +] diff --git a/test/test_api.py b/test/test_api.py index 6a5cd5a..660d02b 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -6,8 +6,9 @@ from flask import Response -from docker_enforcer import app, judge, config, requests_judge, trigger_handler +from docker_enforcer import app, judge, config, requests_judge, trigger_handler, start_requests_judge, docker_helper from dockerenforcer.config import Mode +from dockerenforcer.docker_helper import Container, CheckSource from test.test_helpers import ApiTestHelper, DefaultRulesHelper @@ -16,6 +17,8 @@ class ApiContainerTest(unittest.TestCase): cp_request_rule_regexp = re.compile("^/v1\.[23]\d/containers/test/archive$") cp_request_rule = {"name": "cp not allowed", "rule": lambda r, x=cp_request_rule_regexp: r['requestmethod'] in ['GET', 'HEAD'] and x.match(r['parseduri'].path)} + start_request_rule = {"name": "start not allowed", "rule": lambda r: True} + test_trigger_flag = False test_trigger = {"name": "set local flag", "trigger": lambda v: ApiContainerTest.set_trigger_flag()} forbid_privileged_rule = { @@ -36,6 +39,7 @@ def set_trigger_flag(): def setUpClass(cls): config.mode = Mode.Kill config.log_authz_requests = True + config.check_authz_start_requests = True cls.de = app cls.de.testing = True cls.app = cls.de.test_client() @@ -48,6 +52,13 @@ def setUp(self): judge._image_per_rule_whitelist = {} judge._custom_whitelist_rules = {} + start_requests_judge._rules = [] + start_requests_judge._global_whitelist = [] + start_requests_judge._image_global_whitelist = [] + start_requests_judge._per_rule_whitelist = {} + start_requests_judge._image_per_rule_whitelist = {} + start_requests_judge._custom_whitelist_rules = {} + def _check_response(self, response, allow, msg=None, code=200): self.assertEqual(response.status_code, code) json_res = json.loads(response.data.decode(response.charset)) @@ -80,6 +91,56 @@ def test_request_rule_no_cp_to(self): res = self.app.post('/AuthZPlugin.AuthZReq', data=ApiTestHelper.authz_req_copy_to_cont) self._check_response(res, False, "cp not allowed") + def test_start_request_rule(self): + params = {'name': 'test', 'config': {'image': 'test', 'labels': {}}} + test_containers = Container('6b5d46fc77fb2f182ead9a16e2dd5ea444fcbf94754c7704e459d0aa28bb6b11', params, {}, 0, + CheckSource.AuthzPlugin) + + start_requests_judge._rules = [self.start_request_rule] + with mock.patch.object(docker_helper, 'check_container') as mock_check_container: + mock_check_container.return_value = test_containers + + res = self.app.post('/AuthZPlugin.AuthZReq', data=ApiTestHelper.authz_req_start_container) + self._check_response(res, False, self.start_request_rule['name']) + + mock_check_container.assert_called_with('6b5d46fc77fb2f182ead9a16e2dd5ea444fcbf94754c7704e459d0aa28bb6b11', + CheckSource.AuthzPlugin, True) + + def test_start_request_rule_but_on_whitelist(self): + params = {'name': 'test', 'config': {'image': 'test_image', 'labels': {}}} + test_containers = Container('6b5d46fc77fb2f182ead9a16e2dd5ea444fcbf94754c7704e459d0aa28bb6b11', params, {}, 0, + CheckSource.AuthzPlugin) + + def global_whitelist(): + start_requests_judge._global_whitelist = [re.compile('^test$')] + + def image_global_whitelist(): + start_requests_judge._global_whitelist = [] + start_requests_judge._image_global_whitelist = [re.compile('^test_image$')] + + def per_rule_whitelist(): + start_requests_judge._image_global_whitelist = [] + start_requests_judge._per_rule_whitelist = {self.start_request_rule['name']: [re.compile('^tes.*$')]} + + def image_per_rule_whitelist(): + start_requests_judge._per_rule_whitelist = {} + start_requests_judge._image_per_rule_whitelist = {self.start_request_rule['name']: [re.compile('^test_ima.*$')]} + + tests_parameters = [global_whitelist, image_global_whitelist, per_rule_whitelist, image_per_rule_whitelist] + + for modifier in tests_parameters: + with self.subTest(modifier.__name__): + start_requests_judge._rules = [self.start_request_rule] + modifier() + with mock.patch.object(docker_helper, 'check_container') as mock_check_container: + mock_check_container.return_value = test_containers + + res = self.app.post('/AuthZPlugin.AuthZReq', data=ApiTestHelper.authz_req_start_container) + self._check_response(res, True) + + mock_check_container.assert_called_with('6b5d46fc77fb2f182ead9a16e2dd5ea444fcbf94754c7704e459d0aa28bb6b11', + CheckSource.AuthzPlugin, True) + def test_trigger_when_rule_fails_run_with_mem_check(self): judge._rules = [self.mem_rule] trigger_handler._triggers = [self.test_trigger] @@ -209,3 +270,11 @@ def test_fetch_request_rules(self): def test_fetch_request_rules_html(self): res = self.app.get('/request_rules', headers={"Accept": "text/html"}) self._check_rules_response(res, "text/html") + + def test_fetch_start_request_rules(self): + res = self.app.get('/start_request_rules') + self._check_rules_response(res, "application/json", DefaultRulesHelper.start_request_rules) + + def test_fetch_start_request_rules_html(self): + res = self.app.get('/start_request_rules', headers={"Accept": "text/html"}) + self._check_rules_response(res, "text/html") diff --git a/test/test_docker_image_helper.py b/test/test_docker_image_helper.py index a645ee1..5860726 100644 --- a/test/test_docker_image_helper.py +++ b/test/test_docker_image_helper.py @@ -1,3 +1,4 @@ +import types import unittest from unittest.mock import create_autospec @@ -43,3 +44,38 @@ def test_get_image_uniq_tag_by_id__when_many_repo_tags(self): self._client.inspect_image.return_value = {'RepoTags': [self._image_name2, self._image_name1]} image_tag = self._helper.get_image_uniq_tag_by_id(self._image_id) self.assertEqual(self._image_name2, image_tag) + + def test_merge_container_and_image_labels(self): + image_labels = {'label1': 'label1_image_value', 'label2': 'label2_image_value'} + container_labels = {'label2': 'label2_container_value', 'label3': 'label3_container_value'} + + self._client.inspect_image.return_value = {'Config': {'Labels': image_labels}} + + container = types.SimpleNamespace() + container.params = {'image': 'test', 'config': {'labels': container_labels}} + + final_labels = self._helper.merge_container_and_image_labels(container=container) + + self.assertIn('label1', final_labels) + self.assertEquals('label1_image_value', final_labels['label1']) + + self.assertIn('label2', final_labels) + self.assertEquals('label2_container_value', final_labels['label2']) + + self.assertIn('label3', final_labels) + self.assertEquals('label3_container_value', final_labels['label3']) + + def test_merge_dicts(self): + image_labels = {'label1': 'label1_image_value', 'label2': 'label2_image_value'} + container_labels = {'label2': 'label2_container_value', 'label3': 'label3_container_value'} + + res = self._helper.merge_dicts(None, None) + self.assertIsNone(res) + + res = self._helper.merge_dicts(image_labels, None) + self.assertEqual(image_labels, res) + + res = self._helper.merge_dicts(None, container_labels) + self.assertEqual(container_labels, res) + + diff --git a/test/test_helpers.py b/test/test_helpers.py index 12f9b42..d4f4a2f 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -125,8 +125,11 @@ class ApiTestHelper: authz_req_run_with_privileged_name_test = b'{"RequestMethod":"POST","RequestUri":"/v1.30/containers/create?name=test","RequestBody":"eyJIb3N0bmFtZSI6IiIsIkRvbWFpbm5hbWUiOiIiLCJVc2VyIjoiIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOltdLCJDbWQiOlsic2giXSwiSW1hZ2UiOiJhbHBpbmUiLCJWb2x1bWVzIjp7fSwiV29ya2luZ0RpciI6IiIsIkVudHJ5cG9pbnQiOm51bGwsIk9uQnVpbGQiOm51bGwsIkxhYmVscyI6e30sIkhvc3RDb25maWciOnsiQmluZHMiOm51bGwsIkNvbnRhaW5lcklERmlsZSI6IiIsIkxvZ0NvbmZpZyI6eyJUeXBlIjoiIiwiQ29uZmlnIjp7fX0sIk5ldHdvcmtNb2RlIjoiZGVmYXVsdCIsIlBvcnRCaW5kaW5ncyI6e30sIlJlc3RhcnRQb2xpY3kiOnsiTmFtZSI6Im5vIiwiTWF4aW11bVJldHJ5Q291bnQiOjB9LCJBdXRvUmVtb3ZlIjp0cnVlLCJWb2x1bWVEcml2ZXIiOiIiLCJWb2x1bWVzRnJvbSI6bnVsbCwiQ2FwQWRkIjpudWxsLCJDYXBEcm9wIjpudWxsLCJEbnMiOltdLCJEbnNPcHRpb25zIjpbXSwiRG5zU2VhcmNoIjpbXSwiRXh0cmFIb3N0cyI6bnVsbCwiR3JvdXBBZGQiOm51bGwsIklwY01vZGUiOiIiLCJDZ3JvdXAiOiIiLCJMaW5rcyI6bnVsbCwiT29tU2NvcmVBZGoiOjAsIlBpZE1vZGUiOiIiLCJQcml2aWxlZ2VkIjp0cnVlLCJQdWJsaXNoQWxsUG9ydHMiOmZhbHNlLCJSZWFkb25seVJvb3RmcyI6ZmFsc2UsIlNlY3VyaXR5T3B0IjpudWxsLCJVVFNNb2RlIjoiIiwiVXNlcm5zTW9kZSI6IiIsIlNobVNpemUiOjAsIkNvbnNvbGVTaXplIjpbMCwwXSwiSXNvbGF0aW9uIjoiIiwiQ3B1U2hhcmVzIjowLCJNZW1vcnkiOjEwNDg1NzYwMCwiTmFub0NwdXMiOjAsIkNncm91cFBhcmVudCI6IiIsIkJsa2lvV2VpZ2h0IjowLCJCbGtpb1dlaWdodERldmljZSI6bnVsbCwiQmxraW9EZXZpY2VSZWFkQnBzIjpudWxsLCJCbGtpb0RldmljZVdyaXRlQnBzIjpudWxsLCJCbGtpb0RldmljZVJlYWRJT3BzIjpudWxsLCJCbGtpb0RldmljZVdyaXRlSU9wcyI6bnVsbCwiQ3B1UGVyaW9kIjowLCJDcHVRdW90YSI6MCwiQ3B1UmVhbHRpbWVQZXJpb2QiOjAsIkNwdVJlYWx0aW1lUnVudGltZSI6MCwiQ3B1c2V0Q3B1cyI6IiIsIkNwdXNldE1lbXMiOiIiLCJEZXZpY2VzIjpbXSwiRGV2aWNlQ2dyb3VwUnVsZXMiOm51bGwsIkRpc2tRdW90YSI6MCwiS2VybmVsTWVtb3J5IjowLCJNZW1vcnlSZXNlcnZhdGlvbiI6MCwiTWVtb3J5U3dhcCI6MCwiTWVtb3J5U3dhcHBpbmVzcyI6LTEsIk9vbUtpbGxEaXNhYmxlIjpmYWxzZSwiUGlkc0xpbWl0IjowLCJVbGltaXRzIjpudWxsLCJDcHVDb3VudCI6MCwiQ3B1UGVyY2VudCI6MCwiSU9NYXhpbXVtSU9wcyI6MCwiSU9NYXhpbXVtQmFuZHdpZHRoIjowfSwiTmV0d29ya2luZ0NvbmZpZyI6eyJFbmRwb2ludHNDb25maWciOnt9fX0K","RequestHeaders":{"Content-Length":"1437","Content-Type":"application/json","User-Agent":"Docker-Client/17.06.0-ce (linux)"}}\n' # sudo curl -H "Content-Type: application/json" -X POST -d '{"Image":"ubuntu"}' http://localhost:2375/containers/create authz_req_create_with_only_image = b'{"RequestMethod":"POST","RequestUri":"/containers/create","RequestBody":"eyJJbWFnZSI6InVidW50dSJ9","RequestHeaders":{"Accept":"*/*","Content-Length":"18","Content-Type":"application/json","User-Agent":"curl/7.47.0"}}\n' + # docker start 6b5d46fc77fb2f182ead9a16e2dd5ea444fcbf94754c7704e459d0aa28bb6b11 + authz_req_start_container = b'{"RequestMethod":"POST","RequestUri":"/v1.31/containers/6b5d46fc77fb2f182ead9a16e2dd5ea444fcbf94754c7704e459d0aa28bb6b11/start","RequestHeaders":{"Content-Length":"0","Content-Type":"text/plain","User-Agent":"Docker-Client/17.07.0-ce (linux)"}}\n' class DefaultRulesHelper: rules_json = b'"rules = [\\n {\\n \\"name\\": \\"always false\\",\\n \\"rule\\": lambda container: False\\n }\\n]\\n"' triggers_json = b'"import json\\nimport threading\\n\\nfrom flask import logging\\n\\nlogger = logging.getLogger(\\"docker_enforcer\\")\\n\\ntriggers = [\\n {\\n \\"name\\": \\"additional log verdict\\",\\n \\"trigger\\": lambda v: logger.debug(\\"[{0}] Trigger: container {1} is detected to violate the rule \\\\\\"{2}\\\\\\".\\"\\n .format(threading.current_thread().name, v.subject, json.dumps(v.reasons)))\\n }\\n]\\n"' request_rules = b'"import json\\nimport threading\\n\\nfrom flask import logging\\n\\nlogger = logging.getLogger(\\"docker_enforcer\\")\\n\\nrequest_rules = [\\n {\\n \\"name\\": \\"always false\\",\\n \\"rule\\": lambda request: False\\n }\\n]\\n"' + start_request_rules = b'"start_request_rules = [\\n {\\n \\"name\\": \\"always false\\",\\n \\"rule\\": lambda container: False\\n }\\n]\\n"'