From eb13c94a5b737e2dd97d42b0d33e281786effb43 Mon Sep 17 00:00:00 2001 From: fatpeppapig Date: Fri, 22 May 2026 14:54:04 +0200 Subject: [PATCH 001/121] Fixed order of authentication methods for timeline filter endpoint. --- source/app/blueprints/rest/case/case_timeline_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py index 2c8a83cbc..7371f25b4 100644 --- a/source/app/blueprints/rest/case/case_timeline_routes.py +++ b/source/app/blueprints/rest/case/case_timeline_routes.py @@ -327,8 +327,8 @@ def case_gettimeline_api(asset_id, caseid): @case_timeline_rest_blueprint.route('/case/timeline/advanced-filter', methods=['GET']) -@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) @ac_api_requires() +@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) def case_filter_timeline(caseid): args = request.args.to_dict() query_filter = args.get('q') From df0d2451fe01f7f97a6f5c36c7b7d3b9b26282ff Mon Sep 17 00:00:00 2001 From: whitekernel Date: Mon, 22 Jun 2026 16:06:41 +0200 Subject: [PATCH 002/121] [ADD] Datastore and my profile support v2 --- .../rest/case/case_timeline_routes.py | 103 +++- .../rest/v2/case_routes/datastore.py | 540 ++++++++++++++++-- source/app/blueprints/rest/v2/cases.py | 52 +- source/app/blueprints/rest/v2/dashboard.py | 51 +- source/app/blueprints/rest/v2/profile.py | 54 +- source/app/business/cases.py | 5 +- .../app/datamgmt/activities/activities_db.py | 56 ++ source/app/datamgmt/manage/manage_cases_db.py | 32 +- source/app/schema/marshables.py | 25 + tests/tests_rest_profile.py | 43 +- tests/tests_rest_tasks.py | 50 ++ tests/user.py | 3 + 12 files changed, 943 insertions(+), 71 deletions(-) diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py index 7371f25b4..485b5fbc3 100644 --- a/source/app/blueprints/rest/case/case_timeline_routes.py +++ b/source/app/blueprints/rest/case/case_timeline_routes.py @@ -360,9 +360,97 @@ def case_filter_timeline(caseid): sources = filter_d.get('source') flag = filter_d.get('flag') + try: + page = max(1, int(args.get('page', 1))) + except (TypeError, ValueError): + page = 1 + + try: + per_page = int(args.get('per_page', 0)) + except (TypeError, ValueError): + per_page = 0 + if per_page < 0: + per_page = 0 + if per_page > 500: + per_page = 500 + cache, events_list, tim = _extract_timeline(assets, assets_id, caseid, categories, descriptions, end_date, event_ids, flag, iocs, iocs_id, raws, sources, start_date, tags, titles) + total = len(tim) + if per_page > 0: + last_page = max(1, (total + per_page - 1) // per_page) + if page > last_page: + page = last_page + start = (page - 1) * per_page + end = start + per_page + tim_page = tim[start:end] + next_page = page + 1 if page < last_page else None + + # Drag in descendants that live on later pages AND ancestors that + # live on earlier pages, so any event on this slice ships with its + # full lineage. Without this: + # - a child whose parent is on a later page would appear as a + # spurious root until pagination catches up (the frontend + # promotes events with unknown parent_event_id to roots). + # - a child whose parent is on the current page but whose own + # event_date falls into a later slice would be missing from + # the parent's children until later. + # Ancestors that arrived on a previous page are already cached + # client-side, but re-sending them is cheap and keeps each page + # self-consistent if loaded out of order. + all_by_id: dict[int, dict] = {event['event_id']: event for event in tim} + children_by_parent: dict[int, list[dict]] = {} + for event in tim: + parent_id = event.get('parent_event_id') + if parent_id is None: + continue + children_by_parent.setdefault(parent_id, []).append(event) + + page_ids = {event['event_id'] for event in tim_page} + + # Descendants + queue = list(page_ids) + while queue: + parent_id = queue.pop() + for child in children_by_parent.get(parent_id, ()): + cid = child['event_id'] + if cid in page_ids: + continue + page_ids.add(cid) + tim_page.append(child) + queue.append(cid) + + # Ancestors + queue = list(page_ids) + while queue: + event_id = queue.pop() + event = all_by_id.get(event_id) + if not event: + continue + parent_id = event.get('parent_event_id') + if parent_id is None or parent_id in page_ids: + continue + parent = all_by_id.get(parent_id) + if parent is None: + continue + page_ids.add(parent_id) + tim_page.append(parent) + queue.append(parent_id) + else: + last_page = 1 + page = 1 + tim_page = tim + next_page = None + + pagination = { + "total": total, + "per_page": per_page if per_page > 0 else total, + "current_page": page, + "last_page": last_page, + "next_page": next_page + } + if request.cookies.get('session'): iocs = Ioc.query.with_entities( @@ -379,18 +467,20 @@ def case_filter_timeline(caseid): events_comments_map.setdefault(k, []).append(v) resp = { - "tim": tim, + "tim": tim_page, "comments_map": events_comments_map, "assets": cache, "iocs": [ioc._asdict() for ioc in iocs], "categories": [cat.name for cat in get_events_categories()], - "state": get_timeline_state(caseid=caseid) + "state": get_timeline_state(caseid=caseid), + "pagination": pagination } else: resp = { - "timeline": tim, - "state": get_timeline_state(caseid=caseid) + "timeline": tim_page, + "state": get_timeline_state(caseid=caseid), + "pagination": pagination } return response_success("ok", data=resp) @@ -593,7 +683,10 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor if asset.event_id == ras['event_id']: alki.append( { + "id": asset.asset_id, "name": f"{asset.asset_name} ({asset.type})", + "asset_name": asset.asset_name, + "asset_type": asset.type, "ip": asset.asset_ip, "description": asset.asset_description, "compromised": asset.asset_compromise_status_id == CompromiseStatus.compromised.value @@ -609,7 +702,9 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor alki.append( { + "id": ioc.ioc_id, "name": f"{ioc.ioc_value}", + "ioc_value": ioc.ioc_value, "description": ioc.ioc_description } ) diff --git a/source/app/blueprints/rest/v2/case_routes/datastore.py b/source/app/blueprints/rest/v2/case_routes/datastore.py index 65476e45f..ad881eb25 100644 --- a/source/app/blueprints/rest/v2/case_routes/datastore.py +++ b/source/app/blueprints/rest/v2/case_routes/datastore.py @@ -17,100 +17,144 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import base64 +import datetime import marshmallow.exceptions from flask import Blueprint from flask import request from flask import send_file from pathlib import Path +from app.db import db from app.blueprints.access_controls import ac_api_requires from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access from app.blueprints.access_controls import ac_api_return_access_denied +from app.blueprints.iris_user import iris_current_user from app.blueprints.rest.endpoints import response_api_created +from app.blueprints.rest.endpoints import response_api_deleted from app.blueprints.rest.endpoints import response_api_error from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_success from app.business.cases import cases_exists +from app.datamgmt.datastore.datastore_db import datastore_add_child_node +from app.datamgmt.datastore.datastore_db import datastore_add_file_as_evidence +from app.datamgmt.datastore.datastore_db import datastore_add_file_as_ioc +from app.datamgmt.datastore.datastore_db import datastore_delete_file +from app.datamgmt.datastore.datastore_db import datastore_delete_node from app.datamgmt.datastore.datastore_db import datastore_get_file from app.datamgmt.datastore.datastore_db import datastore_get_interactive_path_node from app.datamgmt.datastore.datastore_db import datastore_get_local_file_path +from app.datamgmt.datastore.datastore_db import datastore_get_path_node +from app.datamgmt.datastore.datastore_db import datastore_get_standard_path +from app.datamgmt.datastore.datastore_db import datastore_rename_node +from app.datamgmt.datastore.datastore_db import ds_list_tree from app.iris_engine.utils.tracker import track_activity from app.models.authorization import CaseAccessLevel +from app.models.models import DataStoreFile +from app.models.models import DataStorePath from app.schema.marshables import DSFileSchema +from app.schema.marshables import DSPathSchema +from app.util import add_obj_history_entry + + +_READ_LEVELS = [CaseAccessLevel.read_only, CaseAccessLevel.full_access] +_WRITE_LEVELS = [CaseAccessLevel.full_access] + + +def _check_case_access(case_identifier, access_levels): + if not cases_exists(case_identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access(case_identifier, access_levels): + return ac_api_return_access_denied(caseid=case_identifier) + return None + + +def _truthy(value): + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in ('1', 'true', 'yes', 'on') class DatastoreOperations: - """Case-scoped datastore file operations used by the rich-text editor - (inline image uploads embedded in notes and case summaries). + """Case-scoped datastore operations exposed via the v2 API. - Only the two endpoints the editor actually needs are exposed here: - the interactive upload and the file view. The broader tree / folder - management still lives on the legacy `datastore_rest_blueprint` until - that UI is migrated too. + Mirrors the legacy `/datastore/...` blueprint feature-for-feature so the + new frontend can manage the per-case file tree without falling back to v1 + endpoints. Folder operations are JSON; file operations accept multipart + form data because they carry an uploaded file alongside metadata. """ - @staticmethod - def _check_case_access(case_identifier, access_levels): - if not cases_exists(case_identifier): - return response_api_not_found() + def __init__(self): + self._file_schema = DSFileSchema() + self._path_schema = DSPathSchema() - if not ac_fast_check_current_user_has_case_access(case_identifier, access_levels): - return ac_api_return_access_denied(caseid=case_identifier) + # --------------------------------------------------------------------- + # Tree + # --------------------------------------------------------------------- + def tree(self, case_identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error - return None + return response_api_success(ds_list_tree(case_identifier)) - def add_interactive(self, case_identifier): - access_error = self._check_case_access(case_identifier, [CaseAccessLevel.full_access]) + # --------------------------------------------------------------------- + # File list (flat, paginated). The legacy UI rendered the whole tree at + # once; the new UI also wants a paginated flat view so the data-table + # mode can lazy-load like every other case section. + # --------------------------------------------------------------------- + def list_files(self, case_identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) if access_error: return access_error - dsp = datastore_get_interactive_path_node(case_identifier) - if not dsp: - return response_api_error('Invalid path node for this case') - - dsf_schema = DSFileSchema() try: - js_data = request.get_json() or {} + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 25)) + except (TypeError, ValueError): + return response_api_error('Invalid pagination parameters') - try: - file_content = base64.b64decode(js_data.get('file_content')) - filename = js_data.get('file_original_name') - except Exception as e: - return response_api_error(str(e)) + order_by = request.args.get('order_by', 'file_date_added') + sort_dir = request.args.get('sort_dir', 'desc').lower() + order_column = getattr(DataStoreFile, order_by, None) + if order_column is None: + return response_api_error(f'Invalid order_by field: {order_by}') - if not filename: - return response_api_error('Data error', data={'file_original_name': ['Missing filename']}) + query = DataStoreFile.query.filter(DataStoreFile.file_case_id == case_identifier) + query = query.order_by(order_column.desc() if sort_dir == 'desc' else order_column.asc()) - dsf_sc, existed = dsf_schema.ds_store_file_b64(filename, file_content, dsp, case_identifier) + paginated = query.paginate(page=page, per_page=per_page, error_out=False) - track_activity( - f'File "{dsf_sc.file_original_name}" added to DS', - caseid=case_identifier - ) + return response_api_success({ + 'total': paginated.total, + 'data': self._file_schema.dump(paginated.items, many=True), + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None + }) - # URL is returned relative to /api/v2 so the frontend can embed it - # directly as an . It's a REST path the browser can GET - # without any further rewriting — `cid` is part of the path. - return response_api_created({ - 'existed': existed, - 'file_url': f'/api/v2/cases/{case_identifier}/datastore/files/{dsf_sc.file_id}', - **dsf_schema.dump(dsf_sc) - }) + # --------------------------------------------------------------------- + # File CRUD + # --------------------------------------------------------------------- + def get_file_info(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error - except marshmallow.exceptions.ValidationError as e: - return response_api_error('Data error', data=e.messages) + dsf = datastore_get_file(identifier, case_identifier) + if not dsf: + return response_api_not_found() - def view(self, case_identifier, identifier): - access_error = self._check_case_access( - case_identifier, - [CaseAccessLevel.read_only, CaseAccessLevel.full_access] - ) + data = self._file_schema.dump(dsf) + data.pop('file_local_name', None) + return response_api_success(data) + + def view_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) if access_error: return access_error - # Sanity-check that the file actually belongs to this case before - # resolving its on-disk location. `datastore_get_local_file_path` - # already filters by case id, but this keeps 404s consistent with - # the rest of the v2 case routes. if not datastore_get_file(identifier, case_identifier): return response_api_not_found() @@ -138,6 +182,306 @@ def view(self, case_identifier, identifier): ) return resp + def add_file(self, case_identifier, folder_identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(folder_identifier, case_identifier) + if not dsp: + return response_api_not_found() + + try: + dsf_sc = self._file_schema.load(request.form, partial=True) + + dsf_sc.file_parent_id = dsp.path_id + dsf_sc.added_by_user_id = iris_current_user.id + dsf_sc.file_date_added = datetime.datetime.now() + dsf_sc.file_local_name = 'tmp_xc' + dsf_sc.file_case_id = case_identifier + dsf_sc.file_is_ioc = _truthy(request.form.get('file_is_ioc')) + dsf_sc.file_is_evidence = _truthy(request.form.get('file_is_evidence')) + add_obj_history_entry(dsf_sc, 'created') + + if dsf_sc.file_is_ioc and not dsf_sc.file_password: + dsf_sc.file_password = 'infected' + + db.session.add(dsf_sc) + db.session.commit() + + uploaded = request.files.get('file_content') + if not uploaded: + db.session.delete(dsf_sc) + db.session.commit() + return response_api_error('Missing file content') + + ds_location = datastore_get_standard_path(dsf_sc, case_identifier) + dsf_sc.file_local_name, dsf_sc.file_size, dsf_sc.file_sha256 = self._file_schema.ds_store_file( + uploaded, + ds_location, + dsf_sc.file_is_ioc, + dsf_sc.file_password + ) + db.session.commit() + + if dsf_sc.file_is_ioc: + datastore_add_file_as_ioc(iris_current_user.id, dsf_sc) + if dsf_sc.file_is_evidence: + datastore_add_file_as_evidence(iris_current_user.id, dsf_sc, case_identifier) + + track_activity( + f'File "{dsf_sc.file_original_name}" added to DS', + caseid=case_identifier + ) + return response_api_created(self._file_schema.dump(dsf_sc)) + + except marshmallow.exceptions.ValidationError as e: + return response_api_error('Data error', data=e.messages) + + def update_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsf = datastore_get_file(identifier, case_identifier) + if not dsf: + return response_api_not_found() + + try: + dsf_sc = self._file_schema.load(request.form, instance=dsf, partial=True) + add_obj_history_entry(dsf_sc, 'updated') + + if 'file_is_ioc' in request.form: + dsf.file_is_ioc = _truthy(request.form.get('file_is_ioc')) + if 'file_is_evidence' in request.form: + dsf.file_is_evidence = _truthy(request.form.get('file_is_evidence')) + + db.session.commit() + + uploaded = request.files.get('file_content') + if uploaded: + ds_location = datastore_get_standard_path(dsf_sc, case_identifier) + dsf_sc.file_local_name, dsf_sc.file_size, dsf_sc.file_sha256 = self._file_schema.ds_store_file( + uploaded, + ds_location, + dsf_sc.file_is_ioc, + dsf_sc.file_password + ) + if dsf_sc.file_is_ioc and not dsf_sc.file_password: + dsf_sc.file_password = 'infected' + db.session.commit() + + if dsf.file_is_ioc: + datastore_add_file_as_ioc(iris_current_user.id, dsf) + if dsf.file_is_evidence: + datastore_add_file_as_evidence(iris_current_user.id, dsf, case_identifier) + + track_activity( + f'File "{dsf.file_original_name}" updated in DS', + caseid=case_identifier + ) + return response_api_success(self._file_schema.dump(dsf_sc)) + + except marshmallow.exceptions.ValidationError as e: + return response_api_error('Data error', data=e.messages) + + def move_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsf = datastore_get_file(identifier, case_identifier) + if not dsf: + return response_api_not_found() + + data = request.get_json() or {} + destination = data.get('destination_node') + if destination is None: + return response_api_error('Missing destination_node') + + dsp = datastore_get_path_node(destination, case_identifier) + if not dsp: + return response_api_error('Invalid destination node ID for this case') + + dsf.file_parent_id = dsp.path_id + db.session.commit() + + track_activity( + f'File "{dsf.file_original_name}" moved to "{dsp.path_name}" in DS', + caseid=case_identifier + ) + return response_api_success(self._file_schema.dump(dsf)) + + def delete_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + if not datastore_get_file(identifier, case_identifier): + return response_api_not_found() + + has_error, logs = datastore_delete_file(identifier, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity(f'File "{identifier}" deleted from DS', caseid=case_identifier) + return response_api_deleted() + + # --------------------------------------------------------------------- + # Folder CRUD + # --------------------------------------------------------------------- + def list_folders(self, case_identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error + + folders = DataStorePath.query.filter( + DataStorePath.path_case_id == case_identifier + ).order_by(DataStorePath.path_id).all() + return response_api_success(self._path_schema.dump(folders, many=True)) + + def get_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + return response_api_success(self._path_schema.dump(dsp)) + + def add_folder(self, case_identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + data = request.get_json() or {} + parent_node = data.get('parent_node') + folder_name = data.get('folder_name') + + if not parent_node or not folder_name: + return response_api_error('parent_node and folder_name are required') + + has_error, logs, node = datastore_add_child_node(parent_node, folder_name, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity(f'Folder "{folder_name}" added to DS', caseid=case_identifier) + return response_api_created(self._path_schema.dump(node)) + + def rename_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + + data = request.get_json() or {} + folder_name = data.get('folder_name') + if not folder_name: + return response_api_error('folder_name is required') + + has_error, logs, dsp_base = datastore_rename_node(identifier, folder_name, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity( + f'Folder "{identifier}" renamed to "{folder_name}" in DS', + caseid=case_identifier + ) + return response_api_success(self._path_schema.dump(dsp_base)) + + def move_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + + data = request.get_json() or {} + destination = data.get('destination_node') + if destination is None: + return response_api_error('Missing destination_node') + + dsp_dst = datastore_get_path_node(destination, case_identifier) + if not dsp_dst: + return response_api_error('Invalid destination node ID for this case') + + if dsp.path_id == dsp_dst.path_id: + return response_api_error('Source and destination folders are the same') + + dsp.path_parent_id = dsp_dst.path_id + db.session.commit() + + track_activity( + f'Folder "{dsp.path_name}" moved to "{dsp_dst.path_name}"', + caseid=case_identifier + ) + return response_api_success(self._path_schema.dump(dsp)) + + def delete_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + + if dsp.path_is_root: + return response_api_error('Cannot delete root folder') + + has_error, logs = datastore_delete_node(identifier, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity(f'Folder "{identifier}" deleted from DS', caseid=case_identifier) + return response_api_deleted() + + # --------------------------------------------------------------------- + # Interactive (base64) upload used by the rich-text editor + # --------------------------------------------------------------------- + def add_interactive(self, case_identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_interactive_path_node(case_identifier) + if not dsp: + return response_api_error('Invalid path node for this case') + + try: + js_data = request.get_json() or {} + + try: + file_content = base64.b64decode(js_data.get('file_content')) + filename = js_data.get('file_original_name') + except Exception as e: + return response_api_error(str(e)) + + if not filename: + return response_api_error('Data error', data={'file_original_name': ['Missing filename']}) + + dsf_sc, existed = self._file_schema.ds_store_file_b64(filename, file_content, dsp, case_identifier) + + track_activity( + f'File "{dsf_sc.file_original_name}" added to DS', + caseid=case_identifier + ) + + return response_api_created({ + 'existed': existed, + 'file_url': f'/api/v2/cases/{case_identifier}/datastore/files/{dsf_sc.file_id}', + **self._file_schema.dump(dsf_sc) + }) + + except marshmallow.exceptions.ValidationError as e: + return response_api_error('Data error', data=e.messages) + datastore_operations = DatastoreOperations() case_datastore_blueprint = Blueprint( @@ -147,13 +491,99 @@ def view(self, case_identifier, identifier): ) +# Tree -------------------------------------------------------------------- +@case_datastore_blueprint.get('/tree') +@ac_api_requires() +def datastore_tree(case_identifier): + return datastore_operations.tree(case_identifier) + + +# Files ------------------------------------------------------------------- +@case_datastore_blueprint.get('/files') +@ac_api_requires() +def datastore_list_files(case_identifier): + return datastore_operations.list_files(case_identifier) + + +@case_datastore_blueprint.get('/files/') +@ac_api_requires() +def datastore_view_file(case_identifier, identifier): + # When a ?info=1 query parameter is set, return the file metadata as JSON + # instead of streaming the file contents. Keeps a single URL for "the + # file" while letting the UI fetch its info card separately. + if _truthy(request.args.get('info')): + return datastore_operations.get_file_info(case_identifier, identifier) + return datastore_operations.view_file(case_identifier, identifier) + + +@case_datastore_blueprint.get('/files//info') +@ac_api_requires() +def datastore_file_info(case_identifier, identifier): + return datastore_operations.get_file_info(case_identifier, identifier) + + +@case_datastore_blueprint.post('/folders//files') +@ac_api_requires() +def datastore_add_file(case_identifier, folder_identifier): + return datastore_operations.add_file(case_identifier, folder_identifier) + + +@case_datastore_blueprint.post('/files/') +@ac_api_requires() +def datastore_update_file(case_identifier, identifier): + return datastore_operations.update_file(case_identifier, identifier) + + +@case_datastore_blueprint.post('/files//move') +@ac_api_requires() +def datastore_move_file(case_identifier, identifier): + return datastore_operations.move_file(case_identifier, identifier) + + +@case_datastore_blueprint.delete('/files/') +@ac_api_requires() +def datastore_delete_file_route(case_identifier, identifier): + return datastore_operations.delete_file(case_identifier, identifier) + + @case_datastore_blueprint.post('/files/interactive') @ac_api_requires() def datastore_add_interactive_file(case_identifier): return datastore_operations.add_interactive(case_identifier) -@case_datastore_blueprint.get('/files/') +# Folders ----------------------------------------------------------------- +@case_datastore_blueprint.get('/folders') @ac_api_requires() -def datastore_view_file(case_identifier, identifier): - return datastore_operations.view(case_identifier, identifier) +def datastore_list_folders(case_identifier): + return datastore_operations.list_folders(case_identifier) + + +@case_datastore_blueprint.post('/folders') +@ac_api_requires() +def datastore_add_folder(case_identifier): + return datastore_operations.add_folder(case_identifier) + + +@case_datastore_blueprint.get('/folders/') +@ac_api_requires() +def datastore_get_folder(case_identifier, identifier): + return datastore_operations.get_folder(case_identifier, identifier) + + +@case_datastore_blueprint.post('/folders//rename') +@ac_api_requires() +def datastore_rename_folder(case_identifier, identifier): + return datastore_operations.rename_folder(case_identifier, identifier) + + +@case_datastore_blueprint.post('/folders//move') +@ac_api_requires() +def datastore_move_folder(case_identifier, identifier): + return datastore_operations.move_folder(case_identifier, identifier) + + +@case_datastore_blueprint.delete('/folders/') +@ac_api_requires() +def datastore_delete_folder(case_identifier, identifier): + return datastore_operations.delete_folder(case_identifier, identifier) diff --git a/source/app/blueprints/rest/v2/cases.py b/source/app/blueprints/rest/v2/cases.py index 6e3f57846..61076360c 100644 --- a/source/app/blueprints/rest/v2/cases.py +++ b/source/app/blueprints/rest/v2/cases.py @@ -34,6 +34,7 @@ from app.blueprints.rest.endpoints import response_api_error from app.blueprints.rest.endpoints import response_api_paginated from app.blueprints.rest.parsing import parse_pagination_parameters +from app.business.activity import activity_search_in_case from app.blueprints.rest.v2.case_routes.assets import case_assets_blueprint from app.blueprints.rest.v2.case_routes.iocs import case_iocs_blueprint from app.blueprints.rest.v2.case_routes.notes import case_notes_blueprint @@ -45,8 +46,10 @@ from app.blueprints.iris_user import iris_current_user from app.business.cases import cases_create from app.business.cases import cases_delete +from app.business.cases import cases_exists from app.business.cases import cases_get_by_identifier from app.business.cases import cases_update +from app.datamgmt.manage.manage_users_db import get_users_list_restricted_from_case from app.models.errors import BusinessProcessingError, ObjectNotFoundError from app.business.cases import cases_filter from app.schema.marshables import CaseSchemaForAPIV2 @@ -85,6 +88,9 @@ def search(self): start_open_date = request.args.get('start_open_date', None, type=str) end_open_date = request.args.get('end_open_date', None, type=str) is_open = request.args.get('is_open', None, type=parse_boolean) + # Free-text search across case name, customer name, and (numeric) case id. + # Powers the context switcher's search box; an empty / whitespace value is ignored. + quick_search = request.args.get('quick_search', None, type=str) filtered_cases = cases_filter( iris_current_user, @@ -101,7 +107,8 @@ def search(self): case_soc_id, start_open_date, end_open_date, - is_open + is_open, + quick_search=quick_search ) return response_api_paginated(self._schema, filtered_cases) @@ -365,3 +372,46 @@ def rest_v2_cases_update(identifier): @ac_api_requires(Permissions.standard_user) def case_routes_delete(identifier): return cases_operations.delete(identifier) + + +@cases_blueprint.get('//access/users') +@ac_api_requires() +def list_case_access_users(identifier): + """Return every user with effective access to this case along with + their access level. Used by the frontend to populate task-assignee + pickers and to surface who can see a given case. + + Access level is the integer enum from `CaseAccessLevel` (1 = deny, + 2 = read_only, 4 = full_access). Callers that need only assignable + users typically filter on full_access (4) client-side, matching the + legacy iris-web behaviour. + """ + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + users = get_users_list_restricted_from_case(identifier) + return response_api_success(users) + + +@cases_blueprint.get('//activities') +@ac_api_requires() +def list_case_activities(identifier): + """Return the recent user activity log for this case. + + Mirrors the legacy `/case/activities/list` endpoint, which the frontend + uses to surface "people involved" on a case. Each row carries the user + name, the activity date, the description and whether the entry was + produced by an API caller. + """ + if not cases_exists(identifier): + return response_api_not_found() + + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + activities = activity_search_in_case(identifier) + return response_api_success(activities) diff --git a/source/app/blueprints/rest/v2/dashboard.py b/source/app/blueprints/rest/v2/dashboard.py index 5eda20964..8df173451 100644 --- a/source/app/blueprints/rest/v2/dashboard.py +++ b/source/app/blueprints/rest/v2/dashboard.py @@ -25,8 +25,8 @@ from app.business.cases import cases_filter_by_user from app.business.cases import cases_filter_by_reviewer from app.business.tasks import tasks_filter_by_user +from app.datamgmt.activities.activities_db import get_recent_activities_for_user from app.schema.marshables import CaseDetailsSchema -from app.schema.marshables import CaseTaskSchema from app.schema.marshables import CaseSchema dashboard_blueprint = Blueprint('dashboard', @@ -50,8 +50,53 @@ def list_own_cases(): @dashboard_blueprint.get('/tasks/list') @ac_api_requires() def list_own_tasks(): - ct = tasks_filter_by_user() - return response_api_success(data=CaseTaskSchema(many=True).dump(ct)) + # `tasks_filter_by_user` projects flat row tuples (task_id, task_title, + # task_case, case_id, status_name, …), not model instances — running + # those through CaseTaskSchema would silently drop the nested `case` + # and `status` fields. Ship the rows as-is so the frontend sees the + # case id, case title, status name, and last update. + rows = tasks_filter_by_user() + data = [] + for row in rows: + d = row._asdict() if hasattr(row, '_asdict') else dict(row) + if d.get('task_last_update') is not None: + d['task_last_update'] = d['task_last_update'].isoformat() + data.append(d) + return response_api_success(data=data) + + +@dashboard_blueprint.get('/activities/recent') +@ac_api_requires() +def list_recent_activities(): + """Recent UI-visible activity entries the current user is allowed to + see. Scoped to cases the user has access to (plus their own activity) + so a multi-tenant deployment doesn't leak cross-customer events. + + Query params: + - limit: int (default 20, max 100) — page size + - offset: int (default 0) — pagination cursor + """ + limit = request.args.get('limit', default=20, type=int) + if limit < 1: + limit = 20 + if limit > 100: + limit = 100 + + offset = request.args.get('offset', default=0, type=int) + if offset < 0: + offset = 0 + + rows = get_recent_activities_for_user(iris_current_user.id, limit=limit, offset=offset) + # The DB query already projected the needed columns — no marshmallow + # schema would add anything useful. Stamp dates as ISO strings and ship. + data = [] + for row in rows: + d = dict(row) + if d.get('activity_date') is not None: + d['activity_date'] = d['activity_date'].isoformat() + data.append(d) + + return response_api_success(data=data) # TODO this endpoint does not adhere to the conventions (verb in URL). diff --git a/source/app/blueprints/rest/v2/profile.py b/source/app/blueprints/rest/v2/profile.py index 09d2c38df..c2300d1cb 100644 --- a/source/app/blueprints/rest/v2/profile.py +++ b/source/app/blueprints/rest/v2/profile.py @@ -16,16 +16,22 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import secrets from flask import Blueprint from flask import request +from flask import session from marshmallow import ValidationError +from app import bc +from app.db import db from app.blueprints.iris_user import iris_current_user from app.blueprints.rest.endpoints import response_api_success from app.blueprints.rest.endpoints import response_api_error from app.blueprints.access_controls import ac_api_requires from app.business.users import users_get from app.business.users import users_update +from app.iris_engine.access_control.utils import ac_get_effective_permissions_of_user +from app.iris_engine.access_control.utils import ac_recompute_effective_ac from app.schema.marshables import UserSchemaForAPIV2 @@ -45,13 +51,47 @@ def update(self): user = users_get(iris_current_user.id) request_data = request.get_json() request_data['user_id'] = iris_current_user.id + + # Password rotation requires proof that whoever is holding this + # session also knows the current password. Stops a hijacked + # session (or any stolen API key) from locking out the legit + # owner. Strip `user_current_password` out before marshmallow + # loads, since it isn't a model field. + new_password = request_data.get('user_password') + current_password = request_data.pop('user_current_password', None) + if new_password: + if not current_password: + return response_api_error( + 'Current password is required to change password', + data={'user_current_password': ['Required field']} + ) + if not bc.check_password_hash(user.password, current_password): + return response_api_error( + 'Current password is incorrect', + data={'user_current_password': ['Incorrect password']} + ) + user = self._update_request_schema.load(request_data, instance=user, partial=True) - user = users_update(user, request_data.get('user_password')) + user = users_update(user, new_password) result = self._schema.dump(user) return response_api_success(result) except ValidationError as e: return response_api_error('Data error', data=e.messages) + def renew_api_key(self): + user = users_get(iris_current_user.id) + user.api_key = secrets.token_urlsafe(nbytes=64) + db.session.commit() + result = self._schema.dump(user) + return response_api_success(result) + + def refresh_permissions(self): + user = users_get(iris_current_user.id) + ac_recompute_effective_ac(iris_current_user.id) + session['permissions'] = ac_get_effective_permissions_of_user(user) + result = self._schema.dump(user) + return response_api_success(result) + profile_operations = ProfileOperations() profile_blueprint = Blueprint('profile_rest_v2', __name__, url_prefix='/me') @@ -67,3 +107,15 @@ def get_profile(): @ac_api_requires() def update_profile(): return profile_operations.update() + + +@profile_blueprint.post('/api-key/renew') +@ac_api_requires() +def renew_api_key(): + return profile_operations.renew_api_key() + + +@profile_blueprint.post('/permissions/refresh') +@ac_api_requires() +def refresh_permissions(): + return profile_operations.refresh_permissions() diff --git a/source/app/business/cases.py b/source/app/business/cases.py index 8ac55bd32..33caff65a 100644 --- a/source/app/business/cases.py +++ b/source/app/business/cases.py @@ -64,7 +64,7 @@ def cases_filter(current_user, pagination_parameters, name=None, case_identifier description=None, classification_identifier=None, owner_identifier=None, opening_user_identifier=None, severity_identifier=None, status_identifier=None, soc_identifier=None, start_open_date=None, end_open_date=None, is_open=None, search_value='', - advanced_filters=None, advanced_logic='and'): + advanced_filters=None, advanced_logic='and', quick_search=None): return get_filtered_cases( current_user.id, pagination_parameters, @@ -83,7 +83,8 @@ def cases_filter(current_user, pagination_parameters, name=None, case_identifier search_value=search_value, is_open=is_open, advanced_filters=advanced_filters, - advanced_logic=advanced_logic) + advanced_logic=advanced_logic, + quick_search=quick_search) def cases_filter_by_user(user, show_all: bool): diff --git a/source/app/datamgmt/activities/activities_db.py b/source/app/datamgmt/activities/activities_db.py index 074a0c032..75d2dad11 100644 --- a/source/app/datamgmt/activities/activities_db.py +++ b/source/app/datamgmt/activities/activities_db.py @@ -132,6 +132,62 @@ def get_all_users_activities(): return user_activities +def get_recent_activities_for_user(user_id, limit=20, offset=0, accessible_case_ids=None): + """Recent UI-visible UserActivity entries that the given user is allowed + to see, ordered newest-first. + + Filters on the same `display_in_ui` flag the legacy global activity + feed uses, then scopes by `case_id IN (...)` so users only see entries + from cases they have access to (or rows authored by the user + themselves). Activity from *other* users on those same cases is + included — this is the whole point: the feed is "what's happening on + the cases I'm part of", not "what I'm doing". + + `offset` lets callers paginate by re-querying with `offset += limit` + until fewer than `limit` rows come back. + + Pass `accessible_case_ids` to avoid an extra lookup if the caller has + already computed it. + """ + if accessible_case_ids is None: + from app.datamgmt.manage.manage_cases_db import user_list_cases_view + accessible_case_ids = user_list_cases_view(user_id) + + case_filter = UserActivity.case_id.in_(accessible_case_ids) if accessible_case_ids else None + + base = UserActivity.query.with_entities( + UserActivity.id, + Cases.name.label('case_name'), + UserActivity.case_id, + User.name.label('user_name'), + UserActivity.user_id, + UserActivity.activity_date, + UserActivity.activity_desc, + UserActivity.user_input, + UserActivity.is_from_api + ).outerjoin( + UserActivity.user + ).outerjoin( + UserActivity.case + ).filter( + UserActivity.display_in_ui == True + ) + + if case_filter is not None: + # Show activity from accessible cases plus activity authored by the + # user themselves on cases they no longer have access to (so users + # don't lose their own recent history when scopes shift). + base = base.filter( + (case_filter) | (UserActivity.user_id == user_id) + ) + else: + base = base.filter(UserActivity.user_id == user_id) + + rows = base.order_by(desc(UserActivity.activity_date)).offset(offset).limit(limit).all() + + return [row._asdict() for row in rows] + + def search_users_activity_in_case(case_identifier): ua = UserActivity.query.with_entities( UserActivity.activity_date, diff --git a/source/app/datamgmt/manage/manage_cases_db.py b/source/app/datamgmt/manage/manage_cases_db.py index 0c673836c..8d377f482 100644 --- a/source/app/datamgmt/manage/manage_cases_db.py +++ b/source/app/datamgmt/manage/manage_cases_db.py @@ -479,7 +479,8 @@ def build_filter_case_query(current_user_id, search_value=None, sort_by=None, sort_dir='asc', - is_open: bool=None + is_open: bool=None, + quick_search: str=None ): """ Get a list of cases from the database, filtered by the given parameters @@ -521,6 +522,15 @@ def build_filter_case_query(current_user_id, if search_value is not None: conditions.append(Cases.name.like(f"%{search_value}%")) + quick_search_term = quick_search.strip() if isinstance(quick_search, str) else None + if quick_search_term: + # Case IDs are prefixed into the title at creation time (e.g. "#42 - Foo"), + # so an ILIKE on Cases.name already catches numeric matches via the prefix. + conditions.append(or_( + Cases.name.ilike(f"%{quick_search_term}%"), + Client.name.ilike(f"%{quick_search_term}%") + )) + if case_open_since is not None: result = date.today() - timedelta(case_open_since) conditions.append(Cases.open_date == result) @@ -535,7 +545,13 @@ def build_filter_case_query(current_user_id, if len(conditions) > 1: conditions = [reduce(and_, conditions)] conditions.append(Cases.case_id.in_(user_list_cases_view(current_user_id))) - query = Cases.query.filter(*conditions) + base_query = Cases.query + # quick_search references Client.name, so make sure the table is joined + # before the filter is applied. Use an outer join so cases without a + # customer are still considered for the name/id match. + if quick_search_term: + base_query = base_query.outerjoin(Client, Cases.client_id == Client.client_id) + query = base_query.filter(*conditions) if case_tags is not None: return query.join(Tags, Tags.tag_title.ilike(f'%{case_tags}%')).filter(CaseTags.case_id == Cases.case_id) @@ -550,7 +566,11 @@ def build_filter_case_query(current_user_id, query = query.join(User, Cases.user_id == User.id).order_by(order_func(User.name)) elif sort_by == 'customer_name': - query = query.join(Client, Cases.client_id == Client.client_id).order_by(order_func(Client.name)) + if quick_search_term: + # Client is already joined via the quick_search outer join; just order by it. + query = query.order_by(order_func(Client.name)) + else: + query = query.join(Client, Cases.client_id == Client.client_id).order_by(order_func(Client.name)) elif sort_by == 'state': query = query.join(CaseState, Cases.state_id == CaseState.state_id).order_by(order_func(CaseState.state_name)) @@ -578,7 +598,8 @@ def get_filtered_cases(current_user_id, search_value: str | None = None, is_open: bool | None = None, advanced_filters: list[dict[str, Any]] | None = None, - advanced_logic: str = 'and' + advanced_logic: str = 'and', + quick_search: str | None = None ): kwargs: dict[str, Any] = { 'current_user_id': current_user_id, @@ -627,6 +648,9 @@ def get_filtered_cases(current_user_id, if search_value is not None: kwargs['search_value'] = search_value + if quick_search is not None: + kwargs['quick_search'] = quick_search + if is_open is not None: kwargs['is_open'] = is_open diff --git a/source/app/schema/marshables.py b/source/app/schema/marshables.py index 4c039e086..9358f438b 100644 --- a/source/app/schema/marshables.py +++ b/source/app/schema/marshables.py @@ -28,6 +28,7 @@ from flask import current_app from marshmallow import EXCLUDE from marshmallow import fields +from marshmallow import post_dump from marshmallow import post_load from marshmallow import pre_load from marshmallow.exceptions import ValidationError @@ -51,6 +52,7 @@ from app.datamgmt.manage.manage_attribute_db import merge_custom_attributes from app.datamgmt.manage.manage_tags_db import add_db_tag from app.datamgmt.case.case_iocs_db import get_ioc_links +from app.datamgmt.case.case_tasks_db import get_task_assignees from app.iris_engine.access_control.utils import ac_mask_from_val_list from app.models.models import SavedFilter from app.models.models import DataStorePath @@ -1980,6 +1982,29 @@ def custom_attributes_merge(self, data: Dict[str, Any], **kwargs: Any) -> Dict[s return data + @post_dump(pass_original=True) + def populate_assignees(self, data: Dict[str, Any], original: Any, **kwargs: Any) -> Dict[str, Any]: + """Inject the assignee list for the task. + + `task_assignees` and `task_assignees_id` are declared as schema + fields, but the `CaseTasks` model itself has no relationship to + `TaskAssignee` — so without this hook both fields always serialise + to `None`, which silently hides the assignees stored in the DB + and breaks the frontend assignee picker on re-edit. + + We query `TaskAssignee` once per dumped task and back-fill both + fields. When the schema is dumped without a model instance (e.g. + from a dict), we leave the data untouched. + """ + task_id = getattr(original, 'id', None) if original is not None else None + if task_id is None: + return data + + assignees = get_task_assignees(task_id) + data['task_assignees'] = assignees + data['task_assignees_id'] = [assignee['id'] for assignee in assignees] + return data + class CaseEvidenceSchema(ma.SQLAlchemyAutoSchema): """Schema for serializing and deserializing CaseEvidence objects. diff --git a/tests/tests_rest_profile.py b/tests/tests_rest_profile.py index dbe55a2f8..8d3e5be7c 100644 --- a/tests/tests_rest_profile.py +++ b/tests/tests_rest_profile.py @@ -50,10 +50,35 @@ def test_update_me_should_modify_user_email(self): def test_update_me_should_modify_user_password(self): user = self._subject.create_user('name', 'aA.1234567890') - user.update('/api/v2/me', {'user_password': 'bB.1234567890'}) + user.update('/api/v2/me', { + 'user_current_password': 'aA.1234567890', + 'user_password': 'bB.1234567890' + }) response = user.login('bB.1234567890') self.assertEqual(200, response.status_code) + def test_update_me_should_return_400_when_changing_password_without_current_password(self): + user = self._subject.create_user('pwdrequired', 'aA.1234567890') + response = user.update('/api/v2/me', {'user_password': 'bB.1234567890'}) + self.assertEqual(400, response.status_code) + + def test_update_me_should_return_400_when_changing_password_with_wrong_current_password(self): + user = self._subject.create_user('pwdwrong', 'aA.1234567890') + response = user.update('/api/v2/me', { + 'user_current_password': 'wrong-password', + 'user_password': 'bB.1234567890' + }) + self.assertEqual(400, response.status_code) + + def test_update_me_should_not_change_password_when_current_password_is_wrong(self): + user = self._subject.create_user('pwdkeep', 'aA.1234567890') + user.update('/api/v2/me', { + 'user_current_password': 'wrong-password', + 'user_password': 'bB.1234567890' + }) + response = user.login('aA.1234567890') + self.assertEqual(200, response.status_code) + def test_update_me_should_modify_ctx_case(self): user = self._subject.create_dummy_user() case_identifier = self._subject.create_dummy_case() @@ -122,3 +147,19 @@ def test_update_me_should_return_400_when_field_user_name_is_not_a_string(self): response = user.update('/api/v2/me', {'user_name': 123}) self.assertEqual(400, response.status_code) + + def test_renew_api_key_should_return_200(self): + user = self._subject.create_dummy_user() + response = user.create('/api/v2/me/api-key/renew', {}) + self.assertEqual(200, response.status_code) + + def test_renew_api_key_should_change_api_key(self): + user = self._subject.create_dummy_user() + before = user.get('/api/v2/me').json()['user_api_key'] + response = user.create('/api/v2/me/api-key/renew', {}).json() + self.assertNotEqual(before, response['user_api_key']) + + def test_refresh_permissions_should_return_200(self): + user = self._subject.create_dummy_user() + response = user.create('/api/v2/me/permissions/refresh', {}) + self.assertEqual(200, response.status_code) diff --git a/tests/tests_rest_tasks.py b/tests/tests_rest_tasks.py index 61e301b16..db0b69bde 100644 --- a/tests/tests_rest_tasks.py +++ b/tests/tests_rest_tasks.py @@ -125,6 +125,56 @@ def test_update_task_should_update_assignees(self): response = self._subject.get('/case/tasks/list', query_parameters={'cid': case_identifier}).json() self.assertEqual(user.get_identifier(), response['data']['tasks'][0]['task_assignees'][0]['id']) + def test_create_task_with_assignees_response_should_include_task_assignees_id(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json() + self.assertEqual([user.get_identifier()], response['task_assignees_id']) + + def test_create_task_with_assignees_response_should_include_task_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json() + self.assertEqual(user.get_identifier(), response['task_assignees'][0]['id']) + + def test_update_task_response_should_include_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'} + response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json() + identifier = response['id'] + response = self._subject.update( + f'/api/v2/cases/{case_identifier}/tasks/{identifier}', + {'task_title': 'dummy title', 'task_status_id': 1, 'task_assignees_id': [user.get_identifier()]} + ).json() + self.assertEqual([user.get_identifier()], response['task_assignees_id']) + + def test_get_task_should_return_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + identifier = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()['id'] + response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks/{identifier}').json() + self.assertEqual([user.get_identifier()], response['task_assignees_id']) + + def test_get_tasks_list_should_return_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body) + response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks').json() + self.assertEqual([user.get_identifier()], response['data'][0]['task_assignees_id']) + + def test_get_task_without_assignees_should_return_empty_assignees_list(self): + case_identifier = self._subject.create_dummy_case() + body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'} + identifier = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()['id'] + response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks/{identifier}').json() + self.assertEqual([], response['task_assignees_id']) + self.assertEqual([], response['task_assignees']) + def test_update_task_without_task_status_id_should_return_400(self): case_identifier = self._subject.create_dummy_case() body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'} diff --git a/tests/user.py b/tests/user.py index 438c7bd65..c0e7bc368 100644 --- a/tests/user.py +++ b/tests/user.py @@ -51,6 +51,9 @@ def update(self, path, body): def delete(self, path): return self._api.delete(path) + def post_multipart_encoded_files(self, path, data, files): + return self._api.post_multipart_encoded_files(path, data, files) + def login(self, password): url = parse.urljoin(self._iris_url, '/api/v2/auth/login') return requests.post(url, json={'username': self._login, 'password': password}) From 15620ade54dff7f3bd2710ee49faadb84942c167 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Mon, 22 Jun 2026 16:06:56 +0200 Subject: [PATCH 003/121] [ADD] Added missing files --- tests/tests_rest_case_activities.py | 71 +++++ tests/tests_rest_dashboard_activities.py | 98 +++++++ tests/tests_rest_datastore.py | 341 +++++++++++++++++++++++ 3 files changed, 510 insertions(+) create mode 100644 tests/tests_rest_case_activities.py create mode 100644 tests/tests_rest_dashboard_activities.py create mode 100644 tests/tests_rest_datastore.py diff --git a/tests/tests_rest_case_activities.py b/tests/tests_rest_case_activities.py new file mode 100644 index 000000000..a82d33c35 --- /dev/null +++ b/tests/tests_rest_case_activities.py @@ -0,0 +1,71 @@ +# IRIS Source Code +# Copyright (C) 2025 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase +from iris import Iris + +_IDENTIFIER_FOR_NONEXISTENT_OBJECT = 123456789 + + +class TestsRestCaseActivities(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_list_case_activities_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities') + self.assertEqual(200, response.status_code) + + def test_list_case_activities_should_return_404_when_case_does_not_exist(self): + response = self._subject.get(f'/api/v2/cases/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/activities') + self.assertEqual(404, response.status_code) + + def test_list_case_activities_should_return_403_when_user_has_no_access_to_case(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + response = user.get(f'/api/v2/cases/{case_identifier}/activities') + self.assertEqual(403, response.status_code) + + def test_list_case_activities_should_return_a_list(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIsInstance(response, list) + + def test_list_case_activities_should_include_case_creation_activity(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertGreaterEqual(len(response), 1) + + def test_list_case_activities_entries_should_include_user_name(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIn('user_name', response[0]) + + def test_list_case_activities_entries_should_include_activity_date(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIn('activity_date', response[0]) + + def test_list_case_activities_entries_should_include_activity_description(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIn('activity_desc', response[0]) diff --git a/tests/tests_rest_dashboard_activities.py b/tests/tests_rest_dashboard_activities.py new file mode 100644 index 000000000..4ada5986a --- /dev/null +++ b/tests/tests_rest_dashboard_activities.py @@ -0,0 +1,98 @@ +# IRIS Source Code +# Copyright (C) 2025 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase +from iris import Iris + + +class TestsRestDashboardActivities(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_list_recent_activities_should_return_200(self): + response = self._subject.get('/api/v2/dashboard/activities/recent') + self.assertEqual(200, response.status_code) + + def test_list_recent_activities_should_return_a_list(self): + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIsInstance(response, list) + + def test_list_recent_activities_should_include_recently_created_case(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertGreaterEqual(len(response), 1) + + def test_list_recent_activities_entries_should_include_user_name(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIn('user_name', response[0]) + + def test_list_recent_activities_entries_should_include_activity_date(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIn('activity_date', response[0]) + + def test_list_recent_activities_entries_should_include_activity_description(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIn('activity_desc', response[0]) + + def test_list_recent_activities_should_honour_limit_query_param(self): + # Create a few cases so we have multiple activity entries. + for _ in range(3): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent?limit=1').json() + self.assertEqual(1, len(response)) + + def test_list_recent_activities_should_clamp_invalid_limit(self): + # Negative / zero limits should fall back to the default rather than + # erroring out. + response = self._subject.get('/api/v2/dashboard/activities/recent?limit=-5') + self.assertEqual(200, response.status_code) + + def test_list_recent_activities_should_honour_offset_query_param(self): + # Two pages of size 1 — the first row of page 2 must differ from + # the first row of page 1 (assuming we have at least 2 activity + # entries, which any populated case will provide). + for _ in range(3): + self._subject.create_dummy_case() + first_page = self._subject.get('/api/v2/dashboard/activities/recent?limit=1&offset=0').json() + second_page = self._subject.get('/api/v2/dashboard/activities/recent?limit=1&offset=1').json() + self.assertEqual(1, len(first_page)) + self.assertEqual(1, len(second_page)) + if first_page and second_page: + # IDs are stable per-row, so two distinct offsets must yield + # two distinct rows. + self.assertNotEqual(first_page[0].get('id'), second_page[0].get('id')) + + def test_list_recent_activities_should_scope_to_user_accessible_cases(self): + # An activity authored on a case the other user has no access to + # must not leak into their feed. + case_identifier = self._subject.create_dummy_case() + # Make sure the case exists (the call itself creates an activity row). + self.assertIsNotNone(case_identifier) + + other_user = self._subject.create_dummy_user() + response = other_user.get('/api/v2/dashboard/activities/recent').json() + # The other user has no access to the case we created — none of the + # returned rows should reference it. + self.assertTrue(all(row.get('case_id') != case_identifier for row in response)) diff --git a/tests/tests_rest_datastore.py b/tests/tests_rest_datastore.py new file mode 100644 index 000000000..f0e0b6e3e --- /dev/null +++ b/tests/tests_rest_datastore.py @@ -0,0 +1,341 @@ +# IRIS Source Code +# Copyright (C) 2024 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import io +from unittest import TestCase +from iris import Iris + +_IDENTIFIER_FOR_NONEXISTENT_OBJECT = 123456789 + + +class TestsRestDatastore(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + def _get_root_folder_id(self, case_identifier): + tree = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/tree').json() + # Tree shape: {'d-': {...}} + root_key = next(iter(tree['data'])) + return int(root_key.split('-')[1]) + + def _create_folder(self, case_identifier, name='Folder', parent=None): + if parent is None: + parent = self._get_root_folder_id(case_identifier) + body = {'parent_node': parent, 'folder_name': name} + return self._subject.create(f'/api/v2/cases/{case_identifier}/datastore/folders', body) + + def _upload_file(self, case_identifier, folder_id, filename='hello.txt', content=b'hello world', + description='desc'): + data = { + 'file_original_name': filename, + 'file_description': description + } + files = {'file_content': (filename, io.BytesIO(content), 'application/octet-stream')} + return self._subject.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder_id}/files', + data, + files + ) + + # ------------------------------------------------------------------ + # tree + # ------------------------------------------------------------------ + def test_get_tree_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/tree') + self.assertEqual(200, response.status_code) + + def test_get_tree_should_return_404_when_case_does_not_exist(self): + response = self._subject.get(f'/api/v2/cases/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/datastore/tree') + self.assertEqual(404, response.status_code) + + def test_get_tree_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + response = user.get(f'/api/v2/cases/{case_identifier}/datastore/tree') + self.assertEqual(403, response.status_code) + + def test_get_tree_should_include_root_directory(self): + case_identifier = self._subject.create_dummy_case() + tree = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/tree').json()['data'] + root_key = next(iter(tree)) + self.assertTrue(tree[root_key].get('is_root')) + + # ------------------------------------------------------------------ + # folder CRUD + # ------------------------------------------------------------------ + def test_add_folder_should_return_201(self): + case_identifier = self._subject.create_dummy_case() + response = self._create_folder(case_identifier, 'Folder1') + self.assertEqual(201, response.status_code) + + def test_add_folder_should_return_400_when_missing_fields(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.create(f'/api/v2/cases/{case_identifier}/datastore/folders', {}) + self.assertEqual(400, response.status_code) + + def test_add_folder_should_return_404_when_case_does_not_exist(self): + response = self._subject.create( + f'/api/v2/cases/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/datastore/folders', + {'parent_node': 1, 'folder_name': 'X'} + ) + self.assertEqual(404, response.status_code) + + def test_add_folder_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + user = self._subject.create_dummy_user() + response = user.create( + f'/api/v2/cases/{case_identifier}/datastore/folders', + {'parent_node': root, 'folder_name': 'X'} + ) + self.assertEqual(403, response.status_code) + + def test_rename_folder_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + created = self._create_folder(case_identifier, 'Folder1').json()['data'] + folder_id = created['path_id'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder_id}/rename', + {'folder_name': 'Renamed'} + ) + self.assertEqual(200, response.status_code) + self.assertEqual('Renamed', response.json()['data']['path_name']) + + def test_rename_folder_should_return_404_when_folder_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/rename', + {'folder_name': 'Renamed'} + ) + self.assertEqual(404, response.status_code) + + def test_move_folder_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + folder_a = self._create_folder(case_identifier, 'A', parent=root).json()['data'] + folder_b = self._create_folder(case_identifier, 'B', parent=root).json()['data'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder_a["path_id"]}/move', + {'destination_node': folder_b['path_id']} + ) + self.assertEqual(200, response.status_code) + + def test_move_folder_should_return_400_when_same_destination(self): + case_identifier = self._subject.create_dummy_case() + folder = self._create_folder(case_identifier, 'A').json()['data'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder["path_id"]}/move', + {'destination_node': folder['path_id']} + ) + self.assertEqual(400, response.status_code) + + def test_delete_folder_should_return_204(self): + case_identifier = self._subject.create_dummy_case() + folder = self._create_folder(case_identifier, 'Folder1').json()['data'] + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder["path_id"]}' + ) + self.assertEqual(204, response.status_code) + + def test_delete_folder_should_return_404_when_folder_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/folders/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}' + ) + self.assertEqual(404, response.status_code) + + def test_delete_root_folder_should_return_400(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + response = self._subject.delete(f'/api/v2/cases/{case_identifier}/datastore/folders/{root}') + self.assertEqual(400, response.status_code) + + def test_list_folders_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/folders') + self.assertEqual(200, response.status_code) + + def test_get_folder_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + folder = self._create_folder(case_identifier, 'Folder1').json()['data'] + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder["path_id"]}' + ) + self.assertEqual(200, response.status_code) + + def test_get_folder_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/folders/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}' + ) + self.assertEqual(404, response.status_code) + + # ------------------------------------------------------------------ + # file CRUD + # ------------------------------------------------------------------ + def test_add_file_should_return_201(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + response = self._upload_file(case_identifier, root) + self.assertEqual(201, response.status_code) + + def test_add_file_should_return_404_when_folder_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._upload_file(case_identifier, _IDENTIFIER_FOR_NONEXISTENT_OBJECT) + self.assertEqual(404, response.status_code) + + def test_add_file_should_return_404_when_case_missing(self): + response = self._upload_file(_IDENTIFIER_FOR_NONEXISTENT_OBJECT, 1) + self.assertEqual(404, response.status_code) + + def test_add_file_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + user = self._subject.create_dummy_user() + files = {'file_content': ('hello.txt', io.BytesIO(b'hi'), 'application/octet-stream')} + response = user.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/folders/{root}/files', + {'file_original_name': 'hello.txt'}, + files + ) + self.assertEqual(403, response.status_code) + + def test_get_file_info_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}/info' + ) + self.assertEqual(200, response.status_code) + + def test_get_file_info_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/info' + ) + self.assertEqual(404, response.status_code) + + def test_view_file_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}' + ) + self.assertEqual(200, response.status_code) + + def test_list_files_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/files') + self.assertEqual(200, response.status_code) + + def test_list_files_should_return_total_zero_for_new_case(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/files').json() + self.assertEqual(0, response['data']['total']) + + def test_list_files_should_return_400_when_order_by_invalid(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files', + {'order_by': 'an_invalid_field'} + ) + self.assertEqual(400, response.status_code) + + def test_list_files_should_return_uploaded_file(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + self._upload_file(case_identifier, root, filename='abc.txt') + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/files').json()['data'] + self.assertEqual(1, response['total']) + + def test_update_file_should_change_description(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + data = {'file_description': 'updated'} + response = self._subject.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}', + data, + {} + ) + self.assertEqual(200, response.status_code) + self.assertEqual('updated', response.json()['data']['file_description']) + + def test_update_file_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}', + {'file_description': 'x'}, + {} + ) + self.assertEqual(404, response.status_code) + + def test_move_file_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + folder = self._create_folder(case_identifier, 'Dest', parent=root).json()['data'] + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}/move', + {'destination_node': folder['path_id']} + ) + self.assertEqual(200, response.status_code) + + def test_move_file_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/move', + {'destination_node': 1} + ) + self.assertEqual(404, response.status_code) + + def test_delete_file_should_return_204(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}' + ) + self.assertEqual(204, response.status_code) + + def test_delete_file_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}' + ) + self.assertEqual(404, response.status_code) + + def test_delete_file_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + user = self._subject.create_dummy_user() + response = user.delete( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}' + ) + self.assertEqual(403, response.status_code) From 3b645f8da9976a47e5820e47017a7889889b7f1e Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 10:58:24 +0200 Subject: [PATCH 004/121] [ADD] Added search on all case elements --- source/app/blueprints/rest/api_v2_routes.py | 2 + source/app/blueprints/rest/v2/search.py | 74 ++++++++++++ source/app/business/search.py | 106 ++++++++++++++++-- source/app/datamgmt/case/case_assets_db.py | 32 ++++++ source/app/datamgmt/case/case_events_db.py | 34 ++++++ .../datamgmt/case/case_evidences_search_db.py | 59 ++++++++++ source/app/datamgmt/case/case_iocs_db.py | 15 ++- source/app/datamgmt/case/case_notes_db.py | 10 +- source/app/datamgmt/case/case_tasks_db.py | 32 ++++++ source/app/datamgmt/comments.py | 10 +- tests/tests_rest_search.py | 77 +++++++++++++ 11 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 source/app/blueprints/rest/v2/search.py create mode 100644 source/app/datamgmt/case/case_evidences_search_db.py create mode 100644 tests/tests_rest_search.py diff --git a/source/app/blueprints/rest/api_v2_routes.py b/source/app/blueprints/rest/api_v2_routes.py index 467658d61..6db2841a8 100644 --- a/source/app/blueprints/rest/api_v2_routes.py +++ b/source/app/blueprints/rest/api_v2_routes.py @@ -32,6 +32,7 @@ from app.blueprints.rest.v2.tags import tags_blueprint from app.blueprints.rest.v2.tasks import tasks_blueprint from app.blueprints.rest.v2.profile import profile_blueprint +from app.blueprints.rest.v2.search import search_blueprint from app.blueprints.rest.v2.alerts_filters import alerts_filters_blueprint @@ -53,4 +54,5 @@ rest_v2_blueprint.register_blueprint(manage_v2_blueprint) rest_v2_blueprint.register_blueprint(tags_blueprint) rest_v2_blueprint.register_blueprint(profile_blueprint) +rest_v2_blueprint.register_blueprint(search_blueprint) rest_v2_blueprint.register_blueprint(alerts_filters_blueprint) diff --git a/source/app/blueprints/rest/v2/search.py b/source/app/blueprints/rest/v2/search.py new file mode 100644 index 000000000..3457cb0f0 --- /dev/null +++ b/source/app/blueprints/rest/v2/search.py @@ -0,0 +1,74 @@ +# IRIS Source Code +# Copyright (C) 2024 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request + +from app.models.authorization import Permissions +from app.blueprints.iris_user import iris_current_user +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_success +from app.business.search import search_across +from app.business.search import SUPPORTED_SEARCH_TYPES + + +search_blueprint = Blueprint('search_rest_v2', __name__, url_prefix='/search') + + +@search_blueprint.get('') +@ac_api_requires(Permissions.search_across_cases) +def search_across_cases(): + # `types` is a comma-separated list (notes,iocs,comments). We accept + # repeated `?types=notes&types=iocs` form too — Flask's getlist + a + # split-on-comma normalises both shapes. + raw_types = request.args.getlist('types') or [] + search_types = [] + for raw in raw_types: + for piece in raw.split(','): + piece = piece.strip() + if piece: + search_types.append(piece) + + if not search_types: + return response_api_error( + f'Missing types. Expected one or more of {SUPPORTED_SEARCH_TYPES}' + ) + + unknown = [t for t in search_types if t not in SUPPORTED_SEARCH_TYPES] + if unknown: + return response_api_error( + f'Unsupported search type(s): {unknown}. Expected one or more of {SUPPORTED_SEARCH_TYPES}' + ) + + search_value = request.args.get('value') + if not search_value: + return response_api_error('Missing search value') + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 25, type=int) + + result = search_across( + search_value=search_value, + search_types=search_types, + user_id=iris_current_user.id, + page=page, + per_page=per_page, + ) + + return response_api_success(data=result) diff --git a/source/app/business/search.py b/source/app/business/search.py index 36b294f30..771c5aa5c 100644 --- a/source/app/business/search.py +++ b/source/app/business/search.py @@ -20,18 +20,108 @@ from app.datamgmt.comments import search_comments from app.datamgmt.case.case_notes_db import search_notes from app.datamgmt.case.case_iocs_db import search_iocs +from app.datamgmt.case.case_assets_db import search_assets +from app.datamgmt.case.case_events_db import search_events +from app.datamgmt.case.case_tasks_db import search_tasks +from app.datamgmt.case.case_evidences_search_db import search_evidences +from app.datamgmt.manage.manage_cases_db import user_list_cases_view + + +# Map every supported search type to (per-type helper, discriminator label). +# Helpers all take `(search_value, accessible_case_ids)` and return a list +# of `_asdict()` row dicts. +_SEARCHERS = { + 'ioc': search_iocs, + 'notes': search_notes, + 'comments': search_comments, + 'assets': search_assets, + 'events': search_events, + 'tasks': search_tasks, + 'evidences': search_evidences, +} + +SUPPORTED_SEARCH_TYPES = tuple(_SEARCHERS.keys()) + + +def _annotate(rows, search_type): + # Tag every row with its discriminator + a stable id field the frontend + # can use as a list key. Each helper projects its own primary key into + # the row already; here we just normalise the name to `result_id`. + id_field_per_type = { + 'ioc': 'ioc_id', + 'notes': 'note_id', + 'comments': 'comment_id', + 'assets': 'asset_id', + 'events': 'event_id', + 'tasks': 'task_id', + 'evidences': 'evidence_id', + } + id_field = id_field_per_type[search_type] + for row in rows: + row['type'] = search_type + row['result_id'] = row.get(id_field) + return rows def search(search_type, search_value): + """Backwards-compatible single-type search used by the legacy + /search route. Returns a list of raw row dicts (no annotation, no + access scoping). + """ track_activity(f'started a global search for {search_value} on {search_type}') - files = [] - if search_type == 'ioc': - files = search_iocs(search_value) + if search_type in _SEARCHERS: + return _SEARCHERS[search_type](search_value) + return [] + + +def search_across(search_value, search_types, user_id, page=1, per_page=25): + """Unified multi-type, access-scoped, paginated search. + + Args: + search_value: pattern with optional `%` wildcards. Pattern semantics + match the legacy per-type helpers (some use exact LIKE, others + wrap the value in `%...%`). + search_types: iterable of supported type strings; unknown types are + silently dropped. + user_id: scope the result set to cases this user has access to. + page: 1-indexed page number. + per_page: page size cap (we clamp to 1..100 to keep responses bounded). + + Returns: + dict with `data` (list of annotated rows) and `pagination` (total, + page, per_page, total_pages). + """ + track_activity(f'started a global search for {search_value} on {list(search_types)}') + + accessible_case_ids = user_list_cases_view(user_id) + + per_page = max(1, min(int(per_page or 25), 100)) + page = max(1, int(page or 1)) + + # Each per-type helper streams its full result set. The combined list + # is sliced for pagination — fine for typical SOC-sized datasets where + # a single search rarely returns more than a few thousand rows. If + # this ever needs to scale further, push pagination into the per-type + # queries with UNION ALL. + combined = [] + for st in search_types: + if st not in _SEARCHERS: + continue + rows = _SEARCHERS[st](search_value, accessible_case_ids=accessible_case_ids) + combined.extend(_annotate(rows, st)) - if search_type == 'notes' and search_value: - files = search_notes(search_value) + total = len(combined) + total_pages = (total + per_page - 1) // per_page if total else 0 + start = (page - 1) * per_page + end = start + per_page - if search_type == 'comments': - files = search_comments(search_value) - return files + return { + 'data': combined[start:end], + 'pagination': { + 'total': total, + 'page': page, + 'per_page': per_page, + 'total_pages': total_pages, + } + } diff --git a/source/app/datamgmt/case/case_assets_db.py b/source/app/datamgmt/case/case_assets_db.py index a56fb6f08..efb8ace8f 100644 --- a/source/app/datamgmt/case/case_assets_db.py +++ b/source/app/datamgmt/case/case_assets_db.py @@ -37,6 +37,7 @@ from app.models.assets import CompromiseStatus from app.models.assets import AssetsType from app.models.assets import CaseAssets +from app.models.customers import Client from app.models.assets import AnalysisStatus from app.models.iocs import Ioc from app.models.models import IocAssetLink @@ -376,3 +377,34 @@ def get_asset_by_name(asset_name, caseid): CaseAssets.case_id == caseid ).first() return asset + + +def search_assets(search_value, accessible_case_ids=None): + # Matches the search-helper idiom used by `search_iocs` / `search_notes` + # — single SQL with `LIKE`, projects a flat row dict via `_asdict()`, + # scopes by the caller's accessible case ids when provided. + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CaseAssets.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + + res = CaseAssets.query.with_entities( + CaseAssets.asset_id, + CaseAssets.asset_name, + CaseAssets.asset_description, + CaseAssets.asset_ip, + CaseAssets.asset_domain, + AssetsType.asset_name.label('asset_type_name'), + Cases.name.label('case_name'), + Cases.case_id, + Client.name.label('customer_name') + ).filter( + and_( + CaseAssets.asset_name.like(f'%{search_value}%'), + CaseAssets.case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).join(CaseAssets.asset_type).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/case/case_events_db.py b/source/app/datamgmt/case/case_events_db.py index 35c7d97eb..948ff2705 100644 --- a/source/app/datamgmt/case/case_events_db.py +++ b/source/app/datamgmt/case/case_events_db.py @@ -35,6 +35,9 @@ from app.models.models import IocAssetLink from app.models.models import IocType from app.models.authorization import User +from app.models.cases import Cases +from app.models.customers import Client +from sqlalchemy import or_ def get_case_events_assets_graph(caseid): @@ -420,3 +423,34 @@ def get_events_by_case(case_identifier): )).order_by( CasesEvent.event_date ).all() + + +def search_events(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CasesEvent.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + + pattern = f'%{search_value}%' + + res = CasesEvent.query.with_entities( + CasesEvent.event_id, + CasesEvent.event_title, + CasesEvent.event_content, + CasesEvent.event_date, + Cases.name.label("case_name"), + Cases.case_id, + Client.name.label("customer_name") + ).filter( + and_( + or_( + CasesEvent.event_title.ilike(pattern), + CasesEvent.event_content.ilike(pattern) + ), + CasesEvent.case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/case/case_evidences_search_db.py b/source/app/datamgmt/case/case_evidences_search_db.py new file mode 100644 index 000000000..fcafd9f8e --- /dev/null +++ b/source/app/datamgmt/case/case_evidences_search_db.py @@ -0,0 +1,59 @@ +# IRIS Source Code +# Copyright (C) 2024 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from sqlalchemy import and_ +from sqlalchemy import or_ + +from app.models.cases import Cases +from app.models.customers import Client +from app.models.evidences import CaseReceivedFile + + +def search_evidences(search_value, accessible_case_ids=None): + # Mirrors `search_iocs` / `search_notes` / `search_comments`: flat + # `_asdict()` rows, optional access scoping via `accessible_case_ids`. + # Matches against filename, description, and hash so the same input + # can find an evidence whether the user types a name or a checksum. + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CaseReceivedFile.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + pattern = f'%{search_value}%' + + res = CaseReceivedFile.query.with_entities( + CaseReceivedFile.id.label('evidence_id'), + CaseReceivedFile.filename, + CaseReceivedFile.file_description, + CaseReceivedFile.file_hash, + Cases.name.label('case_name'), + Cases.case_id, + Client.name.label('customer_name') + ).filter( + and_( + or_( + CaseReceivedFile.filename.ilike(pattern), + CaseReceivedFile.file_description.ilike(pattern), + CaseReceivedFile.file_hash.ilike(pattern) + ), + CaseReceivedFile.case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/case/case_iocs_db.py b/source/app/datamgmt/case/case_iocs_db.py index c0f28bc9a..bc340f53c 100644 --- a/source/app/datamgmt/case/case_iocs_db.py +++ b/source/app/datamgmt/case/case_iocs_db.py @@ -300,9 +300,18 @@ def get_filtered_iocs( return get_filtered_data(Ioc, base_filter, pagination_parameters, request_parameters, relationship_model_map) -def search_iocs(search_value): - search_condition = and_() +def search_iocs(search_value, accessible_case_ids=None): + # Scope the result set to cases the caller can see when + # `accessible_case_ids` is supplied. Empty list means "no access" → no + # results (rather than "no filter"), so we short-circuit to avoid a + # huge IN () query. + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = Ioc.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + res = Ioc.query.with_entities( + Ioc.ioc_id, Ioc.ioc_value.label('ioc_name'), Ioc.ioc_description.label('ioc_description'), Ioc.ioc_misp, @@ -318,7 +327,7 @@ def search_iocs(search_value): Ioc.case_id == Cases.case_id, Client.client_id == Cases.client_id, Ioc.ioc_tlp_id == Tlp.tlp_id, - search_condition + scope_filter ) ).join(Ioc.ioc_type).all() diff --git a/source/app/datamgmt/case/case_notes_db.py b/source/app/datamgmt/case/case_notes_db.py index 7807929e0..7e8a14d45 100644 --- a/source/app/datamgmt/case/case_notes_db.py +++ b/source/app/datamgmt/case/case_notes_db.py @@ -475,12 +475,16 @@ def get_directory_with_note_count(directory): return directory_dict -def search_notes(search_value): - search_condition = and_() +def search_notes(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = Notes.note_case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + notes = Notes.query.filter( Notes.note_content.like(f'%{search_value}%'), Cases.client_id == Client.client_id, - search_condition + scope_filter ).with_entities( Notes.note_id, Notes.note_title, diff --git a/source/app/datamgmt/case/case_tasks_db.py b/source/app/datamgmt/case/case_tasks_db.py index 7592d3df6..1a080f916 100644 --- a/source/app/datamgmt/case/case_tasks_db.py +++ b/source/app/datamgmt/case/case_tasks_db.py @@ -32,10 +32,12 @@ from app.models.models import CaseTasks from app.models.models import TaskAssignee from app.models.cases import Cases +from app.models.customers import Client from app.models.comments import Comments, TaskComments from app.models.models import TaskStatus from app.models.authorization import User from app.models.pagination_parameters import PaginationParameters +from sqlalchemy import or_ def get_tasks_status(): @@ -390,3 +392,33 @@ def update_utask_status(task_id, status, case_id): pass return False + + +def search_tasks(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CaseTasks.task_case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + pattern = f'%{search_value}%' + + res = CaseTasks.query.with_entities( + CaseTasks.id.label('task_id'), + CaseTasks.task_title, + CaseTasks.task_description, + TaskStatus.status_name, + Cases.name.label('case_name'), + Cases.case_id, + Client.name.label('customer_name') + ).filter( + and_( + or_( + CaseTasks.task_title.ilike(pattern), + CaseTasks.task_description.ilike(pattern) + ), + CaseTasks.task_case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).outerjoin(CaseTasks.status).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/comments.py b/source/app/datamgmt/comments.py index 1640ff111..aef9c569f 100644 --- a/source/app/datamgmt/comments.py +++ b/source/app/datamgmt/comments.py @@ -115,12 +115,16 @@ def user_has_comments(user: User): return comment is not None -def search_comments(search_value): - search_condition = and_() +def search_comments(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = Cases.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + comments = Comments.query.filter( Comments.comment_text.like(f'%{search_value}%'), Cases.client_id == Client.client_id, - search_condition + scope_filter ).with_entities( Comments.comment_id, Comments.comment_text, diff --git a/tests/tests_rest_search.py b/tests/tests_rest_search.py new file mode 100644 index 000000000..3a14cc09a --- /dev/null +++ b/tests/tests_rest_search.py @@ -0,0 +1,77 @@ +# IRIS Source Code +# Copyright (C) 2024 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase +from iris import Iris + + +class TestsRestSearch(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_search_should_return_200(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'ioc', 'value': '%'}) + self.assertEqual(200, response.status_code) + + def test_search_should_return_400_when_types_is_missing(self): + response = self._subject.get('/api/v2/search', query_parameters={'value': '%'}) + self.assertEqual(400, response.status_code) + + def test_search_should_return_400_when_value_is_missing(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'ioc'}) + self.assertEqual(400, response.status_code) + + def test_search_should_return_400_when_type_is_unsupported(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'magic', 'value': '%'}) + self.assertEqual(400, response.status_code) + + def test_search_should_return_envelope_with_data_and_pagination(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'notes', 'value': '%'}) + body = response.json() + self.assertIn('data', body) + self.assertIn('pagination', body) + self.assertIn('total', body['pagination']) + self.assertIn('page', body['pagination']) + self.assertIn('per_page', body['pagination']) + + def test_search_should_accept_multiple_types_comma_separated(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'ioc,notes,comments', 'value': '%'}) + self.assertEqual(200, response.status_code) + + def test_search_should_accept_assets_events_tasks_evidences_types(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'assets,events,tasks,evidences', 'value': '%'}) + self.assertEqual(200, response.status_code) + + def test_search_per_page_should_be_clamped(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'notes', 'value': '%', 'per_page': 9999}) + body = response.json() + # Capped at 100 per `search_across`. + self.assertLessEqual(body['pagination']['per_page'], 100) + + def test_search_results_should_be_scoped_to_user_accessible_cases(self): + # A fresh user with no group membership only inherits whatever + # default access groups the seed provides. We just assert the + # envelope shape returns and the call is access-checked — actual + # row visibility is enforced by the data-layer scope filter. + user = self._subject.create_dummy_user() + response = user.get('/api/v2/search?types=notes,ioc,assets,events,tasks,comments,evidences&value=%25') + self.assertEqual(200, response.status_code) From 33ed25252b50239f75a3d814d272dafd1b641579 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 13:12:03 +0200 Subject: [PATCH 005/121] [IMP] Improved global search --- source/app/blueprints/rest/v2/search.py | 17 ++++++++++++++++ source/app/business/search.py | 26 ++++++++++++++++++++++++- tests/tests_rest_search.py | 15 ++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/source/app/blueprints/rest/v2/search.py b/source/app/blueprints/rest/v2/search.py index 3457cb0f0..511cb4665 100644 --- a/source/app/blueprints/rest/v2/search.py +++ b/source/app/blueprints/rest/v2/search.py @@ -62,6 +62,21 @@ def search_across_cases(): page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 25, type=int) + case_id = request.args.get('case_id', default=None, type=int) + + # `case_ids` accepts either comma-separated `?case_ids=1,2,3` or + # repeated `?case_ids=1&case_ids=2` — same flexible shape as the + # `types` param above. + raw_case_ids = request.args.getlist('case_ids') or [] + case_ids = [] + for raw in raw_case_ids: + for piece in raw.split(','): + piece = piece.strip() + if piece: + try: + case_ids.append(int(piece)) + except ValueError: + continue result = search_across( search_value=search_value, @@ -69,6 +84,8 @@ def search_across_cases(): user_id=iris_current_user.id, page=page, per_page=per_page, + case_id=case_id, + case_ids=case_ids or None, ) return response_api_success(data=result) diff --git a/source/app/business/search.py b/source/app/business/search.py index 771c5aa5c..5487e863d 100644 --- a/source/app/business/search.py +++ b/source/app/business/search.py @@ -75,7 +75,7 @@ def search(search_type, search_value): return [] -def search_across(search_value, search_types, user_id, page=1, per_page=25): +def search_across(search_value, search_types, user_id, page=1, per_page=25, case_id=None, case_ids=None): """Unified multi-type, access-scoped, paginated search. Args: @@ -87,6 +87,13 @@ def search_across(search_value, search_types, user_id, page=1, per_page=25): user_id: scope the result set to cases this user has access to. page: 1-indexed page number. per_page: page size cap (we clamp to 1..100 to keep responses bounded). + case_id: optional — restrict to a single case. Kept for back-compat + with the single-case scope param; `case_ids` is the preferred + shape going forward. + case_ids: optional iterable of case ids to scope to. Each id is + intersected with the caller's accessible-case list, so a + request that mentions a forbidden case just drops it + (the caller never learns whether the case exists). Returns: dict with `data` (list of annotated rows) and `pagination` (total, @@ -96,6 +103,23 @@ def search_across(search_value, search_types, user_id, page=1, per_page=25): accessible_case_ids = user_list_cases_view(user_id) + # Normalise: collapse `case_id` (singular, legacy) into the same + # `requested_case_ids` set as the new `case_ids` param. + requested_case_ids = set() + if case_id is not None: + requested_case_ids.add(int(case_id)) + if case_ids: + for cid in case_ids: + try: + requested_case_ids.add(int(cid)) + except (TypeError, ValueError): + continue + + # Intersect with the user's access list so the caller can never + # broaden their scope by guessing case ids they shouldn't see. + if requested_case_ids: + accessible_case_ids = [cid for cid in accessible_case_ids if cid in requested_case_ids] + per_page = max(1, min(int(per_page or 25), 100)) page = max(1, int(page or 1)) diff --git a/tests/tests_rest_search.py b/tests/tests_rest_search.py index 3a14cc09a..20b9d2bbe 100644 --- a/tests/tests_rest_search.py +++ b/tests/tests_rest_search.py @@ -67,6 +67,21 @@ def test_search_per_page_should_be_clamped(self): # Capped at 100 per `search_across`. self.assertLessEqual(body['pagination']['per_page'], 100) + def test_search_should_accept_case_id_scope(self): + case_id = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/search?types=ioc,notes,assets,events,tasks,comments,evidences&value=%25&case_id={case_id}' + ) + self.assertEqual(200, response.status_code) + + def test_search_should_accept_case_ids_scope_csv(self): + c1 = self._subject.create_dummy_case() + c2 = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/search?types=ioc,notes&value=%25&case_ids={c1},{c2}' + ) + self.assertEqual(200, response.status_code) + def test_search_results_should_be_scoped_to_user_accessible_cases(self): # A fresh user with no group membership only inherits whatever # default access groups the seed provides. We just assert the From 127817ca81c3332e83217ee973960777cc9019fe Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 14:35:22 +0200 Subject: [PATCH 006/121] [ADD] v2 activities endpoint with paging, scope and filters --- source/app/blueprints/rest/api_v2_routes.py | 2 + source/app/blueprints/rest/v2/activities.py | 150 ++++++++++++++++++ source/app/business/activity.py | 69 ++++++++ .../app/datamgmt/activities/activities_db.py | 118 ++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 source/app/blueprints/rest/v2/activities.py diff --git a/source/app/blueprints/rest/api_v2_routes.py b/source/app/blueprints/rest/api_v2_routes.py index 6db2841a8..8a983e3ba 100644 --- a/source/app/blueprints/rest/api_v2_routes.py +++ b/source/app/blueprints/rest/api_v2_routes.py @@ -18,6 +18,7 @@ from flask import Blueprint +from app.blueprints.rest.v2.activities import activities_blueprint from app.blueprints.rest.v2.alerts import alerts_blueprint from app.blueprints.rest.v2.assets import assets_blueprint from app.blueprints.rest.v2.events import events_blueprint @@ -56,3 +57,4 @@ rest_v2_blueprint.register_blueprint(profile_blueprint) rest_v2_blueprint.register_blueprint(search_blueprint) rest_v2_blueprint.register_blueprint(alerts_filters_blueprint) +rest_v2_blueprint.register_blueprint(activities_blueprint) diff --git a/source/app/blueprints/rest/v2/activities.py b/source/app/blueprints/rest/v2/activities.py new file mode 100644 index 000000000..17fa031cb --- /dev/null +++ b/source/app/blueprints/rest/v2/activities.py @@ -0,0 +1,150 @@ +# IRIS Source Code +# Copyright (C) 2025 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_permission +from app.blueprints.iris_user import iris_current_user +from app.blueprints.rest.endpoints import response_api_success +from app.business.activity import list_activities +from app.models.authorization import Permissions + + +activities_blueprint = Blueprint('activities_rest_v2', __name__, url_prefix='/activities') + + +def _format_row(row): + # Same projection shape as the legacy /activities/list endpoint, but + # with ``activity_date`` serialised as an ISO string (no Marshmallow + # schema — every column is already a primitive after `with_entities`). + d = row._asdict() if hasattr(row, '_asdict') else dict(row) + if d.get('activity_date') is not None: + d['activity_date'] = d['activity_date'].isoformat() + return d + + +@activities_blueprint.get('') +@ac_api_requires(Permissions.activities_read, Permissions.all_activities_read) +def list_activities_endpoint(): + """Paginated, access-scoped listing of UserActivity rows. + + Query params: + - page (int, default 1) + - per_page (int, default 25, max 200) + - search (str, optional) — ILIKE filter on activity_desc + - include_non_case (bool, default false) — include rows with no + associated case. Only honoured when the caller has the + ``all_activities_read`` permission (otherwise non-case rows from + other users would leak across tenants). + - user_id (int, optional) — restrict to a single author + - case_id (int, optional) — restrict to a single case + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + search_value = request.args.get('search', default=None, type=str) + user_id = request.args.get('user_id', default=None, type=int) + case_id = request.args.get('case_id', default=None, type=int) + + raw_include = (request.args.get('include_non_case') or '').strip().lower() + include_non_case = raw_include in ('1', 'true', 'yes', 'on') + + # `case_ids` and `user_ids` accept the same flexible shapes the rest + # of the v2 API uses: comma-separated `?case_ids=1,2,3` and/or + # repeated `?case_ids=1&case_ids=2`. Non-integer pieces are dropped + # silently rather than 400'ing — keeps URL-state hydration robust. + def _int_list(name): + raw = request.args.getlist(name) or [] + out = [] + for r in raw: + for piece in r.split(','): + piece = piece.strip() + if not piece: + continue + try: + out.append(int(piece)) + except ValueError: + continue + return out or None + + case_ids = _int_list('case_ids') + user_ids = _int_list('user_ids') + + # Optional date window. We accept anything `datetime.fromisoformat` + # understands (YYYY-MM-DD or full ISO with offset). Bad inputs are + # ignored — same robustness principle as the int-list parser above. + from datetime import datetime + + def _iso(name): + raw = request.args.get(name) + if not raw: + return None + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + date_from = _iso('date_from') + date_to = _iso('date_to') + + def _tribool(name): + raw = (request.args.get(name) or '').strip().lower() + if raw in ('1', 'true', 'yes', 'on'): + return True + if raw in ('0', 'false', 'no', 'off'): + return False + return None + + is_from_api = _tribool('is_from_api') + is_manual = _tribool('is_manual') + + can_read_all = ac_current_user_has_permission(Permissions.all_activities_read) + # `include_non_case` only matters for the global "admin" view — + # outside that view it would surface rows from cases the caller can't + # otherwise see. + if not can_read_all: + include_non_case = False + + paginated = list_activities( + user_id=iris_current_user.id, + can_read_all=can_read_all, + page=page, + per_page=per_page, + include_non_case=include_non_case, + search_value=search_value, + scope_user_id=user_id, + case_id=case_id, + user_ids=user_ids, + case_ids=case_ids, + date_from=date_from, + date_to=date_to, + is_from_api=is_from_api, + is_manual=is_manual, + ) + + # We project ourselves rather than going through `response_api_paginated` + # because that helper expects a Marshmallow schema. The rows are already + # flat dicts of primitives. + return response_api_success(data={ + 'total': paginated.total, + 'data': [_format_row(r) for r in paginated.items], + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) diff --git a/source/app/business/activity.py b/source/app/business/activity.py index de3caab04..c341560fc 100644 --- a/source/app/business/activity.py +++ b/source/app/business/activity.py @@ -16,8 +16,77 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +from app.datamgmt.activities.activities_db import list_activities_paginated from app.datamgmt.activities.activities_db import search_users_activity_in_case +from app.datamgmt.manage.manage_cases_db import user_list_cases_view def activity_search_in_case(case_identifier): return search_users_activity_in_case(case_identifier) + + +def list_activities( + user_id, + can_read_all, + page=1, + per_page=25, + include_non_case=False, + search_value=None, + scope_user_id=None, + case_id=None, + user_ids=None, + case_ids=None, + date_from=None, + date_to=None, + is_from_api=None, + is_manual=None, +): + """Paginated, access-scoped activities listing for the Activities page. + + ``can_read_all`` indicates whether the caller holds the + ``all_activities_read`` permission. When False, results are scoped to + cases the caller has access to (matching the legacy behaviour of + ``/activities/list``). When True, the caller sees activity across every + case (the legacy ``/activities/list-all`` view) and can opt-in to + non-case-related rows via ``include_non_case``. + """ + page = max(1, int(page or 1)) + per_page = max(1, min(int(per_page or 25), 200)) + + if can_read_all: + accessible_case_ids = None + else: + # `user_list_cases_view` returns the case ids the user can see. + # Pass through verbatim — `list_activities_paginated` understands + # an empty list as "no cases" and short-circuits accordingly. + accessible_case_ids = user_list_cases_view(user_id) + + # When the caller asks for specific cases but isn't a global reader, + # intersect their request with the accessible-case window so a guess + # at a forbidden case id silently no-ops (rather than 403'ing or + # leaking existence). + effective_case_ids = case_ids + if not can_read_all and case_ids: + accessible_set = set(accessible_case_ids or []) + effective_case_ids = [cid for cid in case_ids if cid in accessible_set] + if not effective_case_ids: + # The caller filtered down to cases they can't see — return + # an empty page by passing an unsatisfiable case filter, + # rather than dropping the filter and showing everything. + effective_case_ids = [-1] + + return list_activities_paginated( + page=page, + per_page=per_page, + accessible_case_ids=accessible_case_ids, + include_non_case=include_non_case, + search_value=search_value or None, + user_id=scope_user_id, + case_id=case_id, + user_ids=user_ids, + case_ids=effective_case_ids, + date_from=date_from, + date_to=date_to, + is_from_api=is_from_api, + is_manual=is_manual, + ) diff --git a/source/app/datamgmt/activities/activities_db.py b/source/app/datamgmt/activities/activities_db.py index 75d2dad11..d08c1d680 100644 --- a/source/app/datamgmt/activities/activities_db.py +++ b/source/app/datamgmt/activities/activities_db.py @@ -188,6 +188,124 @@ def get_recent_activities_for_user(user_id, limit=20, offset=0, accessible_case_ return [row._asdict() for row in rows] +def list_activities_paginated( + page=1, + per_page=25, + accessible_case_ids=None, + include_non_case=False, + search_value=None, + user_id=None, + case_id=None, + user_ids=None, + case_ids=None, + date_from=None, + date_to=None, + is_from_api=None, + is_manual=None, +): + """Paginated, filtered listing of UserActivity rows for the Activities + page. + + Same row shape as ``get_users_activities`` / ``get_all_users_activities`` + but with proper pagination, optional access scoping, and an optional + text filter on ``activity_desc``. Mirrors the shape used elsewhere in + the v2 API (returns a ``Pagination`` object so callers can hand it + straight to ``response_api_paginated``). + + Args: + page: 1-indexed page number. + per_page: page size (caller is expected to clamp). + accessible_case_ids: + * ``None`` → no access filter (caller is admin / has the + ``all_activities_read`` permission). + * iterable → only rows whose ``case_id`` is in the list, or + (when ``include_non_case`` is True) rows with ``case_id IS + NULL`` regardless. + include_non_case: + if True, also include rows with no associated case (login + events, global task changes, etc). When False, only + case-linked activity is returned. + search_value: optional case-insensitive ILIKE filter on the + ``activity_desc`` column. + user_id: optional — restrict to a single user. + case_id: optional — restrict to a single case. + """ + + base = UserActivity.query.with_entities( + UserActivity.id, + Cases.name.label('case_name'), + UserActivity.case_id, + User.name.label('user_name'), + UserActivity.user_id, + UserActivity.activity_date, + UserActivity.activity_desc, + UserActivity.user_input, + UserActivity.is_from_api + ).outerjoin( + UserActivity.user + ).outerjoin( + UserActivity.case + ).filter( + UserActivity.display_in_ui == True + ) + + if accessible_case_ids is not None: + # Empty list ⇒ no accessible cases. If the caller also wants + # non-case-related rows, allow only those; otherwise short- + # circuit to an empty page. + if not accessible_case_ids: + if include_non_case: + base = base.filter(UserActivity.case_id.is_(None)) + else: + base = base.filter(False) + else: + scope = UserActivity.case_id.in_(accessible_case_ids) + if include_non_case: + scope = scope | UserActivity.case_id.is_(None) + base = base.filter(scope) + elif not include_non_case: + # Admin view but the caller doesn't want non-case rows. + base = base.filter(UserActivity.case_id.isnot(None)) + + if search_value: + base = base.filter(UserActivity.activity_desc.ilike(f'%{search_value}%')) + + if user_id is not None: + base = base.filter(UserActivity.user_id == user_id) + + if case_id is not None: + base = base.filter(UserActivity.case_id == case_id) + + # Multi-value filters. We accept the singular forms above too (legacy + # callers) and additively intersect with the multi-value ones — that + # way the UI can drive everything through the list params without + # needing to fall back to the singular shape. + if user_ids: + base = base.filter(UserActivity.user_id.in_(list(user_ids))) + + if case_ids: + # Intersect with the access-scoped list when one is in effect so + # an admin filter on cases X+Y still respects a non-admin's + # accessible-case window if both were passed. + base = base.filter(UserActivity.case_id.in_(list(case_ids))) + + if date_from is not None: + base = base.filter(UserActivity.activity_date >= date_from) + + if date_to is not None: + base = base.filter(UserActivity.activity_date <= date_to) + + if is_from_api is not None: + base = base.filter(UserActivity.is_from_api == bool(is_from_api)) + + if is_manual is not None: + base = base.filter(UserActivity.user_input == bool(is_manual)) + + return base.order_by(desc(UserActivity.activity_date)).paginate( + page=page, per_page=per_page, error_out=False + ) + + def search_users_activity_in_case(case_identifier): ua = UserActivity.query.with_entities( UserActivity.activity_date, From 17fbbcc9ceba0b2048337dadc59bd4e65d6c1203 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 14:50:14 +0200 Subject: [PATCH 007/121] [IMP] Deduplicate activities within a one-minute bucket --- .../app/datamgmt/activities/activities_db.py | 75 ++++++++++++++----- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/source/app/datamgmt/activities/activities_db.py b/source/app/datamgmt/activities/activities_db.py index d08c1d680..4cb870899 100644 --- a/source/app/datamgmt/activities/activities_db.py +++ b/source/app/datamgmt/activities/activities_db.py @@ -18,6 +18,7 @@ from sqlalchemy import and_ from sqlalchemy import desc +from sqlalchemy import func from app.models.cases import Cases from app.models.authorization import User @@ -231,23 +232,17 @@ def list_activities_paginated( case_id: optional — restrict to a single case. """ - base = UserActivity.query.with_entities( - UserActivity.id, - Cases.name.label('case_name'), - UserActivity.case_id, - User.name.label('user_name'), - UserActivity.user_id, - UserActivity.activity_date, - UserActivity.activity_desc, - UserActivity.user_input, - UserActivity.is_from_api - ).outerjoin( - UserActivity.user - ).outerjoin( - UserActivity.case - ).filter( - UserActivity.display_in_ui == True - ) + # The UserActivity table accumulates near-duplicate rows whenever a + # client emits the same change repeatedly within seconds (e.g. a + # note editor's debounced autosave, an alert ingestion script + # re-emitting on retry). For the listing page we collapse rows that + # share (user_id, case_id, activity_desc) within a one-minute bucket + # and surface a single representative row carrying the most-recent + # timestamp plus an `occurrences` count. This is identical to how + # most activity feeds (GitHub, Slack) coalesce bursts. + bucket = func.date_trunc('minute', UserActivity.activity_date).label('bucket') + + base = UserActivity.query.filter(UserActivity.display_in_ui == True) if accessible_case_ids is not None: # Empty list ⇒ no accessible cases. If the caller also wants @@ -301,10 +296,52 @@ def list_activities_paginated( if is_manual is not None: base = base.filter(UserActivity.user_input == bool(is_manual)) - return base.order_by(desc(UserActivity.activity_date)).paginate( - page=page, per_page=per_page, error_out=False + # Aggregation step. We pick MAX(id) as the representative row id so + # the frontend has a stable key for keyed-each updates; MAX(date) is + # the cluster's effective timestamp. `bool_or` collapses the two + # boolean flags — within a duplicate cluster they should be uniform + # anyway, but `bool_or` is correct if they ever diverge (e.g. one + # row came in via API, one via UI, for the same logical change). + aggregated = base.with_entities( + func.max(UserActivity.id).label('id'), + UserActivity.case_id, + UserActivity.user_id, + UserActivity.activity_desc, + bucket, + func.max(UserActivity.activity_date).label('activity_date'), + func.bool_or(UserActivity.user_input).label('user_input'), + func.bool_or(UserActivity.is_from_api).label('is_from_api'), + func.count().label('occurrences'), + ).group_by( + UserActivity.case_id, + UserActivity.user_id, + UserActivity.activity_desc, + bucket, + ).subquery() + + # Re-attach friendly labels. We outer-join because user / case may + # be NULL (login events, global tasks; or users that were deleted + # after writing the row — UserActivity.user_id is nullable). + listing = ( + UserActivity.query.session.query( + aggregated.c.id, + Cases.name.label('case_name'), + aggregated.c.case_id, + User.name.label('user_name'), + aggregated.c.user_id, + aggregated.c.activity_date, + aggregated.c.activity_desc, + aggregated.c.user_input, + aggregated.c.is_from_api, + aggregated.c.occurrences, + ) + .outerjoin(User, User.id == aggregated.c.user_id) + .outerjoin(Cases, Cases.case_id == aggregated.c.case_id) + .order_by(desc(aggregated.c.activity_date)) ) + return listing.paginate(page=page, per_page=per_page, error_out=False) + def search_users_activity_in_case(case_identifier): ua = UserActivity.query.with_entities( From d919a620db8bff24b9c96246e3ef9ab190e89745 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 15:16:59 +0200 Subject: [PATCH 008/121] [ADD] v2 endpoints for Dim (Celery) tasks listing and detail --- source/app/blueprints/rest/api_v2_routes.py | 2 + source/app/blueprints/rest/v2/dim_tasks.py | 101 ++++++++++++++++++ source/app/business/asynchronous_tasks.py | 107 ++++++++++++++++++++ source/app/datamgmt/asynchronous_tasks.py | 45 ++++++++ 4 files changed, 255 insertions(+) create mode 100644 source/app/blueprints/rest/v2/dim_tasks.py diff --git a/source/app/blueprints/rest/api_v2_routes.py b/source/app/blueprints/rest/api_v2_routes.py index 8a983e3ba..7768fa3b1 100644 --- a/source/app/blueprints/rest/api_v2_routes.py +++ b/source/app/blueprints/rest/api_v2_routes.py @@ -27,6 +27,7 @@ from app.blueprints.rest.v2.auth import auth_blueprint from app.blueprints.rest.v2.cases import cases_blueprint from app.blueprints.rest.v2.dashboard import dashboard_blueprint +from app.blueprints.rest.v2.dim_tasks import dim_tasks_blueprint from app.blueprints.rest.v2.global_tasks import global_tasks_blueprint from app.blueprints.rest.v2.iocs import iocs_blueprint from app.blueprints.rest.v2.manage import manage_v2_blueprint @@ -58,3 +59,4 @@ rest_v2_blueprint.register_blueprint(search_blueprint) rest_v2_blueprint.register_blueprint(alerts_filters_blueprint) rest_v2_blueprint.register_blueprint(activities_blueprint) +rest_v2_blueprint.register_blueprint(dim_tasks_blueprint) diff --git a/source/app/blueprints/rest/v2/dim_tasks.py b/source/app/blueprints/rest/v2/dim_tasks.py new file mode 100644 index 000000000..03db6f57c --- /dev/null +++ b/source/app/blueprints/rest/v2/dim_tasks.py @@ -0,0 +1,101 @@ +# IRIS Source Code +# Copyright (C) 2025 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_success +from app.business.asynchronous_tasks import asynchronous_task_get_by_id +from app.business.asynchronous_tasks import asynchronous_tasks_list +from app.business.asynchronous_tasks import dim_tasks_get + + +dim_tasks_blueprint = Blueprint('dim_tasks_rest_v2', __name__, url_prefix='/dim-tasks') + + +@dim_tasks_blueprint.get('') +@ac_api_requires() +def list_dim_tasks_endpoint(): + """Paginated listing of Celery (Dim) tasks. + + Query params: + - page (int, default 1) + - per_page (int, default 25, clamped to 1..200) + - search (str, optional) — ILIKE filter over task name + task id + - status (str, optional) — exact-match on the Celery status column + (SUCCESS / FAILURE / PENDING / STARTED / RETRY). + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + # Clamp here so a hostile caller can't ask for 100k rows. + per_page = max(1, min(per_page, 200)) + + search_value = (request.args.get('search') or '').strip() or None + status = (request.args.get('status') or '').strip() or None + + items, paginated = asynchronous_tasks_list( + page=page, + per_page=per_page, + search_value=search_value, + status=status, + ) + + return response_api_success(data={ + 'total': paginated.total, + 'data': items, + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + + +@dim_tasks_blueprint.get('/') +@ac_api_requires() +def get_dim_task_endpoint(task_id): + """Detail view for a single Dim task. + + Returns the full Celery AsyncResult metadata (logs, traceback, etc.) + plus the row-shape projection used by the listing — handy because + the frontend can hydrate a slide-out panel without a second request. + + 404 only if the meta row itself is missing. A successful AsyncResult + fetch for an unknown id still returns a "PENDING" stub from Celery, + so we use the DB lookup as the source of truth for existence. + """ + projected = asynchronous_task_get_by_id(task_id) + if projected is None: + return response_api_not_found() + + # `dim_tasks_get` does the heavy lifting (unpickling task.info, + # parsing logs/traceback). Keys are human-readable strings — keep + # them as-is so the frontend can render them verbatim in a key/value + # detail card. + details = dim_tasks_get(task_id) + + # `Task finished on` is a Python datetime — serialise to ISO so JSON + # round-trips cleanly. Other values are already primitives. + finished_on = details.get('Task finished on') + if finished_on is not None and hasattr(finished_on, 'isoformat'): + details = {**details, 'Task finished on': finished_on.isoformat()} + + return response_api_success(data={ + 'row': projected, + 'details': details, + }) diff --git a/source/app/business/asynchronous_tasks.py b/source/app/business/asynchronous_tasks.py index d8238d5bd..e7d4c1acb 100644 --- a/source/app/business/asynchronous_tasks.py +++ b/source/app/business/asynchronous_tasks.py @@ -21,6 +21,8 @@ from app import celery from app.datamgmt.asynchronous_tasks import search_asynchronous_tasks +from app.datamgmt.asynchronous_tasks import search_asynchronous_tasks_paginated +from app.datamgmt.asynchronous_tasks import get_asynchronous_task_by_id from iris_interface.IrisInterfaceStatus import IIStatus @@ -88,6 +90,111 @@ def dim_tasks_get(task_identifier): } +def _project_row(row): + """Turn one CeleryTaskMeta record into a flat dict suitable for the + Dim Tasks listing page. + + Same shape as the legacy ``asynchronous_tasks_search`` projection, + but: + * ``date_done`` is an ISO 8601 string (or ``None``) so the frontend + doesn't have to know about Python datetimes; + * ``case_id`` is split out from the human ``case`` label, so the + UI can build a `/case/` link without parsing a string; + * we never propagate exceptions raised by pickle.loads — a corrupt + result blob just leaves the row labelled as a failure rather + than 500-ing the whole page. + """ + tkp = { + 'task_id': row.task_id, + 'state': row.status, + 'case': '', + 'case_id': None, + 'module': row.name, + 'date_done': row.date_done.isoformat() if row.date_done else None, + 'user': 'Unknown', + } + + try: + _ = row.result + except AttributeError: + # Legacy task — pickled by an old IRIS version that no longer + # exists. Leave the bare row in place; the detail endpoint + # surfaces a proper "legacy" message. + return tkp + + if row.name is not None and 'task_hook_wrapper' in row.name: + task_name = f'{row.kwargs}::{row.kwargs}' + else: + task_name = row.name + + user = None + case_name = None + case_identifier = None + if row.kwargs and row.kwargs != b'{}': + try: + kwargs = json.loads(row.kwargs.decode('utf-8')) + except (UnicodeDecodeError, json.JSONDecodeError): + kwargs = None + if kwargs: + user = kwargs.get('init_user') + case_identifier = kwargs.get('caseid') + if case_identifier is not None: + case_name = f'Case #{case_identifier}' + module_name = kwargs.get('module_name') + hook_name = kwargs.get('hook_name') + task_name = f'{module_name}::{hook_name}' + + try: + result = pickle.loads(row.result) if row.result else None + except Exception: + result = None + + if isinstance(result, IIStatus): + try: + success = result.is_success() + except Exception: + success = None + else: + success = None + + tkp['state'] = 'success' if success else (row.status or 'failure') + tkp['user'] = user if user else 'Shadow Iris' + tkp['module'] = task_name + tkp['case'] = case_name if case_name else '' + tkp['case_id'] = case_identifier + + return tkp + + +def asynchronous_tasks_list(page=1, per_page=25, search_value=None, status=None): + """Paginated Dim Tasks listing. + + Returns a tuple ``(items, paginated)`` where ``items`` is the list of + projected dicts and ``paginated`` is the SQLAlchemy ``Pagination`` + object — same handshake as ``list_activities_paginated`` so the + endpoint can build the standard envelope without re-counting. + """ + paginated = search_asynchronous_tasks_paginated( + page=page, + per_page=per_page, + search_value=search_value, + status=status, + ) + items = [_project_row(r) for r in paginated.items] + return items, paginated + + +def asynchronous_task_get_by_id(task_id): + """Find one CeleryTaskMeta by Celery task id and project it. + + Returns ``None`` if the row doesn't exist (so the endpoint can 404). + """ + row = get_asynchronous_task_by_id(task_id) + if row is None: + return None + return _project_row(row) + + def asynchronous_tasks_search(count): tasks = search_asynchronous_tasks(count) diff --git a/source/app/datamgmt/asynchronous_tasks.py b/source/app/datamgmt/asynchronous_tasks.py index 82d8afe9d..ab37934ca 100644 --- a/source/app/datamgmt/asynchronous_tasks.py +++ b/source/app/datamgmt/asynchronous_tasks.py @@ -17,6 +17,7 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. from sqlalchemy import desc +from sqlalchemy import or_ from app.models.models import CeleryTaskMeta @@ -26,3 +27,47 @@ def search_asynchronous_tasks(count): ~ CeleryTaskMeta.name.like('app.iris_engine.updater.updater.%') ).order_by(desc(CeleryTaskMeta.date_done)).limit(count).all() return tasks + + +def search_asynchronous_tasks_paginated( + page=1, + per_page=25, + search_value=None, + status=None, +): + """Paginated listing of CeleryTaskMeta rows for the Dim Tasks page. + + Same row source as ``search_asynchronous_tasks`` but with proper + pagination + optional filters. We filter out updater tasks (same as + the legacy listing) and offer a coarse ``search`` over the task name + plus a ``status`` exact-match filter. + + ``args`` / ``kwargs`` / ``result`` are LargeBinary (pickled) columns + — we deliberately do NOT filter on them, because that would either + require unpickling every row (security + performance hazard) or + binary-substring ILIKE'ing pickled bytes (false positives). The + business layer does the pickle decoding lazily on the items the page + returns. + """ + base = CeleryTaskMeta.query.filter( + ~ CeleryTaskMeta.name.like('app.iris_engine.updater.updater.%') + ) + + if search_value: + like = f'%{search_value}%' + base = base.filter(or_( + CeleryTaskMeta.name.ilike(like), + CeleryTaskMeta.task_id.ilike(like), + )) + + if status: + base = base.filter(CeleryTaskMeta.status == status) + + return base.order_by(desc(CeleryTaskMeta.date_done)).paginate( + page=page, per_page=per_page, error_out=False, + ) + + +def get_asynchronous_task_by_id(task_id): + """Single CeleryTaskMeta row by Celery task id, or None.""" + return CeleryTaskMeta.query.filter(CeleryTaskMeta.task_id == task_id).first() From 650245dfb0a34201f304236f95c4a7b3ebc8abce Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 15:49:31 +0200 Subject: [PATCH 009/121] [ADD] v2 endpoints for case close and reopen --- source/app/blueprints/rest/v2/cases.py | 38 +++++++++++++ source/app/business/cases.py | 78 ++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/source/app/blueprints/rest/v2/cases.py b/source/app/blueprints/rest/v2/cases.py index 61076360c..969489f98 100644 --- a/source/app/blueprints/rest/v2/cases.py +++ b/source/app/blueprints/rest/v2/cases.py @@ -45,9 +45,11 @@ from app.blueprints.rest.v2.case_routes.datastore import case_datastore_blueprint from app.blueprints.iris_user import iris_current_user from app.business.cases import cases_create +from app.business.cases import cases_close from app.business.cases import cases_delete from app.business.cases import cases_exists from app.business.cases import cases_get_by_identifier +from app.business.cases import cases_reopen from app.business.cases import cases_update from app.datamgmt.manage.manage_users_db import get_users_list_restricted_from_case from app.models.errors import BusinessProcessingError, ObjectNotFoundError @@ -321,6 +323,30 @@ def delete(self, identifier): except BusinessProcessingError as e: return response_api_error(e.get_message(), e.get_data()) + def close(self, identifier): + if not ac_fast_check_current_user_has_case_access(identifier, [CaseAccessLevel.full_access]): + return ac_api_return_access_denied(caseid=identifier) + + try: + case = cases_close(identifier) + return response_api_success(CaseDetailsSchema().dump(case)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), e.get_data()) + + def reopen(self, identifier): + if not ac_fast_check_current_user_has_case_access(identifier, [CaseAccessLevel.full_access]): + return ac_api_return_access_denied(caseid=identifier) + + try: + case = cases_reopen(identifier) + return response_api_success(CaseDetailsSchema().dump(case)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), e.get_data()) + # Create blueprint & import child blueprints cases_blueprint = Blueprint('cases', @@ -374,6 +400,18 @@ def case_routes_delete(identifier): return cases_operations.delete(identifier) +@cases_blueprint.post('//close') +@ac_api_requires(Permissions.standard_user) +def case_routes_close(identifier): + return cases_operations.close(identifier) + + +@cases_blueprint.post('//reopen') +@ac_api_requires(Permissions.standard_user) +def case_routes_reopen(identifier): + return cases_operations.reopen(identifier) + + @cases_blueprint.get('//access/users') @ac_api_requires() def list_case_access_users(identifier): diff --git a/source/app/business/cases.py b/source/app/business/cases.py index 33caff65a..c3b165871 100644 --- a/source/app/business/cases.py +++ b/source/app/business/cases.py @@ -234,6 +234,84 @@ def cases_update(case: Cases, updated_case, protagonists, tags) -> Cases: raise BusinessProcessingError('Data error', str(e)) +def cases_close(case_identifier) -> Cases: + """Close a case and cascade the state change to its alerts. + + Mirrors the legacy ``POST /manage/cases/close/`` handler so the + v2 endpoint behaves identically: it flips the case state to + "Closed", closes every linked alert that isn't already closed, maps + the case resolution onto the alert resolution, fires the + ``on_postload_case_update`` module hook, and records a history / + activity entry. + """ + case = get_case(case_identifier) + if not case: + raise ObjectNotFoundError() + + res = close_case(case_identifier) + if not res: + raise ObjectNotFoundError() + + if case.alerts: + close_status = get_alert_status_by_name('Closed') + case_status_id_mapped = map_alert_resolution_to_case_status(case.status_id) + + for alert in case.alerts: + if alert.alert_status_id != close_status.status_id: + alert.alert_status_id = close_status.status_id + alert = call_modules_hook('on_postload_alert_update', alert, caseid=case_identifier) + + if alert.alert_resolution_status_id != case_status_id_mapped: + alert.alert_resolution_status_id = case_status_id_mapped + alert = call_modules_hook('on_postload_alert_resolution_update', alert, + caseid=case_identifier) + + track_activity(f'closing alert ID {alert.alert_id} due to case #{case_identifier} being closed', + caseid=case_identifier, ctx_less=False) + + db.session.add(alert) + + res = call_modules_hook('on_postload_case_update', res, caseid=case_identifier) + + add_obj_history_entry(res, 'case closed') + track_activity(f'closed case ID {case_identifier}', caseid=case_identifier, ctx_less=False) + return res + + +def cases_reopen(case_identifier) -> Cases: + """Reopen a previously-closed case and cascade to its alerts. + + Mirrors ``POST /manage/cases/reopen/``: clears the close date, + flips the state back to "Open", moves every linked alert that + isn't already "Merged" to that state (legacy behaviour — reopening + a case implies its alerts are no longer terminal), and records + history + activity. + """ + case = get_case(case_identifier) + if not case: + raise ObjectNotFoundError() + + res = reopen_case(case_identifier) + if not res: + raise ObjectNotFoundError() + + if case.alerts: + merged_status = get_alert_status_by_name('Merged') + for alert in case.alerts: + if alert.alert_status_id != merged_status.status_id: + alert.alert_status_id = merged_status.status_id + track_activity( + f'alert ID {alert.alert_id} status updated to merged due to case #{case_identifier} being reopen', + caseid=case_identifier, ctx_less=False) + db.session.add(alert) + + res = call_modules_hook('on_postload_case_update', res, caseid=case_identifier) + + add_obj_history_entry(res, 'case reopen') + track_activity(f'reopen case ID {case_identifier}', caseid=case_identifier) + return res + + def cases_export_to_json(case_id): """Fully export a case a JSON""" export = {} From a09128c16d2872db2d2254701d1cb1e7bfdc5cc0 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 15:57:41 +0200 Subject: [PATCH 010/121] [FIX] Detach alert-shared IoCs before deleting a case --- source/app/datamgmt/manage/manage_cases_db.py | 74 ++++++++++++++----- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/source/app/datamgmt/manage/manage_cases_db.py b/source/app/datamgmt/manage/manage_cases_db.py index 8d377f482..d1f1843c1 100644 --- a/source/app/datamgmt/manage/manage_cases_db.py +++ b/source/app/datamgmt/manage/manage_cases_db.py @@ -318,24 +318,62 @@ def get_case_details_rt(case_id): def _delete_iocs(case_identifier): - # TODO should do this with the 2.0 SQLAlchemy API - # TODO maybe this can be performed automatically with cascades - com_ids = IocComments.query.with_entities( - IocComments.comment_id - ).join( - Ioc - ).filter( - IocComments.comment_ioc_id == Ioc.ioc_id, - Ioc.case_id == case_identifier - ).all() - - com_ids = [c.comment_id for c in com_ids] - IocComments.query.filter(IocComments.comment_id.in_(com_ids)).delete() - - Comments.query.filter( - Comments.comment_id.in_(com_ids) - ).delete() - Ioc.query.filter(Ioc.case_id == case_identifier).delete() + # IoCs are shared between cases and alerts via `alert_iocs_association`. + # If we bulk-delete every IoC with `case_id = X` we hit the FK + # constraint as soon as one of them is still referenced from an + # alert (legitimate state: the IoC came in via an alert that wasn't + # merged into this case, or the case is being deleted but the alert + # survives). Mirror the partitioning `_delete_assets` already does: + # - IoCs not referenced by any alert → fully delete (with their + # comments and link tables); + # - IoCs still referenced by an alert → detach by clearing + # `case_id` so the case-level FK no longer pins them, but the + # alert-side association stays intact. + from app.models.iocs import alert_iocs_association + + referenced_subq = db.session.query(alert_iocs_association.c.ioc_id).filter( + alert_iocs_association.c.ioc_id == Ioc.ioc_id + ).exists() + + deletable_ids = [ + row.ioc_id + for row in db.session.query(Ioc.ioc_id).filter( + Ioc.case_id == case_identifier, + ~referenced_subq, + ).all() + ] + + if deletable_ids: + com_ids = [ + c.comment_id + for c in IocComments.query.with_entities(IocComments.comment_id).filter( + IocComments.comment_ioc_id.in_(deletable_ids) + ).all() + ] + if com_ids: + IocComments.query.filter(IocComments.comment_id.in_(com_ids)).delete( + synchronize_session=False) + Comments.query.filter(Comments.comment_id.in_(com_ids)).delete( + synchronize_session=False) + + # IocAssetLink + CaseEventsIoc rows for these IoCs are scoped to + # this case (link tables don't survive the case anyway), but + # CaseEventsIoc filtering at the caller level only covers the + # `case_id` column. Belt-and-braces: clear them by ioc_id too so + # we never hit an "ioc still referenced" FK from a stray link. + IocAssetLink.query.filter(IocAssetLink.ioc_id.in_(deletable_ids)).delete( + synchronize_session=False) + CaseEventsIoc.query.filter(CaseEventsIoc.ioc_id.in_(deletable_ids)).delete( + synchronize_session=False) + + Ioc.query.filter(Ioc.ioc_id.in_(deletable_ids)).delete( + synchronize_session=False) + + # Anything left behind belonged to alerts too — detach from the case + # so the next `Cases.query.filter(...).delete()` doesn't fail on + # `ioc.case_id → cases.case_id`. + Ioc.query.filter(Ioc.case_id == case_identifier).update( + {Ioc.case_id: None}, synchronize_session=False) def _delete_assets(case_identifier): From 5bea50ee1ce3538633f4928aa23c8c3773047d4c Mon Sep 17 00:00:00 2001 From: whitekernel Date: Tue, 23 Jun 2026 16:04:16 +0200 Subject: [PATCH 011/121] [IMP] Independent open/close date bounds for case filters --- source/app/blueprints/rest/v2/cases.py | 6 +++- source/app/business/cases.py | 7 ++-- source/app/datamgmt/manage/manage_cases_db.py | 33 ++++++++++++++++--- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/source/app/blueprints/rest/v2/cases.py b/source/app/blueprints/rest/v2/cases.py index 969489f98..b8a27e2ba 100644 --- a/source/app/blueprints/rest/v2/cases.py +++ b/source/app/blueprints/rest/v2/cases.py @@ -89,6 +89,8 @@ def search(self): case_soc_id = request.args.get('case_soc_id', None, type=str) start_open_date = request.args.get('start_open_date', None, type=str) end_open_date = request.args.get('end_open_date', None, type=str) + start_close_date = request.args.get('start_close_date', None, type=str) + end_close_date = request.args.get('end_close_date', None, type=str) is_open = request.args.get('is_open', None, type=parse_boolean) # Free-text search across case name, customer name, and (numeric) case id. # Powers the context switcher's search box; an empty / whitespace value is ignored. @@ -110,7 +112,9 @@ def search(self): start_open_date, end_open_date, is_open, - quick_search=quick_search + quick_search=quick_search, + start_close_date=start_close_date, + end_close_date=end_close_date, ) return response_api_paginated(self._schema, filtered_cases) diff --git a/source/app/business/cases.py b/source/app/business/cases.py index c3b165871..e17442772 100644 --- a/source/app/business/cases.py +++ b/source/app/business/cases.py @@ -64,7 +64,8 @@ def cases_filter(current_user, pagination_parameters, name=None, case_identifier description=None, classification_identifier=None, owner_identifier=None, opening_user_identifier=None, severity_identifier=None, status_identifier=None, soc_identifier=None, start_open_date=None, end_open_date=None, is_open=None, search_value='', - advanced_filters=None, advanced_logic='and', quick_search=None): + advanced_filters=None, advanced_logic='and', quick_search=None, + start_close_date=None, end_close_date=None): return get_filtered_cases( current_user.id, pagination_parameters, @@ -84,7 +85,9 @@ def cases_filter(current_user, pagination_parameters, name=None, case_identifier is_open=is_open, advanced_filters=advanced_filters, advanced_logic=advanced_logic, - quick_search=quick_search) + quick_search=quick_search, + start_close_date=start_close_date, + end_close_date=end_close_date) def cases_filter_by_user(user, show_all: bool): diff --git a/source/app/datamgmt/manage/manage_cases_db.py b/source/app/datamgmt/manage/manage_cases_db.py index d1f1843c1..7dfbad964 100644 --- a/source/app/datamgmt/manage/manage_cases_db.py +++ b/source/app/datamgmt/manage/manage_cases_db.py @@ -518,14 +518,32 @@ def build_filter_case_query(current_user_id, sort_by=None, sort_dir='asc', is_open: bool=None, - quick_search: str=None + quick_search: str=None, + start_close_date: str = None, + end_close_date: str = None, ): """ Get a list of cases from the database, filtered by the given parameters """ conditions = [] - if start_open_date is not None and end_open_date is not None: - conditions.append(Cases.open_date.between(start_open_date, end_open_date)) + # Open-date window. We accept each bound independently — the legacy + # behaviour required both to be set together, which forced callers + # to fake one end of the range when they only had one side. With + # `>=` / `<=` you can give just "opened since 2026-01-01" without + # also having to pick a closing bound. + if start_open_date is not None: + conditions.append(Cases.open_date >= start_open_date) + if end_open_date is not None: + conditions.append(Cases.open_date <= end_open_date) + + # Close-date window — same independent-bound semantics. Rows with + # `close_date IS NULL` (still-open cases) drop out naturally on any + # bound because NULL comparisons fail; that's the right behaviour + # for a "filter by close date" use case. + if start_close_date is not None: + conditions.append(Cases.close_date >= start_close_date) + if end_close_date is not None: + conditions.append(Cases.close_date <= end_close_date) if case_customer_id is not None: conditions.append(Cases.client_id == case_customer_id) @@ -637,7 +655,9 @@ def get_filtered_cases(current_user_id, is_open: bool | None = None, advanced_filters: list[dict[str, Any]] | None = None, advanced_logic: str = 'and', - quick_search: str | None = None + quick_search: str | None = None, + start_close_date: str | None = None, + end_close_date: str | None = None, ): kwargs: dict[str, Any] = { 'current_user_id': current_user_id, @@ -650,6 +670,11 @@ def get_filtered_cases(current_user_id, if end_open_date is not None: kwargs['end_open_date'] = end_open_date + if start_close_date is not None: + kwargs['start_close_date'] = start_close_date + if end_close_date is not None: + kwargs['end_close_date'] = end_close_date + if case_customer_id is not None: kwargs['case_customer_id'] = case_customer_id From a66ec94d6ee66efdeaf10e74f48c42014feb761e Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 10:52:04 +0200 Subject: [PATCH 012/121] [ADD] v2 endpoints for managing modules and their hooks --- source/app/blueprints/rest/v2/manage.py | 2 + .../rest/v2/manage_routes/modules.py | 174 ++++++++++ source/app/business/modules.py | 310 ++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 source/app/blueprints/rest/v2/manage_routes/modules.py create mode 100644 source/app/business/modules.py diff --git a/source/app/blueprints/rest/v2/manage.py b/source/app/blueprints/rest/v2/manage.py index e16905efe..2758a51b0 100644 --- a/source/app/blueprints/rest/v2/manage.py +++ b/source/app/blueprints/rest/v2/manage.py @@ -22,6 +22,7 @@ from app.blueprints.rest.v2.manage_routes.users import users_blueprint from app.blueprints.rest.v2.manage_routes.customers import customers_blueprint from app.blueprints.rest.v2.manage_routes.server import server_blueprint +from app.blueprints.rest.v2.manage_routes.modules import modules_blueprint manage_v2_blueprint = Blueprint("manage", __name__, url_prefix="/manage") @@ -29,3 +30,4 @@ manage_v2_blueprint.register_blueprint(users_blueprint) manage_v2_blueprint.register_blueprint(customers_blueprint) manage_v2_blueprint.register_blueprint(server_blueprint) +manage_v2_blueprint.register_blueprint(modules_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/modules.py b/source/app/blueprints/rest/v2/manage_routes/modules.py new file mode 100644 index 000000000..ae8f10c5e --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/modules.py @@ -0,0 +1,174 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 REST endpoints for the modules ("plugins") admin page. + +The list payload is intentionally small (a few dozen rows at most across +any real-world deployment) so there's no `page` / `per_page` here — +just a flat list. Add pagination if a deployment ever ships more than +~200 modules. +""" + +from flask import Blueprint +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_created +from app.blueprints.rest.endpoints import response_api_deleted +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_success +from app.business.modules import module_get_detail +from app.business.modules import modules_add +from app.business.modules import modules_delete +from app.business.modules import modules_disable +from app.business.modules import modules_enable +from app.business.modules import modules_export_config +from app.business.modules import modules_hooks_list +from app.business.modules import modules_import_config +from app.business.modules import modules_list +from app.business.modules import modules_set_parameter +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +modules_blueprint = Blueprint('modules_rest_v2', __name__, url_prefix='/modules') + + +@modules_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def list_modules(): + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + return response_api_success(modules_list(page=page, per_page=per_page)) + + +@modules_blueprint.post('') +@ac_api_requires(Permissions.server_administrator) +def add_module(): + request_data = request.get_json() or {} + module_name = request_data.get('module_name') + + try: + module = modules_add(module_name) + return response_api_created(module_get_detail(module.id)) + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.get('/') +@ac_api_requires(Permissions.server_administrator) +def get_module(identifier): + try: + return response_api_success(module_get_detail(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + + +@modules_blueprint.delete('/') +@ac_api_requires(Permissions.server_administrator) +def delete_module(identifier): + try: + modules_delete(identifier) + return response_api_deleted() + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.post('//enable') +@ac_api_requires(Permissions.server_administrator) +def enable_module(identifier): + try: + modules_enable(identifier) + return response_api_success(module_get_detail(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.post('//disable') +@ac_api_requires(Permissions.server_administrator) +def disable_module(identifier): + try: + modules_disable(identifier) + return response_api_success(module_get_detail(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.put('//parameters/') +@ac_api_requires(Permissions.server_administrator) +def set_module_parameter(identifier, param_name): + """Update one parameter. + + The legacy API encoded `mod_id##param_name` as base64 in the URL — + we don't carry that scheme over: the v2 URL puts `module_id` and + `param_name` as discrete path segments. `path:` matches slashes so + parameter names that contain `/` (rare but legal) round-trip cleanly. + """ + request_data = request.get_json() or {} + if 'parameter_value' not in request_data: + return response_api_error('Missing field: parameter_value') + + try: + return response_api_success( + modules_set_parameter(identifier, param_name, request_data['parameter_value']) + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.get('//export-config') +@ac_api_requires(Permissions.server_administrator) +def export_module_config(identifier): + try: + return response_api_success(modules_export_config(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + + +@modules_blueprint.post('//import-config') +@ac_api_requires(Permissions.server_administrator) +def import_module_config(identifier): + request_data = request.get_json() or {} + payload = request_data.get('module_configuration') + if payload is None: + return response_api_error('Missing field: module_configuration') + + try: + return response_api_success(modules_import_config(identifier, payload)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.get('/hooks') +@ac_api_requires(Permissions.server_administrator) +def list_modules_hooks(): + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + return response_api_success(modules_hooks_list(page=page, per_page=per_page)) diff --git a/source/app/business/modules.py b/source/app/business/modules.py new file mode 100644 index 000000000..b2dc44be8 --- /dev/null +++ b/source/app/business/modules.py @@ -0,0 +1,310 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Business layer for the modules ("plugins") admin surface. + +Wraps `app.datamgmt.iris_engine.modules_db` and `module_handler` so the +v2 REST layer can stay thin (parse args → call business → marshal). All +side-effecting calls raise `BusinessProcessingError` / `ObjectNotFoundError` +on failure so the route layer doesn't need to inspect tuple returns. +""" + +import json + +from app.datamgmt.iris_engine.modules_db import delete_module_from_id +from app.datamgmt.iris_engine.modules_db import get_module_config_from_id +from app.datamgmt.iris_engine.modules_db import get_module_from_id +from app.datamgmt.iris_engine.modules_db import iris_module_disable_by_id +from app.datamgmt.iris_engine.modules_db import iris_module_enable_by_id +from app.datamgmt.iris_engine.modules_db import iris_module_name_from_id +from app.datamgmt.iris_engine.modules_db import iris_module_save_parameter +from app.datamgmt.iris_engine.modules_db import iris_modules_list +from app.datamgmt.iris_engine.modules_db import module_list_hooks_view +from app.iris_engine.module_handler.module_handler import check_module_health +from app.iris_engine.module_handler.module_handler import instantiate_module_from_name +from app.iris_engine.module_handler.module_handler import iris_update_hooks +from app.iris_engine.module_handler.module_handler import register_module +from app.iris_engine.utils.tracker import track_activity +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +def _module_row_projection(row): + return { + 'id': row['id'], + 'module_human_name': row['module_human_name'], + 'has_pipeline': row['has_pipeline'], + 'module_version': row['module_version'], + 'interface_version': row['interface_version'], + 'date_added': row['date_added'].isoformat() if row['date_added'] else None, + 'added_by': row['name'], + 'is_active': row['is_active'], + 'configured': row['configured'], + } + + +def modules_list(page=1, per_page=25): + """Return the projection used by the admin list page, paginated. + + The legacy underlying call (`iris_modules_list`) materialises every + row and post-processes them (auto-disable misconfigured modules), + which keeps the behaviour intact. Pagination is done in-memory + afterwards — module fleets stay small (dozens at most), so a full + scan is cheap and we avoid duplicating the auto-disable logic in a + SQL paginate path. + """ + rows = [_module_row_projection(r) for r in iris_modules_list()] + total = len(rows) + per_page = max(1, min(per_page, 200)) + page = max(1, page) + start = (page - 1) * per_page + end = start + per_page + page_items = rows[start:end] + last_page = max(1, (total + per_page - 1) // per_page) if total else 1 + next_page = page + 1 if end < total else None + return { + 'total': total, + 'data': page_items, + 'last_page': last_page, + 'current_page': page, + 'next_page': next_page, + } + + +def modules_get(module_id): + module = get_module_from_id(module_id) + if module is None: + raise ObjectNotFoundError() + return module + + +def module_get_detail(module_id): + """Read-only projection for the module-info modal. + + Returns the same metadata as the list endpoint plus the full + configuration array (so the UI can render Section / Parameter / + Value / Mandatory rows) and the module's description + target + package — those are the bits the modal shows above the config table. + """ + module = modules_get(module_id) + return { + 'id': module.id, + 'module_name': module.module_name, + 'module_human_name': module.module_human_name, + 'module_description': module.module_description, + 'module_version': module.module_version, + 'interface_version': module.interface_version, + 'date_added': module.date_added.isoformat() if module.date_added else None, + 'is_active': module.is_active, + 'has_pipeline': module.has_pipeline, + 'module_type': module.module_type, + 'module_config': module.module_config or [], + } + + +def modules_add(module_name): + """Install + register a new module by its pip package name. + + Three stages mirror the legacy flow: + 1. import + instantiate the python class, + 2. health-check (interface contract), + 3. persist into `IrisModule` (this is what triggers default-value + preset for every declared parameter). + """ + if not module_name: + raise BusinessProcessingError('Module name is required') + + class_, logs = instantiate_module_from_name(module_name) + if not class_: + raise BusinessProcessingError('Cannot import module', data=logs) + + is_ready, logs = check_module_health(class_) + if not is_ready: + raise BusinessProcessingError( + "Module health check didn't pass. Please check logs.", + data=logs, + ) + + module, message = register_module(module_name) + if module is None: + track_activity( + f'addition of IRIS module {module_name} was attempted and failed', + ctx_less=True, + ) + raise BusinessProcessingError(f'Unable to register module: {message}') + + track_activity(f'IRIS module {module_name} was added', ctx_less=True) + return module + + +def modules_delete(module_id): + module = modules_get(module_id) + delete_module_from_id(module.id) + track_activity(f'IRIS module #{module.id} deleted', ctx_less=True) + + +def modules_enable(module_id): + """Activate a module and resync its hooks. + + Re-syncing hooks on enable matters: the module may have been disabled + long enough for its declared hooks to drift from what's in the DB + (e.g. an upgrade added new hooks). Without the resync, calling them + later would silently no-op. + """ + module_name = iris_module_name_from_id(module_id) + if module_name is None: + raise ObjectNotFoundError() + + if not iris_module_enable_by_id(module_id): + raise BusinessProcessingError('Unable to enable module') + + success, logs = iris_update_hooks(module_name, module_id) + if not success: + raise BusinessProcessingError('Unable to update hooks when enabling module', data=logs) + + track_activity(f'IRIS module ({module_name}) #{module_id} enabled', ctx_less=True) + return logs + + +def modules_disable(module_id): + if not iris_module_disable_by_id(module_id): + raise BusinessProcessingError('Unable to disable module') + + track_activity(f'IRIS module #{module_id} disabled', ctx_less=True) + + +def modules_set_parameter(module_id, param_name, value): + """Update a single parameter value on a module's config. + + Returns the refreshed module-detail projection so the UI can swap its + in-memory copy without a second GET. Re-runs `iris_update_hooks` to + pick up any hook changes that depended on the new value. + """ + mod_config, mod_human_name, mod_name = get_module_config_from_id(module_id) + if mod_config is None: + raise ObjectNotFoundError() + + if not any(p.get('param_name') == param_name for p in mod_config): + raise BusinessProcessingError(f'Unknown parameter: {param_name}') + + if not iris_module_save_parameter(module_id, mod_config, param_name, value): + raise BusinessProcessingError('Unable to save parameter') + + track_activity( + f'parameter {param_name} of mod ({mod_human_name}) #{module_id} was updated', + ctx_less=True, + ) + + success, logs = iris_update_hooks(mod_name, module_id) + if not success: + raise BusinessProcessingError('Unable to update hooks', data=logs) + + return module_get_detail(module_id) + + +def modules_export_config(module_id): + mod_config, mod_human_name, mod_name = get_module_config_from_id(module_id) + if mod_config is None: + raise ObjectNotFoundError() + + return { + 'module_name': mod_name, + 'module_human_name': mod_human_name, + 'module_configuration': mod_config, + } + + +def modules_import_config(module_id, payload): + """Bulk-apply a configuration dict to an existing module. + + Accepts either a list of `{param_name, value}` entries or a JSON + string that decodes to one (legacy behaviour — the old form upload + posted the raw file contents as a string). + + Skips unknown parameters silently and returns the list of skipped + names so the caller can warn the user. Unknown params are treated as + "module was downgraded" rather than as a hard error: the rest of the + config still applies cleanly. + """ + if isinstance(payload, str): + try: + payload = json.loads(payload) + except (TypeError, ValueError): + raise BusinessProcessingError('Invalid data', data='Not a JSON file') + + if not isinstance(payload, list): + raise BusinessProcessingError('Invalid data', data='Expected a list of parameters') + + mod_config, _, _ = get_module_config_from_id(module_id) + if mod_config is None: + raise ObjectNotFoundError() + + skipped = [] + for param in payload: + param_name = param.get('param_name') + value = param.get('value') + if param_name is None: + continue + if not iris_module_save_parameter(module_id, mod_config, param_name, value): + skipped.append(param_name) + + track_activity( + f'parameters of mod #{module_id} were updated from config file', + ctx_less=True, + ) + + return { + 'skipped': skipped, + 'module': module_get_detail(module_id), + } + + +def modules_hooks_list(page=1, per_page=25): + """Paginated list of every (module, hook) binding for the Hooks table. + + Same in-memory pagination as `modules_list` — hook counts grow with + `n_modules * n_hooks_per_module` but remain bounded by the size of + the deployment. Materialising the full set per request stays cheap + even when the UI paginates. + """ + rows = [ + { + 'id': row.id, + 'module_name': row.module_name, + 'is_active': row.is_active, + 'hook_name': row.hook_name, + 'hook_description': row.hook_description, + 'is_manual_hook': row.is_manual_hook, + } + for row in module_list_hooks_view() + ] + total = len(rows) + per_page = max(1, min(per_page, 200)) + page = max(1, page) + start = (page - 1) * per_page + end = start + per_page + page_items = rows[start:end] + last_page = max(1, (total + per_page - 1) // per_page) if total else 1 + next_page = page + 1 if end < total else None + return { + 'total': total, + 'data': page_items, + 'last_page': last_page, + 'current_page': page, + 'next_page': next_page, + } From 113053dca177342df030fc32375a5db085934c73 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 10:52:12 +0200 Subject: [PATCH 013/121] [ADD] v2 endpoints for customer contacts CRUD and embed contacts in customer detail --- .../rest/v2/manage_routes/customers.py | 124 ++++++++++++++++++ source/app/business/customers_contacts.py | 42 ++++++ 2 files changed, 166 insertions(+) diff --git a/source/app/blueprints/rest/v2/manage_routes/customers.py b/source/app/blueprints/rest/v2/manage_routes/customers.py index d969a5b40..3d8e2f98c 100644 --- a/source/app/blueprints/rest/v2/manage_routes/customers.py +++ b/source/app/blueprints/rest/v2/manage_routes/customers.py @@ -29,14 +29,21 @@ from app.blueprints.access_controls import ac_api_requires from app.blueprints.access_controls import ac_current_user_has_permission from app.models.authorization import Permissions +from app.schema.marshables import ContactSchema from app.schema.marshables import CustomerSchema from app.models.errors import ObjectNotFoundError from app.models.errors import ElementInUseError +from app.models.errors import BusinessProcessingError from app.business.customers import customers_create_with_user from app.business.customers import customers_filter from app.business.customers import customers_get from app.business.customers import customers_update from app.business.customers import customers_delete +from app.business.customers_contacts import customers_contacts_create +from app.business.customers_contacts import customers_contacts_delete +from app.business.customers_contacts import customers_contacts_get +from app.business.customers_contacts import customers_contacts_list +from app.business.customers_contacts import customers_contacts_update from app.blueprints.iris_user import iris_current_user from app.blueprints.rest.parsing import parse_pagination_parameters @@ -66,6 +73,13 @@ def read(self, identifier): try: customer = customers_get(identifier) result = self._schema.dump(customer) + # Embed contacts so the UI can render the detail panel + # without a second round-trip. Matches the legacy GET + # /manage/customers/ behaviour the SvelteKit client + # already types for (`Customer.contacts?: ...`). + result['contacts'] = ContactSchema().dump( + customers_contacts_list(identifier), many=True + ) return response_api_success(result) except ObjectNotFoundError: return response_api_not_found() @@ -97,9 +111,89 @@ def delete(identifier): return response_api_error(e.get_message()) +class CustomersContactsOperations: + """REST surface for `/customers//contacts[/]`. + + Mirrors `CustomersOperations` shape (search / create / read / + update / delete) so the routing layer below stays uniform. Every + handler verifies that the contact (when one is named) actually + belongs to the parent customer — a contact_id from another + customer would otherwise authorise edits via a sibling URL. + """ + + def __init__(self): + self._schema = ContactSchema() + + @staticmethod + def _ensure_parent(client_id): + customers_get(client_id) + + def _ensure_owns(self, client_id, contact_id): + contact = customers_contacts_get(contact_id) + if contact.client_id != client_id: + raise ObjectNotFoundError() + return contact + + def list(self, client_id): + try: + self._ensure_parent(client_id) + return response_api_success( + self._schema.dump(customers_contacts_list(client_id), many=True) + ) + except ObjectNotFoundError: + return response_api_not_found() + + def create(self, client_id): + try: + self._ensure_parent(client_id) + request_data = request.get_json() or {} + # Force the parent id from the URL — the schema requires + # client_id, and trusting the body would let a client write + # contacts under a different customer. + request_data['client_id'] = client_id + contact = self._schema.load(request_data) + customers_contacts_create(contact) + return response_api_created(self._schema.dump(contact)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + except ObjectNotFoundError: + return response_api_not_found() + + def read(self, client_id, contact_id): + try: + contact = self._ensure_owns(client_id, contact_id) + return response_api_success(self._schema.dump(contact)) + except ObjectNotFoundError: + return response_api_not_found() + + def update(self, client_id, contact_id): + try: + contact = self._ensure_owns(client_id, contact_id) + request_data = request.get_json() or {} + request_data['client_id'] = client_id + self._schema.load(request_data, instance=contact, partial=True) + customers_contacts_update(contact) + return response_api_success(self._schema.dump(contact)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + except ObjectNotFoundError: + return response_api_not_found() + + def delete(self, client_id, contact_id): + try: + contact = self._ensure_owns(client_id, contact_id) + customers_contacts_delete(contact) + return response_api_deleted() + except ObjectNotFoundError: + return response_api_not_found() + except (ElementInUseError, BusinessProcessingError) as e: + return response_api_error(e.get_message()) + + customers_blueprint = Blueprint('customers_rest_v2', __name__, url_prefix='/customers') customers_operations = CustomersOperations() +customers_contacts_operations = CustomersContactsOperations() @customers_blueprint.get('') @@ -130,3 +224,33 @@ def put_customer(identifier): @ac_api_requires(Permissions.customers_write) def delete_user(identifier): return customers_operations.delete(identifier) + + +@customers_blueprint.get('//contacts') +@ac_api_requires(Permissions.customers_read) +def list_customer_contacts(identifier): + return customers_contacts_operations.list(identifier) + + +@customers_blueprint.post('//contacts') +@ac_api_requires(Permissions.customers_write) +def create_customer_contact(identifier): + return customers_contacts_operations.create(identifier) + + +@customers_blueprint.get('//contacts/') +@ac_api_requires(Permissions.customers_read) +def get_customer_contact(identifier, contact_id): + return customers_contacts_operations.read(identifier, contact_id) + + +@customers_blueprint.put('//contacts/') +@ac_api_requires(Permissions.customers_write) +def put_customer_contact(identifier, contact_id): + return customers_contacts_operations.update(identifier, contact_id) + + +@customers_blueprint.delete('//contacts/') +@ac_api_requires(Permissions.customers_write) +def delete_customer_contact(identifier, contact_id): + return customers_contacts_operations.delete(identifier, contact_id) diff --git a/source/app/business/customers_contacts.py b/source/app/business/customers_contacts.py index d7948b3c0..8694a9139 100644 --- a/source/app/business/customers_contacts.py +++ b/source/app/business/customers_contacts.py @@ -16,9 +16,16 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +from typing import List + from app.models.models import Contact from app.models.errors import ObjectNotFoundError +from app.datamgmt.client.client_db import delete_contact from app.datamgmt.client.client_db import get_client_contact +from app.datamgmt.client.client_db import get_client_contacts +from app.datamgmt.client.client_db import update_contact +from app.datamgmt.db_operations import db_create +from app.iris_engine.utils.tracker import track_activity def customers_contacts_get(identifier) -> Contact: @@ -26,3 +33,38 @@ def customers_contacts_get(identifier) -> Contact: if not contact: raise ObjectNotFoundError() return contact + + +def customers_contacts_list(client_id: int) -> List[Contact]: + """Return every contact bound to a given customer, sorted by name. + + The data layer enforces the sort so consumers don't need to — + contacts are typically displayed in a directory-style list and a + stable order across requests matters for pagination UX. + """ + return get_client_contacts(client_id) + + +def customers_contacts_create(contact: Contact) -> Contact: + db_create(contact) + track_activity(f'Added contact {contact.contact_name}', ctx_less=True) + return contact + + +def customers_contacts_update(contact: Contact) -> Contact: + """Persist pending in-session edits on `contact`. + + Mirrors the legacy `update_contact()` helper — the caller has + already mutated the SQLAlchemy instance (typically via + `ContactSchema.load(..., instance=contact)`) so this just flushes + the session. + """ + update_contact() + track_activity(f'Updated contact {contact.contact_name}', ctx_less=True) + return contact + + +def customers_contacts_delete(contact: Contact) -> None: + name = contact.contact_name + delete_contact(contact) + track_activity(f'Deleted contact {name}', ctx_less=True) From 0581e02697c715ea68ac8dea41143b7a3d68b40e Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 10:52:23 +0200 Subject: [PATCH 014/121] [FIX] Paginated v2 envelope returns the next page number instead of a boolean --- source/app/blueprints/rest/endpoints.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/source/app/blueprints/rest/endpoints.py b/source/app/blueprints/rest/endpoints.py index 8bd08a8d9..6b3b669db 100644 --- a/source/app/blueprints/rest/endpoints.py +++ b/source/app/blueprints/rest/endpoints.py @@ -31,10 +31,17 @@ def response_api_success(data): def _get_next_page(paginated_elements: Pagination): + # Return the next page *number* — historically this returned + # `has_next` (a boolean), which made it impossible for clients to + # drive infinite scroll: they got `True` instead of `2`, then + # incremented from `currentPage` and worked by coincidence on page + # 1 but desynced on subsequent loads. `next_num` is None when + # there's no next page, which matches the envelope's documented + # `next_page: int | None` shape. if not paginated_elements.has_next: return None - return paginated_elements.has_next + return paginated_elements.next_num def response_api_paginated(schema, paginated_elements: Pagination): From b018489053be886234e57f676a706c9c5ed384bf Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 11:09:02 +0200 Subject: [PATCH 015/121] [ADD] Search query parameter on the v2 customers listing endpoint --- .../blueprints/rest/v2/manage_routes/customers.py | 11 ++++++++++- source/app/business/customers.py | 4 ++-- source/app/datamgmt/client/client_db.py | 13 ++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/source/app/blueprints/rest/v2/manage_routes/customers.py b/source/app/blueprints/rest/v2/manage_routes/customers.py index 3d8e2f98c..c55da5e2f 100644 --- a/source/app/blueprints/rest/v2/manage_routes/customers.py +++ b/source/app/blueprints/rest/v2/manage_routes/customers.py @@ -56,7 +56,16 @@ def __init__(self): def search(self): pagination_parameters = parse_pagination_parameters(request) user_is_server_administrator = ac_current_user_has_permission(Permissions.server_administrator) - customers = customers_filter(iris_current_user, pagination_parameters, user_is_server_administrator) + # `search` is an ILIKE substring match across name + description. + # Empty / whitespace-only values fall through as None so they + # don't add a no-op `LIKE '%%'` clause. + search = (request.args.get('search') or '').strip() or None + customers = customers_filter( + iris_current_user, + pagination_parameters, + user_is_server_administrator, + search=search, + ) return response_api_paginated(self._schema, customers) def create(self): diff --git a/source/app/business/customers.py b/source/app/business/customers.py index 9e776cc78..ef4bc4059 100644 --- a/source/app/business/customers.py +++ b/source/app/business/customers.py @@ -31,8 +31,8 @@ from app.models.pagination_parameters import PaginationParameters -def customers_filter(user, pagination_parameters: PaginationParameters, is_server_administrator) -> Pagination: - return get_paginated_customers(pagination_parameters, user.id, is_server_administrator) +def customers_filter(user, pagination_parameters: PaginationParameters, is_server_administrator, search: str = None) -> Pagination: + return get_paginated_customers(pagination_parameters, user.id, is_server_administrator, search=search) # TODO maybe this method should be removed and always create a customer with at least a user diff --git a/source/app/datamgmt/client/client_db.py b/source/app/datamgmt/client/client_db.py index ab8202f50..cd4b541b3 100644 --- a/source/app/datamgmt/client/client_db.py +++ b/source/app/datamgmt/client/client_db.py @@ -69,7 +69,7 @@ def get_client_list(current_user_id: int, is_server_administrator: bool) -> List return output -def get_paginated_customers(pagination_parameters: PaginationParameters, current_user_identifier: int, is_server_administrator: bool) -> Pagination: +def get_paginated_customers(pagination_parameters: PaginationParameters, current_user_identifier: int, is_server_administrator: bool, search: Optional[str] = None) -> Pagination: query = Client.query if not is_server_administrator: @@ -78,6 +78,17 @@ def get_paginated_customers(pagination_parameters: PaginationParameters, current UserClient.user_id == current_user_identifier ) + # Case-insensitive substring match across the two human-readable + # fields. Cheap (no full-text index needed at this fleet size) and + # matches the legacy DataTables behaviour where typing in the + # search box filtered both columns. + if search: + needle = f'%{search.strip()}%' + if needle != '%%': + query = query.filter( + Client.name.ilike(needle) | Client.description.ilike(needle) + ) + return paginate(Client, pagination_parameters, query) From 41a7c53e74eb2794202a738167135a7cd73ed79c Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 11:57:57 +0200 Subject: [PATCH 016/121] [ADD] v2 endpoints for the Case Objects taxonomy family backed by a shared CRUD class --- source/app/blueprints/rest/v2/manage.py | 2 + .../rest/v2/manage_routes/case_objects.py | 370 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 source/app/blueprints/rest/v2/manage_routes/case_objects.py diff --git a/source/app/blueprints/rest/v2/manage.py b/source/app/blueprints/rest/v2/manage.py index 2758a51b0..246bf5679 100644 --- a/source/app/blueprints/rest/v2/manage.py +++ b/source/app/blueprints/rest/v2/manage.py @@ -23,6 +23,7 @@ from app.blueprints.rest.v2.manage_routes.customers import customers_blueprint from app.blueprints.rest.v2.manage_routes.server import server_blueprint from app.blueprints.rest.v2.manage_routes.modules import modules_blueprint +from app.blueprints.rest.v2.manage_routes.case_objects import case_objects_blueprint manage_v2_blueprint = Blueprint("manage", __name__, url_prefix="/manage") @@ -31,3 +32,4 @@ manage_v2_blueprint.register_blueprint(customers_blueprint) manage_v2_blueprint.register_blueprint(server_blueprint) manage_v2_blueprint.register_blueprint(modules_blueprint) +manage_v2_blueprint.register_blueprint(case_objects_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/case_objects.py b/source/app/blueprints/rest/v2/manage_routes/case_objects.py new file mode 100644 index 000000000..3e22976aa --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/case_objects.py @@ -0,0 +1,370 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints for the "Case Objects" admin family. + +A single page in the legacy UI bundles five taxonomy types — asset +types, IOC types, case classifications, case states and evidence +types. The CRUD surface for each is identical in shape (list / get / +create / update / delete with substring search on a single name +field) and the only per-resource variance is the model, schema and +primary-key name. So we have one shared `TaxonomyOperations` class, +instantiate it five times with a per-resource `TaxonomyConfig`, and +register each as its own Flask sub-blueprint under +`/manage/case-objects/`. + +Adding a sixth taxonomy here is a one-liner: define the config, add +the blueprint to the registration list at the bottom of the file. +""" + +from dataclasses import dataclass +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Optional +from typing import Type + +from flask import Blueprint +from flask import Response +from flask import request +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_created +from app.blueprints.rest.endpoints import response_api_deleted +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.datamgmt.db_operations import db_create +from app.datamgmt.db_operations import db_delete +from app.datamgmt.filtering import paginate +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.assets import AssetsType +from app.models.authorization import Permissions +from app.models.cases import CaseClassification +from app.models.cases import CaseState +from app.models.errors import ElementInUseError +from app.models.errors import ObjectNotFoundError +from app.models.evidences import EvidenceTypes +from app.models.models import IocType +from app.schema.marshables import AssetTypeSchema +from app.schema.marshables import CaseClassificationSchema +from app.schema.marshables import CaseStateSchema +from app.schema.marshables import EvidenceTypeSchema +from app.schema.marshables import IocTypeSchema +from app.schema.marshables import store_icon + + +@dataclass(frozen=True) +class TaxonomyConfig: + """Per-resource configuration for `TaxonomyOperations`. + + `pk_name` is the model's primary key column name (not always `id` + — legacy schema choices means we have `asset_id`, `type_id`, + `state_id` and `id` floating around). `search_columns` are the + columns to ILIKE against for the search query parameter, in + order. `protected_check` is an optional callable that lets a + taxonomy refuse a delete for a record the engine considers + structural (e.g. the seeded "Open" / "Closed" case states). + """ + + url_prefix: str + blueprint_name: str + model: Type[Any] + schema_factory: Callable[[], Any] + pk_name: str + search_columns: Iterable[str] + activity_label: str + protected_check: Optional[Callable[[Any], Optional[str]]] = None + + +class TaxonomyOperations: + """REST surface shared by every Case Objects sub-resource. + + Methods read the URL identifier directly off the route — Flask + passes it as keyword `identifier`. The schema is instantiated per + request because Marshmallow stateful validators (e.g. the + auto-injected `verify_unique` post-load hook) sometimes carry + state between calls when reused as a module-level singleton. + """ + + def __init__(self, config: TaxonomyConfig): + self._config = config + + def _get(self, identifier): + row = self._config.model.query.filter( + getattr(self._config.model, self._config.pk_name) == identifier + ).first() + if row is None: + raise ObjectNotFoundError() + return row + + def search(self): + pagination_parameters = parse_pagination_parameters(request) + query = self._config.model.query + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + clauses = [] + for column_name in self._config.search_columns: + column = getattr(self._config.model, column_name, None) + if column is not None: + clauses.append(column.ilike(needle)) + if clauses: + from sqlalchemy import or_ + query = query.filter(or_(*clauses)) + paginated = paginate(self._config.model, pagination_parameters, query) + return response_api_paginated(self._config.schema_factory(), paginated) + + def read(self, identifier): + try: + return response_api_success(self._config.schema_factory().dump(self._get(identifier))) + except ObjectNotFoundError: + return response_api_not_found() + + def create(self): + schema = self._config.schema_factory() + try: + request_data = request.get_json() or {} + row = schema.load(request_data) + db_create(row) + track_activity(f'Added {self._config.activity_label} {self._readable(row)}', ctx_less=True) + return response_api_created(schema.dump(row)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + + def update(self, identifier): + schema = self._config.schema_factory() + try: + row = self._get(identifier) + request_data = request.get_json() or {} + # Schemas with `verify_unique` post-load look at the PK on + # the loaded instance to skip the uniqueness check against + # the row itself — force the value here so a partial update + # that omits the PK doesn't trip the check. + request_data[self._config.pk_name] = identifier + schema.load(request_data, instance=row, partial=True) + db.session.commit() + track_activity(f'Updated {self._config.activity_label} {self._readable(row)}', ctx_less=True) + return response_api_success(schema.dump(row)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + except ObjectNotFoundError: + return response_api_not_found() + + def delete(self, identifier): + try: + row = self._get(identifier) + if self._config.protected_check is not None: + msg = self._config.protected_check(row) + if msg: + raise ElementInUseError(msg) + label = self._readable(row) + try: + db_delete(row) + except IntegrityError: + # FK from cases/iocs/assets back to the taxonomy — the + # legacy UI shows the same message on these. Surface + # 400 rather than letting the exception bubble. + db.session.rollback() + raise ElementInUseError( + f'This {self._config.activity_label} is still referenced and cannot be deleted' + ) + track_activity(f'Deleted {self._config.activity_label} {label}', ctx_less=True) + return response_api_deleted() + except ObjectNotFoundError: + return response_api_not_found() + except ElementInUseError as e: + return response_api_error(e.get_message()) + + def _readable(self, row): + """Pick the human-readable label for activity log lines. + + Each model has a different "display name" column; rather than + threading it through TaxonomyConfig we just try the common + candidates. Falls back to the PK so the activity entry is + still searchable if a taxonomy doesn't match. + """ + for candidate in ('name', 'asset_name', 'type_name', 'state_name'): + value = getattr(row, candidate, None) + if value: + return value + return f'#{getattr(row, self._config.pk_name, "?")}' + + +def _build_blueprint(config: TaxonomyConfig) -> Blueprint: + """Wire a `TaxonomyOperations` instance into a Flask blueprint. + + Keeping the route declarations local to this factory means each + resource gets its own URL prefix (`/asset-types`, `/ioc-types`, + …) without copying the five identical route definitions. + """ + blueprint = Blueprint( + config.blueprint_name, + __name__, + url_prefix=f'/{config.url_prefix}', + ) + operations = TaxonomyOperations(config) + + @blueprint.get('') + @ac_api_requires() + def list_taxonomy() -> Response: + return operations.search() + + @blueprint.post('') + @ac_api_requires(Permissions.server_administrator) + def create_taxonomy() -> Response: + return operations.create() + + @blueprint.get('/') + @ac_api_requires() + def read_taxonomy(identifier: int) -> Response: + return operations.read(identifier) + + @blueprint.put('/') + @ac_api_requires(Permissions.server_administrator) + def update_taxonomy(identifier: int) -> Response: + return operations.update(identifier) + + @blueprint.delete('/') + @ac_api_requires(Permissions.server_administrator) + def delete_taxonomy(identifier: int) -> Response: + return operations.delete(identifier) + + return blueprint + + +def _case_state_protected(row: CaseState) -> Optional[str]: + """Refuse deletes for the seeded "protected" case states. + + Mirrors the legacy guard in `manage_case_state.py` — the engine + relies on Open / Closed existing for case lifecycle. + """ + if getattr(row, 'protected', False): + return 'This case state is protected and cannot be deleted' + return None + + +_CONFIGS = [ + TaxonomyConfig( + url_prefix='asset-types', + blueprint_name='case_objects_asset_types_rest_v2', + model=AssetsType, + schema_factory=AssetTypeSchema, + pk_name='asset_id', + search_columns=('asset_name', 'asset_description'), + activity_label='asset type', + ), + TaxonomyConfig( + url_prefix='ioc-types', + blueprint_name='case_objects_ioc_types_rest_v2', + model=IocType, + schema_factory=IocTypeSchema, + pk_name='type_id', + search_columns=('type_name', 'type_description', 'type_taxonomy'), + activity_label='IOC type', + ), + TaxonomyConfig( + url_prefix='case-classifications', + blueprint_name='case_objects_case_classifications_rest_v2', + model=CaseClassification, + schema_factory=CaseClassificationSchema, + pk_name='id', + search_columns=('name', 'name_expanded', 'description'), + activity_label='case classification', + ), + TaxonomyConfig( + url_prefix='case-states', + blueprint_name='case_objects_case_states_rest_v2', + model=CaseState, + schema_factory=CaseStateSchema, + pk_name='state_id', + search_columns=('state_name', 'state_description'), + activity_label='case state', + protected_check=_case_state_protected, + ), + TaxonomyConfig( + url_prefix='evidence-types', + blueprint_name='case_objects_evidence_types_rest_v2', + model=EvidenceTypes, + schema_factory=EvidenceTypeSchema, + pk_name='id', + search_columns=('name', 'description'), + activity_label='evidence type', + ), +] + + +case_objects_blueprint = Blueprint('case_objects_rest_v2', __name__, url_prefix='/case-objects') + +for _config in _CONFIGS: + case_objects_blueprint.register_blueprint(_build_blueprint(_config)) + + +# Asset-type icons ------------------------------------------------------- +# +# Asset types are the only Case Object taxonomy that carries images. Two +# fields (`asset_icon_compromised` / `asset_icon_not_compromised`) store +# filenames under /static/assets/img/graph/. Uploading icons +# is a separate operation from the JSON CRUD above — multipart/form-data +# doesn't fit cleanly into the shared TaxonomyOperations contract — so +# we expose dedicated upload endpoints. The frontend orchestrates the +# two-step "create asset type → upload icon" flow. + +_ASSET_TYPE_ICON_FIELDS = {'compromised', 'not_compromised'} + + +@case_objects_blueprint.post('/asset-types//icon/') +@ac_api_requires(Permissions.server_administrator) +def upload_asset_type_icon(identifier: int, field: str) -> Response: + """Upload one of an asset type's two icons. + + `field` is either `compromised` or `not_compromised`. The file + must be sent as multipart form-data under the key `file` and pass + the existing `allowed_file_icon` check (png/svg only). On success + the new filename is persisted on the column + `asset_icon_` and the full updated row is returned so the + frontend can refresh its detail pane in one round-trip. + """ + if field not in _ASSET_TYPE_ICON_FIELDS: + return response_api_error(f"Unknown icon field: {field}") + + asset_type = AssetsType.query.filter(AssetsType.asset_id == identifier).first() + if asset_type is None: + return response_api_not_found() + + upload = request.files.get('file') + if upload is None or not upload.filename: + return response_api_error('No file uploaded') + + stored_filename, message = store_icon(upload) + if stored_filename is None: + return response_api_error(message or 'Icon upload failed') + + column_name = f'asset_icon_{field}' + setattr(asset_type, column_name, stored_filename) + db.session.commit() + track_activity( + f'Updated icon {field} for asset type {asset_type.asset_name}', + ctx_less=True, + ) + return response_api_success(AssetTypeSchema().dump(asset_type)) From 454855b813b6cc84ca7ea7a41bf07e3722af9841 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 12:29:46 +0200 Subject: [PATCH 017/121] [ADD] v2 CRUD endpoints for case templates accepting plain JSON bodies --- source/app/blueprints/rest/v2/manage.py | 2 + .../rest/v2/manage_routes/case_templates.py | 216 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 source/app/blueprints/rest/v2/manage_routes/case_templates.py diff --git a/source/app/blueprints/rest/v2/manage.py b/source/app/blueprints/rest/v2/manage.py index 246bf5679..1aff5622b 100644 --- a/source/app/blueprints/rest/v2/manage.py +++ b/source/app/blueprints/rest/v2/manage.py @@ -24,6 +24,7 @@ from app.blueprints.rest.v2.manage_routes.server import server_blueprint from app.blueprints.rest.v2.manage_routes.modules import modules_blueprint from app.blueprints.rest.v2.manage_routes.case_objects import case_objects_blueprint +from app.blueprints.rest.v2.manage_routes.case_templates import case_templates_blueprint manage_v2_blueprint = Blueprint("manage", __name__, url_prefix="/manage") @@ -33,3 +34,4 @@ manage_v2_blueprint.register_blueprint(server_blueprint) manage_v2_blueprint.register_blueprint(modules_blueprint) manage_v2_blueprint.register_blueprint(case_objects_blueprint) +manage_v2_blueprint.register_blueprint(case_templates_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/case_templates.py b/source/app/blueprints/rest/v2/manage_routes/case_templates.py new file mode 100644 index 000000000..e07ad0ef9 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/case_templates.py @@ -0,0 +1,216 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints for the Case Templates admin family. + +Differences from the legacy `/manage/case-templates/...` surface: + +* Bodies are plain JSON — no more `{case_template_json: "stringified"}` + envelope. The legacy form was a workaround for upload modals that + posted file contents as a single string field; the v2 page reads + the file client-side and POSTs the parsed object directly. +* List supports pagination + ILIKE substring search via `search`. +""" + +from typing import Any +from typing import Dict +from typing import Optional + +from flask import Blueprint +from flask import Response +from flask import request +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.iris_user import iris_current_user +from app.blueprints.rest.endpoints import response_api_created +from app.blueprints.rest.endpoints import response_api_deleted +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.datamgmt.filtering import paginate +from app.datamgmt.manage.manage_case_templates_db import delete_case_template_by_id +from app.datamgmt.manage.manage_case_templates_db import get_case_template_by_id +from app.datamgmt.manage.manage_case_templates_db import validate_case_template +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import Permissions +from app.models.models import CaseTemplate +from app.schema.marshables import CaseTemplateSchema + + +case_templates_blueprint = Blueprint('case_templates_rest_v2', __name__, url_prefix='/case-templates') + + +_SEARCH_COLUMNS = ('name', 'display_name', 'description', 'author', 'title_prefix') + + +def _to_dict(payload: Any) -> Dict[str, Any]: + """Tolerate both v2-native (object) and legacy (`case_template_json`) bodies. + + The legacy IRIS upload modal POSTed the raw file contents as a + string field — keep accepting that so an admin who has an old + template JSON file can paste it via the upload flow without + having to wrap it. The page itself sends a plain object. + """ + if isinstance(payload, dict) and 'case_template_json' in payload and isinstance( + payload['case_template_json'], str + ): + import json as _json + try: + parsed = _json.loads(payload['case_template_json']) + except Exception as exc: + raise ValueError(f'Invalid JSON in case_template_json: {exc}') + return parsed if isinstance(parsed, dict) else {} + return payload if isinstance(payload, dict) else {} + + +def _persist_from_dict(template_dict: Dict[str, Any], *, update_target: Optional[CaseTemplate] = None) -> CaseTemplate: + """Shared validate + load + commit step for create + update. + + Returns the persisted (or refreshed) `CaseTemplate` row. Raises + `ValueError` with a user-facing message on validation failure so + the route layer can surface it cleanly. + """ + is_update = update_target is not None + error = validate_case_template(template_dict, update=is_update) + if error is not None: + raise ValueError(error) + + schema = CaseTemplateSchema() + try: + if is_update: + data = schema.load(template_dict, partial=True) + update_target.update_from_dict(data) + db.session.commit() + return update_target + template_dict.setdefault('created_by_user_id', iris_current_user.id) + data = schema.load(template_dict) + row = CaseTemplate(**data) + db.session.add(row) + db.session.commit() + return row + except ValidationError as exc: + db.session.rollback() + raise ValueError(str(exc.messages)) + except IntegrityError as exc: + db.session.rollback() + raise ValueError(f'Database error: {exc}') + + +def _list_query(): + """SQLAlchemy query feeding the paginated list. + + We `paginate(model=CaseTemplate, ...)` so the existing pagination + helper can apply `order_by` + `sort_dir` against any column on + the model — same shape as every other v2 list endpoint. + """ + query = CaseTemplate.query + search = (request.args.get('search') or '').strip() or None + if search: + from sqlalchemy import or_ + needle = f'%{search}%' + clauses = [] + for column_name in _SEARCH_COLUMNS: + column = getattr(CaseTemplate, column_name, None) + if column is not None: + clauses.append(column.ilike(needle)) + if clauses: + query = query.filter(or_(*clauses)) + return query + + +# ----- Routes ---------------------------------------------------------- + +@case_templates_blueprint.get('') +@ac_api_requires(Permissions.case_templates_read) +def list_case_templates() -> Response: + pagination_parameters = parse_pagination_parameters(request) + paginated = paginate(CaseTemplate, pagination_parameters, _list_query()) + return response_api_paginated(CaseTemplateSchema(), paginated) + + +@case_templates_blueprint.get('/') +@ac_api_requires(Permissions.case_templates_read) +def get_case_template(identifier: int) -> Response: + row = get_case_template_by_id(identifier) + if row is None: + return response_api_not_found() + return response_api_success(CaseTemplateSchema().dump(row)) + + +@case_templates_blueprint.post('') +@ac_api_requires(Permissions.case_templates_write) +def create_case_template() -> Response: + try: + template_dict = _to_dict(request.get_json() or {}) + except ValueError as exc: + return response_api_error(str(exc)) + + if not template_dict: + return response_api_error('Missing template body') + + try: + row = _persist_from_dict(template_dict) + except ValueError as exc: + return response_api_error('Invalid case template', data=str(exc)) + + track_activity(f"Case template '{row.name}' added", ctx_less=True) + return response_api_created(CaseTemplateSchema().dump(row)) + + +@case_templates_blueprint.put('/') +@ac_api_requires(Permissions.case_templates_write) +def update_case_template(identifier: int) -> Response: + row = get_case_template_by_id(identifier) + if row is None: + return response_api_not_found() + + try: + template_dict = _to_dict(request.get_json() or {}) + except ValueError as exc: + return response_api_error(str(exc)) + + if not template_dict: + return response_api_error('Missing template body') + + try: + row = _persist_from_dict(template_dict, update_target=row) + except ValueError as exc: + return response_api_error('Invalid case template', data=str(exc)) + + track_activity(f"Case template '{row.name}' updated", ctx_less=True) + return response_api_success(CaseTemplateSchema().dump(row)) + + +@case_templates_blueprint.delete('/') +@ac_api_requires(Permissions.case_templates_write) +def delete_case_template(identifier: int) -> Response: + row = get_case_template_by_id(identifier) + if row is None: + return response_api_not_found() + name = row.name + delete_case_template_by_id(identifier) + db.session.commit() + track_activity(f"Case template '{name}' deleted", ctx_less=True) + return response_api_deleted() + + From 68d64afb2a725c4c6f517183c6fc970d2498e135 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 14:08:53 +0200 Subject: [PATCH 018/121] [ADD] v2 endpoint exposing the introspected case template schema for interactive editors --- .../rest/v2/manage_routes/case_templates.py | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/source/app/blueprints/rest/v2/manage_routes/case_templates.py b/source/app/blueprints/rest/v2/manage_routes/case_templates.py index e07ad0ef9..f2ab95c19 100644 --- a/source/app/blueprints/rest/v2/manage_routes/case_templates.py +++ b/source/app/blueprints/rest/v2/manage_routes/case_templates.py @@ -214,3 +214,236 @@ def delete_case_template(identifier: int) -> Response: return response_api_deleted() +# ----- Schema introspection ------------------------------------------- +# +# Powers the interactive editor on the Settings Case Templates page. +# The frontend needs to know, per field: type, required, optional +# length cap, free-form help. Rather than duplicating that metadata +# client-side we introspect `CaseTemplateSchema` at request time so a +# field added to the Marshmallow schema shows up in the UI without +# any frontend change. +# +# The nested template shapes for tasks + note directories + notes are +# NOT real Marshmallow schemas — `validate_case_template` enforces +# them with hand-rolled checks on raw dicts. We mirror those rules +# here as static descriptors so the frontend renders the same fields +# the validator accepts. When the validator gains a new field this +# descriptor must be updated to match. + +import marshmallow.fields as mf +import marshmallow.validate as mv + + +def _describe_marshmallow_field(field) -> Dict[str, Any]: + """Best-effort projection of a Marshmallow field to a UI descriptor. + + Picks a coarse `kind` the frontend can switch on (`string`, + `text`, `integer`, `list[string]`, `list[object]`) and surfaces + `required`, `allow_none`, `missing` plus any `Length` validator's + max constraint — those are the four hints the form widgets need. + + Everything more specific (e.g. enum membership) is left to the + schema's own `verify_*` hooks at save time; we don't try to + enumerate every Marshmallow validator type. + """ + # The plain `String` field is the catch-all; we treat long-form + # description / summary as `text` (multi-line) by name convention + # since Marshmallow doesn't model that distinction. + if isinstance(field, mf.Integer): + kind = 'integer' + elif isinstance(field, mf.List): + # Inspect the inner type to disambiguate string lists (tags) + # from object lists (note_directories with their `notes` + # children). Anything else falls back to 'list[object]'. + inner = field.inner + if isinstance(inner, mf.String): + kind = 'list[string]' + elif isinstance(inner, mf.Dict): + kind = 'list[object]' + else: + kind = 'list[object]' + elif isinstance(field, mf.Boolean): + kind = 'boolean' + elif isinstance(field, mf.DateTime): + kind = 'datetime' + else: + kind = 'string' + + max_length = None + for validator in field.validators or (): + if isinstance(validator, mv.Length) and validator.max is not None: + max_length = validator.max + break + + return { + 'kind': kind, + 'required': bool(field.required), + 'allow_none': bool(field.allow_none), + 'dump_only': bool(field.dump_only), + 'max_length': max_length, + } + + +# Nested template shapes — these match the rules enforced by +# `validate_case_template` in datamgmt/manage/manage_case_templates_db.py. +_TASK_TEMPLATE_FIELDS = [ + { + 'name': 'title', + 'label': 'Title', + 'kind': 'string', + 'required': True, + 'help': 'Becomes the new task title.', + }, + { + 'name': 'description', + 'label': 'Description', + 'kind': 'text', + 'required': False, + 'help': 'Optional. Appears in the task detail panel.', + }, + { + 'name': 'tags', + 'label': 'Tags', + 'kind': 'list[string]', + 'required': False, + 'help': 'Optional. Comma-separated when applied.', + }, +] + +_NOTE_TEMPLATE_FIELDS = [ + { + 'name': 'title', + 'label': 'Title', + 'kind': 'string', + 'required': True, + }, + { + 'name': 'content', + 'label': 'Content', + 'kind': 'text', + 'required': False, + 'help': 'Markdown is supported.', + }, +] + +_NOTE_DIRECTORY_TEMPLATE_FIELDS = [ + { + 'name': 'title', + 'label': 'Directory name', + 'kind': 'string', + 'required': True, + }, + { + 'name': 'notes', + 'label': 'Notes', + 'kind': 'list[object]', + 'required': False, + 'item_schema': 'note', + }, +] + +# Friendly labels + per-field help text. Keyed by the schema's +# field name so a future rename on the schema only needs to update +# the schema; this dict can stay or be edited deliberately. +_FIELD_LABELS: Dict[str, Dict[str, str]] = { + 'name': { + 'label': 'Name', + 'help': "Short, unique slug. Required.", + }, + 'display_name': { + 'label': 'Display name', + 'help': "Shown in the picker; falls back to `name`.", + }, + 'description': { + 'label': 'Description', + 'help': "Admin-side description, not visible on cases.", + }, + 'author': {'label': 'Author', 'help': 'Optional. Up to 128 characters.'}, + 'title_prefix': { + 'label': 'Case title prefix', + 'help': 'Prepended to the case name on apply. Up to 32 characters.', + }, + 'summary': { + 'label': 'Summary', + 'help': 'Appended to the case description on apply.', + }, + 'tags': {'label': 'Tags', 'help': 'Appended to the case tags on apply.'}, + 'classification': { + 'label': 'Classification', + 'help': "Must match the `name` of an existing case classification.", + }, + 'note_directories': { + 'label': 'Note directories', + 'help': 'Created on the case on apply.', + }, + 'tasks': { + 'label': 'Tasks', + 'help': 'Created on the case on apply, with status "To Do".', + }, +} + +# Field-name overrides where the introspected `kind` is wrong for the +# UI (Marshmallow has no `text` field, but `description` / `summary` +# render better as textareas). Keyed by schema field name. +_KIND_OVERRIDES: Dict[str, str] = { + 'description': 'text', + 'summary': 'text', +} + +# Which item descriptor a `list[object]` field renders inside the +# repeater. Keyed by schema field name; missing entries fall back to +# a generic "object" with no fields, which the UI renders as a +# read-only JSON snippet so the form stays useful for unknown shapes. +_ITEM_SCHEMA_BY_FIELD: Dict[str, str] = { + 'tasks': 'task', + 'note_directories': 'note_directory', +} + + +@case_templates_blueprint.get('/schema') +@ac_api_requires(Permissions.case_templates_read) +def get_case_template_schema() -> Response: + """Describe `CaseTemplateSchema` + nested template shapes. + + Returned shape is consumed by the interactive editor to build the + form. Keeping introspection here (rather than client-side) keeps + the form in lockstep with the backend schema — adding a field to + `CaseTemplateSchema` adds an input on the form on the next reload. + + `tasks` is special-cased: it's stored as raw JSON on the model + and never declared as a Marshmallow field, but the post-modifier + treats it as a structured list. We expose it as if it were a + declared field so the UI can build the same repeater the rest of + the form uses. + """ + schema = CaseTemplateSchema() + fields_out: List[Dict[str, Any]] = [] + + # CaseTemplateSchema.fields is an OrderedDict; preserves + # declaration order, which is the order users see in the form. + for name, field in schema.fields.items(): + descriptor = _describe_marshmallow_field(field) + meta = _FIELD_LABELS.get(name, {}) + kind = _KIND_OVERRIDES.get(name, descriptor['kind']) + entry: Dict[str, Any] = { + 'name': name, + 'label': meta.get('label', name.replace('_', ' ').capitalize()), + 'help': meta.get('help', ''), + **descriptor, + 'kind': kind, + } + item_schema = _ITEM_SCHEMA_BY_FIELD.get(name) + if item_schema is not None: + entry['item_schema'] = item_schema + fields_out.append(entry) + + return response_api_success({ + 'fields': fields_out, + 'item_schemas': { + 'task': _TASK_TEMPLATE_FIELDS, + 'note_directory': _NOTE_DIRECTORY_TEMPLATE_FIELDS, + 'note': _NOTE_TEMPLATE_FIELDS, + }, + }) + + From bfbafd8efaf940cf09fa24208b9bdc6f0e0f256e Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 14:56:55 +0200 Subject: [PATCH 019/121] [ADD] v2 endpoints for report templates including render-against-case with user case-access gating --- source/app/blueprints/rest/v2/manage.py | 2 + .../rest/v2/manage_routes/report_templates.py | 543 ++++++++++++++++++ 2 files changed, 545 insertions(+) create mode 100644 source/app/blueprints/rest/v2/manage_routes/report_templates.py diff --git a/source/app/blueprints/rest/v2/manage.py b/source/app/blueprints/rest/v2/manage.py index 1aff5622b..fd54456f6 100644 --- a/source/app/blueprints/rest/v2/manage.py +++ b/source/app/blueprints/rest/v2/manage.py @@ -25,6 +25,7 @@ from app.blueprints.rest.v2.manage_routes.modules import modules_blueprint from app.blueprints.rest.v2.manage_routes.case_objects import case_objects_blueprint from app.blueprints.rest.v2.manage_routes.case_templates import case_templates_blueprint +from app.blueprints.rest.v2.manage_routes.report_templates import report_templates_blueprint manage_v2_blueprint = Blueprint("manage", __name__, url_prefix="/manage") @@ -35,3 +36,4 @@ manage_v2_blueprint.register_blueprint(modules_blueprint) manage_v2_blueprint.register_blueprint(case_objects_blueprint) manage_v2_blueprint.register_blueprint(case_templates_blueprint) +manage_v2_blueprint.register_blueprint(report_templates_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/report_templates.py b/source/app/blueprints/rest/v2/manage_routes/report_templates.py new file mode 100644 index 000000000..584045420 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/report_templates.py @@ -0,0 +1,543 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints for the Report Templates admin family. + +Differences from the legacy `/manage/templates/...` surface: + +* Bodies for metadata-only updates are plain JSON instead of + multipart — uploads stay multipart, but renames / description + edits / language swap don't need to re-upload the file. +* Read endpoints paginate + accept a `search` query parameter. +* `POST //render` triggers the existing + `generate_investigation_report` / `generate_activities_report` + flow and streams the resulting file back inline. The current user + must hold `read_only`-or-better on the target case — same gate + every case-scoped v2 endpoint uses. +* `GET /accessible-cases` powers the case picker in the editor, also + scoped to cases the caller can access. +* `GET /schema` ships the model field metadata + the seeded + `languages` / `report_types` rows so the form can render selects + without extra round-trips. +""" + +import os +import random +import string +import tempfile +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +from flask import Blueprint +from flask import Response +from flask import request +from flask import send_file +from sqlalchemy.exc import IntegrityError +from werkzeug.utils import secure_filename + +from app import app +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +from app.blueprints.iris_user import iris_current_user +from app.blueprints.rest.endpoints import response_api_created +from app.blueprints.rest.endpoints import response_api_deleted +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.business.reports.reports import generate_activities_report +from app.business.reports.reports import generate_investigation_report +from app.datamgmt.filtering import paginate +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import CaseAccessLevel +from app.models.authorization import Permissions +from app.models.authorization import User +from app.models.cases import Cases +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.models import CaseTemplateReport +from app.models.models import Languages +from app.models.models import ReportType +from app.util import FileRemover + + +report_templates_blueprint = Blueprint( + 'report_templates_rest_v2', __name__, url_prefix='/report-templates' +) + + +# Same extension allowlist as the legacy upload — DocxGenerator handles +# .doc/.docx, the Jinja+text pipeline handles .md/.html. Anything else +# would just blow up in the renderer with a less helpful error. +_ALLOWED_EXTENSIONS = {'md', 'html', 'doc', 'docx'} + +# Same file remover used by the legacy report routes — cleans up the +# rendered file from /tmp once the response has streamed. +_FILE_REMOVER = FileRemover() + + +def _allowed_filename(filename: str) -> bool: + return ( + '.' in filename + and filename.rsplit('.', 1)[1].lower() in _ALLOWED_EXTENSIONS + ) + + +def _random_filename(extension: str) -> str: + letters = string.ascii_lowercase + stem = ''.join(random.choice(letters) for _ in range(18)) + return f'{stem}{extension}' + + +def _serialize_template(template: CaseTemplateReport) -> Dict[str, Any]: + """Single source of truth for the template's JSON projection. + + Used by every list / read / update / render-success response so + the frontend sees one shape everywhere. Includes the joined + `created_by` user name and `language` / `report_type` names since + the picker UI uses them as readable labels. + """ + return { + 'id': template.id, + 'name': template.name, + 'description': template.description, + 'naming_format': template.naming_format, + 'internal_reference': template.internal_reference, + 'date_created': template.date_created.isoformat() if template.date_created else None, + 'created_by_user_id': template.created_by_user_id, + 'created_by': template.created_by_user.name if template.created_by_user else None, + 'language_id': template.language_id, + 'language_code': template.language.code if template.language else None, + 'language_name': template.language.name if template.language else None, + 'report_type_id': template.report_type_id, + 'report_type_name': template.report_type.name if template.report_type else None, + } + + +def _get_template(identifier: int) -> CaseTemplateReport: + row = CaseTemplateReport.query.filter(CaseTemplateReport.id == identifier).first() + if row is None: + raise ObjectNotFoundError() + return row + + +def _list_query(): + query = CaseTemplateReport.query + search = (request.args.get('search') or '').strip() or None + if search: + from sqlalchemy import or_ + needle = f'%{search}%' + query = query.filter( + or_( + CaseTemplateReport.name.ilike(needle), + CaseTemplateReport.description.ilike(needle), + CaseTemplateReport.naming_format.ilike(needle), + ) + ) + return query + + +# ----- CRUD ----------------------------------------------------------- + +@report_templates_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def list_report_templates() -> Response: + pagination_parameters = parse_pagination_parameters(request) + paginated = paginate(CaseTemplateReport, pagination_parameters, _list_query()) + # `response_api_paginated` takes a Marshmallow schema; we don't + # have one for this model, so synthesise the envelope directly. + items = [_serialize_template(t) for t in paginated.items] + return response_api_success({ + 'total': paginated.total, + 'data': items, + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + + +@report_templates_blueprint.get('/') +@ac_api_requires(Permissions.server_administrator) +def get_report_template(identifier: int) -> Response: + try: + return response_api_success(_serialize_template(_get_template(identifier))) + except ObjectNotFoundError: + return response_api_not_found() + + +@report_templates_blueprint.post('') +@ac_api_requires(Permissions.server_administrator) +def create_report_template() -> Response: + """Multipart upload: metadata in form fields + the template file + under the `file` key. Mirrors the legacy endpoint so existing + template files round-trip without modification. + """ + upload = request.files.get('file') + if upload is None or not upload.filename: + return response_api_error('No file uploaded') + + if not _allowed_filename(upload.filename): + return response_api_error( + f"File extension not allowed. Use one of: {', '.join(sorted(_ALLOWED_EXTENSIONS))}" + ) + + name = (request.form.get('name') or '').strip() + if not name: + return response_api_error('Field `name` is required') + + description = (request.form.get('description') or '').strip() + naming_format = (request.form.get('naming_format') or '').strip() + language_id = request.form.get('language_id', type=int) + report_type_id = request.form.get('report_type_id', type=int) + + if language_id is None or report_type_id is None: + return response_api_error('`language_id` and `report_type_id` are required') + + if Languages.query.filter(Languages.id == language_id).first() is None: + return response_api_error('Unknown language_id') + if ReportType.query.filter(ReportType.id == report_type_id).first() is None: + return response_api_error('Unknown report_type_id') + + safe_name = secure_filename(upload.filename) + _, extension = os.path.splitext(safe_name) + stored_filename = _random_filename(extension) + + try: + upload.save(os.path.join(app.config['TEMPLATES_PATH'], stored_filename)) + except Exception as exc: + return response_api_error(f'Unable to save uploaded file: {exc}') + + template = CaseTemplateReport( + name=name, + description=description, + naming_format=naming_format, + internal_reference=stored_filename, + language_id=language_id, + report_type_id=report_type_id, + created_by_user_id=iris_current_user.id, + date_created=datetime.utcnow(), + ) + try: + db.session.add(template) + db.session.commit() + except IntegrityError as exc: + db.session.rollback() + # Best-effort cleanup so we don't orphan the just-uploaded + # file when the metadata insert fails. The fs delete is + # secondary — if it fails the orphan stays but the API still + # surfaces the original IntegrityError. + try: + os.unlink(os.path.join(app.config['TEMPLATES_PATH'], stored_filename)) + except Exception: + pass + return response_api_error(f'Database error: {exc}') + + track_activity(f"Report template '{template.name}' added", ctx_less=True) + return response_api_created(_serialize_template(template)) + + +@report_templates_blueprint.put('/') +@ac_api_requires(Permissions.server_administrator) +def update_report_template(identifier: int) -> Response: + """Metadata-only update. Takes JSON so admins can rename / re-tag + a template without re-uploading its file. To replace the file, + delete the row and re-create it — the legacy UI had no edit-with- + upload flow either and there's no reliable way to swap a docx in + place without breaking outstanding render jobs. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + body = request.get_json() or {} + if not isinstance(body, dict): + return response_api_error('Body must be a JSON object') + + if 'name' in body: + name = (body.get('name') or '').strip() + if not name: + return response_api_error('`name` cannot be empty') + template.name = name + + for plain_field in ('description', 'naming_format'): + if plain_field in body: + template.__setattr__(plain_field, (body.get(plain_field) or '').strip()) + + if 'language_id' in body: + lang_id = body['language_id'] + if not isinstance(lang_id, int) or Languages.query.filter(Languages.id == lang_id).first() is None: + return response_api_error('Unknown language_id') + template.language_id = lang_id + + if 'report_type_id' in body: + rt_id = body['report_type_id'] + if not isinstance(rt_id, int) or ReportType.query.filter(ReportType.id == rt_id).first() is None: + return response_api_error('Unknown report_type_id') + template.report_type_id = rt_id + + try: + db.session.commit() + except IntegrityError as exc: + db.session.rollback() + return response_api_error(f'Database error: {exc}') + + track_activity(f"Report template '{template.name}' updated", ctx_less=True) + return response_api_success(_serialize_template(template)) + + +@report_templates_blueprint.delete('/') +@ac_api_requires(Permissions.server_administrator) +def delete_report_template(identifier: int) -> Response: + """Delete metadata + the underlying file. Best-effort file + unlink: if it fails (already removed manually, FS permissions + drift) the row still goes — the legacy endpoint did the same. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + name = template.name + fs_error: Optional[str] = None + try: + os.unlink(os.path.join(app.config['TEMPLATES_PATH'], template.internal_reference)) + except FileNotFoundError: + pass + except Exception as exc: + fs_error = str(exc) + + CaseTemplateReport.query.filter(CaseTemplateReport.id == identifier).delete() + db.session.commit() + + track_activity(f"Report template '{name}' deleted", ctx_less=True) + + if fs_error: + return response_api_success({ + 'warning': f'Template row deleted but the file could not be removed: {fs_error}' + }) + return response_api_deleted() + + +# ----- File download -------------------------------------------------- + +@report_templates_blueprint.get('//download') +@ac_api_requires(Permissions.server_administrator) +def download_report_template(identifier: int) -> Response: + """Stream the raw template file back so an admin can inspect / + edit / copy it locally. Filename uses the human-readable + `template.name` with the stored extension.""" + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + fpath = os.path.join(app.config['TEMPLATES_PATH'], template.internal_reference) + if not os.path.exists(fpath): + return response_api_error('Template file is missing on disk') + + _, extension = os.path.splitext(template.internal_reference) + # Drop the leading dot and any path separators a malicious name + # could carry over from earlier renames. + safe_display = secure_filename(template.name) or 'report_template' + return send_file(fpath, as_attachment=True, download_name=f'{safe_display}{extension}') + + +# ----- Render against a case ----------------------------------------- + +@report_templates_blueprint.post('//render') +@ac_api_requires(Permissions.server_administrator) +def render_report_template(identifier: int) -> Response: + """Render the template against a real case and stream the result. + + Body: `{case_id: int, safe_mode?: bool}`. The current user must + hold `read_only`-or-better on the case — that gate matches what + the legacy `/case/report/generate-{investigation,activities}/...` + routes enforce via `ac_requires_case_identifier`. Dispatch + between investigation / activities is driven by the template's + `report_type` so the UI doesn't have to know which generator to + call. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + body = request.get_json() or {} + case_id = body.get('case_id') + if not isinstance(case_id, int): + return response_api_error('Missing or invalid case_id') + + safe_mode = bool(body.get('safe_mode')) + + if not ac_fast_check_current_user_has_case_access( + case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + # Mask "exists but you can't see it" as 404, same convention + # as the v2 case routes. + return response_api_not_found() + + if Cases.query.filter(Cases.case_id == case_id).first() is None: + return response_api_not_found() + + tmp_dir = tempfile.mkdtemp() + report_type_name = template.report_type.name if template.report_type else '' + + try: + if report_type_name == 'Investigation': + fpath = generate_investigation_report(case_id, template.id, safe_mode, tmp_dir) + elif report_type_name == 'Activities': + fpath = generate_activities_report(case_id, template.id, safe_mode, tmp_dir) + else: + return response_api_error( + f"Unsupported report type '{report_type_name}' on this template" + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + + response = send_file(fpath, as_attachment=True) + _FILE_REMOVER.cleanup_once_done(response, tmp_dir) + return response + + +# ----- Case picker for the render preview ---------------------------- + +@report_templates_blueprint.get('/accessible-cases') +@ac_api_requires(Permissions.server_administrator) +def list_accessible_cases() -> Response: + """Lightweight list of cases the current user can render against. + + Returns at most 200 rows ordered by `case_id DESC`, optionally + filtered by a `search` ILIKE on case name + SOC id. The page + refreshes this list on every keystroke (debounced); 200 is the + inflexion point past which a user is expected to start typing. + + We still re-check `ac_fast_check_current_user_has_case_access` + per row even though the route is admin-gated, so an admin who + explicitly removed themselves from a case still doesn't see it. + """ + from sqlalchemy import or_ + query = Cases.query.with_entities(Cases.case_id, Cases.name, Cases.soc_id) + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + query = query.filter(or_(Cases.name.ilike(needle), Cases.soc_id.ilike(needle))) + query = query.order_by(Cases.case_id.desc()).limit(200) + + items: List[Dict[str, Any]] = [] + for row in query.all(): + if not ac_fast_check_current_user_has_case_access( + row.case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + continue + items.append({ + 'case_id': row.case_id, + 'name': row.name, + 'soc_id': row.soc_id, + }) + + return response_api_success({'data': items}) + + +# ----- Schema + lookups ---------------------------------------------- + +# Friendly labels + help keyed by model field name. The list endpoint +# powers the interactive editor — adding a column on +# CaseTemplateReport just needs an entry here and a render in the +# page. +_FIELD_META = { + 'name': {'label': 'Name', 'help': 'Required. Shown in the picker.'}, + 'description': {'label': 'Description', 'help': "Admin-side description."}, + 'naming_format': { + 'label': 'Output filename format', + 'help': 'Supports tags: %date%, %customer%, %case_name%, %code_name%.', + }, + 'language_id': {'label': 'Language', 'help': 'Used by the renderer for locale-aware formatting.'}, + 'report_type_id': { + 'label': 'Report type', + 'help': "'Investigation' renders the full case export; 'Activities' renders the activity log.", + }, +} + + +@report_templates_blueprint.get('/schema') +@ac_api_requires(Permissions.server_administrator) +def get_report_template_schema() -> Response: + """Editor metadata + seeded lookups in a single round-trip. + + Languages and report types are seeded at install time and only + grow when the operator adds new ones via SQL; bundling them with + the schema keeps the form rendering free of extra requests on + open. + """ + languages = [ + {'id': lang.id, 'name': lang.name, 'code': lang.code} + for lang in Languages.query.order_by(Languages.name).all() + ] + report_types = [ + {'id': rt.id, 'name': rt.name} + for rt in ReportType.query.order_by(ReportType.name).all() + ] + + fields = [ + {'name': 'name', 'kind': 'string', 'required': True, **_FIELD_META['name']}, + {'name': 'description', 'kind': 'text', 'required': False, **_FIELD_META['description']}, + { + 'name': 'naming_format', + 'kind': 'string', + 'required': False, + **_FIELD_META['naming_format'], + }, + { + 'name': 'language_id', + 'kind': 'select', + 'required': True, + 'options_ref': 'languages', + **_FIELD_META['language_id'], + }, + { + 'name': 'report_type_id', + 'kind': 'select', + 'required': True, + 'options_ref': 'report_types', + **_FIELD_META['report_type_id'], + }, + ] + + return response_api_success({ + 'fields': fields, + 'lookups': { + 'languages': languages, + 'report_types': report_types, + }, + 'allowed_extensions': sorted(_ALLOWED_EXTENSIONS), + 'naming_format_tags': ['%date%', '%customer%', '%case_name%', '%code_name%'], + }) + + +# Keep User import "used" — it's referenced indirectly through the +# CaseTemplateReport.created_by_user relationship in the serialiser +# but Python linters can't follow that. +_ = User From 0c8314d4d52601a4be2d5f0390404ce7041eb354 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 15:08:06 +0200 Subject: [PATCH 020/121] [ADD] v2 endpoint to replace a report template's file in place via multipart PUT --- .../rest/v2/manage_routes/report_templates.py | 86 ++++++++++++++++++- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/source/app/blueprints/rest/v2/manage_routes/report_templates.py b/source/app/blueprints/rest/v2/manage_routes/report_templates.py index 584045420..bb6e57619 100644 --- a/source/app/blueprints/rest/v2/manage_routes/report_templates.py +++ b/source/app/blueprints/rest/v2/manage_routes/report_templates.py @@ -260,10 +260,9 @@ def create_report_template() -> Response: @ac_api_requires(Permissions.server_administrator) def update_report_template(identifier: int) -> Response: """Metadata-only update. Takes JSON so admins can rename / re-tag - a template without re-uploading its file. To replace the file, - delete the row and re-create it — the legacy UI had no edit-with- - upload flow either and there's no reliable way to swap a docx in - place without breaking outstanding render jobs. + a template without re-uploading its file. To replace the file + itself, use `PUT //file` (multipart) — kept separate so this + route can stay plain JSON. """ try: template = _get_template(identifier) @@ -339,6 +338,85 @@ def delete_report_template(identifier: int) -> Response: return response_api_deleted() +# ----- File replacement ---------------------------------------------- + +@report_templates_blueprint.put('//file') +@ac_api_requires(Permissions.server_administrator) +def replace_report_template_file(identifier: int) -> Response: + """Replace the underlying template file in place. + + Multipart: a single `file` field, same allowlist as the create + endpoint. The renderer reads the file from disk every time a + report is generated, so an in-place swap is safe between + requests — no caching to invalidate. + + Strategy: write the new file under a fresh random name first, + then atomically flip `internal_reference` to point at it and + unlink the old file. If the upload or DB update fails we drop + the staged file, leaving the original intact. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + upload = request.files.get('file') + if upload is None or not upload.filename: + return response_api_error('No file uploaded') + + if not _allowed_filename(upload.filename): + return response_api_error( + f"File extension not allowed. Use one of: {', '.join(sorted(_ALLOWED_EXTENSIONS))}" + ) + + safe_name = secure_filename(upload.filename) + _, extension = os.path.splitext(safe_name) + new_filename = _random_filename(extension) + new_path = os.path.join(app.config['TEMPLATES_PATH'], new_filename) + + try: + upload.save(new_path) + except Exception as exc: + return response_api_error(f'Unable to save uploaded file: {exc}') + + old_filename = template.internal_reference + template.internal_reference = new_filename + try: + db.session.commit() + except IntegrityError as exc: + db.session.rollback() + # DB write lost — discard the just-uploaded file so we don't + # orphan it on disk. The old file is still pointed at by the + # template row, so the render flow remains usable. + try: + os.unlink(new_path) + except Exception: + pass + return response_api_error(f'Database error: {exc}') + + # Best-effort cleanup of the previous file. If this fails the + # only downside is an orphan on disk — the row already points at + # the new file, so users see the update. Don't fail the request. + try: + os.unlink(os.path.join(app.config['TEMPLATES_PATH'], old_filename)) + except FileNotFoundError: + pass + except Exception: + # Log via track_activity so the orphan is traceable. + track_activity( + f"Report template '{template.name}' file replaced; " + f"could not remove previous file '{old_filename}'", + ctx_less=True, + ) + else: + track_activity( + f"Report template '{template.name}' file replaced", + ctx_less=True, + ) + + return response_api_success(_serialize_template(template)) + + # ----- File download -------------------------------------------------- @report_templates_blueprint.get('//download') From d560ee675cef27231f00d2fb27b850f0363af872 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 15:58:32 +0200 Subject: [PATCH 021/121] [ADD] v2 access-control endpoints covering users, groups, members, case-access, customers, audit, MFA reset and API-key rotation --- source/app/blueprints/rest/v2/manage.py | 2 + .../rest/v2/manage_routes/access_control.py | 216 ++++++++++ .../rest/v2/manage_routes/groups.py | 293 ++++++++++++- .../blueprints/rest/v2/manage_routes/users.py | 385 +++++++++++++++++- 4 files changed, 876 insertions(+), 20 deletions(-) create mode 100644 source/app/blueprints/rest/v2/manage_routes/access_control.py diff --git a/source/app/blueprints/rest/v2/manage.py b/source/app/blueprints/rest/v2/manage.py index fd54456f6..3bdea5c9a 100644 --- a/source/app/blueprints/rest/v2/manage.py +++ b/source/app/blueprints/rest/v2/manage.py @@ -26,6 +26,7 @@ from app.blueprints.rest.v2.manage_routes.case_objects import case_objects_blueprint from app.blueprints.rest.v2.manage_routes.case_templates import case_templates_blueprint from app.blueprints.rest.v2.manage_routes.report_templates import report_templates_blueprint +from app.blueprints.rest.v2.manage_routes.access_control import access_control_blueprint manage_v2_blueprint = Blueprint("manage", __name__, url_prefix="/manage") @@ -37,3 +38,4 @@ manage_v2_blueprint.register_blueprint(case_objects_blueprint) manage_v2_blueprint.register_blueprint(case_templates_blueprint) manage_v2_blueprint.register_blueprint(report_templates_blueprint) +manage_v2_blueprint.register_blueprint(access_control_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/access_control.py b/source/app/blueprints/rest/v2/manage_routes/access_control.py new file mode 100644 index 000000000..9fea2ee61 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/access_control.py @@ -0,0 +1,216 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints supporting the Access Control admin page. + +Two small concerns: + + * `GET /schema` — exposes the `Permissions` (bitmask) and + `CaseAccessLevel` enums + their canonical labels. The frontend + renders the permission editor + the case-access dropdown + directly from this so adding a new permission bit or access + level only needs a backend change. + * `GET /accessible-cases` — server-side scoped case picker for + the per-user / per-group case-access modals. Returns at most + 200 rows, ordered newest-first, filtered by an optional `search` + ILIKE on case name + SOC id. Each row is re-checked against the + current user's case ACL — even though the page is admin-gated, + an admin who's removed themselves from a case still doesn't see + it. + * `POST /recompute-all` — wrapper around the existing + `ac_recompute_all_users_effective_ac()` helper so admins can + rebuild the effective-access cache from the UI after a manual + DB poke. +""" + +from typing import Any +from typing import Dict +from typing import List + +from flask import Blueprint +from flask import Response +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +from app.blueprints.rest.endpoints import response_api_success +from app.iris_engine.access_control.utils import ac_recompute_all_users_effective_ac +from app.models.authorization import CaseAccessLevel +from app.models.authorization import Permissions +from app.models.cases import Cases + + +access_control_blueprint = Blueprint( + 'access_control_rest_v2', __name__, url_prefix='/access-control' +) + + +# Human-readable labels + short descriptions for each permission bit. +# Keyed by enum name. Strictly additive — adding a new permission to +# `app.models.authorization.Permissions` will surface in the response +# even without a label here; the UI falls back to the enum name. +_PERMISSION_LABELS: Dict[str, Dict[str, str]] = { + 'standard_user': { + 'label': 'Standard user', + 'description': 'Bare minimum required to log in. Always implicitly set.', + }, + 'server_administrator': { + 'label': 'Server administrator', + 'description': 'Full administrative access. Take care assigning this.', + }, + 'alerts_read': { + 'label': 'Read alerts', + 'description': 'View the alerts queue.', + }, + 'alerts_write': { + 'label': 'Write alerts', + 'description': 'Triage, edit and escalate alerts.', + }, + 'alerts_delete': { + 'label': 'Delete alerts', + 'description': 'Permanently delete alerts.', + }, + 'search_across_cases': { + 'label': 'Search across cases', + 'description': 'Global search reaches every case (subject to per-case access).', + }, + 'customers_read': { + 'label': 'Read customers', + 'description': 'View the customer directory.', + }, + 'customers_write': { + 'label': 'Write customers', + 'description': 'Create, edit and delete customer records.', + }, + 'case_templates_read': { + 'label': 'Read case templates', + 'description': 'View the case template library.', + }, + 'case_templates_write': { + 'label': 'Write case templates', + 'description': 'Create, edit and delete case templates.', + }, + 'activities_read': { + 'label': 'Read own activities', + 'description': "View activity entries the user generated.", + }, + 'all_activities_read': { + 'label': 'Read all activities', + 'description': 'View activity entries generated by every user.', + }, +} + +_CASE_ACCESS_LABELS: Dict[str, Dict[str, str]] = { + 'deny_all': { + 'label': 'Deny all', + 'description': 'Explicitly refuse access (overrides group / org grants).', + }, + 'read_only': { + 'label': 'Read only', + 'description': 'View the case and its objects without editing.', + }, + 'full_access': { + 'label': 'Full access', + 'description': 'View, edit and add objects to the case.', + }, +} + + +@access_control_blueprint.get('/schema') +@ac_api_requires(Permissions.server_administrator) +def get_access_control_schema() -> Response: + """Enum metadata for the Access Control editor. + + Permissions are exposed as a flat list of `{name, value, label, + description}` ordered by their bit value — that's the order the + editor renders the checklist. CaseAccessLevel is shipped the + same way but as a small mutually-exclusive set the UI renders as + a select. + """ + perms = [] + for perm in sorted(Permissions, key=lambda p: p.value): + meta = _PERMISSION_LABELS.get(perm.name, {}) + perms.append({ + 'name': perm.name, + 'value': perm.value, + 'label': meta.get('label', perm.name.replace('_', ' ').capitalize()), + 'description': meta.get('description', ''), + }) + + access_levels = [] + for level in sorted(CaseAccessLevel, key=lambda l: l.value): + meta = _CASE_ACCESS_LABELS.get(level.name, {}) + access_levels.append({ + 'name': level.name, + 'value': level.value, + 'label': meta.get('label', level.name.replace('_', ' ').capitalize()), + 'description': meta.get('description', ''), + }) + + return response_api_success({ + 'permissions': perms, + 'case_access_levels': access_levels, + }) + + +@access_control_blueprint.get('/accessible-cases') +@ac_api_requires(Permissions.server_administrator) +def list_accessible_cases() -> Response: + """Lightweight case picker for the per-user / per-group case-access + modals. + + Returns at most 200 rows ordered by `case_id DESC`, optionally + filtered by a `search` ILIKE on case name + SOC id. Each + candidate is re-checked with `ac_fast_check_current_user_has_case_access` + so an admin who's explicitly removed themselves from a case + still doesn't see it in the picker. + """ + from sqlalchemy import or_ + query = Cases.query.with_entities(Cases.case_id, Cases.name, Cases.soc_id) + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + query = query.filter(or_(Cases.name.ilike(needle), Cases.soc_id.ilike(needle))) + query = query.order_by(Cases.case_id.desc()).limit(200) + + items: List[Dict[str, Any]] = [] + for row in query.all(): + if not ac_fast_check_current_user_has_case_access( + row.case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + continue + items.append({ + 'case_id': row.case_id, + 'name': row.name, + 'soc_id': row.soc_id, + }) + + return response_api_success({'data': items}) + + +@access_control_blueprint.post('/recompute-all') +@ac_api_requires(Permissions.server_administrator) +def recompute_all_users_access() -> Response: + """Rebuild the effective-access cache for every user. + + Expensive on large fleets — the legacy UI gated this behind a + confirmation. The page does the same client-side; the endpoint + itself just runs the helper synchronously. + """ + ac_recompute_all_users_effective_ac() + return response_api_success({'message': 'Recomputed effective access for all users'}) diff --git a/source/app/blueprints/rest/v2/manage_routes/groups.py b/source/app/blueprints/rest/v2/manage_routes/groups.py index 1a05ed379..aae4a21ae 100644 --- a/source/app/blueprints/rest/v2/manage_routes/groups.py +++ b/source/app/blueprints/rest/v2/manage_routes/groups.py @@ -16,27 +16,59 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +"""v2 endpoints for the Groups admin surface. + +Extends the previously-thin CRUD to support the Access Control page: + + * paginated `GET /` with ILIKE search across group name + description + * `GET //members` + `PUT //members` + `DELETE //members/` + * `GET //cases-access` + * `POST //cases-access` — supports `auto_follow_cases=true` or + an explicit `cases_list` + * `DELETE //cases-access` — bulk removal + +Permission bitmask edits go through the existing `PUT /` route +since they're a regular column on the Group model — no separate +endpoint needed. + +All routes are gated on `Permissions.server_administrator` and +delegate to the existing legacy data + business helpers; no +duplication. +""" + from flask import Blueprint +from flask import Response from flask import request from marshmallow import ValidationError +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.iris_user import iris_current_user from app.blueprints.rest.endpoints import response_api_created -from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.endpoints import response_api_deleted from app.blueprints.rest.endpoints import response_api_error from app.blueprints.rest.endpoints import response_api_not_found -from app.blueprints.rest.endpoints import response_api_deleted -from app.blueprints.access_controls import ac_api_requires -from app.schema.marshables import AuthorizationGroupSchema +from app.blueprints.rest.endpoints import response_api_success from app.business.groups import groups_create +from app.business.groups import groups_delete from app.business.groups import groups_get from app.business.groups import groups_update -from app.business.groups import groups_delete +from app.datamgmt.manage.manage_groups_db import add_all_cases_access_to_group +from app.datamgmt.manage.manage_groups_db import add_case_access_to_group +from app.datamgmt.manage.manage_groups_db import get_group_details +from app.datamgmt.manage.manage_groups_db import get_group_with_members +from app.datamgmt.manage.manage_groups_db import remove_cases_access_from_group +from app.datamgmt.manage.manage_groups_db import remove_user_from_group +from app.datamgmt.manage.manage_groups_db import update_group_members +from app.datamgmt.manage.manage_users_db import get_user +from app.db import db +from app.iris_engine.access_control.utils import ac_ldp_group_removal +from app.iris_engine.access_control.utils import ac_ldp_group_update +from app.iris_engine.access_control.utils import ac_recompute_effective_ac_from_users_list from app.models.authorization import Permissions from app.models.authorization import ac_flag_match_mask from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError -from app.blueprints.iris_user import iris_current_user -from app.iris_engine.access_control.utils import ac_ldp_group_update +from app.schema.marshables import AuthorizationGroupSchema class Groups: @@ -44,6 +76,49 @@ class Groups: def __init__(self): self._schema = AuthorizationGroupSchema() + def search(self): + """Paginated group list with ILIKE search. + + Search applies to name + description with OR semantics so the + page can filter by either. + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + per_page = max(1, min(per_page, 200)) + search = (request.args.get('search') or '').strip() or None + + from app.models.authorization import Group + from sqlalchemy import or_ + query = Group.query + if search: + needle = f'%{search}%' + query = query.filter( + or_(Group.group_name.ilike(needle), Group.group_description.ilike(needle)) + ) + paginated = query.order_by(Group.group_id.asc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + # Hydrate `group_members` + `group_permissions_list` on each + # row so the page can render the master list without a second + # round-trip per row. `get_group_with_members` is the legacy + # helper that attaches these. + items = [] + for g in paginated.items: + hydrated = get_group_with_members(g.group_id) + if hydrated is not None: + items.append(self._schema.dump(hydrated)) + else: + items.append(self._schema.dump(g)) + + return response_api_success({ + 'total': paginated.total, + 'data': items, + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + def create(self): try: request_data = request.get_json() @@ -57,7 +132,10 @@ def create(self): def read(self, identifier): try: group = groups_get(identifier) - result = self._schema.dump(group) + # Re-hydrate with members + permission list so the editor + # has everything it needs to render in one shot. + hydrated = get_group_with_members(identifier) or group + result = self._schema.dump(hydrated) return response_api_success(result) except ObjectNotFoundError: return response_api_not_found() @@ -68,11 +146,20 @@ def update(self, identifier): request_data = request.get_json() request_data['group_id'] = identifier updated_group = self._schema.load(request_data, instance=group, partial=True) - if not ac_flag_match_mask(request_data['group_permissions'], Permissions.server_administrator.value) and ac_ldp_group_update(iris_current_user.id): - return response_api_error('That might not be a good idea Dave', data='Update the group permissions will lock you out') + # Lock-out prevention copied from the legacy handler: + # demoting the server-admin bit on a group the caller + # belongs to would orphan their own admin access. The + # backing helper checks group membership transitively. + if 'group_permissions' in request_data and not ac_flag_match_mask( + request_data['group_permissions'], Permissions.server_administrator.value + ) and ac_ldp_group_update(iris_current_user.id): + return response_api_error( + 'That might not be a good idea Dave', + data='Updating the group permissions will lock you out', + ) groups_update() - result = self._schema.dump(updated_group) - return response_api_success(result) + hydrated = get_group_with_members(identifier) or updated_group + return response_api_success(self._schema.dump(hydrated)) except ValidationError as e: return response_api_error('Data error', data=e.messages) @@ -96,6 +183,12 @@ def delete(self, identifier): groups = Groups() +@groups_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def search_groups(): + return groups.search() + + @groups_blueprint.post('') @ac_api_requires(Permissions.server_administrator) def create_group(): @@ -118,3 +211,179 @@ def update_group(identifier): @ac_api_requires(Permissions.server_administrator) def delete_group(identifier): return groups.delete(identifier) + + +# ---- Members --------------------------------------------------------- + +def _require_group(group_id: int): + """Internal: load with members + 404 if missing.""" + group = get_group_with_members(group_id) + if group is None: + return None, response_api_not_found() + return group, None + + +@groups_blueprint.get('//members') +@ac_api_requires(Permissions.server_administrator) +def list_group_members(identifier: int) -> Response: + group, err = _require_group(identifier) + if err is not None: + return err + return response_api_success({'data': group.group_members}) + + +@groups_blueprint.put('//members') +@ac_api_requires(Permissions.server_administrator) +def put_group_members(identifier: int) -> Response: + """Replace the group's member list. + + Body: `{members: [user_id, ...]}` (or legacy `group_members`). + The data-layer helper performs an in-place diff (remove orphans, + insert newcomers, leave existing in place) so this isn't an + O(N²) churn even with hundreds of users. + """ + body = request.get_json() or {} + members = body.get('members') + if members is None: + members = body.get('group_members') + if not isinstance(members, list): + return response_api_error('`members` must be a list of user IDs') + for uid in members: + if not isinstance(uid, int): + return response_api_error(f'Invalid user id: {uid}') + + group, err = _require_group(identifier) + if err is not None: + return err + + update_group_members(group, members) + refreshed = get_group_with_members(identifier) + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) + + +@groups_blueprint.delete('//members/') +@ac_api_requires(Permissions.server_administrator) +def remove_group_member(identifier: int, user_id: int) -> Response: + """Drop a single user from the group. + + Refuses if the caller removing this user would lock the caller + out — `ac_ldp_group_removal` does the transitive check. + """ + group, err = _require_group(identifier) + if err is not None: + return err + user = get_user(user_id) + if user is None: + return response_api_not_found() + + if ac_ldp_group_removal(user_id=user.id, group_id=group.group_id): + return response_api_error( + "I cannot let you do that Dave", + data='Removing yourself from the group would lose your access rights', + ) + + remove_user_from_group(group, user) + refreshed = get_group_with_members(identifier) + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) + + +# ---- Group case access ---------------------------------------------- + +@groups_blueprint.get('//cases-access') +@ac_api_requires(Permissions.server_administrator) +def get_group_cases_access(identifier: int) -> Response: + """List the group's per-case grants. + + The hydrated `Group` object exposes the rows under + `group_cases_access`; we return the full projection so the page + can render the picker without computing anything client-side. + """ + group = get_group_details(identifier) + if group is None: + return response_api_not_found() + return response_api_success(AuthorizationGroupSchema().dump(group)) + + +@groups_blueprint.post('//cases-access') +@ac_api_requires(Permissions.server_administrator) +def add_group_cases_access(identifier: int) -> Response: + """Grant the group access over cases. + + Body shape variants: + * `{access_level, auto_follow_cases: true}` — grant access on + every existing case and persist the auto-follow flag so + future cases get the grant automatically. + * `{access_level, cases_list: [int, ...]}` — explicit list. + + Either path triggers a recompute of effective access for every + member of the group. + """ + body = request.get_json() or {} + + access_level = body.get('access_level') + if not isinstance(access_level, int): + try: + access_level = int(access_level) + except (TypeError, ValueError): + return response_api_error('`access_level` must be an int') + + auto_follow = bool(body.get('auto_follow_cases')) + cases_list = body.get('cases_list') + + if not auto_follow and not isinstance(cases_list, list): + return response_api_error('`cases_list` must be a list when `auto_follow_cases` is false') + + group, err = _require_group(identifier) + if err is not None: + return err + + if auto_follow: + group, logs = add_all_cases_access_to_group(group, access_level) + group.group_auto_follow = True + group.group_auto_follow_access_level = access_level + db.session.commit() + else: + group, logs = add_case_access_to_group(group, cases_list, access_level) + group.group_auto_follow = False + db.session.commit() + + if not group: + return response_api_error(logs) + + refreshed = get_group_details(identifier) + ac_recompute_effective_ac_from_users_list(refreshed.group_members) + + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) + + +@groups_blueprint.delete('//cases-access') +@ac_api_requires(Permissions.server_administrator) +def delete_group_cases_access(identifier: int) -> Response: + """Drop the group's grants for `cases`. + + Body: `{cases: [int, ...]}`. Members' effective access is + recomputed before the response returns so subsequent reads see + the new state without a manual recompute call. + """ + body = request.get_json() or {} + cases = body.get('cases') + if not isinstance(cases, list): + return response_api_error('`cases` must be a list') + + group, err = _require_group(identifier) + if err is not None: + return err + + try: + success, logs = remove_cases_access_from_group(group.group_id, cases) + db.session.commit() + except Exception as exc: + db.session.rollback() + return response_api_error(str(exc)) + + if not success: + return response_api_error(logs) + + ac_recompute_effective_ac_from_users_list(group.group_members) + refreshed = get_group_details(identifier) + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) diff --git a/source/app/blueprints/rest/v2/manage_routes/users.py b/source/app/blueprints/rest/v2/manage_routes/users.py index d59a4b6d6..e1cd58edf 100644 --- a/source/app/blueprints/rest/v2/manage_routes/users.py +++ b/source/app/blueprints/rest/v2/manage_routes/users.py @@ -16,24 +16,66 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +"""v2 endpoints for the Users admin surface. + +Extends the previously-thin CRUD with the subresources the Access +Control page needs: + + * paginated `GET /` with ILIKE search across login + display name + * `GET //groups` + `PUT //groups` — group membership + * `GET //customers` + `PUT //customers` — customer access + * `GET //cases-access` — list explicit cases + * `POST //cases-access` / `DELETE //cases-access` — bulk edit + * `POST //activate` / `POST //deactivate` + * `POST //api-key/renew` + * `POST //mfa/reset` + * `POST //recompute-access` + * `GET //audit` — effective access trace + +All routes are gated on `Permissions.server_administrator`. The +implementations delegate to the existing legacy data + business +helpers — no duplication, just thin v2 wrappers. +""" + +import secrets +from typing import Any +from typing import Dict +from typing import List + from flask import Blueprint +from flask import Response from flask import request from marshmallow import ValidationError from app.blueprints.access_controls import ac_api_requires +from app.blueprints.iris_user import iris_current_user from app.blueprints.rest.endpoints import response_api_created -from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.endpoints import response_api_deleted from app.blueprints.rest.endpoints import response_api_error from app.blueprints.rest.endpoints import response_api_not_found -from app.blueprints.rest.endpoints import response_api_deleted -from app.schema.marshables import UserSchemaForAPIV2 -from app.models.authorization import Permissions -from app.models.errors import ObjectNotFoundError -from app.models.errors import BusinessProcessingError +from app.blueprints.rest.endpoints import response_api_success from app.business.users import users_create +from app.business.users import users_delete from app.business.users import users_get +from app.business.users import users_reset_mfa from app.business.users import users_update -from app.business.users import users_delete +from app.business.groups import groups_exist +from app.datamgmt.manage.manage_users_db import add_case_access_to_user +from app.datamgmt.manage.manage_users_db import get_filtered_users +from app.datamgmt.manage.manage_users_db import get_user +from app.datamgmt.manage.manage_users_db import get_user_details +from app.datamgmt.manage.manage_users_db import remove_cases_access_from_user +from app.datamgmt.manage.manage_users_db import update_user_customers +from app.datamgmt.manage.manage_users_db import update_user_groups +from app.db import db +from app.iris_engine.access_control.utils import ac_recompute_effective_ac +from app.iris_engine.access_control.utils import ac_trace_effective_user_permissions +from app.iris_engine.access_control.utils import ac_trace_user_effective_cases_access_2 +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import UserSchemaForAPIV2 class Users: @@ -41,6 +83,40 @@ class Users: def __init__(self): self._schema = UserSchemaForAPIV2() + def search(self): + """Paginated user list with ILIKE search on login + display name. + + Search applies to either column with an OR so admins can find + someone by typing either the friendly name or the login slug. + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + per_page = max(1, min(per_page, 200)) + search = (request.args.get('search') or '').strip() or None + + # `get_filtered_users` accepts user_name + user_login as + # independent ILIKE filters; calling it twice would be ugly so + # we pass the same needle as both and ORM_collapses them via + # the function's existing `and_` reduction. To preserve OR + # semantics we instead query manually here. + from app.models.authorization import User + from sqlalchemy import or_ + query = User.query + if search: + needle = f'%{search}%' + query = query.filter(or_(User.name.ilike(needle), User.user.ilike(needle))) + paginated = query.order_by(User.id.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + return response_api_success({ + 'total': paginated.total, + 'data': [self._schema.dump(u) for u in paginated.items], + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + def create(self): try: request_data = request.get_json() @@ -94,6 +170,12 @@ def delete(self, identifier): users_blueprint = Blueprint('users_rest_v2', __name__, url_prefix='/users') +@users_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def search_users(): + return users.search() + + @users_blueprint.post('') @ac_api_requires(Permissions.server_administrator) def create_user(): @@ -102,7 +184,7 @@ def create_user(): @users_blueprint.get('/') @ac_api_requires(Permissions.server_administrator) -def get_user(identifier): +def get_user_endpoint(identifier): return users.read(identifier) @@ -116,3 +198,290 @@ def put_user(identifier): @ac_api_requires(Permissions.server_administrator) def delete_user(identifier): return users.delete(identifier) + + +# ---- Subresources ---------------------------------------------------- + +def _require_user(user_id: int): + """Internal: load + 404 if missing.""" + user = get_user(user_id) + if user is None: + return None, response_api_not_found() + return user, None + + +@users_blueprint.get('//groups') +@ac_api_requires(Permissions.server_administrator) +def list_user_groups(identifier: int) -> Response: + """Return the user's group membership as a list of `{id, name}`. + + Powers the "Groups" tab on the User edit modal and the membership + picker. We don't add a "groups available to add" sub-endpoint + here — the page already loads the full group list elsewhere. + """ + user, err = _require_user(identifier) + if err is not None: + return err + return response_api_success({ + 'data': [ + {'group_id': g.group_id, 'group_name': g.group_name} + for g in user.groups + ] + }) + + +@users_blueprint.put('//groups') +@ac_api_requires(Permissions.server_administrator) +def put_user_groups(identifier: int) -> Response: + """Replace the user's group membership. + + Body: `{groups: [int, ...]}`. Each group id is validated to exist + before any DB write. Matches the legacy "groups_membership" key + by also accepting that for back-compat. + """ + body = request.get_json() or {} + groups = body.get('groups') + if groups is None: + groups = body.get('groups_membership') + if not isinstance(groups, list): + return response_api_error('`groups` must be a list of group IDs') + + user, err = _require_user(identifier) + if err is not None: + return err + + for gid in groups: + if not isinstance(gid, int) or not groups_exist(gid): + return response_api_error(f'Unknown group id: {gid}') + + update_user_groups(identifier, groups) + track_activity(f'groups membership of user {identifier} updated', ctx_less=True) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.put('//customers') +@ac_api_requires(Permissions.server_administrator) +def put_user_customers(identifier: int) -> Response: + """Replace the user's customer membership. + + Body: `{customers: [int, ...]}`. The data-layer call mirrors the + legacy `update_user_customers`; here we just normalise the body. + """ + body = request.get_json() or {} + customers = body.get('customers') + if customers is None: + customers = body.get('customers_membership') + if not isinstance(customers, list): + return response_api_error('`customers` must be a list of customer IDs') + for cid in customers: + if not isinstance(cid, int): + return response_api_error(f'Invalid customer id: {cid}') + + user, err = _require_user(identifier) + if err is not None: + return err + + update_user_customers(user_id=identifier, customers=customers) + track_activity(f'customers membership of user {identifier} updated', ctx_less=True) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.get('//cases-access') +@ac_api_requires(Permissions.server_administrator) +def get_user_cases_access(identifier: int) -> Response: + """List the user's *explicit* per-case access rows. + + Effective access (after merging group / org grants) is exposed + by the audit endpoint below — this one shows just what the admin + can directly edit. + """ + details = get_user_details(user_id=identifier) + if details is None: + return response_api_not_found() + # `get_user_details` returns v2-shaped keys; fall back to the + # legacy key the older serialiser used. + cases_access = details.get('user_cases_access', details.get('cases_access', [])) + return response_api_success({'data': cases_access}) + + +@users_blueprint.post('//cases-access') +@ac_api_requires(Permissions.server_administrator) +def add_user_cases_access(identifier: int) -> Response: + """Grant `access_level` over `cases_list` to the user. + + Body: `{cases_list: [int, ...], access_level: int}`. Existing + grants for the same case are overwritten in place — the + underlying helper handles both insert + update. + """ + body = request.get_json() or {} + cases_list = body.get('cases_list') + access_level = body.get('access_level') + if not isinstance(cases_list, list): + return response_api_error('`cases_list` must be a list') + if not isinstance(access_level, int): + try: + access_level = int(access_level) + except (TypeError, ValueError): + return response_api_error('`access_level` must be an int') + + user, err = _require_user(identifier) + if err is not None: + return err + + user, logs = add_case_access_to_user(user, cases_list, access_level) + if not user: + return response_api_error(logs) + + track_activity( + f'case access level {access_level} for case(s) {cases_list} set for user {user.user}', + ctx_less=True, + ) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.delete('//cases-access') +@ac_api_requires(Permissions.server_administrator) +def delete_user_cases_access(identifier: int) -> Response: + """Drop explicit case grants for `cases`. + + Body: `{cases: [int, ...]}`. DELETE with a body is unusual but + intentional: removing a list of grants is conceptually a single + bulk operation and forcing it through GET-with-query or POST + would be even noisier. + """ + body = request.get_json() or {} + cases = body.get('cases') + if not isinstance(cases, list): + return response_api_error('`cases` must be a list') + + user, err = _require_user(identifier) + if err is not None: + return err + + try: + success, logs = remove_cases_access_from_user(user.id, cases) + db.session.commit() + except Exception as exc: + db.session.rollback() + return response_api_error(str(exc)) + + if not success: + return response_api_error(logs) + + track_activity( + f'cases access for case(s) {cases} deleted for user {user.user}', + ctx_less=True, + ) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.post('//activate') +@ac_api_requires(Permissions.server_administrator) +def activate_user(identifier: int) -> Response: + user, err = _require_user(identifier) + if err is not None: + return err + user.active = True + db.session.commit() + track_activity(f'user {user.user} activated', ctx_less=True) + return response_api_success(Users()._schema.dump(user)) + + +@users_blueprint.post('//deactivate') +@ac_api_requires(Permissions.server_administrator) +def deactivate_user(identifier: int) -> Response: + """Disable a user account. + + Refuses if the caller is trying to deactivate themselves — the + legacy route had the same guard and it's worth keeping; an admin + locking themselves out is irrecoverable without DB access. + """ + if iris_current_user.id == identifier: + return response_api_error( + 'Refusing to deactivate yourself — you would lock yourself out.' + ) + user, err = _require_user(identifier) + if err is not None: + return err + user.active = False + db.session.commit() + track_activity(f'user {user.user} deactivated', ctx_less=True) + return response_api_success(Users()._schema.dump(user)) + + +@users_blueprint.post('//api-key/renew') +@ac_api_requires(Permissions.server_administrator) +def renew_user_api_key(identifier: int) -> Response: + """Rotate the user's API key. Returns the *new* key in the body + once — the admin should copy it immediately; we don't surface it + again on subsequent reads.""" + user, err = _require_user(identifier) + if err is not None: + return err + new_key = secrets.token_urlsafe(nbytes=64) + user.api_key = new_key + db.session.commit() + track_activity(f'API key of user {user.user} renewed', ctx_less=True) + return response_api_success({ + 'user_id': user.id, + 'user': user.user, + 'api_key': new_key, + }) + + +@users_blueprint.post('//mfa/reset') +@ac_api_requires(Permissions.server_administrator) +def reset_user_mfa(identifier: int) -> Response: + """Clear MFA secrets for the user. The underlying business + helper raises `BusinessProcessingError` when the user can't be + found (rather than a typed not-found) — surface both paths + cleanly.""" + try: + users_reset_mfa(identifier) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message()) + track_activity(f'MFA reset for user #{identifier}', ctx_less=True) + return response_api_success({'message': 'MFA reset'}) + + +@users_blueprint.post('//recompute-access') +@ac_api_requires(Permissions.server_administrator) +def recompute_user_access(identifier: int) -> Response: + """Force-recompute the user's effective case access cache. + + Useful after a manual DB poke or to debug effective-access + drift. The legacy UI exposed this on the User Audit modal. + """ + user, err = _require_user(identifier) + if err is not None: + return err + ac_recompute_effective_ac(identifier) + return response_api_success({'message': 'Recomputed'}) + + +@users_blueprint.get('//audit') +@ac_api_requires(Permissions.server_administrator) +def audit_user(identifier: int) -> Response: + """Effective-access trace. + + Returns: + * `access_audit`: per-case rows showing where the user's + effective access came from (direct user grant, group, org). + * `permissions_audit`: per-permission rows showing which + group(s) contribute each bit of the user's effective + permission bitmask. + + Both projections are computed on-the-fly by the iris_engine + helpers; nothing is persisted. + """ + user, err = _require_user(identifier) + if err is not None: + return err + return response_api_success({ + 'access_audit': ac_trace_user_effective_cases_access_2(identifier), + 'permissions_audit': ac_trace_effective_user_permissions(identifier), + }) + + +# Keep linter quiet about unused imports referenced indirectly. +_ = (Any, Dict, List) From 8cbda59a37c7fb7273638c1cbd9e274fe47b5f83 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 16:26:46 +0200 Subject: [PATCH 022/121] [ADD] v2 read-only endpoints for severities, TLP, alert statuses + resolutions, analysis statuses, event categories and task statuses --- source/app/blueprints/rest/v2/manage.py | 2 + .../rest/v2/manage_routes/taxonomies.py | 200 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 source/app/blueprints/rest/v2/manage_routes/taxonomies.py diff --git a/source/app/blueprints/rest/v2/manage.py b/source/app/blueprints/rest/v2/manage.py index 3bdea5c9a..df2bcb517 100644 --- a/source/app/blueprints/rest/v2/manage.py +++ b/source/app/blueprints/rest/v2/manage.py @@ -27,6 +27,7 @@ from app.blueprints.rest.v2.manage_routes.case_templates import case_templates_blueprint from app.blueprints.rest.v2.manage_routes.report_templates import report_templates_blueprint from app.blueprints.rest.v2.manage_routes.access_control import access_control_blueprint +from app.blueprints.rest.v2.manage_routes.taxonomies import taxonomies_blueprint manage_v2_blueprint = Blueprint("manage", __name__, url_prefix="/manage") @@ -39,3 +40,4 @@ manage_v2_blueprint.register_blueprint(case_templates_blueprint) manage_v2_blueprint.register_blueprint(report_templates_blueprint) manage_v2_blueprint.register_blueprint(access_control_blueprint) +manage_v2_blueprint.register_blueprint(taxonomies_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/taxonomies.py b/source/app/blueprints/rest/v2/manage_routes/taxonomies.py new file mode 100644 index 000000000..6a35d8eb6 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/taxonomies.py @@ -0,0 +1,200 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 read-only taxonomy endpoints. + +The seven taxonomies here are seed data: small tables (typically +<20 rows) populated at install time, occasionally extended via SQL +or by admin scripts. The UI only needs to *read* them — case/IOC/ +asset/alert modals look up the rows to populate dropdowns. + +A single file with one blueprint per resource (mounted under +`/api/v2/manage/`) keeps these next to the heavier +`case_objects.py` taxonomies for discoverability. Each blueprint +exposes only `GET /` with optional pagination + ILIKE `search` — no +write methods. Adding write methods later only needs the existing +`Permissions.server_administrator` decorator and a Marshmallow +`load_instance=True` schema. +""" + +from typing import Any +from typing import Iterable +from typing import Type + +from flask import Blueprint +from flask import Response +from flask import request +from sqlalchemy import or_ + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_success +from app.datamgmt.filtering import paginate +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.models.alerts import AlertResolutionStatus +from app.models.alerts import AlertStatus +from app.models.alerts import Severity +from app.models.assets import AnalysisStatus +from app.models.authorization import Permissions +from app.models.iocs import Tlp +from app.models.models import EventCategory +from app.models.models import TaskStatus +from app.schema.marshables import AlertResolutionSchema +from app.schema.marshables import AlertStatusSchema +from app.schema.marshables import AnalysisStatusSchema +from app.schema.marshables import EventCategorySchema +from app.schema.marshables import SeveritySchema +from app.schema.marshables import TaskStatusSchema +from app.schema.marshables import TlpSchema + + +def _list_endpoint( + model: Type[Any], + schema_factory, + search_columns: Iterable[str], + order_column: str, +): + """Build a `GET /` handler for a single taxonomy. + + Returns a paginated envelope so the frontend's generic + list-state plumbing works out of the box. `search` is an ILIKE + against the columns listed; `order_column` is the deterministic + sort applied before paginating. + """ + def handler() -> Response: + pagination_parameters = parse_pagination_parameters(request) + query = model.query + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + clauses = [] + for column_name in search_columns: + column = getattr(model, column_name, None) + if column is not None: + clauses.append(column.ilike(needle)) + if clauses: + query = query.filter(or_(*clauses)) + order_column_obj = getattr(model, order_column, None) + if order_column_obj is not None: + query = query.order_by(order_column_obj.asc()) + paginated = paginate(model, pagination_parameters, query) + return response_api_success({ + 'total': paginated.total, + 'data': schema_factory().dump(paginated.items, many=True), + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + + return handler + + +def _build_readonly_blueprint( + url_prefix: str, + blueprint_name: str, + model: Type[Any], + schema_factory, + search_columns: Iterable[str], + order_column: str, +) -> Blueprint: + bp = Blueprint(blueprint_name, __name__, url_prefix=f'/{url_prefix}') + + @bp.get('') + @ac_api_requires() + def list_route() -> Response: + return _list_endpoint(model, schema_factory, search_columns, order_column)() + + return bp + + +# Each taxonomy gets its own blueprint so URLs stay readable in logs +# and existing-prefix-based proxy rules can target them individually. +severities_blueprint = _build_readonly_blueprint( + url_prefix='severities', + blueprint_name='severities_rest_v2', + model=Severity, + schema_factory=SeveritySchema, + search_columns=('severity_name', 'severity_description'), + order_column='severity_id', +) + +tlp_blueprint = _build_readonly_blueprint( + url_prefix='tlp', + blueprint_name='tlp_rest_v2', + model=Tlp, + schema_factory=TlpSchema, + search_columns=('tlp_name',), + order_column='tlp_id', +) + +alert_statuses_blueprint = _build_readonly_blueprint( + url_prefix='alert-statuses', + blueprint_name='alert_statuses_rest_v2', + model=AlertStatus, + schema_factory=AlertStatusSchema, + search_columns=('status_name', 'status_description'), + order_column='status_id', +) + +alert_resolutions_blueprint = _build_readonly_blueprint( + url_prefix='alert-resolutions', + blueprint_name='alert_resolutions_rest_v2', + model=AlertResolutionStatus, + schema_factory=AlertResolutionSchema, + search_columns=('resolution_status_name', 'resolution_status_description'), + order_column='resolution_status_id', +) + +analysis_statuses_blueprint = _build_readonly_blueprint( + url_prefix='analysis-statuses', + blueprint_name='analysis_statuses_rest_v2', + model=AnalysisStatus, + schema_factory=AnalysisStatusSchema, + search_columns=('name',), + order_column='id', +) + +event_categories_blueprint = _build_readonly_blueprint( + url_prefix='event-categories', + blueprint_name='event_categories_rest_v2', + model=EventCategory, + schema_factory=EventCategorySchema, + search_columns=('name',), + order_column='id', +) + +task_statuses_blueprint = _build_readonly_blueprint( + url_prefix='task-statuses', + blueprint_name='task_statuses_rest_v2', + model=TaskStatus, + schema_factory=TaskStatusSchema, + search_columns=('status_name', 'status_description'), + order_column='id', +) + + +# A single parent blueprint groups them under /api/v2/manage so the +# `manage.py` registration line stays a single import per family. +taxonomies_blueprint = Blueprint('taxonomies_rest_v2', __name__) + +taxonomies_blueprint.register_blueprint(severities_blueprint) +taxonomies_blueprint.register_blueprint(tlp_blueprint) +taxonomies_blueprint.register_blueprint(alert_statuses_blueprint) +taxonomies_blueprint.register_blueprint(alert_resolutions_blueprint) +taxonomies_blueprint.register_blueprint(analysis_statuses_blueprint) +taxonomies_blueprint.register_blueprint(event_categories_blueprint) +taxonomies_blueprint.register_blueprint(task_statuses_blueprint) From 33a98ffc7197310a8b4db52ee3bf7edf935012c1 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Wed, 24 Jun 2026 20:01:48 +0200 Subject: [PATCH 023/121] [ADD] v2 server settings endpoints with versions block, partial-update PUT and POST backup, deprecating the legacy /manage/settings routes --- .../manage/manage_server_settings_routes.py | 3 + .../rest/v2/manage_routes/server.py | 150 +++++++++++++++++- source/app/schema/marshables.py | 30 +++- 3 files changed, 175 insertions(+), 8 deletions(-) diff --git a/source/app/blueprints/rest/manage/manage_server_settings_routes.py b/source/app/blueprints/rest/manage/manage_server_settings_routes.py index adcb7d1f5..d795b6956 100644 --- a/source/app/blueprints/rest/manage/manage_server_settings_routes.py +++ b/source/app/blueprints/rest/manage/manage_server_settings_routes.py @@ -31,6 +31,7 @@ from app.models.authorization import Permissions from app.schema.marshables import ServerSettingsSchema from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import endpoint_deprecated from app.blueprints.responses import response_error from app.blueprints.responses import response_success from dictdiffer import diff @@ -39,6 +40,7 @@ @manage_server_settings_rest_blueprint.route('/manage/server/backups/make-db', methods=['GET']) +@endpoint_deprecated('POST', '/api/v2/manage/server/backups/db') @ac_api_requires(Permissions.server_administrator) def manage_make_db_backup(): @@ -53,6 +55,7 @@ def manage_make_db_backup(): @manage_server_settings_rest_blueprint.route('/manage/settings/update', methods=['POST']) +@endpoint_deprecated('PUT', '/api/v2/manage/server/settings') @ac_api_requires(Permissions.server_administrator) def manage_update_settings(): if not request.is_json: diff --git a/source/app/blueprints/rest/v2/manage_routes/server.py b/source/app/blueprints/rest/v2/manage_routes/server.py index 50ead3641..ddf074141 100644 --- a/source/app/blueprints/rest/v2/manage_routes/server.py +++ b/source/app/blueprints/rest/v2/manage_routes/server.py @@ -15,17 +15,49 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from flask import Response + +"""v2 endpoints for server-wide settings + the DB backup trigger. + +Layout mirrors the rest of the v2 manage surface: a single +`ServerOperations` class, thin routes that delegate. + +`PUT /server/settings` accepts a partial body — only the fields the +admin actually changed need to be sent. The full row is returned on +success so the UI can refresh in one round-trip. + +`POST /server/backups/db` is the v2 replacement for the legacy GET +`/manage/server/backups/make-db`. The legacy GET stays for any +external automation calling it today; the v2 route is POST because +backup is a side-effecting action that shouldn't ride on GET. +""" + +import marshmallow from flask import Blueprint +from flask import Response +from flask import request from app import app -from app.blueprints.rest.endpoints import response_api_success +from app import celery +from app.blueprints.access_controls import ac_api_requires from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_success +from app.datamgmt.manage.manage_srv_settings_db import get_server_settings_as_dict +from app.datamgmt.manage.manage_srv_settings_db import get_srv_settings +from app.db import db +from app.iris_engine.backup.backup import backup_iris_db +from app.iris_engine.updater.updater import remove_periodic_update_checks +from app.iris_engine.updater.updater import setup_periodic_update_checks +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import Permissions +from app.schema.marshables import ServerSettingsSchema +from dictdiffer import diff class ServerOperations: def __init__(self): - pass + self._schema = ServerSettingsSchema() + + # ----- Authentication ------------------------------------------ @staticmethod def get_authentication_settings(): @@ -38,6 +70,94 @@ def get_authentication_settings(): except Exception as e: return response_api_error("Data error", data=str(e)) + # ----- Server settings ---------------------------------------- + + def read_settings(self) -> Response: + """Return the full settings row + a small `versions` block. + + The version block isn't on `ServerSettings`; it's read straight + from `app.config` + the alembic head. Bundling the two lets the + page render its read-only top-of-page strip without a second + round-trip. + """ + settings = get_srv_settings() + from app.datamgmt.manage.manage_srv_settings_db import get_alembic_revision + + return response_api_success({ + 'settings': self._schema.dump(settings), + 'versions': { + 'iris_version': app.config.get('IRIS_VERSION'), + 'api_min': app.config.get('API_MIN_VERSION'), + 'api_max': app.config.get('API_MAX_VERSION'), + 'module_interface_min': app.config.get('MODULES_INTERFACE_MIN_VERSION'), + 'module_interface_max': app.config.get('MODULES_INTERFACE_MAX_VERSION'), + 'db_revision': get_alembic_revision(), + }, + }) + + def update_settings(self) -> Response: + """Partial-update the singleton settings row. + + The schema is loaded with `partial=True` so the admin can send + only the fields they changed. We compute a diff against the + pre-update dump so the activity-log entry mentions exactly + which keys moved — useful when chasing "who turned off MFA?". + Mirrors the legacy `/manage/settings/update` behaviour 1:1 + (including the periodic-update-check side effect when the + `enable_updates_check` flag flips). + """ + if not request.is_json: + return response_api_error('Invalid request') + + body = request.get_json() or {} + settings = get_srv_settings() + original_update_check = settings.enable_updates_check + + try: + original_dump = self._schema.dump(settings) + differences = list(diff(original_dump, body)) + changes = [ + {d[1]: d[2]} for d in differences if d[0] == 'change' + ] + updated = self._schema.load(body, instance=settings, partial=True) + db.session.commit() + + # Periodic update-check Celery task is added/removed only + # when the toggle actually flips. Reading the cached value + # *before* the load+commit is critical — `updated` is the + # same instance as `settings`, so its attribute is already + # the new value once load() returns. + if original_update_check != updated.enable_updates_check: + if updated.enable_updates_check: + setup_periodic_update_checks(celery) + else: + remove_periodic_update_checks() + + track_activity(f'Server settings updated: {changes}', ctx_less=True) + # Re-cache the dump on app.config so other code paths that + # read `app.config['SERVER_SETTINGS']` see the new values + # without an extra DB hit. The legacy route did the same. + app.config['SERVER_SETTINGS'] = self._schema.dump(updated) + return response_api_success(app.config['SERVER_SETTINGS']) + + except marshmallow.exceptions.ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + + # ----- Database backup ----------------------------------------- + + @staticmethod + def make_db_backup() -> Response: + """Trigger a synchronous Postgres dump. + + Returns the log lines from the dump runner on success so the + admin can see what got backed up where. On failure the same + log lines come back as `data` on the error envelope. + """ + has_error, logs = backup_iris_db() + if has_error: + return response_api_error('Backup failed', data=logs) + return response_api_success({'logs': logs}) + server_blueprint = Blueprint("server_rest_v2", __name__, url_prefix="/server") @@ -47,3 +167,27 @@ def get_authentication_settings(): @server_blueprint.get("/authentication-settings") def server_get_authsettings() -> Response: return server_operations.get_authentication_settings() + + +@server_blueprint.get('/settings') +@ac_api_requires(Permissions.server_administrator) +def server_get_settings() -> Response: + return server_operations.read_settings() + + +@server_blueprint.put('/settings') +@ac_api_requires(Permissions.server_administrator) +def server_put_settings() -> Response: + return server_operations.update_settings() + + +@server_blueprint.post('/backups/db') +@ac_api_requires(Permissions.server_administrator) +def server_make_db_backup() -> Response: + return server_operations.make_db_backup() + + +# Silence the unused-import linter — `get_server_settings_as_dict` is +# re-exported here so any future v2 route that needs the cached dict +# (rather than the ORM row) finds it under the conventional name. +_ = get_server_settings_as_dict diff --git a/source/app/schema/marshables.py b/source/app/schema/marshables.py index 9358f438b..b38d65407 100644 --- a/source/app/schema/marshables.py +++ b/source/app/schema/marshables.py @@ -1464,19 +1464,39 @@ def ds_store_file(self, file_storage: FileStorage, location: Path, is_ioc: bool, class ServerSettingsSchema(ma.SQLAlchemyAutoSchema): - """Schema for serializing and deserializing ServerSettings objects. + """Schema for the singleton `ServerSettings` row. - This schema defines the fields to include when serializing and deserializing ServerSettings objects. - It includes fields for the HTTP proxy, HTTPS proxy, and whether to prevent post-modification repush. + Every editable column on the model is declared explicitly so the + SvelteKit `/settings/server` page can introspect the field list + via `Meta.fields` if it ever needs to. Two columns are intentionally + excluded from load and load-only respectively: + * `id` — the row is a singleton anchored at id=1; clients never + get to touch it. + * `has_updates_available` — written by the periodic update + checker, surfaced read-only on the page. """ - http_proxy: Optional[str] = fields.String(required=False, allow_none=False) - https_proxy: Optional[str] = fields.String(required=False, allow_none=False) + http_proxy: Optional[str] = fields.String(required=False, allow_none=True) + https_proxy: Optional[str] = fields.String(required=False, allow_none=True) prevent_post_mod_repush: Optional[bool] = fields.Boolean(required=False) + prevent_post_objects_repush: Optional[bool] = fields.Boolean(required=False) + has_updates_available: Optional[bool] = fields.Boolean(dump_only=True) + enable_updates_check: Optional[bool] = fields.Boolean(required=False) + password_policy_min_length: Optional[int] = fields.Integer(required=False) + password_policy_upper_case: Optional[bool] = fields.Boolean(required=False) + password_policy_lower_case: Optional[bool] = fields.Boolean(required=False) + password_policy_digit: Optional[bool] = fields.Boolean(required=False) + password_policy_special_chars: Optional[str] = fields.String(required=False, allow_none=True) + enforce_mfa: Optional[bool] = fields.Boolean(required=False) + force_confirmation_before_delete: Optional[bool] = fields.Boolean(required=False) class Meta: model = ServerSettings load_instance = True + # `id` is excluded because the row is a singleton — there's + # only ever id=1 and the API shouldn't expose a way to change + # it. + exclude = ('id',) unknown = EXCLUDE From 4f8b62631c9dee96c622b2b225dc322425e44026 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Thu, 25 Jun 2026 08:38:02 +0200 Subject: [PATCH 024/121] [UPD] Updated dash and activities --- source/app/blueprints/rest/v2/dashboard.py | 31 +++++++++ .../app/datamgmt/activities/activities_db.py | 64 +++++++++++++++++++ tests/tests_rest_dashboard_activities.py | 27 ++++++++ 3 files changed, 122 insertions(+) diff --git a/source/app/blueprints/rest/v2/dashboard.py b/source/app/blueprints/rest/v2/dashboard.py index 8df173451..00ae13748 100644 --- a/source/app/blueprints/rest/v2/dashboard.py +++ b/source/app/blueprints/rest/v2/dashboard.py @@ -26,6 +26,7 @@ from app.business.cases import cases_filter_by_reviewer from app.business.tasks import tasks_filter_by_user from app.datamgmt.activities.activities_db import get_recent_activities_for_user +from app.datamgmt.activities.activities_db import get_recent_major_case_activities_for_user from app.schema.marshables import CaseDetailsSchema from app.schema.marshables import CaseSchema @@ -99,6 +100,36 @@ def list_recent_activities(): return response_api_success(data=data) +@dashboard_blueprint.get('/activities/cases/major') +@ac_api_requires() +def list_recent_major_case_activities(): + """Recent major case activities (created / closed) for dashboard use. + + Query params: + - limit: int (default 20, max 100) — page size + - offset: int (default 0) — pagination cursor + """ + limit = request.args.get('limit', default=20, type=int) + if limit < 1: + limit = 20 + if limit > 100: + limit = 100 + + offset = request.args.get('offset', default=0, type=int) + if offset < 0: + offset = 0 + + rows = get_recent_major_case_activities_for_user(iris_current_user.id, limit=limit, offset=offset) + data = [] + for row in rows: + d = dict(row) + if d.get('activity_date') is not None: + d['activity_date'] = d['activity_date'].isoformat() + data.append(d) + + return response_api_success(data=data) + + # TODO this endpoint does not adhere to the conventions (verb in URL). # We should rather have /api/v2/reviews? @dashboard_blueprint.get('/reviews/list') diff --git a/source/app/datamgmt/activities/activities_db.py b/source/app/datamgmt/activities/activities_db.py index 4cb870899..0d1f84f65 100644 --- a/source/app/datamgmt/activities/activities_db.py +++ b/source/app/datamgmt/activities/activities_db.py @@ -19,9 +19,12 @@ from sqlalchemy import and_ from sqlalchemy import desc from sqlalchemy import func +from sqlalchemy import or_ +from sqlalchemy.orm import aliased from app.models.cases import Cases from app.models.authorization import User +from app.models.customers import Client from app.models.models import UserActivity @@ -189,6 +192,67 @@ def get_recent_activities_for_user(user_id, limit=20, offset=0, accessible_case_ return [row._asdict() for row in rows] +def get_recent_major_case_activities_for_user( + user_id, + limit=20, + offset=0, + accessible_case_ids=None, +): + """Recent high-signal case lifecycle activities (created/closed). + + Returns case-linked activity rows enriched with case owner, case opener, + and customer names so dashboard widgets can render a compact "who/what" + summary without N follow-up requests. + """ + if accessible_case_ids is None: + from app.datamgmt.manage.manage_cases_db import user_list_cases_view + accessible_case_ids = user_list_cases_view(user_id) + + case_filter = UserActivity.case_id.in_(accessible_case_ids) if accessible_case_ids else None + + owner_alias = aliased(User) + opener_alias = aliased(User) + + activity_desc_lower = func.lower(UserActivity.activity_desc) + is_case_created = activity_desc_lower.like('new case%created%') + is_case_closed = or_( + activity_desc_lower == 'case closed', + activity_desc_lower.like('closed case id %') + ) + + base = UserActivity.query.with_entities( + UserActivity.id, + UserActivity.case_id, + UserActivity.activity_date, + UserActivity.activity_desc, + Cases.name.label('case_name'), + owner_alias.name.label('owner_name'), + opener_alias.name.label('opened_by_name'), + Client.name.label('customer_name') + ).join( + Cases, UserActivity.case_id == Cases.case_id + ).outerjoin( + owner_alias, Cases.owner_id == owner_alias.id + ).outerjoin( + opener_alias, Cases.user_id == opener_alias.id + ).outerjoin( + Client, Cases.client_id == Client.client_id + ).filter( + UserActivity.display_in_ui == True, + or_(is_case_created, is_case_closed) + ) + + if case_filter is not None: + base = base.filter( + (case_filter) | (UserActivity.user_id == user_id) + ) + else: + base = base.filter(UserActivity.user_id == user_id) + + rows = base.order_by(desc(UserActivity.activity_date)).offset(offset).limit(limit).all() + return [row._asdict() for row in rows] + + def list_activities_paginated( page=1, per_page=25, diff --git a/tests/tests_rest_dashboard_activities.py b/tests/tests_rest_dashboard_activities.py index 4ada5986a..c8d35900c 100644 --- a/tests/tests_rest_dashboard_activities.py +++ b/tests/tests_rest_dashboard_activities.py @@ -96,3 +96,30 @@ def test_list_recent_activities_should_scope_to_user_accessible_cases(self): # The other user has no access to the case we created — none of the # returned rows should reference it. self.assertTrue(all(row.get('case_id') != case_identifier for row in response)) + + def test_list_recent_major_case_activities_should_return_200(self): + response = self._subject.get('/api/v2/dashboard/activities/cases/major') + self.assertEqual(200, response.status_code) + + def test_list_recent_major_case_activities_should_include_required_fields(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/cases/major').json() + + self.assertGreaterEqual(len(response), 1) + self.assertIn('case_id', response[0]) + self.assertIn('case_name', response[0]) + self.assertIn('owner_name', response[0]) + self.assertIn('opened_by_name', response[0]) + self.assertIn('customer_name', response[0]) + + def test_list_recent_major_case_activities_should_honour_limit_offset(self): + for _ in range(3): + self._subject.create_dummy_case() + + first_page = self._subject.get('/api/v2/dashboard/activities/cases/major?limit=1&offset=0').json() + second_page = self._subject.get('/api/v2/dashboard/activities/cases/major?limit=1&offset=1').json() + + self.assertEqual(1, len(first_page)) + self.assertEqual(1, len(second_page)) + if first_page and second_page: + self.assertNotEqual(first_page[0].get('id'), second_page[0].get('id')) From 6bcff7b343a9aa476507facb9b5cae347b8a0f81 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Thu, 25 Jun 2026 08:51:00 +0200 Subject: [PATCH 025/121] [FIX] Fixed GHSA-g588-5gmf-p5cx by excluding MFA secrets and webauthn credentials from user schemas and marking sensitive fields load-only Backport of f1d41f4e from v2.4.29. UserSchema, UserFullSchema, and BasicUserSchema were leaking mfa_secrets and webauthn_credentials in serialized responses, letting an attacker bypass MFA or impersonate the user (CWE-201, SBA-ADV-20260126-04). user_password is now load-only on UserSchema, and DSFileSchema.file_local_name (server-side path) is declared load-only at the schema layer instead of being del'd in the route handler. The matching v2 schema (UserSchemaForAPIV2) already had these guards; this commit aligns the legacy schemas still in use. --- source/app/blueprints/rest/datastore_routes.py | 1 - source/app/schema/marshables.py | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/source/app/blueprints/rest/datastore_routes.py b/source/app/blueprints/rest/datastore_routes.py index ff7e8a92d..467d629bf 100644 --- a/source/app/blueprints/rest/datastore_routes.py +++ b/source/app/blueprints/rest/datastore_routes.py @@ -98,7 +98,6 @@ def datastore_info_file(cur_id: int, caseid: int): file_schema = DSFileSchema() file = file_schema.dump(file) - del file['file_local_name'] return response_success("", data=file) diff --git a/source/app/schema/marshables.py b/source/app/schema/marshables.py index b38d65407..cdee07853 100644 --- a/source/app/schema/marshables.py +++ b/source/app/schema/marshables.py @@ -255,7 +255,7 @@ class UserSchema(ma.SQLAlchemyAutoSchema): user_name: str = auto_field('name', required=True, validate=Length(min=2)) user_login: str = auto_field('user', required=True, validate=Length(min=2)) user_email: str = auto_field('email', required=True, validate=Length(min=2)) - user_password: Optional[str] = auto_field('password', required=False) + user_password: Optional[str] = auto_field('password', required=False, load_only=True) user_isadmin: bool = fields.Boolean(required=True) user_id: Optional[int] = fields.Integer(required=False) user_primary_organisation_id: Optional[int] = fields.Integer(required=False) @@ -265,7 +265,8 @@ class Meta: model = User load_instance = True include_fk = True - exclude = ['api_key', 'password', 'ctx_case', 'ctx_human_case', 'user', 'name', 'email', 'is_service_account'] + exclude = ['api_key', 'password', 'ctx_case', 'ctx_human_case', 'user', 'name', 'email', + 'is_service_account', 'mfa_secrets', 'webauthn_credentials'] unknown = EXCLUDE @pre_load() @@ -1130,7 +1131,7 @@ class Meta: model = User load_instance = True include_fk = True - exclude = ['password', 'ctx_case', 'ctx_human_case'] + exclude = ['password', 'ctx_case', 'ctx_human_case', 'mfa_secrets', 'webauthn_credentials'] unknown = EXCLUDE @@ -1319,6 +1320,7 @@ class DSFileSchema(ma.SQLAlchemyAutoSchema): file_original_name: str = auto_field('file_original_name', required=True, validate=Length(min=1), allow_none=False) file_description: str = auto_field('file_description', allow_none=False) file_content: Optional[bytes] = fields.Raw(required=False) + file_local_name: Optional[str] = auto_field('file_local_name', required=False, load_only=True) class Meta: model = DataStoreFile @@ -2225,7 +2227,7 @@ class Meta: model = User load_instance = True exclude = ['password', 'api_key', 'ctx_case', 'ctx_human_case', 'active', 'external_id', 'in_dark_mode', - 'id', 'name', 'email', 'user', 'uuid'] + 'id', 'name', 'email', 'user', 'uuid', 'mfa_secrets', 'webauthn_credentials'] unknown = EXCLUDE From a749b4069bfe6b075cbfc7c7931c9b8996aa8821 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Thu, 25 Jun 2026 08:59:15 +0200 Subject: [PATCH 026/121] [FIX] Fixed GHSA-w78h-mx7h-qm3h by allowlisting writable fields on user, profile and taxonomy admin endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of f84b7b98 from v2.4.29, extended to cover the v2 surface that didn't exist when the original fix shipped. The legacy /manage/users, /manage/asset-type, /manage/ioc-types, /profile/update endpoints accepted the raw request body and handed it straight to the marshmallow schema. The schemas' `unknown = EXCLUDE` drops unknown keys but every declared field on the schema is still accepted — so a caller could overwrite the primary key (mass-assignment of asset_id / type_id), self-promote to admin via user_isadmin on the profile endpoint, or set arbitrary attributes the GUI never exposes (SBA-ADV-20260128-01 / CWE-915). Each writable endpoint now goes through an explicit per-resource allowlist before the schema is loaded. The taxonomy CRUD on rest/v2/manage_routes/case_objects.py declares the allowlist on the shared TaxonomyConfig so the asset-type, ioc-type, classification, state and evidence-type sub-blueprints all inherit the protection without duplication. GHSA-g588 already made user_password load_only on UserSchema, so the legacy /manage/users/add no longer needs the `del udata['user_password']` guard either; remove it. --- .../rest/manage/manage_assets_type_routes.py | 21 +++++++++++-- .../rest/manage/manage_ioc_types_routes.py | 23 ++++++++++++-- .../blueprints/rest/manage/manage_users.py | 31 +++++++++++++++++-- .../rest/v2/manage_routes/case_objects.py | 31 +++++++++++++++++-- .../blueprints/rest/v2/manage_routes/users.py | 30 ++++++++++++++++-- source/app/blueprints/rest/v2/profile.py | 29 +++++++++++------ 6 files changed, 144 insertions(+), 21 deletions(-) diff --git a/source/app/blueprints/rest/manage/manage_assets_type_routes.py b/source/app/blueprints/rest/manage/manage_assets_type_routes.py index 7c9f445e6..3451c7f71 100644 --- a/source/app/blueprints/rest/manage/manage_assets_type_routes.py +++ b/source/app/blueprints/rest/manage/manage_assets_type_routes.py @@ -37,6 +37,23 @@ manage_assets_type_rest_blueprint = Blueprint('manage_assets_type_rest', __name__) +# Allowlist of form fields an administrator may write via the asset-type +# add/update endpoints. Anything else (notably asset_id, the primary key) is +# silently dropped before the schema is loaded, closing the mass-assignment +# vector reported as GHSA-w78h-mx7h-qm3h / SBA-ADV-20260128-01 / CWE-915. +_ASSET_TYPE_WRITABLE_FIELDS = { + 'asset_name', + 'asset_description', + 'asset_icon_compromised', + 'asset_icon_not_compromised', + 'csrf_token', +} + + +def _filter_asset_type_form(form): + return {k: v for k, v in form.items() if k in _ASSET_TYPE_WRITABLE_FIELDS} + + @manage_assets_type_rest_blueprint.route('/manage/asset-type/list') @ac_api_requires() def list_assets(): @@ -89,7 +106,7 @@ def view_assets(cur_id): asset_schema = AssetTypeSchema() try: - asset_sc = asset_schema.load(request.form, instance=asset_type) + asset_sc = asset_schema.load(_filter_asset_type_form(request.form), instance=asset_type) fpath_nc = asset_schema.load_store_icon(request.files.get('asset_icon_not_compromised'), 'asset_icon_not_compromised') @@ -116,7 +133,7 @@ def add_assets(): asset_schema = AssetTypeSchema() try: - asset_sc = asset_schema.load(request.form) + asset_sc = asset_schema.load(_filter_asset_type_form(request.form)) fpath_nc = asset_schema.load_store_icon(request.files.get('asset_icon_not_compromised'), 'asset_icon_not_compromised') diff --git a/source/app/blueprints/rest/manage/manage_ioc_types_routes.py b/source/app/blueprints/rest/manage/manage_ioc_types_routes.py index 3f275fd89..203330a4d 100644 --- a/source/app/blueprints/rest/manage/manage_ioc_types_routes.py +++ b/source/app/blueprints/rest/manage/manage_ioc_types_routes.py @@ -35,6 +35,25 @@ manage_ioc_type_rest_blueprint = Blueprint('manage_ioc_types_rest', __name__) +# Allowlist of fields administrators may write when adding or updating an +# IOC type. Blocks mass-assignment of type_id (GHSA-w78h-mx7h-qm3h / +# CWE-915, SBA-ADV-20260128-01). +_IOC_TYPE_WRITABLE_FIELDS = { + 'csrf_token', + 'type_name', + 'type_description', + 'type_taxonomy', + 'type_validation_regex', + 'type_validation_expect', +} + + +def _filter_ioc_type_payload(jsdata): + if not isinstance(jsdata, dict): + return {} + return {k: v for k, v in jsdata.items() if k in _IOC_TYPE_WRITABLE_FIELDS} + + @manage_ioc_type_rest_blueprint.route('/manage/ioc-types/list', methods=['GET']) @ac_api_requires() def list_ioc_types(): @@ -64,7 +83,7 @@ def add_ioc_type_api(): try: - ioct_sc = ioct_schema.load(request.get_json()) + ioct_sc = ioct_schema.load(_filter_ioc_type_payload(request.get_json())) db.session.add(ioct_sc) db.session.commit() @@ -111,7 +130,7 @@ def update_ioc(cur_id): try: - ioct_sc = ioct_schema.load(request.get_json(), instance=ioc_type) + ioct_sc = ioct_schema.load(_filter_ioc_type_payload(request.get_json()), instance=ioc_type) if ioct_sc: track_activity(f"updated ioc type type {ioct_sc.type_name}") diff --git a/source/app/blueprints/rest/manage/manage_users.py b/source/app/blueprints/rest/manage/manage_users.py index 75d2431f0..a387c9f88 100644 --- a/source/app/blueprints/rest/manage/manage_users.py +++ b/source/app/blueprints/rest/manage/manage_users.py @@ -58,6 +58,32 @@ log = app.logger +# Allowlist of fields an administrator may write when creating or updating a +# user. Anything else the caller tries to sneak in (id, uuid, mfa_secrets, +# webauthn_credentials, mfa_setup_complete, api_key, external_id, ...) is +# silently dropped before the schema is loaded. Closes the mass-assignment +# vector reported as GHSA-w78h-mx7h-qm3h / SBA-ADV-20260128-01 / CWE-915. +_ADMIN_USER_WRITABLE_FIELDS = { + 'csrf_token', + 'user_id', + 'user_name', + 'user_login', + 'user_email', + 'user_password', + 'user_isadmin', + 'user_is_service_account', + 'user_primary_organisation_id', + 'user_roles_str', + 'active', +} + + +def _filter_admin_user_payload(jsdata): + if not isinstance(jsdata, dict): + return {} + return {k: v for k, v in jsdata.items() if k in _ADMIN_USER_WRITABLE_FIELDS} + + @manage_users_rest_blueprint.route('/manage/users/list', methods=['GET']) @ac_api_requires(Permissions.server_administrator) def manage_users_list(): @@ -116,7 +142,7 @@ def add_user(): # validate before saving user_schema = UserSchema() - jsdata = request.get_json() + jsdata = _filter_admin_user_payload(request.get_json()) jsdata['user_id'] = 0 jsdata['active'] = jsdata.get('active', True) cuser = user_schema.load(jsdata, partial=True) @@ -129,7 +155,6 @@ def add_user(): udata = user_schema.dump(user) udata['user_api_key'] = user.api_key - del udata['user_password'] if cuser: track_activity(f"created user {user.user}", ctx_less=True) @@ -312,7 +337,7 @@ def update_user_api(cur_id): # validate before saving user_schema = UserSchema() - jsdata = request.get_json() + jsdata = _filter_admin_user_payload(request.get_json()) jsdata['user_id'] = cur_id cuser = user_schema.load(jsdata, instance=user, partial=True) update_user(user, password=jsdata.get('user_password')) diff --git a/source/app/blueprints/rest/v2/manage_routes/case_objects.py b/source/app/blueprints/rest/v2/manage_routes/case_objects.py index 3e22976aa..ee3531cfb 100644 --- a/source/app/blueprints/rest/v2/manage_routes/case_objects.py +++ b/source/app/blueprints/rest/v2/manage_routes/case_objects.py @@ -85,6 +85,13 @@ class TaxonomyConfig: order. `protected_check` is an optional callable that lets a taxonomy refuse a delete for a record the engine considers structural (e.g. the seeded "Open" / "Closed" case states). + `writable_fields` is the allowlist of input fields the create / + update endpoints will forward to the schema; anything else + (notably the primary key) is dropped before load to close the + mass-assignment vector reported as GHSA-w78h-mx7h-qm3h / + SBA-ADV-20260128-01 / CWE-915. The PK is excluded on purpose: + `update()` forces it from the URL parameter, `create()` lets the + DB assign it. """ url_prefix: str @@ -94,6 +101,7 @@ class TaxonomyConfig: pk_name: str search_columns: Iterable[str] activity_label: str + writable_fields: Iterable[str] protected_check: Optional[Callable[[Any], Optional[str]]] = None @@ -109,6 +117,18 @@ class TaxonomyOperations: def __init__(self, config: TaxonomyConfig): self._config = config + self._writable = frozenset(config.writable_fields) + + def _filter_payload(self, data): + """Drop everything not in the per-resource writable allowlist. + + Closes the mass-assignment vector reported as GHSA-w78h-mx7h-qm3h + / SBA-ADV-20260128-01 / CWE-915. The primary key is never in the + allowlist — `update()` forces it from the URL parameter. + """ + if not isinstance(data, dict): + return {} + return {k: v for k, v in data.items() if k in self._writable} def _get(self, identifier): row = self._config.model.query.filter( @@ -144,7 +164,7 @@ def read(self, identifier): def create(self): schema = self._config.schema_factory() try: - request_data = request.get_json() or {} + request_data = self._filter_payload(request.get_json()) row = schema.load(request_data) db_create(row) track_activity(f'Added {self._config.activity_label} {self._readable(row)}', ctx_less=True) @@ -156,7 +176,7 @@ def update(self, identifier): schema = self._config.schema_factory() try: row = self._get(identifier) - request_data = request.get_json() or {} + request_data = self._filter_payload(request.get_json()) # Schemas with `verify_unique` post-load look at the PK on # the loaded instance to skip the uniqueness check against # the row itself — force the value here so a partial update @@ -273,6 +293,8 @@ def _case_state_protected(row: CaseState) -> Optional[str]: pk_name='asset_id', search_columns=('asset_name', 'asset_description'), activity_label='asset type', + writable_fields=('asset_name', 'asset_description', + 'asset_icon_compromised', 'asset_icon_not_compromised'), ), TaxonomyConfig( url_prefix='ioc-types', @@ -282,6 +304,8 @@ def _case_state_protected(row: CaseState) -> Optional[str]: pk_name='type_id', search_columns=('type_name', 'type_description', 'type_taxonomy'), activity_label='IOC type', + writable_fields=('type_name', 'type_description', 'type_taxonomy', + 'type_validation_regex', 'type_validation_expect'), ), TaxonomyConfig( url_prefix='case-classifications', @@ -291,6 +315,7 @@ def _case_state_protected(row: CaseState) -> Optional[str]: pk_name='id', search_columns=('name', 'name_expanded', 'description'), activity_label='case classification', + writable_fields=('name', 'name_expanded', 'description'), ), TaxonomyConfig( url_prefix='case-states', @@ -301,6 +326,7 @@ def _case_state_protected(row: CaseState) -> Optional[str]: search_columns=('state_name', 'state_description'), activity_label='case state', protected_check=_case_state_protected, + writable_fields=('state_name', 'state_description'), ), TaxonomyConfig( url_prefix='evidence-types', @@ -310,6 +336,7 @@ def _case_state_protected(row: CaseState) -> Optional[str]: pk_name='id', search_columns=('name', 'description'), activity_label='evidence type', + writable_fields=('name', 'description'), ), ] diff --git a/source/app/blueprints/rest/v2/manage_routes/users.py b/source/app/blueprints/rest/v2/manage_routes/users.py index e1cd58edf..b8d54de66 100644 --- a/source/app/blueprints/rest/v2/manage_routes/users.py +++ b/source/app/blueprints/rest/v2/manage_routes/users.py @@ -78,6 +78,32 @@ from app.schema.marshables import UserSchemaForAPIV2 +# Allowlist of fields an administrator may write when creating or updating a +# user via the v2 admin endpoints. Anything else the caller tries to sneak in +# (uuid, mfa_secrets, webauthn_credentials, mfa_setup_complete, api_key, +# external_id, ...) is silently dropped before the schema is loaded. Closes +# the mass-assignment vector reported as GHSA-w78h-mx7h-qm3h / +# SBA-ADV-20260128-01 / CWE-915. +_ADMIN_USER_WRITABLE_FIELDS = { + 'user_id', + 'user_active', + 'user_name', + 'user_login', + 'user_email', + 'user_password', + 'user_isadmin', + 'user_is_service_account', + 'user_primary_organisation_id', + 'user_roles_str', +} + + +def _filter_admin_user_payload(data): + if not isinstance(data, dict): + return {} + return {k: v for k, v in data.items() if k in _ADMIN_USER_WRITABLE_FIELDS} + + class Users: def __init__(self): @@ -119,7 +145,7 @@ def search(self): def create(self): try: - request_data = request.get_json() + request_data = _filter_admin_user_payload(request.get_json()) request_data['user_id'] = 0 request_data['user_active'] = request_data.get('user_active', True) user = self._schema.load(request_data) @@ -141,7 +167,7 @@ def update(self, identifier): try: user = users_get(identifier) - request_data = request.get_json() + request_data = _filter_admin_user_payload(request.get_json()) request_data['user_id'] = identifier new_user = self._schema.load(request_data, instance=user, partial=True) user_updated = users_update(new_user, request_data.get('user_password')) diff --git a/source/app/blueprints/rest/v2/profile.py b/source/app/blueprints/rest/v2/profile.py index c2300d1cb..ace2cf20a 100644 --- a/source/app/blueprints/rest/v2/profile.py +++ b/source/app/blueprints/rest/v2/profile.py @@ -49,16 +49,20 @@ def get(self): def update(self): try: user = users_get(iris_current_user.id) - request_data = request.get_json() - request_data['user_id'] = iris_current_user.id - - # Password rotation requires proof that whoever is holding this - # session also knows the current password. Stops a hijacked - # session (or any stolen API key) from locking out the legit - # owner. Strip `user_current_password` out before marshmallow - # loads, since it isn't a model field. - new_password = request_data.get('user_password') - current_password = request_data.pop('user_current_password', None) + # Self-service profile updates expose only a password change in + # the GUI. Restricting the payload to that one field stops any + # client from sneaking attributes the schema would otherwise + # accept (user_login, user_email, user_isadmin, user_name, ...) + # and overwriting the user's own row — the mass-assignment + # vector reported as GHSA-w78h-mx7h-qm3h / SBA-ADV-20260128-01 / + # CWE-915. `user_current_password` is not a model field and is + # popped off before the schema sees the payload. + raw = request.get_json() + if not isinstance(raw, dict): + raw = {} + new_password = raw.get('user_password') + current_password = raw.get('user_current_password') + if new_password: if not current_password: return response_api_error( @@ -71,6 +75,11 @@ def update(self): data={'user_current_password': ['Incorrect password']} ) + request_data = { + 'user_password': new_password, + 'user_id': iris_current_user.id, + } + user = self._update_request_schema.load(request_data, instance=user, partial=True) user = users_update(user, new_password) result = self._schema.dump(user) From 9abd9333ca1e2c800d0c0ac6b7031cc01ae3cd9b Mon Sep 17 00:00:00 2001 From: whitekernel Date: Thu, 25 Jun 2026 09:05:46 +0200 Subject: [PATCH 027/121] [FIX] Fixed GHSA-vjc3-7jwv-j9qf by tightening the post-login next-url allowlist Backport of 371c640e from v2.4.29, adapted to develop's _is_safe_url in business/auth.py. The previous check only verified that urlparse(target).scheme and urlparse(target).netloc were empty. urlparse treats `attacker.com?cid=1` as a path with an empty netloc, so the guard accepted attacker-controlled hosts; browsers resolving the resulting `Location: attacker.com` header route the user to the attacker's origin (CWE-601 / SBA-ADV-20260126-02). _is_safe_url now requires the target to be a non-empty string starting with a single `/` (rejecting both protocol-relative `//evil.com` and backslash variants like `/\\evil.com`) and free of control characters, with the urlparse check kept as defense in depth. --- source/app/business/auth.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/source/app/business/auth.py b/source/app/business/auth.py index db75484a4..30a509d92 100644 --- a/source/app/business/auth.py +++ b/source/app/business/auth.py @@ -91,12 +91,28 @@ def validate_local_login(username: str, password: str): def _is_safe_url(target): + """Return True iff `target` is safe to use in a 302 Location header. + + A safe target is a *relative* path on this application. The previous + implementation only checked `parsed.scheme` and `parsed.netloc`, which is + bypassed by payloads like `attacker.com?cid=1` — urlparse treats that as a + path with an empty netloc, but browsers resolving a `Location: attacker.com` + header will route the user to the attacker's host. That's GHSA-vjc3-7jwv-j9qf + / SBA-ADV-20260126-02 / CWE-601. + + The strict rules: + - non-empty string + - no control characters (incl. tab/newline) or backslashes (some browsers + normalise `\\` -> `/`, turning `/\\evil.com` into `//evil.com`) + - starts with a single `/` (not `//`, which is protocol-relative) + - urlparse confirms no scheme and no netloc — defense in depth """ - Check whether the target URL is safe for redirection by ensuring that it is a relative URL - (i.e., does not specify a scheme or netloc). - """ - # Remove backslashes to mitigate obfuscation - target = target.replace('\\', '') + if not target or not isinstance(target, str): + return False + if any(ord(c) < 0x20 or c == '\\' for c in target): + return False + if not target.startswith('/') or target.startswith('//'): + return False parsed = urlparse(target) return not parsed.scheme and not parsed.netloc @@ -106,13 +122,9 @@ def _filter_next_url(next_url, context_case): Ensures that the URL to which the user is redirected is safe. If the provided URL is not safe or is missing, a default URL (typically the index page) is returned. """ - if not next_url: + if not _is_safe_url(next_url): return url_for('index.index', cid=context_case) - # Remove backslashes to mitigate obfuscation - next_url = next_url.replace('\\', '') - if _is_safe_url(next_url): - return next_url - return url_for('index.index', cid=context_case) + return next_url def wrap_login_user(user, is_oidc=False): From 30ee619acc859cc7aabe98e54059489aa183b7c8 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Thu, 25 Jun 2026 09:10:36 +0200 Subject: [PATCH 028/121] [FIX] Fixed GHSA-qhqj-8qw6-wp8v by confining datastore file ops to DATASTORE_PATH and allowlisting writable upload fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of 57c1b804 from v2.4.29, extended to the v2 datastore route that didn't exist when the original fix shipped. datastore_delete_file and datastore_get_local_file_path now resolve the recorded file_local_name and refuse to operate on a path outside the configured DATASTORE_PATH. Even though the dsf row is legitimately fetched through (file_id, case_id), a stored path pointing elsewhere (stale row from a different mount, attacker-seeded value) would let an unlink or send_file escape the datastore — CWE-22. The legacy /datastore/file/add + /datastore/file/update routes and the v2 add_file + update_file methods now project the multipart form down to an allowlist before the schema is loaded, blocking mass-assignment of file_id, file_local_name, file_case_id, file_sha256, file_size, added_by_user_id, file_date_added and friends. --- .../app/blueprints/rest/datastore_routes.py | 24 ++++++++++++-- .../rest/v2/case_routes/datastore.py | 23 +++++++++++-- source/app/datamgmt/datastore/datastore_db.py | 32 ++++++++++++++++++- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/source/app/blueprints/rest/datastore_routes.py b/source/app/blueprints/rest/datastore_routes.py index 467d629bf..abcc73fc9 100644 --- a/source/app/blueprints/rest/datastore_routes.py +++ b/source/app/blueprints/rest/datastore_routes.py @@ -55,6 +55,26 @@ datastore_rest_blueprint = Blueprint('datastore_rest', __name__) +# Allowlist of multipart form fields a caller may write through the file +# add/update endpoints. Anything else (notably file_id, file_local_name, +# file_case_id, file_sha256, file_size, added_by_user_id, file_date_added, +# ...) is dropped before the schema is loaded. Closes the mass-assignment +# vector reported as GHSA-qhqj-8qw6-wp8v / CWE-915. +_DS_FILE_WRITABLE_FIELDS = ( + 'file_original_name', + 'file_description', + 'file_password', + 'file_is_ioc', + 'file_is_evidence', + 'file_parent_id', +) + + +def _filter_ds_file_form(form): + """Project a flask.request.form dict down to the writable allowlist.""" + return {field: form.get(field) for field in _DS_FILE_WRITABLE_FIELDS if field in form} + + @datastore_rest_blueprint.route('/datastore/list/tree', methods=['GET']) @ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) @ac_api_requires() @@ -113,7 +133,7 @@ def datastore_update_file(cur_id: int, caseid: int): dsf_schema = DSFileSchema() try: - dsf_sc = dsf_schema.load(request.form, instance=dsf, partial=True) + dsf_sc = dsf_schema.load(_filter_ds_file_form(request.form), instance=dsf, partial=True) add_obj_history_entry(dsf_sc, 'updated') dsf.file_is_ioc = request.form.get('file_is_ioc') is not None or request.form.get('file_is_ioc') is True @@ -235,7 +255,7 @@ def datastore_add_file(cur_id: int, caseid: int): dsf_schema = DSFileSchema() try: - dsf_sc = dsf_schema.load(request.form, partial=True) + dsf_sc = dsf_schema.load(_filter_ds_file_form(request.form), partial=True) dsf_sc.file_parent_id = dsp.path_id dsf_sc.added_by_user_id = iris_current_user.id diff --git a/source/app/blueprints/rest/v2/case_routes/datastore.py b/source/app/blueprints/rest/v2/case_routes/datastore.py index ad881eb25..1b4970bbe 100644 --- a/source/app/blueprints/rest/v2/case_routes/datastore.py +++ b/source/app/blueprints/rest/v2/case_routes/datastore.py @@ -76,6 +76,25 @@ def _truthy(value): return str(value).strip().lower() in ('1', 'true', 'yes', 'on') +# Allowlist of multipart form fields a caller may write through the file +# add/update endpoints. Anything else (notably file_id, file_local_name, +# file_case_id, file_sha256, file_size, added_by_user_id, file_date_added, +# ...) is dropped before the schema is loaded. Closes the mass-assignment +# vector reported as GHSA-qhqj-8qw6-wp8v / CWE-915. +_DS_FILE_WRITABLE_FIELDS = ( + 'file_original_name', + 'file_description', + 'file_password', + 'file_is_ioc', + 'file_is_evidence', + 'file_parent_id', +) + + +def _filter_ds_file_form(form): + return {field: form.get(field) for field in _DS_FILE_WRITABLE_FIELDS if field in form} + + class DatastoreOperations: """Case-scoped datastore operations exposed via the v2 API. @@ -192,7 +211,7 @@ def add_file(self, case_identifier, folder_identifier): return response_api_not_found() try: - dsf_sc = self._file_schema.load(request.form, partial=True) + dsf_sc = self._file_schema.load(_filter_ds_file_form(request.form), partial=True) dsf_sc.file_parent_id = dsp.path_id dsf_sc.added_by_user_id = iris_current_user.id @@ -248,7 +267,7 @@ def update_file(self, case_identifier, identifier): return response_api_not_found() try: - dsf_sc = self._file_schema.load(request.form, instance=dsf, partial=True) + dsf_sc = self._file_schema.load(_filter_ds_file_form(request.form), instance=dsf, partial=True) add_obj_history_entry(dsf_sc, 'updated') if 'file_is_ioc' in request.form: diff --git a/source/app/datamgmt/datastore/datastore_db.py b/source/app/datamgmt/datastore/datastore_db.py index 9f25cbf28..aa4007508 100644 --- a/source/app/datamgmt/datastore/datastore_db.py +++ b/source/app/datamgmt/datastore/datastore_db.py @@ -19,12 +19,15 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import datetime +import logging from pathlib import Path from sqlalchemy import and_ from sqlalchemy import func from app import app + +log = logging.getLogger(__name__) from app.datamgmt.db_operations import db_create from app.datamgmt.db_operations import db_delete from app.db import db @@ -334,7 +337,21 @@ def datastore_delete_file(cur_id, cid): fln = Path(dsf.file_local_name) if fln.is_file(): - fln.unlink(missing_ok=True) + # The on-disk path can be anything the row's file_local_name column + # points to. Before unlinking, confirm it actually lives under + # DATASTORE_PATH — refuse otherwise, even though the dsf row was + # legitimately resolved through (file_id, case_id). Guards against + # an attacker who managed to seed a file_local_name pointing + # outside the datastore (GHSA-qhqj-8qw6-wp8v / CWE-22). + datastore_path = Path(app.config['DATASTORE_PATH']).resolve() + file_path = fln.resolve() + if datastore_path in file_path.parents or datastore_path == file_path: + fln.unlink(missing_ok=True) + else: + log.warning( + f'File {file_path} physically not deleted — ' + f'attempted deletion outside datastore directory.' + ) db_delete(dsf) @@ -395,6 +412,19 @@ def datastore_get_local_file_path(file_id, caseid): if dsf is None: return True, 'Invalid DS file ID for this case' + # Defense in depth: confirm the recorded on-disk path is inside the + # datastore root before handing it back to send_file. Guards against an + # attacker who managed to seed file_local_name pointing outside the + # datastore (GHSA-qhqj-8qw6-wp8v / CWE-22). + datastore_path = Path(app.config['DATASTORE_PATH']).resolve() + file_path = Path(dsf.file_local_name).resolve() + if datastore_path not in file_path.parents and datastore_path != file_path: + log.warning( + f'File {file_path} not found in datastore — ' + f'attempted access outside datastore directory.' + ) + return True, '' + return False, dsf From 7b8cd9fdde2797b51a6551ba84afff492f418554 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Thu, 25 Jun 2026 09:14:41 +0200 Subject: [PATCH 029/121] [FIX] Fixed GHSA-8hwq-v6vm-9grr by blocking alert re-attribution and requiring POST on logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of b202f540 from v2.4.29, extended to cover the v2 alerts update endpoint that didn't exist when the original fix shipped. Alert update (legacy single + batch, and v2 PUT /alerts/) now strips alert_id, alert_customer_id and alert_creation_time from the payload before the schema is loaded. Allowing those via the API let a user with write access to one customer re-attribute an alert to a customer they cannot see — silently hiding it from the rightful owner and planting it under another tenant's view (CWE-863 / SBA-ADV-20260128-05). The logout endpoint moves from GET to POST. A plain on a third-party page would otherwise log the user out without consent (RFC 7231 §4.2.1: GET must be safe; CWE-650 / SBA-ADV-20260128-03). The sidenav link wraps a hidden form so existing accessibility / styling keeps working. --- source/app/blueprints/rest/alerts_routes.py | 38 +++++++++++++++++-- .../app/blueprints/rest/dashboard_routes.py | 7 +++- source/app/blueprints/rest/v2/alerts.py | 25 +++++++++++- source/app/templates/includes/sidenav.html | 4 +- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/source/app/blueprints/rest/alerts_routes.py b/source/app/blueprints/rest/alerts_routes.py index 7fb4699ea..46ad34ad0 100644 --- a/source/app/blueprints/rest/alerts_routes.py +++ b/source/app/blueprints/rest/alerts_routes.py @@ -67,6 +67,30 @@ alerts_rest_blueprint = Blueprint('alerts_rest', __name__) +# Fields that must be immutable on alert update. The web UI never changes +# these, and allowing them via the API lets a user with write access to one +# customer re-attribute an alert to a customer they cannot see — either to +# plant fake alerts under another customer's name, or (combined with an XSS +# vector) to make another user move an alert into an attacker-controlled +# customer. See GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863. +# +# alert_id: primary key, must not be rewritten +# alert_customer_id: ownership, re-attribution bypasses customer-scoped ACL +# alert_creation_time: audit integrity; set once at creation +_ALERT_UPDATE_READONLY_FIELDS = frozenset({ + 'alert_id', + 'alert_customer_id', + 'alert_creation_time', +}) + + +def _strip_readonly_update_fields(payload): + """Remove fields that must never be mutated via the alert-update API.""" + if not isinstance(payload, dict): + return payload + return {k: v for k, v in payload.items() if k not in _ALERT_UPDATE_READONLY_FIELDS} + + def _load(request_data, **kwargs): alert_schema = AlertSchema() return alert_schema.load(request_data, **kwargs) @@ -332,8 +356,11 @@ def alerts_update_route(alert_id) -> Response: do_status_hook = False try: - # Load the JSON data from the request - data = request.get_json() + # Drop fields the caller must not be allowed to change on update + # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863). Done before + # any other processing so these values never reach the activity log + # or the ORM. + data = _strip_readonly_update_fields(request.get_json()) activity_data = [] for key, value in data.items(): @@ -411,9 +438,12 @@ def alerts_batch_update_route() -> Response: # Load the JSON data from the request data = request.get_json() - # Get the list of alert IDs and updates from the request data + # Get the list of alert IDs and updates from the request data. Strip + # immutable fields from the batch payload so one API call can't silently + # re-attribute every selected alert to a different customer + # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863). alert_ids: List[int] = data.get('alert_ids', []) - updates = data.get('updates', {}) + updates = _strip_readonly_update_fields(data.get('updates', {})) if not updates.get('alert_tags'): updates.pop('alert_tags', None) diff --git a/source/app/blueprints/rest/dashboard_routes.py b/source/app/blueprints/rest/dashboard_routes.py index 1eafa68ad..9b7e15f5b 100644 --- a/source/app/blueprints/rest/dashboard_routes.py +++ b/source/app/blueprints/rest/dashboard_routes.py @@ -68,8 +68,11 @@ ) -# Logout user -@dashboard_rest_blueprint.route('/logout') +# Logout user — POST-only to prevent CSRF. A plain on a +# third-party page would otherwise log the user out of IRIS without consent +# (RFC 7231 §4.2.1: GET must be safe; CWE-650; GHSA-8hwq-v6vm-9grr; +# SBA-ADV-20260128-03). +@dashboard_rest_blueprint.route('/logout', methods=['POST']) def logout(): """ Logout function. Erase its session and redirect to index i.e login diff --git a/source/app/blueprints/rest/v2/alerts.py b/source/app/blueprints/rest/v2/alerts.py index b3a4f3d21..3f4fcf9f9 100644 --- a/source/app/blueprints/rest/v2/alerts.py +++ b/source/app/blueprints/rest/v2/alerts.py @@ -48,6 +48,24 @@ from app.models.errors import ObjectNotFoundError +# Fields that must be immutable on alert update. See GHSA-8hwq-v6vm-9grr +# / SBA-ADV-20260128-05 / CWE-863 — re-attributing alert_customer_id lets +# a user with write access to one tenant move an alert under a tenant they +# cannot see; alert_id is the primary key; alert_creation_time is audit +# integrity (set once at creation). +_ALERT_READONLY_UPDATE_FIELDS = frozenset({ + 'alert_id', + 'alert_customer_id', + 'alert_creation_time', +}) + + +def _strip_readonly_alert_fields(payload): + if not isinstance(payload, dict): + return payload + return {k: v for k, v in payload.items() if k not in _ALERT_READONLY_UPDATE_FIELDS} + + class AlertsOperations: def __init__(self): @@ -232,7 +250,12 @@ def update(self, identifier): identifier, fallback_customer_access=ac_current_user_has_customer_access ) - request_data = request.get_json() + # Drop fields the caller must not be allowed to change on update + # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863). The + # customer_id is the worst: re-attributing an alert to a + # customer the caller cannot see hides it from the rightful + # owner and plants it under another tenant's view. + request_data = _strip_readonly_alert_fields(request.get_json()) updated_alert = self._schema.load(request_data, instance=alert, partial=True) activity_data = [] diff --git a/source/app/templates/includes/sidenav.html b/source/app/templates/includes/sidenav.html index 7b201e196..bfa1b9407 100644 --- a/source/app/templates/includes/sidenav.html +++ b/source/app/templates/includes/sidenav.html @@ -29,7 +29,9 @@
  • - + + + Logout
  • From 2a111d37082dd9bb09eb5b24696ec86b06b33b68 Mon Sep 17 00:00:00 2001 From: whitekernel Date: Thu, 25 Jun 2026 09:25:00 +0200 Subject: [PATCH 030/121] [FIX] Sanitised HTML custom attributes via bleach and forced unsafe datastore downloads as attachments --- .../app/blueprints/rest/datastore_routes.py | 10 +++++- .../rest/v2/case_routes/datastore.py | 11 +++++- source/app/jinja_filters.py | 36 +++++++++++++++++++ .../modals/modal_attributes_tabs.html | 2 +- source/requirements.txt | 1 + 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/source/app/blueprints/rest/datastore_routes.py b/source/app/blueprints/rest/datastore_routes.py index abcc73fc9..5e57939c4 100644 --- a/source/app/blueprints/rest/datastore_routes.py +++ b/source/app/blueprints/rest/datastore_routes.py @@ -237,7 +237,15 @@ def datastore_view_file(cur_id: int, caseid: int): return response_error(f'File {dsf.file_local_name} does not exists on the server. ' f'Update or delete virtual entry') - resp = send_file(dsf.file_local_name, as_attachment=False, + # Keep inline display only for file types the browser cannot execute as + # script. SVG can embed