diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 42539ab64..8dd4eab3f 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -32,6 +32,8 @@ services: - POSTGRES_ADMIN_USER - POSTGRES_ADMIN_PASSWORD - POSTGRES_DB + env_file: + - .env networks: - iris_backend volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 50d9f44e3..513b96bdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,28 +25,42 @@ services: extends: file: docker-compose.base.yml service: db - image: ${DB_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_db}:${DB_IMAGE_TAG:-v2.4.20} - + build: + context: docker/db + image: iriswebapp_db:v2.4.7 + ports: + - "0.0.0.0:5432:5432" app: extends: file: docker-compose.base.yml service: app - image: ${APP_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_app}:${APP_IMAGE_TAG:-v2.4.20} + build: + context: . + dockerfile: docker/webApp/Dockerfile + image: iriswebapp_app:v2.4.7 + ports: + - "0.0.0.0:8000:8000" worker: extends: file: docker-compose.base.yml service: worker - image: ${APP_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_app}:${APP_IMAGE_TAG:-v2.4.20} - + build: + context: . + dockerfile: docker/webApp/Dockerfile + image: iriswebapp_app:v2.4.7 nginx: extends: file: docker-compose.base.yml service: nginx - image: ${NGINX_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_nginx}:${NGINX_IMAGE_TAG:-v2.4.20} - + build: + context: ./docker/nginx + args: + NGINX_CONF_GID: 1234 + NGINX_CONF_FILE: nginx.conf + image: iriswebapp_nginx:v2.4.7 volumes: iris-downloads: @@ -59,4 +73,3 @@ networks: name: iris_backend iris_frontend: name: iris_frontend - diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh index 58c79278c..773f9791d 100644 --- a/docker/nginx/entrypoint.sh +++ b/docker/nginx/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash # IRIS Source Code # Copyright (C) 2021 - Airbus CyberSecurity (SAS) diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index bb027e551..800e2b2e6 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -26,7 +26,11 @@ events { http { map $request_uri $csp_header { - default "default-src 'self' https://analytics.dfir-iris.org; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"; + default "default-src 'self' https://analytics.dfir-iris.org https://cdn.jsdelivr.net https://jsoncrack.com https://stackpath.bootstrapcdn.com; + script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; + style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; + img-src 'self' data:; + frame-src 'self' https://jsoncrack.com;"; } include /etc/nginx/mime.types; diff --git a/docker/webApp/Dockerfile b/docker/webApp/Dockerfile index da15e9446..d0ad8318c 100644 --- a/docker/webApp/Dockerfile +++ b/docker/webApp/Dockerfile @@ -83,6 +83,8 @@ COPY --from=compile-js-image /ui/dist/ /iriswebapp/static/ RUN chmod +x /iriswebapp/dependencies/evtxdump_binaries/linux/x64/fd RUN chmod +x /iriswebapp/dependencies/evtxdump_binaries/linux/x64/evtx_dump +# RUN chmod +x iris-entrypoint.sh wait-for-iriswebapp.sh entrypoint.sh + RUN chmod +x iris-entrypoint.sh RUN chmod +x wait-for-iriswebapp.sh -#ENTRYPOINT [ "./iris-entrypoint.sh" ] +#ENTRYPOINT ["./iris-entrypoint.sh"] diff --git a/docker/webApp/iris-entrypoint.sh b/docker/webApp/iris-entrypoint.sh old mode 100755 new mode 100644 diff --git a/source/app/alembic/versions/bf5eab0b7ace_add_case_template_id_to_case_model.py b/source/app/alembic/versions/bf5eab0b7ace_add_case_template_id_to_case_model.py new file mode 100644 index 000000000..b855e1d87 --- /dev/null +++ b/source/app/alembic/versions/bf5eab0b7ace_add_case_template_id_to_case_model.py @@ -0,0 +1,27 @@ +"""Add case_template_id to Case model + +Revision ID: bf5eab0b7ace +Revises: d5a720d1b99b +Create Date: 2025-05-15 03:28:27.999462 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import String +from app.alembic.alembic_utils import _table_has_column + +# revision identifiers, used by Alembic. +revision = 'bf5eab0b7ace' +down_revision = 'd5a720d1b99b' +branch_labels = None +depends_on = None + + +def upgrade(): + # Check if the column doesn't exist before adding it + if not _table_has_column('cases', 'case_template_id'): + op.add_column('cases', sa.Column('case_template_id', String(length=256), nullable=True)) + + +def downgrade(): + pass diff --git a/source/app/blueprints/case/case_routes.py b/source/app/blueprints/case/case_routes.py new file mode 100644 index 000000000..58410e910 --- /dev/null +++ b/source/app/blueprints/case/case_routes.py @@ -0,0 +1,456 @@ +# IRIS Source Code +# Copyright (C) 2021 - Airbus CyberSecurity (SAS) - DFIR-IRIS Team +# ir@cyberactionlab.net - 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 binascii +import marshmallow +import traceback +from flask import Blueprint +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from flask_login import current_user +from flask_socketio import emit +from flask_socketio import join_room +from flask_wtf import FlaskForm +from sqlalchemy import and_ +from sqlalchemy import desc + +from app import app +from app import db +from app import socket_io +from app.blueprints.case.case_assets_routes import case_assets_blueprint +from app.blueprints.case.case_graphs_routes import case_graph_blueprint +from app.blueprints.case.case_ioc_routes import case_ioc_blueprint +from app.blueprints.case.case_notes_routes import case_notes_blueprint +from app.blueprints.case.case_rfiles_routes import case_rfiles_blueprint +from app.blueprints.case.case_tasks_routes import case_tasks_blueprint +from app.blueprints.case.case_triggers_routes import case_triggers_blueprint +from app.blueprints.case.case_timeline_routes import case_timeline_blueprint +from app.datamgmt.case.case_db import case_exists, get_review_id_from_name +from app.datamgmt.case.case_db import case_get_desc_crc +from app.datamgmt.case.case_db import get_activities_report_template +from app.datamgmt.case.case_db import get_case +from app.datamgmt.case.case_db import get_case_report_template +from app.datamgmt.case.case_db import get_case_tags +from app.datamgmt.manage.manage_groups_db import add_case_access_to_group +from app.datamgmt.manage.manage_groups_db import get_group_with_members +from app.datamgmt.manage.manage_groups_db import get_groups_list +from app.datamgmt.manage.manage_users_db import get_user +from app.datamgmt.manage.manage_users_db import get_users_list_restricted_from_case +from app.datamgmt.manage.manage_users_db import set_user_case_access +from app.datamgmt.reporter.report_db import export_case_json +from app.forms import PipelinesCaseForm +from app.iris_engine.access_control.utils import ac_get_all_access_level, ac_fast_check_current_user_has_case_access, \ + ac_fast_check_user_has_case_access +from app.iris_engine.access_control.utils import ac_set_case_access_for_users +from app.iris_engine.module_handler.module_handler import list_available_pipelines +from app.iris_engine.utils.tracker import track_activity +from app.models import CaseStatus, ReviewStatusList +from app.models import UserActivity +from app.models.authorization import CaseAccessLevel +from app.models.authorization import User +from app.schema.marshables import TaskLogSchema, CaseSchema, CaseDetailsSchema +from app.util import ac_api_case_requires, add_obj_history_entry +from app.util import ac_case_requires +from app.util import ac_socket_requires +from app.util import response_error +from app.util import response_success + +app.register_blueprint(case_timeline_blueprint) +app.register_blueprint(case_notes_blueprint) +app.register_blueprint(case_assets_blueprint) +app.register_blueprint(case_ioc_blueprint) +app.register_blueprint(case_rfiles_blueprint) +app.register_blueprint(case_graph_blueprint) +app.register_blueprint(case_tasks_blueprint) +app.register_blueprint(case_triggers_blueprint) + +case_blueprint = Blueprint('case', + __name__, + template_folder='templates') + +event_tags = ["Network", "Server", "ActiveDirectory", "Computer", "Malware", "User Interaction"] + + +log = app.logger + + +# CONTENT ------------------------------------------------ +@case_blueprint.route('/case', methods=['GET']) +@ac_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_r(caseid, url_redir): + + if url_redir: + return redirect(url_for('case.case_r', cid=caseid, redirect=True)) + + case = get_case(caseid) + setattr(case, 'case_tags', get_case_tags(caseid)) + form = FlaskForm() + + reports = get_case_report_template() + reports = [row for row in reports] + + reports_act = get_activities_report_template() + reports_act = list(reports_act) #[row for row in reports_act] + + if not case: + return render_template('select_case.html') + + desc_crc32, description = case_get_desc_crc(caseid) + setattr(case, 'status_name', CaseStatus(case.status_id).name.replace('_', ' ').title()) + + return render_template('case.html', case=case, desc=description, crc=desc_crc32, + reports=reports, reports_act=reports_act, form=form) + + +@case_blueprint.route('/case/exists', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_exists_r(caseid): + + if case_exists(caseid): + return response_success('Case exists') + return response_error('Case does not exist', 404) + + +@case_blueprint.route('/case/pipelines-modal', methods=['GET']) +@ac_case_requires(CaseAccessLevel.full_access) +def case_pipelines_modal(caseid, url_redir): + if url_redir: + return redirect(url_for('case.case_r', cid=caseid, redirect=True)) + + case = get_case(caseid) + + form = PipelinesCaseForm() + + pl = list_available_pipelines() + + form.pipeline.choices = [("{}-{}".format(ap[0], ap[1]['pipeline_internal_name']), + ap[1]['pipeline_human_name'])for ap in pl] + + # Return default page of case management + pipeline_args = [("{}-{}".format(ap[0], ap[1]['pipeline_internal_name']), + ap[1]['pipeline_human_name'], ap[1]['pipeline_args'])for ap in pl] + + return render_template('modal_case_pipelines.html', case=case, form=form, pipeline_args=pipeline_args) + + +@socket_io.on('change') +@ac_socket_requires(CaseAccessLevel.full_access) +def socket_summary_onchange(data): + + data['last_change'] = current_user.user + emit('change', data, to=data['channel'], skip_sid=request.sid) + + +@socket_io.on('save') +@ac_socket_requires(CaseAccessLevel.full_access) +def socket_summary_onsave(data): + + data['last_saved'] = current_user.user + emit('save', data, to=data['channel'], skip_sid=request.sid) + + +@socket_io.on('clear_buffer') +@ac_socket_requires(CaseAccessLevel.full_access) +def socket_summary_clear_buffer(message): + + emit('clear_buffer', message) + + +@socket_io.on('join') +@ac_socket_requires(CaseAccessLevel.full_access) +def get_message(data): + + room = data['channel'] + join_room(room=room) + emit('join', {'message': f"{current_user.user} just joined"}, room=room) + + +@case_blueprint.route('/case/summary/update', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def desc_fetch(caseid): + + js_data = request.get_json() + case = get_case(caseid) + if not case: + return response_error('Invalid case ID') + + case.description = js_data.get('case_description') + crc = binascii.crc32(case.description.encode('utf-8')) + db.session.commit() + track_activity("updated summary", caseid) + + if not request.cookies.get('session'): + # API call so we propagate the message to everyone + data = { + "case_description": case.description, + "last_saved": current_user.user + } + socket_io.emit('save', data, to=f"case-{caseid}") + + return response_success("Summary updated", data=crc) + + +@case_blueprint.route('/case/summary/fetch', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def summary_fetch(caseid): + desc_crc32, description = case_get_desc_crc(caseid) + + return response_success("Summary fetch", data={'case_description': description, 'crc32': desc_crc32}) + + +@case_blueprint.route('/case/activities/list', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def activity_fetch(caseid): + ua = UserActivity.query.with_entities( + UserActivity.activity_date, + User.name, + UserActivity.activity_desc, + UserActivity.is_from_api + ).filter(and_( + UserActivity.case_id == caseid, + UserActivity.display_in_ui == True + )).join( + UserActivity.user + ).order_by( + desc(UserActivity.activity_date) + ).limit(40).all() + + output = [a._asdict() for a in ua] + + return response_success("", data=output) + + +@case_blueprint.route("/case/export", methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def export_case(caseid): + return response_success('', data=export_case_json(caseid)) + + +@case_blueprint.route("/case/meta", methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def meta_case(caseid): + case_details = get_case(caseid) + return response_success('', data= CaseDetailsSchema().dump(case_details)) + + +@case_blueprint.route('/case/tasklog/add', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_add_tasklog(caseid): + + log_schema = TaskLogSchema() + + try: + + log_data = log_schema.load(request.get_json()) + + ua = track_activity(log_data.get('log_content'), caseid, user_input=True) + + except marshmallow.exceptions.ValidationError as e: + return response_error(msg="Data error", data=e.messages) + + return response_success("Log saved", data=ua) + + +@case_blueprint.route('/case/users/list', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_get_users(caseid): + + users = get_users_list_restricted_from_case(caseid) + + return response_success(data=users) + + +@case_blueprint.route('/case/groups/access/modal', methods=['GET']) +@ac_case_requires(CaseAccessLevel.full_access) +def groups_cac_view(caseid, url_redir): + if url_redir: + return redirect(url_for('case.case_r', cid=caseid, redirect=True)) + + groups = get_groups_list() + access_levels = ac_get_all_access_level() + + return render_template('modal_cac_to_groups.html', groups=groups, access_levels=access_levels, caseid=caseid) + + +@case_blueprint.route('/case/access/set-group', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def group_cac_set_case(caseid): + + data = request.get_json() + if not data: + return response_error("Invalid request") + + if data.get('case_id') != caseid: + return response_error("Inconsistent case ID") + + case = get_case(caseid) + if not case: + return response_error("Invalid case ID") + + group_id = data.get('group_id') + access_level = data.get('access_level') + + group = get_group_with_members(group_id) + + try: + + success, logs = add_case_access_to_group(group, [data.get('case_id')], access_level) + + if success: + success, logs = ac_set_case_access_for_users(group.group_members, caseid, access_level) + + except Exception as e: + log.error("Error while setting case access for group: {}".format(e)) + log.error(traceback.format_exc()) + return response_error(msg=str(e)) + + if success: + track_activity("case access set to {} for group {}".format(data.get('access_level'), group_id), caseid) + add_obj_history_entry(case, "access changed to {} for group {}".format(data.get('access_level'), group_id), + commit=True) + + return response_success(msg=logs) + + return response_error(msg=logs) + + +@case_blueprint.route('/case/access/set-user', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def user_cac_set_case(caseid): + + data = request.get_json() + if not data: + return response_error("Invalid request") + + if data.get('user_id') == current_user.id: + return response_error("I can't let you do that, Dave") + + user = get_user(data.get('user_id')) + if not user: + return response_error("Invalid user ID") + + if data.get('case_id') != caseid: + return response_error("Inconsistent case ID") + + case = get_case(caseid) + if not case: + return response_error('Invalid case ID') + + try: + + success, logs = set_user_case_access(user.id, data.get('case_id'), data.get('access_level')) + track_activity("case access set to {} for user {}".format(data.get('access_level'), user.name), caseid) + add_obj_history_entry(case, "access changed to {} for user {}".format(data.get('access_level'), user.name)) + + db.session.commit() + + except Exception as e: + log.error("Error while setting case access for user: {}".format(e)) + log.error(traceback.format_exc()) + return response_error(msg=str(e)) + + if success: + return response_success(msg=logs) + + return response_error(msg=logs) + + +@case_blueprint.route('/case/update-status', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_update_status(caseid): + + case = get_case(caseid) + if not case: + return response_error('Invalid case ID') + + status = request.get_json().get('status_id') + case_status = {item.value for item in CaseStatus} + + # case_status = set(item.value for item in CaseStatus) + + try: + status = int(status) + except ValueError: + return response_error('Invalid status') + except TypeError: + return response_error('Invalid status. Expected int') + + if status not in case_status: + return response_error('Invalid status') + + case.status_id = status + add_obj_history_entry(case, f'status updated to {CaseStatus(status).name}') + + db.session.commit() + + return response_success("Case status updated", data=case.status_id) + + +@case_blueprint.route('/case/md-helper', methods=['GET']) +@ac_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_md_helper(caseid, url_redir): + + return render_template('case_md_helper.html') + + +@case_blueprint.route('/case/review/update', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_review(caseid): + + case = get_case(caseid) + if not case: + return response_error('Invalid case ID') + + action = request.get_json().get('action') + reviewer_id = request.get_json().get('reviewer_id') + + if action == 'start': + review_name = ReviewStatusList.review_in_progress + elif action in ["cancel", "request"]: + review_name = ReviewStatusList.pending_review + elif action == 'no_review': + review_name = ReviewStatusList.no_review_required + elif action == 'to_review': + review_name = ReviewStatusList.not_reviewed + elif action == 'done': + review_name = ReviewStatusList.reviewed + else: + return response_error('Invalid action') + + case.review_status_id = get_review_id_from_name(review_name) + if reviewer_id: + try: + reviewer_id = int(reviewer_id) + except ValueError: + return response_error('Invalid reviewer ID') + + if not ac_fast_check_user_has_case_access(reviewer_id, caseid, [CaseAccessLevel.full_access]): + return response_error('Invalid reviewer ID') + + case.reviewer_id = reviewer_id + + db.session.commit() + + add_obj_history_entry(case, f'review status updated to {review_name}') + track_activity(f'review status updated to {review_name}', caseid) + + db.session.commit() + + return response_success("Case review updated", data=CaseSchema().dump(case)) diff --git a/source/app/blueprints/case/case_tasks_routes.py b/source/app/blueprints/case/case_tasks_routes.py new file mode 100644 index 000000000..24d0645a5 --- /dev/null +++ b/source/app/blueprints/case/case_tasks_routes.py @@ -0,0 +1,422 @@ +# IRIS Source Code +# Copyright (C) 2021 - Airbus CyberSecurity (SAS) - DFIR-IRIS Team +# ir@cyberactionlab.net - 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 datetime import datetime +import json + +import marshmallow +from flask import Blueprint, jsonify +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from flask_login import current_user +from flask_wtf import FlaskForm + +from app import db +from app.blueprints.rest.case_comments import case_comment_update +from app.datamgmt.case.case_db import get_case +from app.datamgmt.case.case_tasks_db import add_comment_to_task +from app.datamgmt.case.case_tasks_db import add_task +from app.datamgmt.case.case_tasks_db import delete_task +from app.datamgmt.case.case_tasks_db import delete_task_comment +from app.datamgmt.case.case_tasks_db import get_case_task_comment +from app.datamgmt.case.case_tasks_db import get_case_task_comments +from app.datamgmt.case.case_tasks_db import get_case_tasks_comments_count +from app.datamgmt.case.case_tasks_db import get_task +from app.datamgmt.case.case_tasks_db import get_tasks_status +from app.datamgmt.case.case_tasks_db import get_tasks_with_assignees +from app.datamgmt.case.case_tasks_db import update_task_assignees +from app.datamgmt.case.case_tasks_db import update_task_status +from app.datamgmt.manage.manage_attribute_db import get_default_custom_attributes +from app.datamgmt.states import get_tasks_state +from app.datamgmt.states import update_tasks_state +from app.forms import CaseTaskForm +from app.iris_engine.module_handler.module_handler import call_modules_hook +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import CaseAccessLevel +from app.models.authorization import User +from app.models.models import CaseTasks +from app.schema.marshables import CaseTaskSchema +from app.schema.marshables import CommentSchema +from app.util import ac_api_case_requires, ac_api_requires +from app.util import ac_case_requires +from app.util import response_error +from app.util import response_success +from app.datamgmt.manage.manage_cases_db import execute_and_save_action +from app.datamgmt.manage.manage_task_response_db import get_task_response_by_id, get_task_responses_list + +case_tasks_blueprint = Blueprint('case_tasks', + __name__, + template_folder='templates') + + +# CONTENT ------------------------------------------------ +@case_tasks_blueprint.route('/case/tasks', methods=['GET']) +@ac_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_tasks(caseid, url_redir): + if url_redir: + return redirect(url_for('case_tasks.case_tasks', cid=caseid, redirect=True)) + + form = FlaskForm() + case = get_case(caseid) + return render_template("case_tasks.html", case=case, form=form) + +@case_tasks_blueprint.route('/case/jsoneditor', methods=['POST']) +@ac_case_requires(CaseAccessLevel.full_access) +def save_data(caseid, url_redir): + try: + data = request.get_json() + + if not isinstance(data, dict): + raise ValueError("Request payload is not a valid JSON object") + + payload = data.get('payload') + task_id = data.get('task_id') + action_id = data.get('action_id') + + if not payload or not task_id or not action_id: + raise KeyError("Missing one or more required keys: 'payload', 'task_id', 'action_id'") + + action_response = execute_and_save_action(payload, task_id, action_id) + + return jsonify({"status": "success", "message": "Data saved successfully!", "data": action_response}) + except KeyError as e: + return jsonify({"status": "error", "message": f"Missing key: {e}"}), 400 + except ValueError as e: + return jsonify({"status": "error", "message": str(e)}), 400 + except Exception as e: + return jsonify({"status": "error", "message": str(e)}) + + +@case_tasks_blueprint.route('/case/tasks/list', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_get_tasks(caseid): + ct = get_tasks_with_assignees(caseid) + + if not ct: + output = [] + else: + output = ct + + ret = { + "tasks_status": get_tasks_status(), + "tasks": output, + "state": get_tasks_state(caseid=caseid) + } + + return response_success("", data=ret) + +@case_tasks_blueprint.route('/case/tasks/state', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_get_tasks_state(caseid): + os = get_tasks_state(caseid=caseid) + if os: + return response_success(data=os) + return response_error('No tasks state for this case.') + +@case_tasks_blueprint.route('/case/tasks/status/update/', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_task_statusupdate(cur_id, caseid): + task = get_task(task_id=cur_id) + if not task: + return response_error("Invalid task ID for this case") + + if request.is_json: + + if update_task_status(request.json.get('task_status_id'), cur_id, caseid): + task_schema = CaseTaskSchema() + + return response_success("Task status updated", data=task_schema.dump(task)) + + return response_error("Invalid status") + return response_error("Invalid request") + + +@case_tasks_blueprint.route('/case/tasks/add/modal', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_add_task_modal(caseid): + + task = CaseTasks() + task.custom_attributes = get_default_custom_attributes('task') + form = CaseTaskForm() + form.task_status_id.choices = [(a.id, a.status_name) for a in get_tasks_status()] + form.task_assignees_id.choices = [] + + return render_template("modal_add_case_task.html", form=form, task=task, uid=current_user.id, user_name=None, + attributes=task.custom_attributes ) + + +@case_tasks_blueprint.route('/case/tasks/add', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_add_task(caseid): + try: + # validate before saving + task_schema = CaseTaskSchema() + request_data = call_modules_hook('on_preload_task_create', data=request.get_json(), caseid=caseid) + + if 'task_assignee_id' in request_data or 'task_assignees_id' not in request_data: + return response_error('task_assignee_id is not valid anymore since v1.5.0') + + task_assignee_list = request_data['task_assignees_id'] + del request_data['task_assignees_id'] + task = task_schema.load(request_data) + + ctask = add_task(task=task, + assignee_id_list=task_assignee_list, + user_id=current_user.id, + caseid=caseid + ) + + ctask = call_modules_hook('on_postload_task_create', data=ctask, caseid=caseid) + + if ctask: + track_activity(f"added task \"{ctask.task_title}\"", caseid=caseid) + return response_success("Task '{}' added".format(ctask.task_title), data=task_schema.dump(ctask)) + + return response_error("Unable to create task for internal reasons") + + except marshmallow.exceptions.ValidationError as e: + return response_error(msg="Data error", data=e.messages) + + +@case_tasks_blueprint.route('/case/tasks/', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_task_view(cur_id, caseid): + task = get_tasks_with_assignees(caseid) + if not task: + return response_error("Invalid task ID for this case") + + task_schema = CaseTaskSchema() + + return response_success(data=task_schema.dump(task)) + + +@case_tasks_blueprint.route('/case/tasks//modal', methods=['GET']) +@ac_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_task_view_modal(cur_id, caseid, url_redir): + if url_redir: + return redirect(url_for('case_tasks.case_tasks', cid=caseid, redirect=True)) + + form = CaseTaskForm() + + task = get_tasks_with_assignees(caseid) + form.task_status_id.choices = [(a.id, a.status_name) for a in get_tasks_status()] + form.task_assignees_id.choices = [] + + if not task: + return response_error("Invalid task ID for this case") + + form.task_title.render_kw = {'value': task.task_title} + form.task_description.data = task.task_description + user_name, = User.query.with_entities(User.name).filter(User.id == task.task_userid_update).first() + comments_map = get_case_tasks_comments_count([task.id]) + taskActionResponses = get_task_responses_list(task.id) + # Serialize datetime objects for rendering + return render_template("modal_add_case_task.html", form=form, task=task, user_name=user_name, + comments_map=comments_map, attributes=task.custom_attributes ,taskActionResponses=taskActionResponses) + +@case_tasks_blueprint.route('/case/task/action_responses/', methods=['GET']) +def case_task_action_response(cur_id): + try: + + taskActionResponses = get_task_responses_list(cur_id) + + # Serialize datetime objects for rendering and the 'body' field + for taskActionResponse in taskActionResponses: + if isinstance(taskActionResponse['created_at'], datetime): + taskActionResponse['created_at'] = taskActionResponse['created_at'].strftime("%Y-%m-%d %H:%M:%S") + if taskActionResponse.get('updated_at') and isinstance(taskActionResponse['updated_at'], datetime): + taskActionResponse['updated_at'] = taskActionResponse['updated_at'].strftime("%Y-%m-%d %H:%M:%S") + if taskActionResponse.get('body'): + try: + taskActionResponse['body'] = json.dumps(taskActionResponse['body']) + except (TypeError, ValueError): + taskActionResponse['body'] = str(taskActionResponse['body']) + + return jsonify({"success": True, "data": taskActionResponses}) # Properly return as JSON + + except Exception as e: + # Log the exception for debugging + return jsonify({"success": False, "error": str(e)}), 500 + +@case_tasks_blueprint.route('/case/task/action_response/', methods=['GET']) +def case_task_action_response_by_id(cur_id): + action_response = get_task_response_by_id(cur_id) + + if action_response: + return response_success("Task action response fetched successfully", data=action_response) + return response_error("No action response found for this task", 404) + + + + +@case_tasks_blueprint.route('/case/tasks/update/', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_edit_task(cur_id, caseid): + try: + task = get_tasks_with_assignees(caseid) + if not task: + return response_error("Invalid task ID for this case") + + request_data = call_modules_hook('on_preload_task_update', data=request.get_json(), caseid=caseid) + + if 'task_assignee_id' in request_data or 'task_assignees_id' not in request_data: + return response_error('task_assignee_id is not valid anymore since v1.5.0') + + # validate before saving + task_assignee_list = request_data['task_assignees_id'] + del request_data['task_assignees_id'] + task_schema = CaseTaskSchema() + + request_data['id'] = cur_id + task = task_schema.load(request_data, instance=task) + + task.task_userid_update = current_user.id + task.task_last_update = datetime.utcnow() + + update_task_assignees(task, task_assignee_list, caseid) + + update_tasks_state(caseid=caseid) + + db.session.commit() + + task = call_modules_hook('on_postload_task_update', data=task, caseid=caseid) + + if task: + track_activity(f"updated task \"{task.task_title}\" (status {task.status.status_name})", + caseid=caseid) + return response_success("Task '{}' updated".format(task.task_title), data=task_schema.dump(task)) + + return response_error("Unable to update task for internal reasons") + + except marshmallow.exceptions.ValidationError as e: + return response_error(msg="Data error", data=e.messages) + + +@case_tasks_blueprint.route('/case/tasks/delete/', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_delete_task(cur_id, caseid): + call_modules_hook('on_preload_task_delete', data=cur_id, caseid=caseid) + task = get_tasks_with_assignees(caseid) + if not task: + return response_error("Invalid task ID for this case") + + delete_task(task.id) + + update_tasks_state(caseid=caseid) + + call_modules_hook('on_postload_task_delete', data=cur_id, caseid=caseid) + + track_activity(f"deleted task \"{task.task_title}\"") + + return response_success("Task deleted") + + +@case_tasks_blueprint.route('/case/tasks//comments/modal', methods=['GET']) +@ac_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_comment_task_modal(cur_id, caseid, url_redir): + if url_redir: + return redirect(url_for('case_task.case_task', cid=caseid, redirect=True)) + + task = get_task(cur_id) + if not task: + return response_error('Invalid task ID') + + return render_template("modal_conversation.html", element_id=cur_id, element_type='tasks', + title=task.task_title) + + +@case_tasks_blueprint.route('/case/tasks//comments/list', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_comment_task_list(cur_id, caseid): + + task_comments = get_case_task_comments(cur_id) + if task_comments is None: + return response_error('Invalid task ID') + + return response_success(data=CommentSchema(many=True).dump(task_comments)) + + +@case_tasks_blueprint.route('/case/tasks//comments/add', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_comment_task_add(cur_id, caseid): + + try: + task = get_task(cur_id) + if not task: + return response_error('Invalid task ID') + + comment_schema = CommentSchema() + + comment = comment_schema.load(request.get_json()) + comment.comment_case_id = caseid + comment.comment_user_id = current_user.id + comment.comment_date = datetime.now() + comment.comment_update_date = datetime.now() + db.session.add(comment) + db.session.commit() + + add_comment_to_task(task.id, comment.comment_id) + + db.session.commit() + + hook_data = { + "comment": comment_schema.dump(comment), + "task": CaseTaskSchema().dump(task) + } + call_modules_hook('on_postload_task_commented', data=hook_data, caseid=caseid) + + track_activity(f"task \"{task.task_title}\" commented", caseid=caseid) + return response_success("Task commented", data=comment_schema.dump(comment)) + + except marshmallow.exceptions.ValidationError as e: + return response_error(msg="Data error", data=e.normalized_messages()) + + +@case_tasks_blueprint.route('/case/tasks//comments/', methods=['GET']) +@ac_api_case_requires(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +def case_comment_task_get(cur_id, com_id, caseid): + + comment = get_case_task_comment(cur_id, com_id) + if not comment: + return response_error("Invalid comment ID") + + return response_success(data=comment._asdict()) + + +@case_tasks_blueprint.route('/case/tasks//comments//edit', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_comment_task_edit(cur_id, com_id, caseid): + + return case_comment_update(com_id, 'tasks', caseid) + + +@case_tasks_blueprint.route('/case/tasks//comments//delete', methods=['POST']) +@ac_api_case_requires(CaseAccessLevel.full_access) +def case_comment_task_delete(cur_id, com_id, caseid): + + success, msg = delete_task_comment(cur_id, com_id) + if not success: + return response_error(msg) + + call_modules_hook('on_postload_task_comment_delete', data=com_id, caseid=caseid) + + track_activity(f"comment {com_id} on task {cur_id} deleted", caseid=caseid) + return response_success(msg) + diff --git a/source/app/blueprints/pages/case/case_routes.py b/source/app/blueprints/pages/case/case_routes.py index 4c4b1f051..b4213bbcc 100644 --- a/source/app/blueprints/pages/case/case_routes.py +++ b/source/app/blueprints/pages/case/case_routes.py @@ -61,7 +61,7 @@ def case_r(caseid, url_redir): reports = [row for row in reports] reports_act = get_activities_report_template() - reports_act = [row for row in reports_act] + reports_act = list(reports_act)#[row for row in reports_act] if not case: return render_template('select_case.html') diff --git a/source/app/blueprints/pages/case/case_triggers_routes.py b/source/app/blueprints/pages/case/case_triggers_routes.py new file mode 100644 index 000000000..6b25382f6 --- /dev/null +++ b/source/app/blueprints/pages/case/case_triggers_routes.py @@ -0,0 +1,54 @@ +from flask import Blueprint, jsonify, request, redirect, render_template, url_for +import json +from flask_wtf import FlaskForm +from app.datamgmt.case.case_db import get_case +from app.datamgmt.manage.manage_case_response_db import get_case_responses_list_by_case_id + +case_triggers_blueprint = Blueprint('case_triggers', + __name__, + template_folder='templates') + +@case_triggers_blueprint.route('/case/triggers', methods=['GET']) +def case_triggers(): + # Retrieve query parameters from the URL + caseid = request.args.get('cid') + url_redir = request.args.get('url_redir', type=bool) + + if url_redir: + return redirect(url_for('case_triggers.case_triggers', cid=caseid, redirect=True)) + + form = FlaskForm() + case = get_case(caseid) + return render_template("case_triggers.html", case=case, form=form) + + +@case_triggers_blueprint.route('/case/triggers-list/', methods=['GET']) +def case_triggers_list(cur_id): + + try: + # Retrieve the triggers list + triggers = get_case_responses_list_by_case_id(cur_id) + # Serialize datetime objects for rendering + for trigger in triggers: + # Format created_at + if 'created_at' in trigger and trigger['created_at']: + trigger['created_at'] = trigger['created_at'].strftime("%Y-%m-%d %H:%M:%S") + + # Format updated_at + if 'updated_at' in trigger and trigger['updated_at']: + trigger['updated_at'] = trigger['updated_at'].strftime("%Y-%m-%d %H:%M:%S") + + # Serialize body + if 'body' in trigger and trigger['body']: + try: + trigger['body'] = json.dumps(trigger['body']) + except (TypeError, ValueError): + trigger['body'] = str(trigger['body']) # Fallback to string representation + + # Return the JSON response + return jsonify({"success": True, "data": triggers}) + + except Exception as e: + # Log the exception for debugging (optional: use a logger instead of print) + print(f"Error processing case triggers: {e}") + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/source/app/blueprints/pages/case/templates/case-nav.html b/source/app/blueprints/pages/case/templates/case-nav.html index ea9bde0ca..7530fdb8f 100644 --- a/source/app/blueprints/pages/case/templates/case-nav.html +++ b/source/app/blueprints/pages/case/templates/case-nav.html @@ -40,6 +40,11 @@ Evidence + + diff --git a/source/app/blueprints/pages/case/templates/case-nav_actions.html b/source/app/blueprints/pages/case/templates/case-nav_actions.html new file mode 100644 index 000000000..ff30fd259 --- /dev/null +++ b/source/app/blueprints/pages/case/templates/case-nav_actions.html @@ -0,0 +1,64 @@ +
+ +
diff --git a/source/app/blueprints/pages/case/templates/case.html b/source/app/blueprints/pages/case/templates/case.html index 8073f55d6..d50985a8b 100644 --- a/source/app/blueprints/pages/case/templates/case.html +++ b/source/app/blueprints/pages/case/templates/case.html @@ -11,6 +11,7 @@ {% include 'includes/navigation_ext.html' %} {% include 'includes/sidenav.html' %} +
@@ -18,7 +19,7 @@
{% else %}
- {% endif %} + {% endif %}
@@ -28,51 +29,76 @@

{{ case.name|unquote }}

-
+
-
Open on {{ case.open_date }} by {{ case.user.name }}
-
Owned by {{ case.owner.name }}
- {% if case.close_date %} -
Closed on {{ case.close_date }}
- {% endif %} -
-
-
- {% if case.severity %} {{ case.severity.severity_name }}{% endif %} - {{ case.status_name }} +
Open on {{ case.open_date }} by {{ + case.user.name }}
+
Owned by {{ case.owner.name }}
+ {% if case.close_date %} +
Closed on {{ case.close_date }}
+ {% endif %}
-
-
-
-
Customer : {{ case.client.name }}
+
+
+ {% if case.severity %} {{ + case.severity.severity_name }}{% endif %} + {{ case.status_name + }} +
+
+
+
+
Customer : {{ + case.client.name }}
+
+
+ {% if case.soc_id %}
SOC ID + : {{ case.soc_id }}
{% endif %} +
-
- {% if case.soc_id %}
SOC ID : {{ case.soc_id }}
{% endif %} -
-
- {% if case.state %}
{{ case.state.state_name }}
{% endif %} - {% if case.classification %}
{{ case.classification.name_expanded }}
{% endif %} - {% if case.alerts| length > 0 %}
{{ case.alerts| length }} related alerts
{% endif %} + {% if case.state %}
{{ + case.state.state_name }}
{% endif %} + {% if case.classification %}
{{ + case.classification.name_expanded }}
{% endif %} + {% if case.alerts| length > 0 %}
{{ + case.alerts| length }} related alerts
{% endif %} {% if case.review_status.status_name == "Reviewed" %} -
Case reviewed by {% if case.reviewer.id == current_user.id %} you {% else %} {{ case.reviewer.name }} {% endif %}
+
Case reviewed by {% if + case.reviewer.id == current_user.id %} you {% else %} {{ case.reviewer.name }} + {% endif %}
{% endif %}
{% if case.case_tags %} {% for tag in case.case_tags %} - {{ tag }} + {{ + tag }} {% endfor %} {% endif %}
@@ -84,31 +110,42 @@
{% include 'case-nav_landing.html' %}
- + - {% if case.reviewer_id == current_user.id and case.review_status.status_name != "Reviewed" and case.review_status.status_name != "Not reviewed" %} -