diff --git a/backend/apps/common/tests.py b/backend/apps/common/tests.py new file mode 100644 index 0000000..d31bd97 --- /dev/null +++ b/backend/apps/common/tests.py @@ -0,0 +1,21 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase + +from apps.common.worker_runner import _run_once_or_raise + + +class WorkerRunnerTests(SimpleTestCase): + def test_run_once_refreshes_runtime_config_before_work(self): + calls = [] + + def run_once(): + calls.append("run_once") + return True + + with patch("apps.common.worker_runner._refresh_runtime_config_cache") as refresh: + result = _run_once_or_raise("test worker", run_once) + + self.assertTrue(result.processed) + refresh.assert_called_once() + self.assertEqual(calls, ["run_once"]) diff --git a/backend/apps/common/worker_runner.py b/backend/apps/common/worker_runner.py index 5d9e61d..1165d03 100644 --- a/backend/apps/common/worker_runner.py +++ b/backend/apps/common/worker_runner.py @@ -49,8 +49,15 @@ def _worker_label(worker_name): return worker_name if worker_name.endswith("worker") else f"{worker_name} worker" +def _refresh_runtime_config_cache(): + from apps.settings.runtime_config import invalidate + + invalidate() + + def _run_once_or_raise(worker_label, run_once): try: + _refresh_runtime_config_cache() return _coerce_result(run_once()) except CommandError: raise @@ -95,7 +102,7 @@ def run_worker( try: while True: try: - result = _coerce_result(run_once()) + result = _run_once_or_raise(worker_label, run_once) if result.message: command.stdout.write(result.message) if _should_sleep(result, sleep_policy): diff --git a/backend/apps/webhook/elk_actions.py b/backend/apps/webhook/elk_actions.py index acb74dc..69f8afd 100644 --- a/backend/apps/webhook/elk_actions.py +++ b/backend/apps/webhook/elk_actions.py @@ -5,12 +5,14 @@ from datetime import UTC, timedelta from django.utils import timezone -from apps.settings.runtime_config import get_elk_config +from apps.settings.runtime_config import get_elk_config, invalidate from apps.webhook.service import handle_kibana_webhook from integrations.siem.clients import get_elk_client logger = logging.getLogger(__name__) +ELK_CLIENT_CONFIG_FIELDS = ("host", "api_key", "verify_certs", "request_timeout_seconds") + @dataclass class ProcessResult: @@ -25,7 +27,7 @@ def format_es_time(value): class ELKActionProcessor: def __init__(self, *, elk_client=None, index_name=None, interval_seconds=None, size=None): - config = get_elk_config() + config = self.load_config() self.elk_client = elk_client self.index_name_override = index_name self.interval_seconds_override = interval_seconds @@ -33,8 +35,18 @@ class ELKActionProcessor: self.index_name = index_name if index_name is not None else config["action_index"] self.interval_seconds = interval_seconds if interval_seconds is not None else config["action_poll_interval_seconds"] self.size = size if size is not None else config["action_size"] + self.elk_client_config = self.client_config(config) self.last_check_time = None + @staticmethod + def load_config(): + invalidate("elk") + return get_elk_config() + + @staticmethod + def client_config(config): + return tuple(config[field] for field in ELK_CLIENT_CONFIG_FIELDS) + @staticmethod def parse_hits(hits): if isinstance(hits, str): @@ -44,7 +56,11 @@ class ELKActionProcessor: return hits if isinstance(hits, list) else [] def refresh_config(self): - config = get_elk_config() + config = self.load_config() + elk_client_config = self.client_config(config) + if self.elk_client is None and elk_client_config != self.elk_client_config: + get_elk_client.cache_clear() + self.elk_client_config = elk_client_config if self.index_name_override is None: self.index_name = config["action_index"] if self.interval_seconds_override is None: diff --git a/backend/apps/webhook/tests.py b/backend/apps/webhook/tests.py index a472aa3..75c7696 100644 --- a/backend/apps/webhook/tests.py +++ b/backend/apps/webhook/tests.py @@ -1,11 +1,14 @@ +from datetime import timedelta from unittest.mock import patch from django.test import SimpleTestCase, TestCase from django.urls import Resolver404, resolve +from django.utils import timezone from rest_framework.test import APIClient -from apps.settings.models import RuntimeConfig -from apps.settings.runtime_config import invalidate +from apps.settings.models import RuntimeConfig, SiemElkConfig +from apps.settings.runtime_config import get_elk_config, invalidate +from apps.webhook.elk_actions import ELKActionProcessor from apps.webhook.schemas import WebhookResult from apps.webhook.service import ( WebhookRedisError, @@ -28,6 +31,26 @@ class FailingRedisClient: raise RuntimeError("redis unavailable") +class FakeElkClient: + def __init__(self): + self.searches = [] + + def search(self, **kwargs): + self.searches.append(kwargs) + return { + "hits": { + "hits": [ + { + "_source": { + "rule": {"name": "rule-without-hits"}, + "context": {"hits": []}, + } + } + ] + } + } + + class WebhookServiceTests(TestCase): def set_stream_maxlen(self, value): config = RuntimeConfig.get_current() @@ -108,6 +131,30 @@ class WebhookServiceTests(TestCase): ) +class ELKActionProcessorTests(TestCase): + def test_process_once_refreshes_config_cached_before_settings_change(self): + config = SiemElkConfig.get_current() + config.host = "https://elk.example.com:9200" + config.api_key = "test-api-key" + config.process_alert_from_index_enabled = False + config.save() + invalidate("elk") + + elk_client = FakeElkClient() + processor = ELKActionProcessor(elk_client=elk_client, interval_seconds=60) + self.assertFalse(get_elk_config()["process_alert_from_index_enabled"]) + + config.process_alert_from_index_enabled = True + config.save(update_fields=["process_alert_from_index_enabled", "updated_at"]) + + end_time = timezone.now() + result = processor.process_once(start_time=end_time - timedelta(seconds=60), end_time=end_time) + + self.assertEqual(result.actions, 1) + self.assertEqual(result.skipped, 1) + self.assertEqual(len(elk_client.searches), 1) + + class WebhookAPITests(SimpleTestCase): def setUp(self): self.client = APIClient() diff --git a/deploy/asp-compose/compose.yaml b/deploy/asp-compose/compose.yaml index 2173b23..a3e219b 100644 --- a/deploy/asp-compose/compose.yaml +++ b/deploy/asp-compose/compose.yaml @@ -35,7 +35,7 @@ services: asp-asgi: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget --no-check-certificate -qO- https://localhost/ >/dev/null"] + test: ["CMD-SHELL", "wget --no-check-certificate -qO- https://127.0.0.1/ >/dev/null"] interval: 30s timeout: 5s retries: 5