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 585479cfb346..d08e294e0fc1 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -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 03cb5365fad8..a1c9ea5abe6b 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" @@ -253,4 +269,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 5aabed8339c5..ebe96be5134a 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, @@ -129,6 +152,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 34b001c7a08e..df22b62781d4 100644 --- a/tests/case/test_case_service.py +++ b/tests/case/test_case_service.py @@ -193,7 +193,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,