From 333daf5819c47024dbbea36de855fe8a90bcebdd Mon Sep 17 00:00:00 2001 From: drosetti Date: Wed, 10 Sep 2025 15:05:31 +0200 Subject: [PATCH 01/23] added test for plugins --- api_app/ingestors_manager/views.py | 11 +- api_app/views.py | 8 +- tests/api_app/analyzers_manager/test_views.py | 96 +++++++++ .../api_app/connectors_manager/test_views.py | 90 ++++++++ tests/api_app/ingestors_manager/test_views.py | 195 ++++++++++++++++++ tests/api_app/pivots_manager/test_views.py | 87 ++++++++ tests/api_app/playbooks_manager/test_views.py | 60 ++++++ .../api_app/visualizers_manager/test_views.py | 42 ++++ 8 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 tests/api_app/ingestors_manager/test_views.py diff --git a/api_app/ingestors_manager/views.py b/api_app/ingestors_manager/views.py index 13b0c732b5..1609b58981 100644 --- a/api_app/ingestors_manager/views.py +++ b/api_app/ingestors_manager/views.py @@ -4,6 +4,7 @@ import logging from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from api_app.ingestors_manager.models import IngestorConfig @@ -16,10 +17,16 @@ class IngestorConfigViewSet(PythonConfigViewSet): serializer_class = IngestorConfigSerializer - def disable_in_org(self, request, pk=None): + @action( + methods=["post"], + detail=True, + url_path="organization", + ) + def disable_in_org(self, request, name=None): return Response(status=status.HTTP_404_NOT_FOUND) - def enable_in_org(self, request, pk=None): + @disable_in_org.mapping.delete + def enable_in_org(self, request, name=None): return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/api_app/views.py b/api_app/views.py index e12d80d572..1e0f019851 100644 --- a/api_app/views.py +++ b/api_app/views.py @@ -7,6 +7,7 @@ from abc import ABCMeta, abstractmethod from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, Q from django.db.models.functions import Trunc from django.http import FileResponse @@ -1454,7 +1455,10 @@ def get_permissions(self): ) def plugin_config(self, request, name=None): logger.info(f"get plugin_config from user {request.user}, name {name}") - obj: PythonConfig = self.get_queryset().get(name=name) + try: + obj: PythonConfig = self.get_queryset().get(name=name) + except ObjectDoesNotExist: + raise NotFound("Requested plugin does not exist.") try: plugin_configs: PluginConfig = PluginConfig.objects.filter( **{obj.snake_case_name: obj.pk} @@ -1494,7 +1498,7 @@ def plugin_config(self, request, name=None): param_obj["exist"] = True org_config.append(copy.deepcopy(param_obj)) # override default config with user config (if any) - print(pc.data) + logger.debug(pc.data) for config in [ config for config in pc.data diff --git a/tests/api_app/analyzers_manager/test_views.py b/tests/api_app/analyzers_manager/test_views.py index 5c27343d06..01362a2fd4 100644 --- a/tests/api_app/analyzers_manager/test_views.py +++ b/tests/api_app/analyzers_manager/test_views.py @@ -176,6 +176,102 @@ def test_delete(self): response = self.client.delete(f"{self.URL}/{ac1.name}") self.assertEqual(response.status_code, 204) + def test_get(self): + # 1 - existing analyzer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Quad9_DNS") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 30}, + "description": "Retrieve current domain resolution with Quad9 DoH (DNS over " + "HTTPS)", + "disabled": True, + "docker_based": False, + "id": 101, + "mapping_data_model": {}, + "maximum_tlp": "AMBER", + "name": "Quad9_DNS", + "not_supported_filetypes": [], + "observable_supported": ["domain", "url"], + "parameters": { + "query_type": { + "description": "Query type against the chosen " "DNS resolver.", + "id": 206, + "is_secret": False, + "required": False, + "type": "str", + "value": None, + } + }, + "python_module": "dns.dns_resolvers.quad9_dns_resolver.Quad9DNSResolver", + "run_hash": False, + "run_hash_type": "", + "supported_filetypes": [], + "type": "observable", + }, + ) + # 2 - missing analyzer + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No AnalyzerConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing analyzer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Quad9_DNS/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + result["user_config"][0].pop( + "updated_at" + ) # auto filled by the model and hard to mock + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": "Quad9_DNS", + "attribute": "query_type", + "connector_config": None, + "description": "Query type against the chosen DNS resolver.", + "exist": True, + "for_organization": False, + "id": 159, + "ingestor_config": None, + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 206, + "pivot_config": None, + "required": False, + "type": "str", + "value": "A", + "visualizer_config": None, + } + ], + }, + ) + # 2 - existing analyzer, no config + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Quad9_Malicious_Detector/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), {"organization_config": [], "user_config": []} + ) + # 3 - missing analyzer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/missing_analyzer/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"analyzer config": "Requested plugin does not exist."}}, + ) + class AnalyzerActionViewSetTests(CustomViewSetTestCase, PluginActionViewsetTestCase): fixtures = [ diff --git a/tests/api_app/connectors_manager/test_views.py b/tests/api_app/connectors_manager/test_views.py index e9cf499a0b..575c83e497 100644 --- a/tests/api_app/connectors_manager/test_views.py +++ b/tests/api_app/connectors_manager/test_views.py @@ -48,6 +48,96 @@ def test_health_check(self): pc1.delete() pc2.delete() + def test_get(self): + # 1 - existing connector + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Slack") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "description": "Send the analysis link to a slack channel", + "disabled": True, + "id": 3, + "maximum_tlp": "RED", + "name": "Slack", + "python_module": 3, + "run_on_failure": True, + }, + ) + # 2 - missing connector + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No ConnectorConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing connector + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Slack/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + # auto filled by the model and hard to mock + for user_config in result["user_config"]: + user_config.pop("updated_at", "") + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": None, + "attribute": "slack_username", + "connector_config": "Slack", + "description": "Slack username to tag on the message", + "exist": True, + "for_organization": False, + "id": 8, + "ingestor_config": None, + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 12, + "pivot_config": None, + "required": False, + "type": "str", + "value": "", + "visualizer_config": None, + }, + { + "attribute": "token", + "description": "Slack token for authentication", + "exist": False, + "is_secret": True, + "parameter": 13, + "required": True, + "type": "str", + "value": None, + }, + { + "attribute": "channel", + "description": "Slack channel to send messages", + "exist": False, + "is_secret": True, + "parameter": 14, + "required": True, + "type": "str", + "value": None, + }, + ], + }, + ) + # 2 - missing connector + response = self.client.get(f"{self.URL}/missing_connector/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"connector config": "Requested plugin does not exist."}}, + ) + class ConnectorActionViewSetTests(CustomViewSetTestCase, PluginActionViewsetTestCase): fixtures = [ diff --git a/tests/api_app/ingestors_manager/test_views.py b/tests/api_app/ingestors_manager/test_views.py new file mode 100644 index 0000000000..c89630fc82 --- /dev/null +++ b/tests/api_app/ingestors_manager/test_views.py @@ -0,0 +1,195 @@ +# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl +# See the file 'LICENSE' for copying permission. + +from api_app.ingestors_manager.models import IngestorConfig +from certego_saas.apps.organization.organization import Membership, Organization +from tests import CustomViewSetTestCase +from tests.api_app.test_views import AbstractConfigViewSetTestCaseMixin + + +class IngestorConfigViewSetTestCase( + AbstractConfigViewSetTestCaseMixin, CustomViewSetTestCase +): + URL = "/api/ingestor" + + @classmethod + @property + def model_class(cls) -> IngestorConfig: + return IngestorConfig + + def test_organization_disable(self): + org, _ = Organization.objects.get_or_create(name="test") + Membership.objects.get_or_create( + user=self.user, organization=org, is_owner=False + ) + response = self.client.post(f"{self.URL}/GreedyBear/organization") + self.assertEqual(response.status_code, 404) + + def test_organization_enable(self): + org, _ = Organization.objects.get_or_create(name="test") + Membership.objects.get_or_create( + user=self.user, organization=org, is_owner=False + ) + response = self.client.delete(f"{self.URL}/GreedyBear/organization") + self.assertEqual(response.status_code, 404) + + def test_get(self): + # 1 - existing ingestor + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/GreedyBear") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "delay": "00:00:00", + "description": "Queries feeds which are generated by the [GreedyBear " + "Project](https://intelowlproject.github.io/docs/GreedyBear/Introduction/).", + "disabled": True, + "health_check_status": True, + "health_check_task": None, + "id": 4, + "maximum_jobs": 50, + "name": "GreedyBear", + "playbooks_choice": ["Popular_IP_Reputation_Services"], + "python_module": 215, + "routing_key": "ingestor", + "schedule": { + "day_of_month": "*", + "day_of_week": "*", + "hour": "0", + "minute": "0", + "month_of_year": "*", + }, + "soft_time_limit": 60, + }, + ) + # 2 - missing ingestor + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No IngestorConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing ingestor + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/GreedyBear/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + # auto filled by the model and hard to mock + for user_config in result["user_config"]: + user_config.pop("updated_at", "") + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": None, + "attribute": "url", + "connector_config": None, + "description": "API endpoint", + "exist": True, + "for_organization": False, + "id": 353, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 538, + "pivot_config": None, + "required": False, + "type": "str", + "value": "https://greedybear.honeynet.org", + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "limit", + "connector_config": None, + "description": "Max number of results.", + "exist": True, + "for_organization": False, + "id": 354, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 539, + "pivot_config": None, + "required": False, + "type": "int", + "value": 50, + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "feed_type", + "connector_config": None, + "description": "The available feed types are log4j, cowrie, " + "and all.", + "exist": True, + "for_organization": False, + "id": 355, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 540, + "pivot_config": None, + "required": False, + "type": "str", + "value": "all", + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "attack_type", + "connector_config": None, + "description": "The available attack_type are scanner, " + "payload_request, and all.", + "exist": True, + "for_organization": False, + "id": 356, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 541, + "pivot_config": None, + "required": False, + "type": "str", + "value": "all", + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "age", + "connector_config": None, + "description": "The available age are recent and persistent.", + "exist": True, + "for_organization": False, + "id": 357, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 542, + "pivot_config": None, + "required": False, + "type": "str", + "value": "recent", + "visualizer_config": None, + }, + ], + }, + ) + # 2 - missing ingestor + response = self.client.get(f"{self.URL}/missing_ingestor/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"ingestor config": "Requested plugin does not exist."}}, + ) diff --git a/tests/api_app/pivots_manager/test_views.py b/tests/api_app/pivots_manager/test_views.py index af3871fc51..ac9f08f855 100644 --- a/tests/api_app/pivots_manager/test_views.py +++ b/tests/api_app/pivots_manager/test_views.py @@ -208,3 +208,90 @@ def test_delete(self): self.client.force_authenticate(m_user.user) response = self.client.delete(f"{self.URL}/{pc1.name}") self.assertEqual(response.status_code, 204) + + def test_get(self): + # 1 - existing pivot + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/AbuseIpToSubmission") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "delay": "00:00:00", + "description": "This Plugin leverages results from the Abusix analyzer to " + "extract the abuse contacts of an IP address to pivot to the " + "AbuseSubmitter connector.", + "disabled": True, + "health_check_status": True, + "health_check_task": None, + "id": 1, + "name": "AbuseIpToSubmission", + "parameters": { + "field_to_compare": { + "description": "Dotted path to the field", + "id": 315, + "is_secret": False, + "required": True, + "type": "str", + "value": None, + } + }, + "playbooks_choice": ["Send_Abuse_Email"], + "python_module": "compare.Compare", + "related_analyzer_configs": ["Abusix"], + "related_configs": ["Abusix"], + "related_connector_configs": [], + "routing_key": "default", + "soft_time_limit": 60, + }, + ) + # 2 - missing pivot + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual(result, {"detail": "No PivotConfig matches the given query."}) + + def test_get_config(self): + # 1 - existing pivot + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/AbuseIpToSubmission/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + result["user_config"][0].pop( + "updated_at" + ) # auto filled by the model and hard to mock + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": None, + "attribute": "field_to_compare", + "connector_config": None, + "description": "Dotted path to the field", + "exist": True, + "for_organization": False, + "id": 291, + "ingestor_config": None, + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 315, + "pivot_config": "AbuseIpToSubmission", + "required": True, + "type": "str", + "value": "abuse_contacts.0", + "visualizer_config": None, + } + ], + }, + ) + # 3 - missing pivot + response = self.client.get(f"{self.URL}/missing_pivot/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"pivot config": "Requested plugin does not exist."}}, + ) diff --git a/tests/api_app/playbooks_manager/test_views.py b/tests/api_app/playbooks_manager/test_views.py index 96147c57c7..14f2fceb4c 100644 --- a/tests/api_app/playbooks_manager/test_views.py +++ b/tests/api_app/playbooks_manager/test_views.py @@ -164,3 +164,63 @@ def test_create(self): pc.delete() finally: ac.delete() + + def test_get(self): + # 1 - existing visualizer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Dns") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "analyzers": [ + "AdGuard", + "Classic_DNS", + "CloudFlare_DNS", + "CloudFlare_Malicious_Detector", + "DNS0_EU", + "DNS0_EU_Malicious_Detector", + "Google_DNS", + "Quad9_DNS", + "Quad9_Malicious_Detector", + "UltraDNS_DNS", + "UltraDNS_Malicious_Detector", + ], + "connectors": [], + "description": "Retrieve information from DNS about the domain", + "disabled": False, + "for_organization": False, + "id": 1, + "is_editable": False, + "name": "Dns", + "owner": None, + "pivots": [], + "runtime_configuration": { + "analyzers": {}, + "connectors": {}, + "pivots": {}, + "visualizers": {}, + }, + "scan_check_time": "1:00:00:00", + "scan_mode": 2, + "starting": True, + "tags": [], + "tlp": "AMBER", + "type": ["domain"], + "visualizers": ["DNS"], + "weight": 0, + }, + ) + # 2 - missing playbook + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No PlaybookConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing playbook + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Dns/plugin_config") + self.assertEqual(response.status_code, 404, response.content) diff --git a/tests/api_app/visualizers_manager/test_views.py b/tests/api_app/visualizers_manager/test_views.py index b79fb8ebea..0d5d523916 100644 --- a/tests/api_app/visualizers_manager/test_views.py +++ b/tests/api_app/visualizers_manager/test_views.py @@ -16,3 +16,45 @@ class VisualizerConfigViewSetTestCase( @property def model_class(cls) -> Type[VisualizerConfig]: return VisualizerConfig + + def test_get(self): + # 1 - existing visualizer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/DNS") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "description": "Visualize information about DNS resolvers and DNS malicious " + "detectors", + "disabled": True, + "id": 1, + "name": "DNS", + "playbooks": ["Dns"], + "python_module": 128, + }, + ) + # 2 - missing visualizer + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No VisualizerConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing visualizer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/DNS/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), {"organization_config": [], "user_config": []} + ) + # 2 - missing visualizer + response = self.client.get(f"{self.URL}/missing_visualizer/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"visualizer config": "Requested plugin does not exist."}}, + ) From 8141a2ebbf465f5cc6f654a61350a98eea7ffc7b Mon Sep 17 00:00:00 2001 From: drosetti Date: Thu, 11 Sep 2025 12:37:35 +0200 Subject: [PATCH 02/23] fixed utc filter in history --- frontend/src/components/Routes.jsx | 8 ++++---- .../investigations/table/InvestigationsTable.jsx | 14 ++++++++++---- frontend/src/components/jobs/table/JobsTable.jsx | 13 ++++++++++--- .../src/components/userEvents/UserEventsTable.jsx | 14 ++++++++++---- frontend/src/constants/miscConst.js | 1 + 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Routes.jsx b/frontend/src/components/Routes.jsx index 0e9c206216..79828a4558 100644 --- a/frontend/src/components/Routes.jsx +++ b/frontend/src/components/Routes.jsx @@ -2,10 +2,10 @@ import React, { Suspense } from "react"; import { FallBackLoading } from "@certego/certego-ui"; import { Navigate, useParams } from "react-router-dom"; -import { format } from "date-fns"; +import { fromZonedTime } from "date-fns-tz"; import AuthGuard from "../wrappers/AuthGuard"; import IfAuthRedirectGuard from "../wrappers/IfAuthRedirectGuard"; -import { datetimeFormatStr, JobResultSections } from "../constants/miscConst"; +import { JobResultSections, localTimezone } from "../constants/miscConst"; const Home = React.lazy(() => import("./home/Home")); const Login = React.lazy(() => import("./auth/Login")); @@ -50,9 +50,9 @@ function CustomRedirect() { return ( diff --git a/frontend/src/components/investigations/table/InvestigationsTable.jsx b/frontend/src/components/investigations/table/InvestigationsTable.jsx index 85765aec22..824a0dea98 100644 --- a/frontend/src/components/investigations/table/InvestigationsTable.jsx +++ b/frontend/src/components/investigations/table/InvestigationsTable.jsx @@ -21,10 +21,10 @@ import { import useTitle from "react-use/lib/useTitle"; -import { format } from "date-fns-tz"; +import { format, fromZonedTime } from "date-fns-tz"; import { INVESTIGATION_BASE_URI } from "../../../constants/apiURLs"; import { investigationTableColumns } from "./investigationTableColumns"; -import { datetimeFormatStr } from "../../../constants/miscConst"; +import { datetimeFormatStr, localTimezone } from "../../../constants/miscConst"; import { TimePicker } from "../../common/TimePicker"; // constants @@ -90,12 +90,18 @@ export function InvestigationTable({ // this update the value after some times, this give user time to pick the datetime useDebounceInput( - { name: "start_time__gte", value: fromDateType }, + { + name: "start_time__gte", + value: fromZonedTime(fromDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); useDebounceInput( - { name: "start_time__lte", value: toDateType }, + { + name: "start_time__lte", + value: fromZonedTime(toDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); diff --git a/frontend/src/components/jobs/table/JobsTable.jsx b/frontend/src/components/jobs/table/JobsTable.jsx index 46285be233..945664b6a5 100644 --- a/frontend/src/components/jobs/table/JobsTable.jsx +++ b/frontend/src/components/jobs/table/JobsTable.jsx @@ -3,6 +3,7 @@ import React from "react"; import { Container, Row, Col, UncontrolledTooltip } from "reactstrap"; import { MdInfoOutline } from "react-icons/md"; +import { fromZonedTime } from "date-fns-tz"; import { Loader, SyncButton, @@ -16,7 +17,7 @@ import { format } from "date-fns"; import { jobTableColumns } from "./jobTableColumns"; import { JOB_BASE_URI } from "../../../constants/apiURLs"; import { usePluginConfigurationStore } from "../../../stores/usePluginConfigurationStore"; -import { datetimeFormatStr } from "../../../constants/miscConst"; +import { datetimeFormatStr, localTimezone } from "../../../constants/miscConst"; import { TimePicker } from "../../common/TimePicker"; // constants @@ -75,12 +76,18 @@ export function JobsTable({ searchFromDateValue, searchToDateValue }) { // this update the value after some times, this give user time to pick the datetime useDebounceInput( - { name: "received_request_time__gte", value: fromDateType }, + { + name: "received_request_time__gte", + value: fromZonedTime(fromDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); useDebounceInput( - { name: "received_request_time__lte", value: toDateType }, + { + name: "received_request_time__lte", + value: fromZonedTime(toDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); diff --git a/frontend/src/components/userEvents/UserEventsTable.jsx b/frontend/src/components/userEvents/UserEventsTable.jsx index 5971d5b5e6..47528e5d3d 100644 --- a/frontend/src/components/userEvents/UserEventsTable.jsx +++ b/frontend/src/components/userEvents/UserEventsTable.jsx @@ -13,8 +13,8 @@ import { import useTitle from "react-use/lib/useTitle"; -import { format } from "date-fns-tz"; -import { datetimeFormatStr } from "../../constants/miscConst"; +import { format, fromZonedTime } from "date-fns-tz"; +import { datetimeFormatStr, localTimezone } from "../../constants/miscConst"; import { TimePicker } from "../common/TimePicker"; import { JsonEditor } from "../common/JsonEditor"; @@ -108,12 +108,18 @@ export function UserEventsTable({ // this update the value after some times, this give user time to pick the datetime useDebounceInput( - { name: "event_date__gte", value: fromDateType }, + { + name: "event_date__gte", + value: fromZonedTime(fromDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); useDebounceInput( - { name: "event_date__lte", value: toDateType }, + { + name: "event_date__lte", + value: fromZonedTime(toDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); diff --git a/frontend/src/constants/miscConst.js b/frontend/src/constants/miscConst.js index 9770f14abe..8ccace65ea 100644 --- a/frontend/src/constants/miscConst.js +++ b/frontend/src/constants/miscConst.js @@ -23,6 +23,7 @@ export const HTTPMethods = Object.freeze({ }); export const datetimeFormatStr = "yyyy-MM-dd'T'HH:mm:ss"; +export const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; export const HistoryPages = Object.freeze({ JOBS: "jobs", From ed6d2d21aac74cc5ffd84f48d3aa86b61c9fd47e Mon Sep 17 00:00:00 2001 From: drosetti Date: Fri, 12 Sep 2025 10:23:07 +0200 Subject: [PATCH 03/23] added description to kill chain phases in user event --- .../components/userEvents/UserEventModal.jsx | 68 ++++++++++++------- frontend/src/constants/dataModelConst.js | 16 +++++ .../userEvents/UserEventModal.test.jsx | 20 ++---- 3 files changed, 65 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/userEvents/UserEventModal.jsx b/frontend/src/components/userEvents/UserEventModal.jsx index 56bb10fe40..c205adb264 100644 --- a/frontend/src/components/userEvents/UserEventModal.jsx +++ b/frontend/src/components/userEvents/UserEventModal.jsx @@ -22,9 +22,11 @@ import { MdInfoOutline } from "react-icons/md"; import { ArrowToggleIcon, addToast, + selectStyles, useDebounceInput, } from "@certego/certego-ui"; +import ReactSelect from "react-select"; import { USER_EVENT_ANALYZABLE, USER_EVENT_IP_WILDCARD, @@ -34,6 +36,7 @@ import { import { Evaluations, DataModelKillChainPhases, + DataModelKillChainPhasesDescriptions, } from "../../constants/dataModelConst"; import { ListInput } from "../common/form/ListInput"; import { TagSelectInput } from "../common/form/TagSelectInput"; @@ -114,11 +117,18 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { const editedFields = {}; Object.entries(formik.values).forEach(([key, value]) => { if ( - JSON.stringify(value) !== JSON.stringify(formik.initialValues[key]) && - key !== "analyzables" + /* order matters! kill chain also HTML and cannot be converted into JSON + check before the fields and then check if they are different from the default values + */ + !["analyzables", "kill_chain_phase"].includes(key) && + JSON.stringify(value) !== JSON.stringify(formik.initialValues[key]) ) { editedFields[key] = value; } + // special cases for kill chain: it has a key with html as value + if (formik.values.kill_chain_phase !== "") { + editedFields.kill_chain_phase = formik.values.kill_chain_phase.value; + } }); const evaluation = { decay_progression: formik.values.decay_progression, @@ -553,28 +563,40 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { Kill chain phase: - - - - {Object.values(DataModelKillChainPhases) + + ( - - ))} - + .map((killChainPhase) => ({ + value: killChainPhase, + label: ( +
+
+
{killChainPhase} 
+
+ {DataModelKillChainPhasesDescriptions[ + killChainPhase.toUpperCase() + ] || ""} +
+
+
+ ), + }))} + styles={selectStyles} + value={formik.values.kill_chain_phase} + onChange={(killChainPhase) => + formik.setFieldValue( + "kill_chain_phase", + killChainPhase, + false, + ) + } + />
diff --git a/frontend/src/constants/dataModelConst.js b/frontend/src/constants/dataModelConst.js index 0de76c7b4a..04cc811fde 100644 --- a/frontend/src/constants/dataModelConst.js +++ b/frontend/src/constants/dataModelConst.js @@ -28,3 +28,19 @@ export const DataModelKillChainPhases = Object.freeze({ C2: "c2", ACTION: "action", }); + +export const DataModelKillChainPhasesDescriptions = Object.freeze({ + RECONNAISSANCE: + "Attackers research their target to identify vulnerabilities, gathering information on security defenses, corporate structure, and potential entry points.", + WEAPONIZATION: + "The attacker crafts a malicious payload, such as a virus or malware, tailored to exploit the identified weaknesses.", + DELIVERY: + "The weaponized payload is sent to the target, often through methods like phishing emails or malicious links.", + EXPLOITATION: + "The malicious code runs on the target system, exploiting the vulnerability and gaining access.", + INSTALLATION: + "The malware establishes persistent access on the compromised system, often by installing backdoors or trojans.", + C2: "Attackers establish a remote communication channel with the compromised system to issue commands and control their operation.", + ACTION: + "The final phase where attackers achieve their ultimate goals, such as stealing data exfiltrating them, encrypting files for ransom, or disrupting services.", +}); diff --git a/frontend/tests/components/userEvents/UserEventModal.test.jsx b/frontend/tests/components/userEvents/UserEventModal.test.jsx index 5ac37767e0..50fc05f9ec 100644 --- a/frontend/tests/components/userEvents/UserEventModal.test.jsx +++ b/frontend/tests/components/userEvents/UserEventModal.test.jsx @@ -79,10 +79,7 @@ describe("test UserEventModal component", () => { const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); // advanced fields @@ -196,10 +193,7 @@ describe("test UserEventModal component", () => { const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); const advancedFields = screen.getByRole("button", { name: /Advanced fields/i, @@ -325,10 +319,7 @@ describe("test UserEventModal component", () => { const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); const advancedFields = screen.getByRole("button", { name: /Advanced fields/i, @@ -396,10 +387,7 @@ describe("test UserEventModal component", () => { const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); // advanced fields From 25c8ceb19c91f0f2d34463dc9d0f8c991f3c4035 Mon Sep 17 00:00:00 2001 From: drosetti Date: Fri, 12 Sep 2025 18:15:26 +0200 Subject: [PATCH 04/23] customer dropdown for evaluation --- .../components/userEvents/UserEventModal.jsx | 118 +++++++++++----- .../userEvents/UserEventModal.test.jsx | 128 +++++++++++++++--- 2 files changed, 193 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/userEvents/UserEventModal.jsx b/frontend/src/components/userEvents/UserEventModal.jsx index c205adb264..44c830ce74 100644 --- a/frontend/src/components/userEvents/UserEventModal.jsx +++ b/frontend/src/components/userEvents/UserEventModal.jsx @@ -54,6 +54,37 @@ import { HASH_REGEX, } from "../../constants/regexConst"; +const evaluationOptions = Object.freeze([ + Object.freeze({ + label: "EXTREMELY EVIL", + description: + "Set the evaluation to malicious with max reliability. Set to malicious artifacts related to malwares.", + evaluation: Evaluations.MALICIOUS, + reliability: 10, + }), + Object.freeze({ + label: "MALICIOUS", + description: + "Set the evaluation to malicious with medium reliability. Set to malicious artifacts that COULD be related to malwares.", + evaluation: Evaluations.MALICIOUS, + reliability: 6, + }), + Object.freeze({ + label: "CLEAN", + description: + "Set the evaluation to trusted with medium reliability. Set to trusted artifacts previously infected.", + evaluation: Evaluations.TRUSTED, + reliability: 6, + }), + Object.freeze({ + label: "TRUSTED", + description: + "Set the evaluation to trusted with max reliability. Set to trusted artifacts that will NEVER be related to malicious behaviours.", + evaluation: Evaluations.TRUSTED, + reliability: 10, + }), +]); + export function UserEventModal({ analyzables, toggle, isOpen }) { console.debug("UserEventModal rendered!"); @@ -120,16 +151,29 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { /* order matters! kill chain also HTML and cannot be converted into JSON check before the fields and then check if they are different from the default values */ - !["analyzables", "kill_chain_phase"].includes(key) && + !["evaluation", "analyzables", "kill_chain_phase"].includes(key) && JSON.stringify(value) !== JSON.stringify(formik.initialValues[key]) ) { editedFields[key] = value; } // special cases for kill chain: it has a key with html as value - if (formik.values.kill_chain_phase !== "") { + if (key === "kill_chain_phase" && value !== "") { editedFields.kill_chain_phase = formik.values.kill_chain_phase.value; } + // special cases for evaluation: it has a key with html and some other fields + if (key === "evaluation" && value !== "") { + console.debug(key, value, value.value); + console.debug( + evaluationOptions.find( + (evOption) => evOption.label === value.value, + ), + ); + editedFields[key] = evaluationOptions.find( + (evOption) => evOption.label === value.value, + ).evaluation; + } }); + console.debug("editedFields", editedFields); const evaluation = { decay_progression: formik.values.decay_progression, decay_timedelta_days: formik.values.decay_timedelta_days, @@ -138,6 +182,7 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { reliability: formik.values.reliability, }, }; + console.debug("evaluation", evaluation); const failed = []; Promise.allSettled( @@ -298,6 +343,8 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [wildcard]); + console.debug(formik.values); + return ( - - - - {[Evaluations.MALICIOUS, Evaluations.TRUSTED] - .sort() - .map((value) => ( -