diff --git a/src/dispatch/case/enums.py b/src/dispatch/case/enums.py index de667c0ebdd8..a549425922f9 100644 --- a/src/dispatch/case/enums.py +++ b/src/dispatch/case/enums.py @@ -9,10 +9,52 @@ class CaseStatus(DispatchEnum): class CaseResolutionReason(DispatchEnum): + benign = "Benign" + contained = "Contained" + escalated = "Escalated" false_positive = "False Positive" - user_acknowledge = "User Acknowledged" + information_gathered = "Information Gathered" + insufficient_information = "Insufficient Information" mitigated = "Mitigated" - escalated = "Escalated" + operational_error = "Operational Error" + policy_violation = "Policy Violation" + user_acknowledged = "User Acknowledged" + + +class CaseResolutionReasonDescription(DispatchEnum): + """Descriptions for case resolution reasons.""" + + benign = ( + "The event was legitimate but posed no security threat, such as expected behavior " + "from a known application or user." + ) + contained = ( + "(True positive) The event was a legitimate threat but was contained to prevent " + "further spread or damage." + ) + escalated = "There was enough information to create an incident based on the security event." + false_positive = "The event was incorrectly flagged as a security event." + information_gathered = ( + "Used when a case was opened with the primary purpose of collecting information." + ) + insufficient_information = ( + "There was not enough information to determine the nature of the event conclusively." + ) + mitigated = ( + "(True Positive) The event was a legitimate security threat and was successfully " + "mitigated before causing harm." + ) + operational_error = ( + "The event was caused by a mistake in system configuration or user operation, " + "not malicious activity." + ) + policy_violation = ( + "The event was a breach of internal security policies but did not result in a " + "security incident." + ) + user_acknowledged = ( + "While the event was suspicious it was confirmed by the actor to be intentional." + ) class CostModelType(DispatchEnum): diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py index 38381bd26e4c..60c225fb4fbe 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -42,7 +42,7 @@ send_case_rating_feedback_message, send_case_update_notifications, send_event_paging_message, - send_event_update_prompt_reminder + send_event_update_prompt_reminder, ) from .models import Case from .service import get @@ -210,7 +210,7 @@ def case_auto_close_flow(case: Case, db_session: Session): "Runs the case auto close flow." # we mark the case as closed case.resolution = "Auto closed via case type auto close configuration." - case.resolution_reason = CaseResolutionReason.user_acknowledge + case.resolution_reason = CaseResolutionReason.user_acknowledged case.status = CaseStatus.closed db_session.add(case) db_session.commit() diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index 0c79c900ba6e..d09eda61a706 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -27,7 +27,7 @@ from dispatch.auth.models import DispatchUser, MfaChallengeStatus from dispatch.case import flows as case_flows from dispatch.case import service as case_service -from dispatch.case.enums import CaseResolutionReason, CaseStatus +from dispatch.case.enums import CaseResolutionReason, CaseStatus, CaseResolutionReasonDescription from dispatch.case.models import Case, CaseCreate, CaseRead, CaseUpdate from dispatch.case.type import service as case_type_service from dispatch.config import DISPATCH_UI_URL @@ -68,6 +68,7 @@ from dispatch.plugins.dispatch_slack.decorators import message_dispatcher from dispatch.plugins.dispatch_slack.enums import SlackAPIErrorCode from dispatch.plugins.dispatch_slack.fields import ( + DefaultActionIds, DefaultBlockIds, case_priority_select, case_resolution_reason_select, @@ -287,7 +288,7 @@ def handle_update_case_command( ), case_visibility_select( initial_option={"text": case.visibility, "value": case.visibility}, - ) + ), ] modal = Modal( @@ -2084,10 +2085,13 @@ def resolve_button_click( reason = case.resolution_reason blocks = [ ( - case_resolution_reason_select(initial_option={"text": reason, "value": reason}) + case_resolution_reason_select( + initial_option={"text": reason, "value": reason}, dispatch_action=True + ) if reason - else case_resolution_reason_select() + else case_resolution_reason_select(dispatch_action=True) ), + Context(elements=[MarkdownText(text="Select a resolution reason to see its description")]), resolution_input(initial_value=case.resolution), ] @@ -2102,6 +2106,62 @@ def resolve_button_click( client.views_open(trigger_id=body["trigger_id"], view=modal) +@app.action( + DefaultActionIds.case_resolution_reason_select, + middleware=[action_context_middleware, db_middleware], +) +def handle_resolution_reason_select_action( + ack: Ack, + body: dict, + client: WebClient, + context: BoltContext, + db_session: Session, +): + """Handles the resolution reason select action.""" + ack() + + # Get the selected resolution reason + values = body["view"]["state"]["values"] + block_id = DefaultBlockIds.case_resolution_reason_select + action_id = DefaultActionIds.case_resolution_reason_select + resolution_reason = values[block_id][action_id]["selected_option"]["value"] + + # Get the description for the selected reason + try: + # Map the resolution reason string to the enum key + reason_key = resolution_reason.lower().replace(" ", "_") + description = CaseResolutionReasonDescription[reason_key].value + except KeyError: + description = "No description available" + + # Get the current case + case = case_service.get(db_session=db_session, case_id=int(context["subject"].id)) + + # Rebuild the modal with the updated description + blocks = [ + case_resolution_reason_select( + initial_option={"text": resolution_reason, "value": resolution_reason}, + dispatch_action=True, + ), + Context(elements=[MarkdownText(text=f"*Description:* {description}")]), + resolution_input(initial_value=case.resolution), + ] + + modal = Modal( + title="Resolve Case", + blocks=blocks, + submit="Resolve", + close="Close", + callback_id=CaseResolveActions.submit, + private_metadata=context["subject"].json(), + ).build() + + client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + @app.action(CaseNotificationActions.triage, middleware=[button_context_middleware, db_middleware]) def triage_button_click( ack: Ack, body: dict, db_session: Session, context: BoltContext, client: WebClient @@ -2884,7 +2944,7 @@ def resolve_case( ) case_in = CaseUpdate( title=case.title, - resolution_reason=CaseResolutionReason.user_acknowledge, + resolution_reason=CaseResolutionReason.user_acknowledged, resolution=context_from_user, visibility=case.visibility, status=CaseStatus.closed, diff --git a/src/dispatch/static/dispatch/src/case/CaseResolutionSearchPopover.vue b/src/dispatch/static/dispatch/src/case/CaseResolutionSearchPopover.vue index c3a8c299ac91..c9d8f5f95d2c 100644 --- a/src/dispatch/static/dispatch/src/case/CaseResolutionSearchPopover.vue +++ b/src/dispatch/static/dispatch/src/case/CaseResolutionSearchPopover.vue @@ -1,6 +1,5 @@ + + diff --git a/src/dispatch/static/dispatch/src/case/DetailsTab.vue b/src/dispatch/static/dispatch/src/case/DetailsTab.vue index e5d8e2ee9919..bf11617c4c51 100644 --- a/src/dispatch/static/dispatch/src/case/DetailsTab.vue +++ b/src/dispatch/static/dispatch/src/case/DetailsTab.vue @@ -27,9 +27,26 @@ + :menu-props="{ contentClass: 'resolution-menu' }" + > + + { if (value && value.length > 1) { return "Only one is allowed" @@ -251,4 +267,32 @@ export default { opacity: 0.6; pointer-events: none; } + +.resolution-item { + margin-left: 12px; + margin-bottom: 4px; + padding: 8px 0; +} + +.resolution-menu { + max-width: 300px; +} + +:deep(.v-list-item) { + padding: 8px 16px; +} + +:deep(.v-select__content) { + max-width: 300px; +} + +/* Lighten tooltip info icons */ +:deep(.v-tooltip .v-icon) { + color: #cccccc !important; +} + +/* Alternative approach - target the icon directly */ +:deep(.mdi-information) { + color: #b0b0b0 !important; +} diff --git a/src/dispatch/static/dispatch/src/case/store.js b/src/dispatch/static/dispatch/src/case/store.js index 5eff0a806e6c..c5743a6226f9 100644 --- a/src/dispatch/static/dispatch/src/case/store.js +++ b/src/dispatch/static/dispatch/src/case/store.js @@ -8,6 +8,29 @@ import PluginApi from "@/plugin/api" import AuthApi from "@/auth/api" import router from "@/router" +const resolutionTooltips = { + Benign: + "The event was legitimate but posed no security threat, such as expected behavior from a known application or user.", + Contained: + "(True positive) The event was a legitimate threat but was contained to prevent further spread or damage.", + Escalated: "There was enough information to create an incident based on the security event.", + "False Positive": "The event was incorrectly flagged as a security event.", + "Information Gathered": + "Used when a case was opened with the primary purpose of collecting information.", + "Insufficient Information": + "There was not enough information to determine the nature of the event conclusively.", + Mitigated: + "(True Positive) The event was a legitimate security threat and was successfully mitigated before causing harm.", + "Operational Error": + "The event was caused by a mistake in system configuration or user operation, not malicious activity.", + "Policy Violation": + "The event was a breach of internal security policies but did not result in a security incident.", + "User Acknowledged": + "While the event was suspicious it was confirmed by the actor to be intentional.", +} + +const resolutionReasons = Object.keys(resolutionTooltips) + const getDefaultSelectedState = () => { return { assignee: null, @@ -109,6 +132,8 @@ const state = { }, default_project: null, current_user_role: null, + resolutionReasons, + resolutionTooltips, } const getters = { diff --git a/src/dispatch/static/dispatch/src/components/SearchPopover.vue b/src/dispatch/static/dispatch/src/components/SearchPopover.vue index 7eb7f2b6b9d8..2ce593f21e4d 100644 --- a/src/dispatch/static/dispatch/src/components/SearchPopover.vue +++ b/src/dispatch/static/dispatch/src/components/SearchPopover.vue @@ -3,11 +3,14 @@ import { computed, ref, watch } from "vue" import { useHotKey } from "@/composables/useHotkey" import type { Ref } from "vue" +type Key = keyof typeof KeyboardEvent.prototype + const props = defineProps<{ - hotkeys: string[] + hotkeys: Key[] initialValue: string items: any[] label: string + tooltips?: Record // Optional tooltip text for each item }>() const emit = defineEmits(["item-selected"]) @@ -90,11 +93,9 @@ const toggleMenu = () => { single-line hide-details flat - > - - + :placeholder="props.label" + class="small-placeholder" + />
@@ -105,7 +106,30 @@ const toggleMenu = () => {
+ + + { border: 1px solid rgb(239, 241, 244) !important; border-radius: 4px; /* adjust as needed */ } + +.small-placeholder { + :deep(input::placeholder) { + font-size: 14px; + } +} diff --git a/tests/case/test_case_service.py b/tests/case/test_case_service.py index c06ada6a175a..f187e86a577e 100644 --- a/tests/case/test_case_service.py +++ b/tests/case/test_case_service.py @@ -10,13 +10,13 @@ def test_get(session, case: Case): from dispatch.case.service import get - case_id = getattr(case, 'id', None) + case_id = getattr(case, "id", None) if case_id is None: raise AssertionError("case.id is None; cannot run test_get.") - if hasattr(case_id, '__int__'): + if hasattr(case_id, "__int__"): case_id = int(case_id) t_case = get(db_session=session, case_id=case_id) - if t_case is not None and getattr(t_case, 'id', None) is not None: + if t_case is not None and getattr(t_case, "id", None) is not None: assert isinstance(t_case.id, int) assert t_case.id == case_id else: @@ -26,13 +26,13 @@ def test_get(session, case: Case): def test_get_by_name(session, case: Case): from dispatch.case.service import get_by_name - case_name = getattr(case, 'name', None) + case_name = getattr(case, "name", None) if case_name is None: raise AssertionError("case.name is None; cannot run test_get_by_name.") - if hasattr(case_name, '__str__'): + if hasattr(case_name, "__str__"): case_name = str(case_name) t_case = get_by_name(db_session=session, project_id=case.project.id, name=case_name) - if t_case is not None and getattr(t_case, 'name', None) is not None: + if t_case is not None and getattr(t_case, "name", None) is not None: assert isinstance(t_case.name, str) assert t_case.name == case_name else: @@ -195,7 +195,7 @@ def test_update(session, case: Case, project): title="XXX", description="YYY", resolution="True Positive", - resolution_reason=CaseResolutionReason.user_acknowledge, + resolution_reason=CaseResolutionReason.user_acknowledged, status=CaseStatus.closed, visibility=Visibility.restricted, assignee=case.assignee, @@ -210,21 +210,21 @@ def test_update(session, case: Case, project): db_session=session, case=case, case_in=case_in, current_user=current_user ) if case_out is not None: - assert getattr(case_out, 'title', None) == "XXX" - assert getattr(case_out, 'description', None) == "YYY" - assert getattr(case_out, 'resolution', None) == "True Positive" - assert getattr(case_out, 'status', None) == CaseStatus.closed - assert getattr(case_out, 'visibility', None) == Visibility.restricted + assert getattr(case_out, "title", None) == "XXX" + assert getattr(case_out, "description", None) == "YYY" + assert getattr(case_out, "resolution", None) == "True Positive" + assert getattr(case_out, "status", None) == CaseStatus.closed + assert getattr(case_out, "visibility", None) == Visibility.restricted def test_delete(session, case: Case): from dispatch.case.service import delete as case_delete from dispatch.case.service import get as case_get - case_id = getattr(case, 'id', None) + case_id = getattr(case, "id", None) if case_id is None: raise AssertionError("case.id is None; cannot run test_delete.") - if hasattr(case_id, '__int__'): + if hasattr(case_id, "__int__"): case_id = int(case_id) case_delete( db_session=session,