diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py index 2c8a83cbc..bb35950ea 100644 --- a/source/app/blueprints/rest/case/case_timeline_routes.py +++ b/source/app/blueprints/rest/case/case_timeline_routes.py @@ -707,7 +707,7 @@ def case_edit_event(cur_id, caseid): return response_success("Event updated", data=event_dump) except ValidationError as e: - return response_error(e.get_message(), data=e.get_data()) + return response_error('Data error', data=e.normalized_messages()) except BusinessProcessingError as e: return response_error(e.get_message(), data=e.get_data()) diff --git a/source/app/business/events.py b/source/app/business/events.py index 573c12179..9f7b9a673 100644 --- a/source/app/business/events.py +++ b/source/app/business/events.py @@ -35,12 +35,55 @@ from app.iris_engine.module_handler.module_handler import call_modules_hook +def _validate_event_parent_relationship(event: CasesEvent): + """Validate that parent assignment stays inside case and does not create cycles.""" + parent_event_id = event.parent_event_id + if parent_event_id is None: + return + + if parent_event_id == event.event_id: + raise BusinessProcessingError('An event cannot be its own parent') + + visited_parent_ids = set() + guard = 0 + max_depth = 10000 + + while parent_event_id is not None and guard < max_depth: + if parent_event_id in visited_parent_ids: + raise BusinessProcessingError('Invalid parent event hierarchy') + visited_parent_ids.add(parent_event_id) + + parent_event = CasesEvent.query.with_entities( + CasesEvent.event_id, + CasesEvent.case_id, + CasesEvent.parent_event_id + ).filter( + CasesEvent.event_id == parent_event_id + ).first() + if not parent_event: + raise BusinessProcessingError('Invalid parent event ID') + + if parent_event.case_id != event.case_id: + raise BusinessProcessingError('Parent event must belong to the same case') + + if parent_event.event_id == event.event_id: + raise BusinessProcessingError('Parent event assignment would create a cycle') + + parent_event_id = parent_event.parent_event_id + guard += 1 + + if guard >= max_depth: + raise BusinessProcessingError('Parent event hierarchy is too deep') + + def events_create(case_identifier, event: CasesEvent, event_category_id, event_assets, event_iocs, sync_iocs_assets) -> CasesEvent: event.case_id = case_identifier event.event_added = datetime.utcnow() event.user_id = iris_current_user.id + _validate_event_parent_relationship(event) + add_obj_history_entry(event, 'created') db.session.add(event) @@ -75,6 +118,8 @@ def events_get(identifier) -> CasesEvent: def events_update(event: CasesEvent, event_category_id, event_assets, event_iocs, event_sync_iocs_assets) -> CasesEvent: + _validate_event_parent_relationship(event) + add_obj_history_entry(event, 'updated') update_timeline_state(event.case_id) diff --git a/tests/tests_rest_events.py b/tests/tests_rest_events.py index f93bf837c..3267b26dd 100644 --- a/tests/tests_rest_events.py +++ b/tests/tests_rest_events.py @@ -368,6 +368,32 @@ def test_update_event_should_set_event_parent_id_when_provided(self): response = self._subject.update(f'/api/v2/cases/{case_identifier}/events/{identifier}', body).json() self.assertEqual(parent_event_identifier, response['parent_event_id']) + def test_update_event_should_return_400_when_parent_assignment_creates_cycle(self): + case_identifier = self._subject.create_dummy_case() + + parent_body = {'event_title': 'parent', 'event_category_id': 1, + 'event_date': '2025-03-26T00:00:00.000', 'event_tz': '+00:00', + 'event_assets': [], 'event_iocs': []} + parent_event = self._subject.create(f'/api/v2/cases/{case_identifier}/events', parent_body).json() + + child_body = {'event_title': 'child', 'event_category_id': 1, + 'event_date': '2025-03-26T01:00:00.000', 'event_tz': '+00:00', + 'event_assets': [], 'event_iocs': [], + 'parent_event_id': parent_event['event_id']} + child_event = self._subject.create(f'/api/v2/cases/{case_identifier}/events', child_body).json() + + # Try to make the parent a child of its own child: this must be rejected. + update_parent_body = {'event_title': 'parent', 'event_category_id': 1, + 'event_date': '2025-03-26T00:00:00.000', 'event_tz': '+00:00', + 'event_assets': [], 'event_iocs': [], + 'parent_event_id': child_event['event_id']} + + response = self._subject.update( + f'/api/v2/cases/{case_identifier}/events/{parent_event["event_id"]}', + update_parent_body + ) + self.assertEqual(400, response.status_code) + def test_delete_event_should_return_204(self): case_identifier = self._subject.create_dummy_case() body = {'event_title': 'title', 'event_category_id': 1, diff --git a/tests/tests_rest_miscellaneous.py b/tests/tests_rest_miscellaneous.py index 389c3ebb2..bb71c4563 100644 --- a/tests/tests_rest_miscellaneous.py +++ b/tests/tests_rest_miscellaneous.py @@ -56,6 +56,28 @@ def test_get_timeline_state_should_return_200(self): response = self._subject.get('/case/timeline/state', query_parameters={'cid': 1}) self.assertEqual(200, response.status_code) + def test_legacy_timeline_event_update_should_return_400_for_validation_error(self): + case_identifier = self._subject.create_dummy_case() + body = {'event_title': 'title', 'event_category_id': 1, + 'event_date': '2025-03-26T00:00:00.000', 'event_tz': '+00:00', + 'event_assets': [], 'event_iocs': []} + event = self._subject.create(f'/api/v2/cases/{case_identifier}/events', body).json() + + invalid_update_payload = {'event_title': 'title', + 'event_category_id': None, + 'event_date': '2025-03-26T00:00:00.000', + 'event_tz': '+00:00', + 'event_assets': [], + 'event_iocs': []} + + response = self._subject.create( + f'/case/timeline/events/update/{event["event_id"]}', + invalid_update_payload, + query_parameters={'cid': case_identifier} + ) + + self.assertEqual(400, response.status_code) + # TODO should probably move this in a test suite related to modules? # TODO skipping this tests, because it randomly triggers exceptions in the iriswebappp_worker # (psycopg2.errors.NotNullViolation) null value in column "client_id" violates not-null constraint diff --git a/ui/src/pages/case.timeline.js b/ui/src/pages/case.timeline.js index 1320f15c3..b14acec18 100644 --- a/ui/src/pages/case.timeline.js +++ b/ui/src/pages/case.timeline.js @@ -3,6 +3,7 @@ var selector_active; var current_timeline; var g_event_id = null; var g_event_desc_editor = null; +var timeline_drag_reparent_in_progress = false; function edit_in_event_desc() { if($('#container_event_desc_content').is(':visible')) { @@ -705,6 +706,11 @@ function buildEvent(event_data, compact, comments_map, tree, tesk, tmb, idx, rea + +