From 97d391a6360f029b03af255ef605d895ffaa8863 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Thu, 18 Feb 2021 01:32:40 +0000 Subject: [PATCH] [Security Solution] Detection rules for case UI (#91434) * Adding type field to client * Removing context and adding association type * Handle alerts from multiple indices * Adding flow for adding a sub case * Making progress on creating alerts from rules * Refactored add comment to handle case and sub case * Starting sub case API and refactoring of case client * Fleshing out find cases * Finished the find cases api * Filtering comments by association type * Fixing tests and types * Updating snapshots * Cleaning up comment references * Working unit tests * Fixing integration tests and got ES to work * Unit tests and api integration test working * Refactoring find and get_status * Starting patch, and update * script for sub cases * Removing converted_by and fixing type errors * Adding docs for script * Removing converted_by and fixing integration test * init expanded rows * Adding sub case id to comment routes * Removing stringify comparison * styling * clean up * add status column * styling * hide actions if it has sub-cases * Adding delete api and tests * generated alert * Updating license * missed license files * Integration tests passing * Adding more tests for sub cases * wip * Find int tests, scoped client, patch sub user actions * fixing types and call cluster * fixing get sub case param issue * Adding user actions for sub cases * Preventing alerts on collections and refactoring user * Allowing type to be updated for ind cases * subcases attached to api * combine enum on UI for simplification * Refactoring and writing tests * Fixing sub case status filtering * add alerts count * Adding more tests not allowing gen alerts patch * Working unit tests * Push to connector gets all sub case comments * Writing more tests and cleaning up * Updating push functionality for generated alerts and sub cases * Adding comment about updating collection sync * use CaseType to check if it is a sub-case * fix types and disable selection if it has subcases * isEmpty * Detection rule correctly adding alerts to sub case * update api and functionality to accept sub case * integration part I * fix integration with case connector * Fix manual attach * Fix types * Fix bug when updating * Fix bug with user actions * Fix react key error * Fix bug when pushing a lot of alerts * fix lint error * Fix limit * fix title on sub case * fix unit tests * rm bazel * fix unit tests and cypress test * enable delete case icon * revert change * review * Fix the scripts alerts generation code * temp work * Fix rule types and add migration * fix types * fix types error * Remove query alerts * Fix rules * fix types * fix lint error * fix types * delete a sub case * rm unused i18n * fix delete cases * fix unit tests * fix unit test * update Case type * fix types * fix unit test * final integration between rule and case * fix integration test * fix unit test + bring back connector in action of rule Co-authored-by: Jonathan Buttner Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas --- x-pack/plugins/case/common/api/cases/case.ts | 1 + .../plugins/case/common/api/cases/comment.ts | 7 +- .../case/common/api/cases/user_actions.ts | 1 + x-pack/plugins/case/common/api/helpers.ts | 6 + x-pack/plugins/case/common/constants.ts | 10 + .../case/server/client/cases/create.test.ts | 3 + .../plugins/case/server/client/cases/get.ts | 16 +- .../plugins/case/server/client/cases/mock.ts | 4 + .../case/server/client/cases/update.test.ts | 5 + .../plugins/case/server/client/cases/utils.ts | 1 + .../case/server/client/comments/add.test.ts | 24 ++ .../case/server/client/comments/add.ts | 29 ++- x-pack/plugins/case/server/client/types.ts | 1 + .../case/server/client/user_actions/get.ts | 30 ++- .../plugins/case/server/common/utils.test.ts | 24 ++ .../case/server/connectors/case/index.test.ts | 4 + .../case/server/connectors/case/index.ts | 51 +++- .../case/server/connectors/case/schema.ts | 14 +- .../plugins/case/server/connectors/index.ts | 24 ++ .../api/__fixtures__/mock_saved_objects.ts | 8 + .../api/cases/comments/patch_comment.test.ts | 8 + .../api/cases/comments/post_comment.test.ts | 4 + .../routes/api/cases/patch_cases.test.ts | 3 + .../server/routes/api/cases/post_case.test.ts | 1 + .../user_actions/get_all_user_actions.ts | 35 ++- .../plugins/case/server/routes/api/index.ts | 8 +- .../case/server/routes/api/utils.test.ts | 8 + .../plugins/case/server/routes/api/utils.ts | 7 +- .../server/saved_object_types/comments.ts | 10 + .../server/saved_object_types/migrations.ts | 6 +- .../case/server/scripts/sub_cases/index.ts | 31 ++- .../case/server/services/alerts/index.ts | 7 +- .../server/services/user_actions/index.ts | 15 +- .../security_solution/common/constants.ts | 2 +- .../components/add_comment/index.test.tsx | 7 +- .../cases/components/add_comment/index.tsx | 15 +- .../cases/components/all_cases/actions.tsx | 64 ++--- .../cases/components/all_cases/columns.tsx | 45 +++- .../components/all_cases/expanded_row.tsx | 68 +++++ .../cases/components/all_cases/helpers.ts | 37 +++ .../cases/components/all_cases/index.test.tsx | 4 + .../cases/components/all_cases/index.tsx | 39 ++- .../components/all_cases/translations.ts | 4 + .../components/case_view/helpers.test.tsx | 22 +- .../cases/components/case_view/helpers.ts | 41 +-- .../cases/components/case_view/index.test.tsx | 8 + .../cases/components/case_view/index.tsx | 76 +----- .../components/confirm_delete_case/index.tsx | 10 +- .../confirm_delete_case/translations.ts | 5 + .../connectors/case/alert_fields.tsx | 4 +- .../connectors/case/existing_case.tsx | 101 ++++++-- .../connectors/case/translations.ts | 14 ++ .../cases/components/create/form_context.tsx | 11 +- .../public/cases/components/create/mock.ts | 3 +- .../add_to_case_action.test.tsx | 18 +- .../timeline_actions/add_to_case_action.tsx | 19 +- .../create_case_modal.tsx | 5 +- .../use_create_case_modal/index.tsx | 10 +- .../components/user_action_tree/helpers.tsx | 236 ++++++++++++++++-- .../components/user_action_tree/index.tsx | 97 +++++-- .../user_action_tree/translations.ts | 26 +- .../user_action_alert_comment_event.test.tsx | 28 +-- .../user_action_alert_comment_event.tsx | 58 ++++- .../user_action_copy_link.tsx | 14 +- .../user_action_show_alert.test.tsx | 16 +- .../user_action_show_alert.tsx | 23 +- .../public/cases/containers/api.ts | 98 +++++++- .../public/cases/containers/mock.ts | 8 +- .../public/cases/containers/types.ts | 30 ++- .../cases/containers/use_delete_cases.tsx | 10 +- .../public/cases/containers/use_get_case.tsx | 40 +-- .../containers/use_get_case_user_actions.tsx | 56 +++-- .../containers/use_post_comment.test.tsx | 55 +++- .../cases/containers/use_post_comment.tsx | 12 +- .../cases/containers/use_update_case.test.tsx | 24 +- .../cases/containers/use_update_case.tsx | 72 ++++-- .../containers/use_update_comment.test.tsx | 29 ++- .../cases/containers/use_update_comment.tsx | 5 +- .../public/cases/pages/case_details.tsx | 11 +- .../public/cases/pages/index.tsx | 12 +- .../components/link_to/redirect_to_case.tsx | 32 ++- .../public/common/components/links/index.tsx | 9 +- .../components/alerts_table/actions.test.tsx | 4 +- .../components/alerts_table/actions.tsx | 236 +++++++++++++----- .../investigate_in_timeline_action.tsx | 63 +++-- .../components/alerts_table/types.ts | 2 +- .../detection_engine/alerts/use_query.tsx | 5 +- .../signals/single_bulk_create.ts | 16 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../tests/cases/comments/patch_comment.ts | 24 ++ .../tests/cases/comments/post_comment.ts | 16 ++ .../basic/tests/cases/patch_cases.ts | 16 ++ .../basic/tests/connectors/case.ts | 28 ++- .../case_api_integration/common/lib/mock.ts | 8 +- 95 files changed, 1917 insertions(+), 542 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 49643ca1f4d0..33a93952b0e2 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -123,6 +123,7 @@ export const CaseResponseRt = rt.intersection([ version: rt.string, }), rt.partial({ + subCaseIds: rt.array(rt.string), subCases: rt.array(SubCaseResponseRt), comments: rt.array(CommentResponseRt), }), diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index cfc6099fa4bb..41ad0e87f14d 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -52,7 +52,11 @@ export const ContextTypeUserRt = rt.type({ export const AlertCommentRequestRt = rt.type({ type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), alertId: rt.union([rt.array(rt.string), rt.string]), - index: rt.string, + index: rt.union([rt.array(rt.string), rt.string]), + rule: rt.type({ + id: rt.union([rt.string, rt.null]), + name: rt.union([rt.string, rt.null]), + }), }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); @@ -108,6 +112,7 @@ export const CommentsResponseRt = rt.type({ export const AllCommentsResponseRt = rt.array(CommentResponseRt); +export type AttributesTypeAlerts = rt.TypeOf; export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index de9e88993df9..6c8e0de80903 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -49,6 +49,7 @@ const CaseUserActionResponseRT = rt.intersection([ case_id: rt.string, comment_id: rt.union([rt.string, rt.null]), }), + rt.partial({ sub_case_id: rt.string }), ]); export const CaseUserActionAttributesRt = CaseUserActionBasicRT; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 9c290c0a4d61..00c8ff402c80 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -13,6 +13,7 @@ import { SUB_CASE_DETAILS_URL, SUB_CASES_URL, CASE_PUSH_URL, + SUB_CASE_USER_ACTIONS_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -38,6 +39,11 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str export const getCaseUserActionUrl = (id: string): string => { return CASE_USER_ACTIONS_URL.replace('{case_id}', id); }; + +export const getSubCaseUserActionUrl = (caseID: string, subCaseID: string): string => { + return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +}; + export const getCasePushUrl = (caseId: string, connectorId: string): string => { return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId); }; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 5d34ed120ff6..cc69c7ecc290 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { DEFAULT_MAX_SIGNALS } from '../../security_solution/common/constants'; + export const APP_ID = 'case'; /** @@ -19,6 +21,7 @@ export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`; export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; +export const SUB_CASE_USER_ACTIONS_URL = `${SUB_CASE_DETAILS_URL}/user_actions`; export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; @@ -45,3 +48,10 @@ export const SUPPORTED_CONNECTORS = [ JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID, ]; + +/** + * Alerts + */ + +export const MAX_ALERTS_PER_SUB_CASE = 5000; +export const MAX_GENERATED_ALERTS_PER_SUB_CASE = MAX_ALERTS_PER_SUB_CASE / DEFAULT_MAX_SIGNALS; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index 065825472954..3016a57f2187 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -76,6 +76,7 @@ describe('create', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", @@ -174,6 +175,7 @@ describe('create', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", @@ -239,6 +241,7 @@ describe('create', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts index eab43a0c4d45..ab0b97abbcb7 100644 --- a/x-pack/plugins/case/server/client/cases/get.ts +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -26,19 +26,24 @@ export const get = async ({ includeComments = false, includeSubCaseComments = false, }: GetParams): Promise => { - const theCase = await caseService.getCase({ - client: savedObjectsClient, - id, - }); + const [theCase, subCasesForCaseId] = await Promise.all([ + caseService.getCase({ + client: savedObjectsClient, + id, + }), + caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), + ]); + + const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); if (!includeComments) { return CaseResponseRt.encode( flattenCaseSavedObject({ savedObject: theCase, + subCaseIds, }) ); } - const theComments = await caseService.getAllCaseComments({ client: savedObjectsClient, id, @@ -53,6 +58,7 @@ export const get = async ({ flattenCaseSavedObject({ savedObject: theCase, comments: theComments.saved_objects, + subCaseIds, totalComment: theComments.total, totalAlerts: countAlertsForID({ comments: theComments, id }), }) diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts index 2be9f4105983..809c4ad1ea1b 100644 --- a/x-pack/plugins/case/server/client/cases/mock.ts +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -54,6 +54,10 @@ export const commentAlert: CommentResponse = { id: 'mock-comment-1', alertId: 'alert-id-1', index: 'alert-index-1', + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, type: CommentType.alert as const, created_at: '2019-11-25T21:55:00.177Z', created_by: { diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 53e233c74deb..7a3e4458f25c 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -71,6 +71,7 @@ describe('update', () => { "syncAlerts": true, }, "status": "closed", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", @@ -166,6 +167,7 @@ describe('update', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", @@ -233,6 +235,7 @@ describe('update', () => { "syncAlerts": true, }, "status": "in-progress", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", @@ -300,6 +303,7 @@ describe('update', () => { "syncAlerts": true, }, "status": "closed", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", @@ -371,6 +375,7 @@ describe('update', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 78bdc6d282c6..fda4142bf77c 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -314,6 +314,7 @@ export const getCommentContextFromAttributes = ( type: attributes.type, alertId: attributes.alertId, index: attributes.index, + rule: attributes.rule, }; default: return { diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index 315203a1f5e1..c9b1e4fd1327 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -75,6 +75,10 @@ describe('addComment', () => { type: CommentType.alert, alertId: 'test-id', index: 'test-index', + rule: { + id: 'test-rule1', + name: 'test-rule', + }, }, }); @@ -94,6 +98,10 @@ describe('addComment', () => { "index": "test-index", "pushed_at": null, "pushed_by": null, + "rule": Object { + "id": "test-rule1", + "name": "test-rule", + }, "type": "alert", "updated_at": null, "updated_by": null, @@ -231,6 +239,10 @@ describe('addComment', () => { type: CommentType.alert, alertId: 'test-alert', index: 'test-index', + rule: { + id: 'test-rule1', + name: 'test-rule', + }, }, }); @@ -265,6 +277,10 @@ describe('addComment', () => { type: CommentType.alert, alertId: 'test-alert', index: 'test-index', + rule: { + id: 'test-rule1', + name: 'test-rule', + }, }, }); @@ -406,6 +422,10 @@ describe('addComment', () => { type: CommentType.alert, index: 'test-index', alertId: 'test-id', + rule: { + id: 'test-rule1', + name: 'test-rule', + }, }, }) .catch((e) => { @@ -478,6 +498,10 @@ describe('addComment', () => { type: CommentType.alert, alertId: 'test-alert', index: 'test-index', + rule: { + id: 'test-rule1', + name: 'test-rule', + }, }, }) .catch((e) => { diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 7dd1b4a8f6c5..0a86c1825fed 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -38,6 +38,8 @@ import { import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CommentableCase } from '../../common'; import { CaseClientHandler } from '..'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common/constants'; async function getSubCase({ caseService, @@ -56,7 +58,20 @@ async function getSubCase({ }): Promise> { const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { - return mostRecentSubCase; + const subCaseAlertsAttachement = await caseService.getAllSubCaseComments({ + client: savedObjectsClient, + id: mostRecentSubCase.id, + options: { + fields: [], + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + page: 1, + perPage: 1, + }, + }); + + if (subCaseAlertsAttachement.total <= MAX_GENERATED_ALERTS_PER_SUB_CASE) { + return mostRecentSubCase; + } } const newSubCase = await caseService.createSubCase({ @@ -160,7 +175,11 @@ const addGeneratedAlerts = async ({ await caseClient.updateAlertsStatus({ ids, status: subCase.attributes.status, - indices: new Set([newComment.attributes.index]), + indices: new Set([ + ...(Array.isArray(newComment.attributes.index) + ? newComment.attributes.index + : [newComment.attributes.index]), + ]), }); } @@ -282,7 +301,11 @@ export const addComment = async ({ await caseClient.updateAlertsStatus({ ids, status: updatedCase.status, - indices: new Set([newComment.attributes.index]), + indices: new Set([ + ...(Array.isArray(newComment.attributes.index) + ? newComment.attributes.index + : [newComment.attributes.index]), + ]), }); } diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index a8f64227daf8..ba5677426c22 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -59,6 +59,7 @@ export interface CaseClientGetAlerts { export interface CaseClientGetUserActions { caseId: string; + subCaseId?: string; } export interface MappingsClient { diff --git a/x-pack/plugins/case/server/client/user_actions/get.ts b/x-pack/plugins/case/server/client/user_actions/get.ts index 8a4e45f71b9c..f6371b8e8b1e 100644 --- a/x-pack/plugins/case/server/client/user_actions/get.ts +++ b/x-pack/plugins/case/server/client/user_actions/get.ts @@ -6,7 +6,11 @@ */ import { SavedObjectsClientContract } from 'kibana/server'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; import { CaseUserActionServiceSetup } from '../../services'; @@ -14,24 +18,36 @@ interface GetParams { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; caseId: string; + subCaseId?: string; } export const get = async ({ savedObjectsClient, userActionService, caseId, + subCaseId, }: GetParams): Promise => { const userActions = await userActionService.getUserActions({ client: savedObjectsClient, caseId, + subCaseId, }); return CaseUserActionsResponseRt.encode( - userActions.saved_objects.map((ua) => ({ - ...ua.attributes, - action_id: ua.id, - case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', - comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, - })) + userActions.saved_objects.reduce((acc, ua) => { + if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) { + return acc; + } + return [ + ...acc, + { + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '', + }, + ]; + }, []) ); }; diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts index d89feb009f80..5e6a86358de2 100644 --- a/x-pack/plugins/case/server/common/utils.test.ts +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -99,6 +99,10 @@ describe('common utils', () => { alertId: ['a', 'b', 'c'], index: '', type: CommentType.generatedAlert, + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, }, ], }, @@ -118,6 +122,10 @@ describe('common utils', () => { alertId: ['a', 'b', 'c'], index: '', type: CommentType.alert, + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, }, ], }, @@ -139,6 +147,10 @@ describe('common utils', () => { alertId: ['a', 'b'], index: '', type: CommentType.alert, + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, }, { comment: '', @@ -164,6 +176,10 @@ describe('common utils', () => { alertId: ['a', 'b'], index: '', type: CommentType.alert, + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, }, ], }, @@ -197,6 +213,10 @@ describe('common utils', () => { alertId: ['a', 'b'], index: '', type: CommentType.alert, + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, }, ], }, @@ -224,6 +244,10 @@ describe('common utils', () => { alertId: ['a', 'b'], index: '', type: CommentType.alert, + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, }, ], }, diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 6b7e395bae4d..4be519858db1 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -717,6 +717,10 @@ describe('case connector', () => { type: CommentType.alert, alertId: 'test-id', index: 'test-index', + rule: { + id: null, + name: null, + }, }, }, }; diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 34b407616cfe..a64cba567ce4 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -122,23 +122,48 @@ async function executor( /** * This converts a connector style generated alert ({_id: string} | {_id: string}[]) to the expected format of addComment. */ +interface AttachmentAlerts { + ids: string[]; + indices: string[]; + rule: { id: string | null; name: string | null }; +} export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => { if (isCommentGeneratedAlert(comment)) { - const alertId: string[] = []; - if (Array.isArray(comment.alerts)) { - alertId.push( - ...comment.alerts.map((alert: { _id: string }) => { - return alert._id; - }) + try { + const genAlerts: Array<{ + _id: string; + _index: string; + ruleId: string | undefined; + ruleName: string | undefined; + }> = JSON.parse( + `${comment.alerts.substring(0, comment.alerts.lastIndexOf('__SEPARATOR__'))}]`.replace( + /__SEPARATOR__/gi, + ',' + ) ); - } else { - alertId.push(comment.alerts._id); + + const { ids, indices, rule } = genAlerts.reduce( + (acc, { _id, _index, ruleId, ruleName }) => { + // Mutation is faster than destructing. + // Mutation usually leads to side effects but for this scenario it's ok to do it. + acc.ids.push(_id); + acc.indices.push(_index); + // We assume one rule per batch of alerts + acc.rule = { id: ruleId ?? null, name: ruleName ?? null }; + return acc; + }, + { ids: [], indices: [], rule: { id: null, name: null } } + ); + + return { + type: CommentType.generatedAlert, + alertId: ids, + index: indices, + rule, + }; + } catch (e) { + throw new Error(`Error parsing generated alert in case connector -> ${e.message}`); } - return { - type: CommentType.generatedAlert, - alertId, - index: comment.index, - }; } else { return comment; } diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index cdeb00209f84..ac34ad40cfa1 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -17,17 +17,9 @@ const ContextTypeUserSchema = schema.object({ comment: schema.string(), }); -const AlertIDSchema = schema.object( - { - _id: schema.string(), - }, - { unknowns: 'ignore' } -); - const ContextTypeAlertGroupSchema = schema.object({ type: schema.literal(CommentType.generatedAlert), - alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), - index: schema.string(), + alerts: schema.string(), }); export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type; @@ -37,6 +29,10 @@ const ContextTypeAlertSchema = schema.object({ // allowing either an array or a single value to preserve the previous API of attaching a single alert ID alertId: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), index: schema.string(), + rule: schema.object({ + id: schema.nullable(schema.string()), + name: schema.nullable(schema.string()), + }), }); export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type; diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index 056ccff2733a..898d61301a14 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -65,3 +65,27 @@ export const isCommentAlert = ( ): comment is ContextTypeAlertSchemaType => { return comment.type === CommentType.alert; }; + +/** + * Separator field for the case connector alerts string parser. + */ +const separator = '__SEPARATOR__'; + +interface AlertIDIndex { + _id: string; + _index: string; + ruleId: string; + ruleName: string; +} + +/** + * Creates the format that the connector's parser is expecting, it should result in something like this: + * [{"_id":"1","_index":"index1"}__SEPARATOR__{"_id":"id2","_index":"index2"}__SEPARATOR__] + * + * This should only be used for testing purposes. + */ +export function createAlertsString(alerts: AlertIDIndex[]) { + return `[${alerts.reduce((acc, alert) => { + return `${acc}${JSON.stringify(alert)}${separator}`; + }, '')}]`; +} diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 2fe0be3e08ed..e67a6f6dd334 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -346,6 +346,10 @@ export const mockCaseComments: Array> = [ }, pushed_at: null, pushed_by: null, + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', @@ -379,6 +383,10 @@ export const mockCaseComments: Array> = [ }, pushed_at: null, pushed_by: null, + rule: { + id: 'rule-id-2', + name: 'rule-name-2', + }, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 9dec910f9fc4..1ebd336c83af 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -69,6 +69,10 @@ describe('PATCH comment', () => { type: CommentType.alert, alertId: 'new-id', index: 'test-index', + rule: { + id: 'rule-id', + name: 'rule', + }, id: commentID, version: 'WzYsMV0=', }, @@ -218,6 +222,10 @@ describe('PATCH comment', () => { type: CommentType.alert, alertId: 'test-id', index: 'test-index', + rule: { + id: 'rule-id', + name: 'rule', + }, id: 'mock-comment-1', version: 'WzEsMV0=', }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index fb51b8f76d0e..807ec0d089a5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -68,6 +68,10 @@ describe('POST comment', () => { type: CommentType.alert, alertId: 'test-id', index: 'test-index', + rule: { + id: 'rule-id', + name: 'rule-name', + }, }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index e50d14e5c66c..b3f87211c954 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -81,6 +81,7 @@ describe('PATCH cases', () => { "syncAlerts": true, }, "status": "closed", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", @@ -154,6 +155,7 @@ describe('PATCH cases', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", @@ -222,6 +224,7 @@ describe('PATCH cases', () => { "syncAlerts": true, }, "status": "in-progress", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 53829157c5b0..e1669203d3de 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -213,6 +213,7 @@ describe('POST cases', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts index 06e929cc40e6..488f32a79581 100644 --- a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -9,9 +9,9 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; +import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; -export function initGetAllUserActionsApi({ router }: RouteDeps) { +export function initGetAllCaseUserActionsApi({ router }: RouteDeps) { router.get( { path: CASE_USER_ACTIONS_URL, @@ -39,3 +39,34 @@ export function initGetAllUserActionsApi({ router }: RouteDeps) { } ); } + +export function initGetAllSubCaseUserActionsApi({ router }: RouteDeps) { + router.get( + { + path: SUB_CASE_USER_ACTIONS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + sub_case_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + + const caseClient = context.case.getCaseClient(); + const caseId = request.params.case_id; + const subCaseId = request.params.sub_case_id; + + try { + return response.ok({ + body: await caseClient.getUserActions({ caseId, subCaseId }), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index f2fd986dd8a3..12d1da36077c 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -14,7 +14,10 @@ import { initPushCaseApi } from './cases/push_case'; import { initGetReportersApi } from './cases/reporters/get_reporters'; import { initGetCasesStatusApi } from './cases/status/get_status'; import { initGetTagsApi } from './cases/tags/get_tags'; -import { initGetAllUserActionsApi } from './cases/user_actions/get_all_user_actions'; +import { + initGetAllCaseUserActionsApi, + initGetAllSubCaseUserActionsApi, +} from './cases/user_actions/get_all_user_actions'; import { initDeleteCommentApi } from './cases/comments/delete_comment'; import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; @@ -52,7 +55,8 @@ export function initCaseApi(deps: RouteDeps) { initPatchCasesApi(deps); initPostCaseApi(deps); initPushCaseApi(deps); - initGetAllUserActionsApi(deps); + initGetAllCaseUserActionsApi(deps); + initGetAllSubCaseUserActionsApi(deps); // Sub cases initGetSubCaseApi(deps); initPatchSubCasesApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 1efec927efb6..f6bc1e4f7189 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -401,6 +401,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", @@ -440,6 +441,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "Data Destruction", @@ -483,6 +485,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", @@ -530,6 +533,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "closed", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", @@ -594,6 +598,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", @@ -649,6 +654,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", @@ -727,6 +733,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "LOLBins", @@ -781,6 +788,7 @@ describe('Utils', () => { "syncAlerts": true, }, "status": "open", + "subCaseIds": undefined, "subCases": undefined, "tags": Array [ "defacement", diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index bc82f656f477..084b1a17a143 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import { badRequest, boomify, isBoom } from '@hapi/boom'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; @@ -120,7 +121,8 @@ export interface AlertInfo { const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => { if (isCommentRequestTypeAlertOrGenAlert(comment)) { acc.ids.push(...getAlertIds(comment)); - acc.indices.add(comment.index); + const indices = Array.isArray(comment.index) ? comment.index : [comment.index]; + indices.forEach((index) => acc.indices.add(index)); } return acc; }; @@ -249,12 +251,14 @@ export const flattenCaseSavedObject = ({ totalComment = comments.length, totalAlerts = 0, subCases, + subCaseIds, }: { savedObject: SavedObject; comments?: Array>; totalComment?: number; totalAlerts?: number; subCases?: SubCaseResponse[]; + subCaseIds?: string[]; }): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', @@ -264,6 +268,7 @@ export const flattenCaseSavedObject = ({ ...savedObject.attributes, connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), subCases, + subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined, }); export const flattenSubCaseSavedObject = ({ diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 9eabf744f2e1..a4fdc24b6e4e 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -63,6 +63,16 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, + rule: { + properties: { + id: { + type: 'keyword', + }, + name: { + type: 'keyword', + }, + }, + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index a0b22c49d0bc..21ef27de1ec8 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -173,8 +173,9 @@ interface SanitizedComment { type: CommentType; } -interface SanitizedCommentAssociationType { +interface SanitizedCommentFoSubCases { associationType: AssociationType; + rule: { id: string | null; name: string | null }; } export const commentsMigrations = { @@ -192,11 +193,12 @@ export const commentsMigrations = { }, '7.12.0': ( doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { + ): SavedObjectSanitizedDoc => { return { ...doc, attributes: { ...doc.attributes, + rule: { id: null, name: null }, associationType: AssociationType.case, }, references: doc.references || [], diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts index 2ea9718d1848..9dd577c40c74 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -16,6 +16,7 @@ import { import { CommentType } from '../../../common/api/cases/comment'; import { CASES_URL } from '../../../common/constants'; import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; +import { ContextTypeGeneratedAlertType, createAlertsString } from '../../connectors'; main(); @@ -105,6 +106,18 @@ async function handleGenGroupAlerts(argv: any) { } console.log('Case id: ', caseID); + const comment: ContextTypeGeneratedAlertType = { + type: CommentType.generatedAlert, + alerts: createAlertsString( + argv.ids.map((id: string) => ({ + _id: id, + _index: argv.signalsIndex, + ruleId: argv.ruleID, + ruleName: argv.ruleName, + })) + ), + }; + const executeResp = await client.request< ActionTypeExecutorResult >({ @@ -115,11 +128,7 @@ async function handleGenGroupAlerts(argv: any) { subAction: 'addComment', subActionParams: { caseId: caseID, - comment: { - type: CommentType.generatedAlert, - alerts: argv.ids.map((id: string) => ({ _id: id })), - index: argv.signalsIndex, - }, + comment, }, }, }, @@ -175,6 +184,18 @@ async function main() { type: 'string', default: '.siem-signals-default', }, + ruleID: { + alias: 'ri', + describe: 'siem signals rule id', + type: 'string', + default: 'rule-id', + }, + ruleName: { + alias: 'rn', + describe: 'siem signals rule name', + type: 'string', + default: 'rule-name', + }, }) .demandOption(['ids']); }, diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 320d32ac0d78..a19e533418bc 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -11,6 +11,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { ElasticsearchClient } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; +import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants'; export type AlertServiceContract = PublicMethodsOf; @@ -95,14 +96,14 @@ export class AlertService { query: { bool: { filter: { - bool: { - should: ids.map((_id) => ({ match: { _id } })), - minimum_should_match: 1, + ids: { + values: ids, }, }, }, }, }, + size: MAX_ALERTS_PER_SUB_CASE, ignore_unavailable: true, }); diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts index 091775827c6a..d05ada0dba30 100644 --- a/x-pack/plugins/case/server/services/user_actions/index.ts +++ b/x-pack/plugins/case/server/services/user_actions/index.ts @@ -13,11 +13,16 @@ import { } from 'kibana/server'; import { CaseUserActionAttributes } from '../../../common/api'; -import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_USER_ACTION_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; import { ClientArgs } from '..'; interface GetCaseUserActionArgs extends ClientArgs { caseId: string; + subCaseId?: string; } export interface UserActionItem { @@ -41,18 +46,20 @@ export interface CaseUserActionServiceSetup { export class CaseUserActionService { constructor(private readonly log: Logger) {} public setup = async (): Promise => ({ - getUserActions: async ({ client, caseId }: GetCaseUserActionArgs) => { + getUserActions: async ({ client, caseId, subCaseId }: GetCaseUserActionArgs) => { try { + const id = subCaseId ?? caseId; + const type = subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; const caseUserActionInfo = await client.find({ type: CASE_USER_ACTION_SAVED_OBJECT, fields: [], - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + hasReference: { type, id }, page: 1, perPage: 1, }); return await client.find({ type: CASE_USER_ACTION_SAVED_OBJECT, - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + hasReference: { type, id }, page: 1, perPage: caseUserActionInfo.total, sortField: 'action_at', diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index bc71df5d9e00..31b4cef1a9d4 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -171,7 +171,7 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID]; /* Rule notifications options */ -export const ENABLE_CASE_CONNECTOR = false; +export const ENABLE_CASE_CONNECTOR = true; export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.email', '.slack', diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx index c447e00cbb94..d02f7e0ee096 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx @@ -79,7 +79,12 @@ describe('AddComment ', () => { await waitFor(() => { expect(onCommentSaving).toBeCalled(); - expect(postComment).toBeCalledWith(addCommentProps.caseId, sampleData, onCommentPosted); + expect(postComment).toBeCalledWith({ + caseId: addCommentProps.caseId, + data: sampleData, + subCaseId: undefined, + updateCase: onCommentPosted, + }); expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(''); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index 01b86a989e02..c94ef75523e2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -39,11 +39,15 @@ interface AddCommentProps { onCommentSaving?: () => void; onCommentPosted: (newCase: Case) => void; showLoading?: boolean; + subCaseId?: string; } export const AddComment = React.memo( forwardRef( - ({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => { + ( + { caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, + ref + ) => { const { isLoading, postComment } = usePostComment(); const { form } = useForm({ @@ -80,10 +84,15 @@ export const AddComment = React.memo( if (onCommentSaving != null) { onCommentSaving(); } - postComment(caseId, { ...data, type: CommentType.user }, onCommentPosted); + postComment({ + caseId, + data: { ...data, type: CommentType.user }, + updateCase: onCommentPosted, + subCaseId, + }); reset(); } - }, [onCommentPosted, onCommentSaving, postComment, reset, submit, caseId]); + }, [caseId, onCommentPosted, onCommentSaving, postComment, reset, submit, subCaseId]); return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx index ae2d98795599..8178e7e9f9e8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx @@ -9,7 +9,7 @@ import { Dispatch } from 'react'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; import { CaseStatuses } from '../../../../../case/common/api'; -import { Case } from '../../containers/types'; +import { Case, SubCase } from '../../containers/types'; import { UpdateCase } from '../../containers/use_get_cases'; import * as i18n from './translations'; @@ -19,6 +19,9 @@ interface GetActions { deleteCaseOnClick: (deleteCase: Case) => void; } +const hasSubCases = (subCases: SubCase[] | null | undefined) => + subCases != null && subCases?.length > 0; + export const getActions = ({ caseStatus, dispatchUpdate, @@ -32,33 +35,34 @@ export const getActions = ({ type: 'icon', 'data-test-subj': 'action-delete', }, - caseStatus === CaseStatuses.open - ? { - description: i18n.CLOSE_CASE, - icon: 'folderCheck', - name: i18n.CLOSE_CASE, - onClick: (theCase: Case) => - dispatchUpdate({ - updateKey: 'status', - updateValue: CaseStatuses.closed, - caseId: theCase.id, - version: theCase.version, - }), - type: 'icon', - 'data-test-subj': 'action-close', - } - : { - description: i18n.REOPEN_CASE, - icon: 'folderExclamation', - name: i18n.REOPEN_CASE, - onClick: (theCase: Case) => - dispatchUpdate({ - updateKey: 'status', - updateValue: CaseStatuses.open, - caseId: theCase.id, - version: theCase.version, - }), - type: 'icon', - 'data-test-subj': 'action-open', - }, + { + available: (item) => caseStatus === CaseStatuses.open && !hasSubCases(item.subCases), + description: i18n.CLOSE_CASE, + icon: 'folderCheck', + name: i18n.CLOSE_CASE, + onClick: (theCase: Case) => + dispatchUpdate({ + updateKey: 'status', + updateValue: CaseStatuses.closed, + caseId: theCase.id, + version: theCase.version, + }), + type: 'icon', + 'data-test-subj': 'action-close', + }, + { + available: (item) => caseStatus !== CaseStatuses.open && !hasSubCases(item.subCases), + description: i18n.REOPEN_CASE, + icon: 'folderExclamation', + name: i18n.REOPEN_CASE, + onClick: (theCase: Case) => + dispatchUpdate({ + updateKey: 'status', + updateValue: CaseStatuses.open, + caseId: theCase.id, + version: theCase.version, + }), + type: 'icon', + 'data-test-subj': 'action-open', + }, ]; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx index 38f1b343670c..47db362c7b4b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx @@ -14,17 +14,20 @@ import { EuiTableActionsColumnType, EuiTableComputedColumnType, EuiTableFieldDataColumnType, - HorizontalAlignment, } from '@elastic/eui'; +import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; import { CaseStatuses } from '../../../../../case/common/api'; import { getEmptyTagValue } from '../../../common/components/empty_value'; -import { Case } from '../../containers/types'; +import { Case, SubCase } from '../../containers/types'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { CaseDetailsLink } from '../../../common/components/links'; import * as i18n from './translations'; +import { Status } from '../status'; +import { getSubCasesStatusCountsBadges, isSubCase } from './helpers'; +import { ALERTS } from '../../../app/home/translations'; export type CasesColumns = | EuiTableFieldDataColumnType @@ -54,10 +57,14 @@ export const getCasesColumns = ( const columns = [ { name: i18n.NAME, - render: (theCase: Case) => { + render: (theCase: Case | SubCase) => { if (theCase.id != null && theCase.title != null) { const caseDetailsLinkComponent = !isModal ? ( - + {theCase.title} ) : ( @@ -122,7 +129,17 @@ export const getCasesColumns = ( truncateText: true, }, { - align: 'right' as HorizontalAlignment, + align: RIGHT_ALIGNMENT, + field: 'totalAlerts', + name: ALERTS, + sortable: true, + render: (totalAlerts: Case['totalAlerts']) => + totalAlerts != null + ? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`) + : getEmptyTagValue(), + }, + { + align: RIGHT_ALIGNMENT, field: 'totalComment', name: i18n.COMMENTS, sortable: true, @@ -183,6 +200,24 @@ export const getCasesColumns = ( return getEmptyTagValue(); }, }, + { + name: i18n.STATUS, + render: (theCase: Case) => { + if (theCase?.subCases == null || theCase.subCases.length === 0) { + if (theCase.status == null) { + return getEmptyTagValue(); + } + return ; + } + + const badges = getSubCasesStatusCountsBadges(theCase.subCases); + return badges.map(({ color, count }, index) => ( + + {count} + + )); + }, + }, { name: i18n.ACTIONS, actions, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx new file mode 100644 index 000000000000..bb4bd0f98949 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui'; +import styled from 'styled-components'; +import { Case } from '../../containers/types'; +import { CasesColumns } from './columns'; +import { AssociationType } from '../../../../../case/common/api'; + +type ExpandedRowMap = Record | {}; + +const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any +const BasicTable = styled(EuiBasicTable)` + thead { + display: none; + } + + tbody { + .euiTableCellContent { + padding: 8px !important; + } + .euiTableRowCell { + border: 0; + } + } +`; +BasicTable.displayName = 'BasicTable'; + +export const getExpandedRowMap = ({ + data, + columns, +}: { + data: Case[] | null; + columns: CasesColumns[]; +}): ExpandedRowMap => { + if (data == null) { + return {}; + } + + return data.reduce((acc, curr) => { + if (curr.subCases != null) { + const subCases = curr.subCases.map((subCase, index) => ({ + ...subCase, + caseParentId: curr.id, + title: `${curr.title} ${index + 1}`, + associationType: AssociationType.subCase, + })); + return { + ...acc, + [curr.id]: ( + + ), + }; + } else { + return acc; + } + }, {}); +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts new file mode 100644 index 000000000000..1ab36d3c6722 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { filter } from 'lodash/fp'; +import { AssociationType, CaseStatuses } from '../../../../../case/common/api'; +import { Case, SubCase } from '../../containers/types'; +import { statuses } from '../status'; + +export const isSubCase = (theCase: Case | SubCase): theCase is SubCase => + (theCase as SubCase).caseParentId !== undefined && + (theCase as SubCase).associationType === AssociationType.subCase; + +export const getSubCasesStatusCountsBadges = ( + subCases: SubCase[] +): Array<{ name: CaseStatuses; color: string; count: number }> => { + return [ + { + name: CaseStatuses.open, + color: statuses[CaseStatuses.open].color, + count: filter({ status: CaseStatuses.open }, subCases).length, + }, + { + name: CaseStatuses['in-progress'], + color: statuses[CaseStatuses['in-progress']].color, + count: filter({ status: CaseStatuses['in-progress'] }, subCases).length, + }, + { + name: CaseStatuses.closed, + color: statuses[CaseStatuses.closed].color, + count: filter({ status: CaseStatuses.closed }, subCases).length, + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 318143426af5..a44ccd238484 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -210,9 +210,12 @@ describe('AllCases', () => { id: null, createdAt: null, createdBy: null, + status: null, + subCases: null, tags: null, title: null, totalComment: null, + totalAlerts: null, }, ], }, @@ -542,6 +545,7 @@ describe('AllCases', () => { }, id: '1', status: 'open', + subCaseIds: [], tags: ['coke', 'pepsi'], title: 'Another horrible breach!!', totalAlerts: 0, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index 001251a8a71a..ce0fea07bf47 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - EuiBasicTable, + EuiBasicTable as _EuiBasicTable, EuiContextMenuPanel, EuiEmptyPrompt, EuiFlexGroup, @@ -53,6 +53,7 @@ import { SecurityPageName } from '../../../app/types'; import { useKibana } from '../../../common/lib/kibana'; import { APP_ID } from '../../../../common/constants'; import { Stats } from '../status'; +import { getExpandedRowMap } from './expanded_row'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -83,6 +84,14 @@ const getSortField = (field: string): SortFieldCase => { return SortFieldCase.createdAt; }; +const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any +const BasicTable = styled(EuiBasicTable)` + .euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent { + padding: 8px 0 8px 32px; + } +`; +BasicTable.displayName = 'BasicTable'; + interface AllCasesProps { onRowClick?: (theCase?: Case) => void; isModal?: boolean; @@ -130,7 +139,7 @@ export const AllCases = React.memo( isUpdated, updateBulkStatus, } = useUpdateCases(); - const [deleteThisCase, setDeleteThisCase] = useState({ + const [deleteThisCase, setDeleteThisCase] = useState({ title: '', id: '', }); @@ -190,7 +199,7 @@ export const AllCases = React.memo( const toggleDeleteModal = useCallback( (deleteCase: Case) => { handleToggleModal(); - setDeleteThisCase(deleteCase); + setDeleteThisCase({ id: deleteCase.id, title: deleteCase.title, type: deleteCase.type }); }, [handleToggleModal] ); @@ -201,7 +210,11 @@ export const AllCases = React.memo( if (caseIds.length === 1) { const singleCase = selectedCases.find((theCase) => theCase.id === caseIds[0]); if (singleCase) { - return setDeleteThisCase({ id: singleCase.id, title: singleCase.title }); + return setDeleteThisCase({ + id: singleCase.id, + title: singleCase.title, + type: singleCase.type, + }); } } const convertToDeleteCases: DeleteCase[] = caseIds.map((id) => ({ id })); @@ -315,6 +328,16 @@ export const AllCases = React.memo( () => getCasesColumns(userCanCrud ? actions : [], filterOptions.status, isModal), [actions, filterOptions.status, userCanCrud, isModal] ); + + const itemIdToExpandedRowMap = useMemo( + () => + getExpandedRowMap({ + columns: memoizedGetCasesColumns, + data: data.cases, + }), + [data.cases, memoizedGetCasesColumns] + ); + const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, @@ -330,7 +353,10 @@ export const AllCases = React.memo( }; const euiBasicTableSelectionProps = useMemo>( - () => ({ onSelectionChange: setSelectedCases }), + () => ({ + selectable: (theCase) => isEmpty(theCase.subCases), + onSelectionChange: setSelectedCases, + }), [setSelectedCases] ); const isCasesLoading = useMemo( @@ -472,12 +498,13 @@ export const AllCases = React.memo( )} - {i18n.NO_CASES}} diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/translations.ts b/x-pack/plugins/security_solution/public/cases/components/all_cases/translations.ts index bcc5df6c39e5..3b27ef25eda1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/translations.ts @@ -103,3 +103,7 @@ export const SERVICENOW_LINK_ARIA = i18n.translate( defaultMessage: 'click to view the incident on servicenow', } ); + +export const STATUS = i18n.translate('xpack.securitySolution.case.caseTable.status', { + defaultMessage: 'Status', +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx index a1ee825aa533..70e6636cc737 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -8,7 +8,7 @@ import { AssociationType, CommentType } from '../../../../../case/common/api'; import { Comment } from '../../containers/types'; -import { getRuleIdsFromComments, buildAlertsQuery } from './helpers'; +import { getManualAlertIdsWithNoRuleId, buildAlertsQuery } from './helpers'; const comments: Comment[] = [ { @@ -19,6 +19,10 @@ const comments: Comment[] = [ id: 'comment-id', createdAt: '2020-02-19T23:06:33.798Z', createdBy: { username: 'elastic' }, + rule: { + id: null, + name: null, + }, pushedAt: null, pushedBy: null, updatedAt: null, @@ -35,6 +39,10 @@ const comments: Comment[] = [ createdBy: { username: 'elastic' }, pushedAt: null, pushedBy: null, + rule: { + id: 'rule-id-2', + name: 'rule-name-2', + }, updatedAt: null, updatedBy: null, version: 'WzQ3LDFc', @@ -42,9 +50,9 @@ const comments: Comment[] = [ ]; describe('Case view helpers', () => { - describe('getRuleIdsFromComments', () => { - it('it returns the rules ids from the comments', () => { - expect(getRuleIdsFromComments(comments)).toEqual(['alert-id-1', 'alert-id-2']); + describe('getAlertIdsFromComments', () => { + it('it returns the alert id from the comments where rule is not defined', () => { + expect(getManualAlertIdsWithNoRuleId(comments)).toEqual(['alert-id-1']); }); }); @@ -54,13 +62,13 @@ describe('Case view helpers', () => { query: { bool: { filter: { - bool: { - should: [{ match: { _id: 'alert-id-1' } }, { match: { _id: 'alert-id-2' } }], - minimum_should_match: 1, + ids: { + values: ['alert-id-1', 'alert-id-2'], }, }, }, }, + size: 10000, }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts index 6b92e414675e..3dece29e64ac 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts @@ -5,28 +5,37 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import { CommentType } from '../../../../../case/common/api'; import { Comment } from '../../containers/types'; -export const getRuleIdsFromComments = (comments: Comment[]) => - comments.reduce((ruleIds, comment: Comment) => { - if (comment.type === CommentType.alert) { +export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => { + const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => { + if (comment.type === CommentType.alert && isEmpty(comment.rule.id)) { const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; - return [...ruleIds, ...ids]; + ids.forEach((id) => alertIds.add(id)); + return alertIds; } + return alertIds; + }, new Set()); + return [...dedupeAlerts]; +}; - return ruleIds; - }, []); - -export const buildAlertsQuery = (ruleIds: string[]) => ({ - query: { - bool: { - filter: { - bool: { - should: ruleIds.map((_id) => ({ match: { _id } })), - minimum_should_match: 1, +// TODO we need to allow -> docValueFields: [{ field: "@timestamp" }], +export const buildAlertsQuery = (alertIds: string[]) => { + if (alertIds.length === 0) { + return {}; + } + return { + query: { + bool: { + filter: { + ids: { + values: alertIds, + }, }, }, }, - }, -}); + size: 10000, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index dc0ef9ad026a..7a5f6647a8dc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -629,6 +629,14 @@ describe('CaseView ', () => { loading: true, data: { hits: { hits: [] } }, })); + useGetCaseUserActionsMock.mockReturnValue({ + caseServices: {}, + caseUserActions: [], + hasDataToPush: false, + isError: false, + isLoading: true, + participants: [], + }); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 0eaa867077a4..e42431e55ee2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -42,8 +42,6 @@ import { normalizeActionConnector, getNoneConnector, } from '../configure_cases/utils'; -import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; -import { buildAlertsQuery, getRuleIdsFromComments } from './helpers'; import { DetailsPanel } from '../../../timelines/components/side_panel'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; @@ -55,6 +53,7 @@ import * as i18n from './translations'; interface Props { caseId: string; + subCaseId?: string; userCanCrud: boolean; } @@ -87,32 +86,8 @@ export interface CaseProps extends Props { updateCase: (newCase: Case) => void; } -interface Signal { - rule: { - id: string; - name: string; - to: string; - from: string; - }; -} - -interface SignalHit { - _id: string; - _index: string; - _source: { - '@timestamp': string; - signal: Signal; - }; -} - -export type Alert = { - _id: string; - _index: string; - '@timestamp': string; -} & Signal; - export const CaseComponent = React.memo( - ({ caseId, caseData, fetchCase, updateCase, userCanCrud }) => { + ({ caseId, caseData, fetchCase, subCaseId, updateCase, userCanCrud }) => { const dispatch = useDispatch(); const { formatUrl, search } = useFormatUrl(SecurityPageName.case); const allCasesLink = getCaseUrl(search); @@ -127,45 +102,18 @@ export const CaseComponent = React.memo( hasDataToPush, isLoading: isLoadingUserActions, participants, - } = useGetCaseUserActions(caseId, caseData.connector.id); + } = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId); const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({ caseId, + subCaseId, }); - const alertsQuery = useMemo(() => buildAlertsQuery(getRuleIdsFromComments(caseData.comments)), [ - caseData.comments, - ]); - /** * For the future developer: useSourcererScope is security solution dependent. * You can use useSignalIndex as an alternative. */ - const { browserFields, docValueFields, selectedPatterns } = useSourcererScope( - SourcererScopeName.detections - ); - - const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts( - alertsQuery, - selectedPatterns[0] - ); - - const alerts = useMemo( - () => - alertsData?.hits.hits.reduce>( - (acc, { _id, _index, _source }) => ({ - ...acc, - [_id]: { - _id, - _index, - '@timestamp': _source['@timestamp'], - ..._source.signal, - }, - }), - {} - ) ?? {}, - [alertsData?.hits.hits] - ); + const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections); // Update Fields const onUpdateField = useCallback( @@ -350,10 +298,10 @@ export const CaseComponent = React.memo( ); useEffect(() => { - if (initLoadingData && !isLoadingUserActions && !isLoadingAlerts) { + if (initLoadingData && !isLoadingUserActions) { setInitLoadingData(false); } - }, [initLoadingData, isLoadingAlerts, isLoadingUserActions]); + }, [initLoadingData, isLoadingUserActions]); const backOptions = useMemo( () => ({ @@ -435,18 +383,17 @@ export const CaseComponent = React.memo( {!initLoadingData && ( <> @@ -513,8 +460,8 @@ export const CaseComponent = React.memo( } ); -export const CaseView = React.memo(({ caseId, userCanCrud }: Props) => { - const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId); +export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => { + const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId); if (isError) { return null; } @@ -531,6 +478,7 @@ export const CaseView = React.memo(({ caseId, userCanCrud }: Props) => { return ( void; @@ -36,7 +36,13 @@ const ConfirmDeleteCaseModalComp: React.FC = ({ defaultFocusedButton="confirm" onCancel={onCancel} onConfirm={onConfirm} - title={isPlural ? i18n.DELETE_SELECTED_CASES : i18n.DELETE_TITLE(caseTitle)} + title={ + isPlural + ? i18n.DELETE_SELECTED_CASES + : caseTitle == null + ? i18n.DELETE_THIS_CASE + : i18n.DELETE_TITLE(caseTitle) + } > {isPlural ? i18n.CONFIRM_QUESTION_PLURAL : i18n.CONFIRM_QUESTION} diff --git a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/translations.ts b/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/translations.ts index 2c2ebc536079..0bd37fa18281 100644 --- a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/translations.ts @@ -14,6 +14,11 @@ export const DELETE_TITLE = (caseTitle: string) => defaultMessage: 'Delete "{caseTitle}"', }); +export const DELETE_THIS_CASE = (caseTitle: string) => + i18n.translate('xpack.securitySolution.case.confirmDeleteCase.deleteThisCase', { + defaultMessage: 'Delete this case', + }); + export const CONFIRM_QUESTION = i18n.translate( 'xpack.securitySolution.case.confirmDeleteCase.confirmQuestion', { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx index 656257f2b36c..d5c90bd09a6d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx @@ -27,9 +27,7 @@ const Container = styled.div` const defaultAlertComment = { type: CommentType.generatedAlert, - alerts: '{{context.alerts}}', - index: '{{context.rule.output_index}}', - ruleId: '{{context.rule.id}}', + alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{rule.id}}", "ruleName": "{{rule.name}}"}__SEPARATOR__{{/context.alerts}}]`, }; const CaseParamsFields: React.FunctionComponent> = ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx index 348e488a36a8..1c786bade975 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx @@ -5,10 +5,22 @@ * 2.0. */ -import React, { memo, useMemo, useCallback } from 'react'; -import { useGetCases } from '../../../containers/use_get_cases'; +import { + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiTextColor, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React, { memo, useEffect, useCallback, useState } from 'react'; +import { CaseType } from '../../../../../../case/common/api'; +import { Case } from '../../../containers/types'; +import { useDeleteCases } from '../../../containers/use_delete_cases'; +import { useGetCase } from '../../../containers/use_get_case'; +import { ConfirmDeleteCaseModal } from '../../confirm_delete_case'; import { useCreateCaseModal } from '../../use_create_case_modal'; -import { CasesDropdown, ADD_CASE_BUTTON_ID } from './cases_dropdown'; +import * as i18n from './translations'; interface ExistingCaseProps { selectedCase: string | null; @@ -16,37 +28,72 @@ interface ExistingCaseProps { } const ExistingCaseComponent: React.FC = ({ onCaseChanged, selectedCase }) => { - const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases(); + const { data, isLoading, isError } = useGetCase(selectedCase ?? ''); + const [createdCase, setCreatedCase] = useState(null); - const onCaseCreated = useCallback(() => refetchCases(), [refetchCases]); - - const { modal, openModal } = useCreateCaseModal({ onCaseCreated }); - - const onChange = useCallback( - (id: string) => { - if (id === ADD_CASE_BUTTON_ID) { - openModal(); - return; - } - - onCaseChanged(id); + const onCaseCreated = useCallback( + (newCase: Case) => { + onCaseChanged(newCase.id); + setCreatedCase(newCase); }, - [onCaseChanged, openModal] + [onCaseChanged] ); - const isCasesLoading = useMemo( - () => isLoadingCases.includes('cases') || isLoadingCases.includes('caseUpdate'), - [isLoadingCases] - ); + const { modal, openModal } = useCreateCaseModal({ caseType: CaseType.collection, onCaseCreated }); + + // Delete case + const { + dispatchResetIsDeleted, + handleOnDeleteConfirm, + handleToggleModal, + isLoading: isDeleting, + isDeleted, + isDisplayConfirmDeleteModal, + } = useDeleteCases(); + + useEffect(() => { + if (isDeleted) { + setCreatedCase(null); + onCaseChanged(''); + dispatchResetIsDeleted(); + } + }, [isDeleted, dispatchResetIsDeleted, onCaseChanged]); + + useEffect(() => { + if (!isLoading && !isError && data != null) { + setCreatedCase(data); + onCaseChanged(data.id); + } + }, [data, isLoading, isError, onCaseChanged]); return ( <> - + {createdCase == null && isEmpty(selectedCase) && ( + + {i18n.CREATE_CASE} + + )} + {createdCase == null && isLoading && } + {createdCase != null && !isLoading && ( + <> + + + {createdCase.title}{' '} + {!isDeleting && ( + + )} + {isDeleting && } + + + + + )} {modal} ); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts index c9553455f687..731e94a17d92 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts @@ -85,3 +85,17 @@ export const CASE_CONNECTOR_ADD_NEW_CASE = i18n.translate( defaultMessage: 'Add new case', } ); + +export const CREATE_CASE = i18n.translate( + 'xpack.securitySolution.case.components.connectors.case.createCaseLabel', + { + defaultMessage: 'Create case', + } +); + +export const CONNECTED_CASE = i18n.translate( + 'xpack.securitySolution.case.components.connectors.case.connectedCaseLabel', + { + defaultMessage: 'Connected case', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index cc38e07cf49e..83b8870ab597 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -19,6 +19,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service' import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; +import { CaseType } from '../../../../../case/common/api'; const initialCaseValue: FormProps = { description: '', @@ -30,10 +31,15 @@ const initialCaseValue: FormProps = { }; interface Props { + caseType?: CaseType; onSuccess?: (theCase: Case) => void; } -export const FormContext: React.FC = ({ children, onSuccess }) => { +export const FormContext: React.FC = ({ + caseType = CaseType.individual, + children, + onSuccess, +}) => { const { connectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); @@ -61,6 +67,7 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { const updatedCase = await postCase({ ...dataWithoutConnectorId, + type: caseType, connector: connectorToUpdate, settings: { syncAlerts }, }); @@ -77,7 +84,7 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { } } }, - [connectors, postCase, onSuccess, pushCaseToExternalService] + [caseType, connectors, postCase, onSuccess, pushCaseToExternalService] ); const { form } = useForm({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts index 909b49940e18..81a7fe9cd938 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CasePostRequest } from '../../../../../case/common/api'; +import { CasePostRequest, CaseType } from '../../../../../case/common/api'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; import { choices } from '../connectors/mock'; @@ -14,6 +14,7 @@ export const sampleData: CasePostRequest = { description: 'what a great description', tags: sampleTags, title: 'what a cool title', + type: CaseType.individual, connector: { fields: null, id: 'none', diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index dc331e81c62d..aa1305f1f655 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -163,10 +163,14 @@ describe('AddToCaseAction', () => { wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); - expect(postComment.mock.calls[0][0]).toBe('new-case'); - expect(postComment.mock.calls[0][1]).toEqual({ + expect(postComment.mock.calls[0][0].caseId).toBe('new-case'); + expect(postComment.mock.calls[0][0].data).toEqual({ alertId: 'test-id', index: 'test-index', + rule: { + id: null, + name: null, + }, type: 'alert', }); }); @@ -196,10 +200,14 @@ describe('AddToCaseAction', () => { wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click'); - expect(postComment.mock.calls[0][0]).toBe('selected-case'); - expect(postComment.mock.calls[0][1]).toEqual({ + expect(postComment.mock.calls[0][0].caseId).toBe('selected-case'); + expect(postComment.mock.calls[0][0].data).toEqual({ alertId: 'test-id', index: 'test-index', + rule: { + id: null, + name: null, + }, type: 'alert', }); }); @@ -208,7 +216,7 @@ describe('AddToCaseAction', () => { usePostCommentMock.mockImplementation(() => { return { ...defaultPostComment, - postComment: jest.fn().mockImplementation((caseId, data, updateCase) => updateCase()), + postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => updateCase()), }; }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 1b21db049156..aa9cec2d6b5b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -44,6 +44,7 @@ const AddToCaseActionComponent: React.FC = ({ }) => { const eventId = ecsRowData._id; const eventIndex = ecsRowData._index; + const rule = ecsRowData.signal?.rule; const { navigateToApp } = useKibana().services.application; const [, dispatchToaster] = useStateToaster(); @@ -71,21 +72,25 @@ const AddToCaseActionComponent: React.FC = ({ const attachAlertToCase = useCallback( (theCase: Case) => { closeCaseFlyoutOpen(); - postComment( - theCase.id, - { + postComment({ + caseId: theCase.id, + data: { type: CommentType.alert, alertId: eventId, index: eventIndex ?? '', + rule: { + id: rule?.id != null ? rule.id[0] : null, + name: rule?.name != null ? rule.name[0] : null, + }, }, - () => + updateCase: () => dispatchToaster({ type: 'addToaster', toast: createUpdateSuccessToaster(theCase, onViewCaseClick), - }) - ); + }), + }); }, - [closeCaseFlyoutOpen, postComment, eventId, eventIndex, dispatchToaster, onViewCaseClick] + [closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule, dispatchToaster, onViewCaseClick] ); const onCaseClicked = useCallback( diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 8dd5080666cb..2806e358fcee 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -14,11 +14,13 @@ import { CreateCaseForm } from '../create/form'; import { SubmitCaseButton } from '../create/submit_button'; import { Case } from '../../containers/types'; import * as i18n from '../../translations'; +import { CaseType } from '../../../../../case/common/api'; export interface CreateCaseModalProps { isModalOpen: boolean; onCloseCaseModal: () => void; onSuccess: (theCase: Case) => void; + caseType?: CaseType; } const Container = styled.div` @@ -32,6 +34,7 @@ const CreateModalComponent: React.FC = ({ isModalOpen, onCloseCaseModal, onSuccess, + caseType = CaseType.individual, }) => { return isModalOpen ? ( @@ -39,7 +42,7 @@ const CreateModalComponent: React.FC = ({ {i18n.CREATE_TITLE} - + diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx index 86313ebcb3bf..3dc852a19e73 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx @@ -6,11 +6,13 @@ */ import React, { useState, useCallback, useMemo } from 'react'; +import { CaseType } from '../../../../../case/common/api'; import { Case } from '../../containers/types'; import { CreateCaseModal } from './create_case_modal'; export interface UseCreateCaseModalProps { onCaseCreated: (theCase: Case) => void; + caseType?: CaseType; } export interface UseCreateCaseModalReturnedValues { modal: JSX.Element; @@ -19,7 +21,10 @@ export interface UseCreateCaseModalReturnedValues { openModal: () => void; } -export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) => { +export const useCreateCaseModal = ({ + caseType = CaseType.individual, + onCaseCreated, +}: UseCreateCaseModalProps) => { const [isModalOpen, setIsModalOpen] = useState(false); const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); @@ -35,6 +40,7 @@ export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) = () => ({ modal: ( void; + alertId: string; + index: string; + loadingAlertData: boolean; + ruleId: string; + ruleName: string; }): EuiCommentProps => { return { username: ( @@ -212,7 +231,15 @@ export const getAlertComment = ({ ), className: 'comment-alert', type: 'update', - event: , + event: ( + + ), 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, timestamp: , timelineIcon: 'bell', @@ -222,23 +249,188 @@ export const getAlertComment = ({ - {alert != null ? ( - - ) : ( - - )} + ), }; }; + +export const toStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.reduce((acc, v) => { + if (v != null) { + switch (typeof v) { + case 'number': + case 'boolean': + return [...acc, v.toString()]; + case 'object': + try { + return [...acc, JSON.stringify(v)]; + } catch { + return [...acc, 'Invalid Object']; + } + case 'string': + return [...acc, v]; + default: + return [...acc, `${v}`]; + } + } + return acc; + }, []); + } else if (value == null) { + return []; + } else if (!Array.isArray(value) && typeof value === 'object') { + try { + return [JSON.stringify(value)]; + } catch { + return ['Invalid Object']; + } + } else { + return [`${value}`]; + } +}; + +export const formatAlertToEcsSignal = (alert: {}): Ecs => + Object.keys(alert).reduce((accumulator, key) => { + const item = get(alert, key); + if (item != null && isObject(item)) { + return { ...accumulator, [key]: formatAlertToEcsSignal(item) }; + } else if (Array.isArray(item) || isString(item) || isNumber(item)) { + return { ...accumulator, [key]: toStringArray(item) }; + } + return accumulator; + }, {} as Ecs); + +const EMPTY_ARRAY: TimelineNonEcsData[] = []; +export const getGeneratedAlertsAttachment = ({ + action, + alertIds, + ruleId, + ruleName, +}: { + action: CaseUserActions; + alertIds: string[]; + ruleId: string; + ruleName: string; +}): EuiCommentProps => { + const fetchEcsAlertsData = async (fetchAlertIds?: string[]): Promise => { + if (isEmpty(fetchAlertIds)) { + return []; + } + const alertResponse = await KibanaServices.get().http.fetch< + SearchResponse<{ '@timestamp': string; [key: string]: unknown }> + >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { + method: 'POST', + body: JSON.stringify(buildAlertsQuery(fetchAlertIds ?? [])), + }); + return ( + alertResponse?.hits.hits.reduce( + (acc, { _id, _index, _source }) => [ + ...acc, + { + ...formatAlertToEcsSignal(_source as {}), + _id, + _index, + timestamp: _source['@timestamp'], + }, + ], + [] + ) ?? [] + ); + }; + return { + username: , + className: 'comment-alert', + type: 'update', + event: ( + + ), + 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, + timestamp: , + timelineIcon: 'bell', + actions: ( + + + + + + + + + ), + }; +}; + +interface Signal { + rule: { + id: string; + name: string; + to: string; + from: string; + }; +} + +interface SignalHit { + _id: string; + _index: string; + _source: { + '@timestamp': string; + signal: Signal; + }; +} + +export interface Alert { + _id: string; + _index: string; + '@timestamp': string; + signal: Signal; + [key: string]: unknown; +} + +export const useFetchAlertData = (alertIds: string[]): [boolean, Record] => { + const { selectedPatterns } = useSourcererScope(SourcererScopeName.detections); + const alertsQuery = useMemo(() => buildAlertsQuery(alertIds), [alertIds]); + + const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts( + alertsQuery, + selectedPatterns[0] + ); + + const alerts = useMemo( + () => + alertsData?.hits.hits.reduce>( + (acc, { _id, _index, _source }) => ({ + ...acc, + [_id]: { + ...formatAlertToEcsSignal(_source), + _id, + _index, + timestamp: _source['@timestamp'], + }, + }), + {} + ) ?? {}, + [alertsData?.hits.hits] + ); + + return [isLoadingAlerts, alerts]; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index 3b81fc0afccf..2a9f99465251 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import classNames from 'classnames'; - import { EuiFlexGroup, EuiFlexItem, @@ -14,9 +12,12 @@ import { EuiCommentList, EuiCommentProps, } from '@elastic/eui'; +import classNames from 'classnames'; +import { isEmpty } from 'lodash'; import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; +import { isRight } from 'fp-ts/Either'; import * as i18n from './translations'; @@ -24,23 +25,31 @@ import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; -import { ActionConnector, CommentType } from '../../../../../case/common/api'; +import { + ActionConnector, + AlertCommentRequestRt, + CommentType, + ContextTypeUserRt, +} from '../../../../../case/common/api'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; -import { Alert, OnUpdateFields } from '../case_view'; +import { OnUpdateFields } from '../case_view'; import { getConnectorLabelTitle, getLabelTitle, getPushedServiceLabelTitle, getPushInfo, getUpdateAction, - getAlertComment, + getAlertAttachment, + getGeneratedAlertsAttachment, + useFetchAlertData, } from './helpers'; import { UserActionAvatar } from './user_action_avatar'; import { UserActionMarkdown } from './user_action_markdown'; import { UserActionTimestamp } from './user_action_timestamp'; import { UserActionUsername } from './user_action_username'; import { UserActionContentToolbar } from './user_action_content_toolbar'; +import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers'; export interface UserActionTreeProps { caseServices: CaseServices; @@ -53,7 +62,6 @@ export interface UserActionTreeProps { onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; updateCase: (newCase: Case) => void; userCanCrud: boolean; - alerts: Record; onShowAlertDetails: (alertId: string, index: string) => void; } @@ -112,10 +120,9 @@ export const UserActionTree = React.memo( onUpdateField, updateCase, userCanCrud, - alerts, onShowAlertDetails, }: UserActionTreeProps) => { - const { commentId } = useParams<{ commentId?: string }>(); + const { commentId, subCaseId } = useParams<{ commentId?: string; subCaseId?: string }>(); const handlerTimeoutId = useRef(0); const addCommentRef = useRef(null); const [initLoading, setInitLoading] = useState(true); @@ -124,6 +131,10 @@ export const UserActionTree = React.memo( const currentUser = useCurrentUser(); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); + const [loadingAlertData, manualAlertsData] = useFetchAlertData( + getManualAlertIdsWithNoRuleId(caseData.comments) + ); + const handleManageMarkdownEditId = useCallback( (id: string) => { if (!manageMarkdownEditIds.includes(id)) { @@ -218,9 +229,10 @@ export const UserActionTree = React.memo( onCommentPosted={handleUpdate} onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)} showLoading={false} + subCaseId={subCaseId} /> ), - [caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId] + [caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId, subCaseId] ); useEffect(() => { @@ -279,11 +291,16 @@ export const UserActionTree = React.memo( const userActions: EuiCommentProps[] = useMemo( () => caseUserActions.reduce( + // eslint-disable-next-line complexity (comments, action, index) => { // Comment creation if (action.commentId != null && action.action === 'create') { const comment = caseData.comments.find((c) => c.id === action.commentId); - if (comment != null && comment.type === CommentType.user) { + if ( + comment != null && + isRight(ContextTypeUserRt.decode(comment)) && + comment.type === CommentType.user + ) { return [ ...comments, { @@ -335,16 +352,65 @@ export const UserActionTree = React.memo( ), }, ]; - // TODO: need to handle CommentType.generatedAlert here to - } else if (comment != null && comment.type === CommentType.alert) { + } else if ( + comment != null && + isRight(AlertCommentRequestRt.decode(comment)) && + comment.type === CommentType.alert + ) { // TODO: clean this up const alertId = Array.isArray(comment.alertId) ? comment.alertId.length > 0 ? comment.alertId[0] : '' : comment.alertId; - const alert = alerts[alertId]; - return [...comments, getAlertComment({ action, alert, onShowAlertDetails })]; + + const alertIndex = Array.isArray(comment.index) + ? comment.index.length > 0 + ? comment.index[0] + : '' + : comment.index; + + if (isEmpty(alertId)) { + return comments; + } + + const ruleId = comment?.rule?.id ?? manualAlertsData[alertId]?.rule?.id?.[0] ?? ''; + const ruleName = + comment?.rule?.name ?? + manualAlertsData[alertId]?.rule?.name?.[0] ?? + i18n.UNKNOWN_RULE; + + return [ + ...comments, + getAlertAttachment({ + action, + alertId, + index: alertIndex, + loadingAlertData, + ruleId, + ruleName, + onShowAlertDetails, + }), + ]; + } else if (comment != null && comment.type === CommentType.generatedAlert) { + // TODO: clean this up + const alertIds = Array.isArray(comment.alertId) + ? comment.alertId + : [comment.alertId]; + + if (isEmpty(alertIds)) { + return comments; + } + + return [ + ...comments, + getGeneratedAlertsAttachment({ + action, + alertIds, + ruleId: comment.rule?.id ?? '', + ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE, + }), + ]; } } @@ -438,10 +504,11 @@ export const UserActionTree = React.memo( handleManageQuote, handleSaveComment, isLoadingIds, + loadingAlertData, + manualAlertsData, manageMarkdownEditIds, selectedOutlineCommentId, userCanCrud, - alerts, onShowAlertDetails, ] ); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/translations.ts b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/translations.ts index ede216d562f1..46f36615b1a4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/translations.ts @@ -42,6 +42,19 @@ export const ALERT_COMMENT_LABEL_TITLE = i18n.translate( } ); +export const GENERATED_ALERT_COMMENT_LABEL_TITLE = i18n.translate( + 'xpack.securitySolution.case.caseView.generatedAlertCommentLabelTitle', + { + defaultMessage: 'were added from', + } +); + +export const GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE = (totalCount: number) => + i18n.translate('xpack.securitySolution.case.caseView.generatedAlertCountCommentLabelTitle', { + values: { totalCount }, + defaultMessage: `{totalCount} {totalCount, plural, =1 {alert} other {alerts}}`, + }); + export const ALERT_RULE_DELETED_COMMENT_LABEL = i18n.translate( 'xpack.securitySolution.case.caseView.alertRuleDeletedLabelTitle', { @@ -56,9 +69,16 @@ export const SHOW_ALERT_TOOLTIP = i18n.translate( } ); -export const ALERT_NOT_FOUND_TOOLTIP = i18n.translate( - 'xpack.securitySolution.case.caseView.showAlertDeletedTooltip', +export const SEND_ALERT_TO_TIMELINE = i18n.translate( + 'xpack.securitySolution.case.caseView.sendAlertToTimelineTooltip', { - defaultMessage: 'Alert not found', + defaultMessage: 'Investigate in timeline', + } +); + +export const UNKNOWN_RULE = i18n.translate( + 'xpack.securitySolution.case.caseView.unknownRule.label', + { + defaultMessage: 'Unknown rule', } ); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx index f0ff145a269f..228945bacf8a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx @@ -11,19 +11,14 @@ import { mount } from 'enzyme'; import { TestProviders } from '../../../common/mock'; import { useKibana } from '../../../common/lib/kibana'; import { AlertCommentEvent } from './user_action_alert_comment_event'; +import { CommentType } from '../../../../../case/common/api'; const props = { - alert: { - _id: 'alert-id-1', - _index: 'alert-index-1', - '@timestamp': '2021-01-07T13:58:31.487Z', - rule: { - id: 'rule-id-1', - name: 'Awesome rule', - from: '2021-01-07T13:58:31.487Z', - to: '2021-01-07T14:58:31.487Z', - }, - }, + alertId: 'alert-id-1', + ruleId: 'rule-id-1', + ruleName: 'Awesome rule', + alertsCount: 1, + commentType: CommentType.alert, }; jest.mock('../../../common/lib/kibana'); @@ -54,7 +49,8 @@ describe('UserActionAvatar ', () => { it('does NOT render the link when the alert is undefined', async () => { const wrapper = mount( - + {/* @ts-expect-error */} + ); @@ -62,13 +58,13 @@ describe('UserActionAvatar ', () => { wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() ).toBeFalsy(); - expect(wrapper.text()).toBe('added an alert'); + expect(wrapper.text()).toBe('added an alert from '); }); it('does NOT render the link when the rule is undefined', async () => { const alert = { - _id: 'alert-id-1', - _index: 'alert-index-1', + alertId: 'alert-id-1', + commentType: CommentType.alert, }; const wrapper = mount( @@ -82,7 +78,7 @@ describe('UserActionAvatar ', () => { wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() ).toBeFalsy(); - expect(wrapper.text()).toBe('added an alert'); + expect(wrapper.text()).toBe('added an alert from '); }); it('navigate to app on link click', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index 183116167f84..2a604b7c54d6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -6,24 +6,36 @@ */ import React, { memo, useCallback } from 'react'; -import { EuiLink } from '@elastic/eui'; +import { EuiText, EuiLoadingSpinner } from '@elastic/eui'; import { APP_ID } from '../../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; -import { getRuleDetailsUrl } from '../../../common/components/link_to'; +import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to'; import { SecurityPageName } from '../../../app/types'; -import { Alert } from '../case_view'; import * as i18n from './translations'; +import { CommentType } from '../../../../../case/common/api'; +import { LinkAnchor } from '../../../common/components/links'; interface Props { - alert: Alert | undefined; + alertId: string; + commentType: CommentType; + ruleId: string; + ruleName: string; + alertsCount?: number; + loadingAlertData?: boolean; } -const AlertCommentEventComponent: React.FC = ({ alert }) => { - const ruleName = alert?.rule?.name ?? null; - const ruleId = alert?.rule?.id ?? null; +const AlertCommentEventComponent: React.FC = ({ + alertId, + loadingAlertData = false, + ruleId, + ruleName, + alertsCount, + commentType, +}) => { const { navigateToApp } = useKibana().services.application; + const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.detections); const onLinkClick = useCallback( (ev: { preventDefault: () => void }) => { @@ -35,15 +47,37 @@ const AlertCommentEventComponent: React.FC = ({ alert }) => { [ruleId, navigateToApp] ); - return ruleId != null && ruleName != null ? ( + return commentType !== CommentType.generatedAlert ? ( <> {`${i18n.ALERT_COMMENT_LABEL_TITLE} `} - - {ruleName} - + {loadingAlertData && } + {!loadingAlertData && ruleId !== '' && ( + + {ruleName} + + )} + {!loadingAlertData && ruleId === '' && {ruleName}} ) : ( - <>{i18n.ALERT_RULE_DELETED_COMMENT_LABEL} + <> + {i18n.GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE(alertsCount ?? 0)}{' '} + {i18n.GENERATED_ALERT_COMMENT_LABEL_TITLE}{' '} + {loadingAlertData && } + {!loadingAlertData && ruleId !== '' && ( + + {ruleName} + + )} + {!loadingAlertData && ruleId === '' && {ruleName}} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx index 4bd4734e3067..ff4e15119746 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx @@ -18,24 +18,26 @@ interface UserActionCopyLinkProps { id: string; } -const UserActionCopyLinkComponent = ({ id }: UserActionCopyLinkProps) => { - const { detailName: caseId } = useParams<{ detailName: string }>(); +const UserActionCopyLinkComponent = ({ id: commentId }: UserActionCopyLinkProps) => { + const { detailName: caseId, subCaseId } = useParams<{ detailName: string; subCaseId?: string }>(); const { formatUrl } = useFormatUrl(SecurityPageName.case); const handleAnchorLink = useCallback(() => { copy( - formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId: id }), { absolute: true }) + formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId, subCaseId }), { + absolute: true, + }) ); - }, [caseId, formatUrl, id]); + }, [caseId, commentId, formatUrl, subCaseId]); return ( {i18n.COPY_REFERENCE_LINK}

}>
); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx index 5d619a39d0e7..789a6eb68e0f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx @@ -8,20 +8,24 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { UserActionShowAlert } from './user_action_show_alert'; +import { RuleEcs } from '../../../../common/ecs/rule'; const props = { id: 'action-id', + alertId: 'alert-id', + index: 'alert-index', alert: { _id: 'alert-id', _index: 'alert-index', - '@timestamp': '2021-01-07T13:58:31.487Z', + timestamp: '2021-01-07T13:58:31.487Z', rule: { - id: 'rule-id', - name: 'Awesome Rule', - from: '2021-01-07T13:58:31.487Z', - to: '2021-01-07T14:58:31.487Z', - }, + id: ['rule-id'], + name: ['Awesome Rule'], + from: ['2021-01-07T13:58:31.487Z'], + to: ['2021-01-07T14:58:31.487Z'], + } as RuleEcs, }, + onShowAlertDetails: jest.fn(), }; describe('UserActionShowAlert ', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.tsx index ea4994d1c809..4f5ce0080641 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.tsx @@ -7,25 +7,24 @@ import React, { memo, useCallback } from 'react'; import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; -import deepEqual from 'fast-deep-equal'; - -import { Alert } from '../case_view'; import * as i18n from './translations'; interface UserActionShowAlertProps { id: string; - alert: Alert; + alertId: string; + index: string; onShowAlertDetails: (alertId: string, index: string) => void; } const UserActionShowAlertComponent = ({ id, - alert, + alertId, + index, onShowAlertDetails, }: UserActionShowAlertProps) => { - const onClick = useCallback(() => onShowAlertDetails(alert._id, alert._index), [ - alert._id, - alert._index, + const onClick = useCallback(() => onShowAlertDetails(alertId, index), [ + alertId, + index, onShowAlertDetails, ]); return ( @@ -41,10 +40,4 @@ const UserActionShowAlertComponent = ({ ); }; -export const UserActionShowAlert = memo( - UserActionShowAlertComponent, - (prevProps, nextProps) => - prevProps.id === nextProps.id && - deepEqual(prevProps.alert, nextProps.alert) && - prevProps.onShowAlertDetails === nextProps.onShowAlertDetails -); +export const UserActionShowAlert = memo(UserActionShowAlertComponent); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 00a45aadd2ae..c87e210b42bc 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { assign } from 'lodash'; + import { CasePatchRequest, CasePostRequest, @@ -16,6 +18,9 @@ import { CaseUserActionsResponse, CommentRequest, CommentType, + SubCasePatchRequest, + SubCaseResponse, + SubCasesResponse, User, } from '../../../../case/common/api'; @@ -25,6 +30,8 @@ import { CASE_STATUS_URL, CASE_TAGS_URL, CASES_URL, + SUB_CASE_DETAILS_URL, + SUB_CASES_PATCH_DEL_URL, } from '../../../../case/common/constants'; import { @@ -32,6 +39,8 @@ import { getCasePushUrl, getCaseDetailsUrl, getCaseUserActionUrl, + getSubCaseDetailsUrl, + getSubCaseUserActionUrl, } from '../../../../case/common/api/helpers'; import { KibanaServices } from '../../common/lib/kibana'; @@ -73,6 +82,34 @@ export const getCase = async ( return convertToCamelCase(decodeCaseResponse(response)); }; +export const getSubCase = async ( + caseId: string, + subCaseId: string, + includeComments: boolean = true, + signal: AbortSignal +): Promise => { + const [caseResponse, subCaseResponse] = await Promise.all([ + KibanaServices.get().http.fetch(getCaseDetailsUrl(caseId), { + method: 'GET', + query: { + includeComments: false, + }, + signal, + }), + KibanaServices.get().http.fetch(getSubCaseDetailsUrl(caseId, subCaseId), { + method: 'GET', + query: { + includeComments, + }, + signal, + }), + ]); + const response = assign(caseResponse, subCaseResponse); + const subCaseIndex = response.subCaseIds?.findIndex((scId) => scId === response.id) ?? -1; + response.title = `${response.title}${subCaseIndex >= 0 ? ` ${subCaseIndex + 1}` : ''}`; + return convertToCamelCase(decodeCaseResponse(response)); +}; + export const getCasesStatus = async (signal: AbortSignal): Promise => { const response = await KibanaServices.get().http.fetch(CASE_STATUS_URL, { method: 'GET', @@ -111,6 +148,21 @@ export const getCaseUserActions = async ( return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; }; +export const getSubCaseUserActions = async ( + caseId: string, + subCaseId: string, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + getSubCaseUserActionUrl(caseId, subCaseId), + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + export const getCases = async ({ filterOptions = { search: '', @@ -167,6 +219,35 @@ export const patchCase = async ( return convertToCamelCase(decodeCasesResponse(response)); }; +export const patchSubCase = async ( + caseId: string, + subCaseId: string, + updatedSubCase: Pick, + version: string, + signal: AbortSignal +): Promise => { + const subCaseResponse = await KibanaServices.get().http.fetch( + SUB_CASE_DETAILS_URL, + { + method: 'PATCH', + body: JSON.stringify({ cases: [{ ...updatedSubCase, id: caseId, version }] }), + signal, + } + ); + const caseResponse = await KibanaServices.get().http.fetch( + getCaseDetailsUrl(caseId), + { + method: 'GET', + query: { + includeComments: false, + }, + signal, + } + ); + const response = subCaseResponse.map((subCaseResp) => assign(caseResponse, subCaseResp)); + return convertToCamelCase(decodeCasesResponse(response)); +}; + export const patchCasesStatus = async ( cases: BulkUpdateStatus[], signal: AbortSignal @@ -182,13 +263,15 @@ export const patchCasesStatus = async ( export const postComment = async ( newComment: CommentRequest, caseId: string, - signal: AbortSignal + signal: AbortSignal, + subCaseId?: string ): Promise => { const response = await KibanaServices.get().http.fetch( `${CASES_URL}/${caseId}/comments`, { method: 'POST', body: JSON.stringify(newComment), + ...(subCaseId ? { query: { subCaseId } } : {}), signal, } ); @@ -200,7 +283,8 @@ export const patchComment = async ( commentId: string, commentUpdate: string, version: string, - signal: AbortSignal + signal: AbortSignal, + subCaseId?: string ): Promise => { const response = await KibanaServices.get().http.fetch(getCaseCommentsUrl(caseId), { method: 'PATCH', @@ -210,6 +294,7 @@ export const patchComment = async ( id: commentId, version, }), + ...(subCaseId ? { query: { subCaseId } } : {}), signal, }); return convertToCamelCase(decodeCaseResponse(response)); @@ -224,6 +309,15 @@ export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promi return response; }; +export const deleteSubCases = async (caseIds: string[], signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch(SUB_CASES_PATCH_DEL_URL, { + method: 'DELETE', + query: { ids: JSON.stringify(caseIds) }, + signal, + }); + return response; +}; + export const pushCase = async ( caseId: string, connectorId: string, diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 80d4816bedd5..d8692da986cb 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -26,6 +26,7 @@ import { ConnectorTypes } from '../../../../case/common/api/connectors'; export { connectorsMock } from './configure/mock'; export const basicCaseId = 'basic-case-id'; +export const basicSubCaseId = 'basic-sub-case-id'; const basicCommentId = 'basic-comment-id'; const basicCreatedAt = '2020-02-19T23:06:33.798Z'; const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; @@ -63,6 +64,10 @@ export const alertComment: Comment = { createdBy: elasticUser, pushedAt: null, pushedBy: null, + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, updatedAt: null, updatedBy: null, version: 'WzQ3LDFc', @@ -95,6 +100,7 @@ export const basicCase: Case = { settings: { syncAlerts: true, }, + subCaseIds: [], }; export const basicCasePost: Case = { @@ -217,7 +223,7 @@ export const basicCaseSnake: CaseResponse = { external_service: null, updated_at: basicUpdatedAt, updated_by: elasticUserSnake, -}; +} as CaseResponse; export const casesStatusSnake: CasesStatusResponse = { count_closed_cases: 130, diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index 30ea83444346..d2931a790bd7 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -18,7 +18,9 @@ import { AssociationType, } from '../../../../case/common/api'; -export { CaseConnector, ActionConnector } from '../../../../case/common/api'; +export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../case/common/api'; + +export type AllCaseType = AssociationType & CaseType; export type Comment = CommentRequest & { associationType: AssociationType; @@ -52,26 +54,37 @@ export interface CaseExternalService { externalTitle: string; externalUrl: string; } -export interface Case { + +interface BasicCase { id: string; closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; - connector: CaseConnector; createdAt: string; createdBy: ElasticUser; - description: string; - externalService: CaseExternalService | null; status: CaseStatuses; - tags: string[]; title: string; totalAlerts: number; totalComment: number; - type: CaseType; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; +} + +export interface SubCase extends BasicCase { + associationType: AssociationType; + caseParentId: string; +} + +export interface Case extends BasicCase { + connector: CaseConnector; + description: string; + externalService: CaseExternalService | null; + subCases?: SubCase[] | null; + subCaseIds: string[]; settings: CaseAttributes['settings']; + tags: string[]; + type: CaseType; } export interface QueryParams { @@ -138,6 +151,7 @@ export interface ActionLicense { export interface DeleteCase { id: string; title?: string; + type?: CaseType; } export interface FieldMappings { @@ -153,7 +167,7 @@ export type UpdateKey = keyof Pick< export interface UpdateByKey { updateKey: UpdateKey; updateValue: CasePatchRequest[UpdateKey]; - fetchCaseUserActions?: (caseId: string) => void; + fetchCaseUserActions?: (caseId: string, subCaseId?: string) => void; updateCase?: (newCase: Case) => void; caseData: Case; onSuccess?: () => void; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx index b777b16b1c0c..923c20dcf8eb 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx @@ -12,7 +12,7 @@ import { useStateToaster, } from '../../common/components/toasters'; import * as i18n from './translations'; -import { deleteCases } from './api'; +import { deleteCases, deleteSubCases } from './api'; import { DeleteCase } from './types'; interface DeleteState { @@ -87,7 +87,13 @@ export const useDeleteCases = (): UseDeleteCase => { try { dispatch({ type: 'FETCH_INIT' }); const caseIds = cases.map((theCase) => theCase.id); - await deleteCases(caseIds, abortCtrl.signal); + // We don't allow user batch delete sub cases on UI at the moment. + if (cases[0].type != null || cases.length > 1) { + await deleteCases(caseIds, abortCtrl.signal); + } else { + await deleteSubCases(caseIds, abortCtrl.signal); + } + if (!cancel) { dispatch({ type: 'FETCH_SUCCESS', payload: true }); displaySuccessToast( diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 45827a4bebff..1c4476e3cb2b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import { useEffect, useReducer, useCallback } from 'react'; +import { isEmpty } from 'lodash'; +import { useEffect, useReducer, useCallback, useRef } from 'react'; import { CaseStatuses, CaseType } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; -import { getCase } from './api'; +import { getCase, getSubCase } from './api'; import { getNoneConnector } from '../components/configure_cases/utils'; interface CaseState { @@ -77,6 +78,7 @@ export const initialData: Case = { updatedAt: null, updatedBy: null, version: '', + subCaseIds: [], settings: { syncAlerts: true, }, @@ -87,31 +89,32 @@ export interface UseGetCase extends CaseState { updateCase: (newCase: Case) => void; } -export const useGetCase = (caseId: string): UseGetCase => { +export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: true, isError: false, data: initialData, }); const [, dispatchToaster] = useStateToaster(); + const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); const updateCase = useCallback((newCase: Case) => { dispatch({ type: 'UPDATE_CASE', payload: newCase }); }, []); const callFetch = useCallback(async () => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { dispatch({ type: 'FETCH_INIT' }); try { - const response = await getCase(caseId, true, abortCtrl.signal); - if (!didCancel) { + const response = await (subCaseId + ? getSubCase(caseId, subCaseId, true, abortCtrl.current.signal) + : getCase(caseId, true, abortCtrl.current.signal)); + if (!didCancel.current) { dispatch({ type: 'FETCH_SUCCESS', payload: response }); } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, @@ -121,17 +124,22 @@ export const useGetCase = (caseId: string): UseGetCase => { } } }; + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [caseId]); + }, [caseId, subCaseId]); useEffect(() => { - callFetch(); + if (!isEmpty(caseId)) { + callFetch(); + } + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [caseId]); + }, [caseId, subCaseId]); return { ...state, fetchCase: callFetch, updateCase }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx index 8ebd46e64296..12e5f6643351 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx @@ -6,12 +6,12 @@ */ import { isEmpty, uniqBy } from 'lodash/fp'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { CaseFullExternalService } from '../../../../case/common/api/cases'; -import { getCaseUserActions } from './api'; +import { getCaseUserActions, getSubCaseUserActions } from './api'; import * as i18n from './translations'; import { CaseConnector, CaseExternalService, CaseUserActions, ElasticUser } from './types'; import { convertToCamelCase, parseString } from './utils'; @@ -46,7 +46,7 @@ export const initialData: CaseUserActionsState = { }; export interface UseGetCaseUserActions extends CaseUserActionsState { - fetchCaseUserActions: (caseId: string) => void; + fetchCaseUserActions: (caseId: string, subCaseId?: string) => void; } const getExternalService = (value: string): CaseExternalService | null => @@ -238,26 +238,29 @@ export const getPushedInfo = ( export const useGetCaseUserActions = ( caseId: string, - caseConnectorId: string + caseConnectorId: string, + subCaseId?: string ): UseGetCaseUserActions => { const [caseUserActionsState, setCaseUserActionsState] = useState( initialData ); - + const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); const [, dispatchToaster] = useStateToaster(); const fetchCaseUserActions = useCallback( - (thisCaseId: string) => { - let didCancel = false; - const abortCtrl = new AbortController(); + (thisCaseId: string, thisSubCaseId?: string) => { const fetchData = async () => { - setCaseUserActionsState({ - ...caseUserActionsState, - isLoading: true, - }); try { - const response = await getCaseUserActions(thisCaseId, abortCtrl.signal); - if (!didCancel) { + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + + const response = await (thisSubCaseId + ? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrl.current.signal) + : getCaseUserActions(thisCaseId, abortCtrl.current.signal)); + if (!didCancel.current) { // Attention Future developer // We are removing the first item because it will always be the creation of the case // and we do not want it to simplify our life @@ -265,7 +268,11 @@ export const useGetCaseUserActions = ( ? uniqBy('actionBy.username', response).map((cau) => cau.actionBy) : []; - const caseUserActions = !isEmpty(response) ? response.slice(1) : []; + const caseUserActions = !isEmpty(response) + ? thisSubCaseId + ? response + : response.slice(1) + : []; setCaseUserActionsState({ caseUserActions, ...getPushedInfo(caseUserActions, caseConnectorId), @@ -275,7 +282,7 @@ export const useGetCaseUserActions = ( }); } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, @@ -292,21 +299,24 @@ export const useGetCaseUserActions = ( } } }; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; }, // eslint-disable-next-line react-hooks/exhaustive-deps - [caseUserActionsState, caseConnectorId] + [caseConnectorId] ); useEffect(() => { if (!isEmpty(caseId)) { - fetchCaseUserActions(caseId); + fetchCaseUserActions(caseId, subCaseId); } + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [caseId, caseConnectorId]); + }, [caseId, subCaseId]); return { ...caseUserActionsState, fetchCaseUserActions }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx index f9d4454f63ff..42cd0deafa04 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx @@ -9,7 +9,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { CommentType } from '../../../../case/common/api'; import { usePostComment, UsePostComment } from './use_post_comment'; -import { basicCaseId } from './mock'; +import { basicCaseId, basicSubCaseId } from './mock'; import * as api from './api'; jest.mock('./api'); @@ -40,7 +40,7 @@ describe('usePostComment', () => { }); }); - it('calls postComment with correct arguments', async () => { + it('calls postComment with correct arguments - case', async () => { const spyOnPostCase = jest.spyOn(api, 'postComment'); await act(async () => { @@ -49,9 +49,38 @@ describe('usePostComment', () => { ); await waitForNextUpdate(); - result.current.postComment(basicCaseId, samplePost, updateCaseCallback); + result.current.postComment({ + caseId: basicCaseId, + data: samplePost, + updateCase: updateCaseCallback, + }); await waitForNextUpdate(); - expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal); + expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal, undefined); + }); + }); + + it('calls postComment with correct arguments - sub case', async () => { + const spyOnPostCase = jest.spyOn(api, 'postComment'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePostComment() + ); + await waitForNextUpdate(); + + result.current.postComment({ + caseId: basicCaseId, + data: samplePost, + updateCase: updateCaseCallback, + subCaseId: basicSubCaseId, + }); + await waitForNextUpdate(); + expect(spyOnPostCase).toBeCalledWith( + samplePost, + basicCaseId, + abortCtrl.signal, + basicSubCaseId + ); }); }); @@ -61,7 +90,11 @@ describe('usePostComment', () => { usePostComment() ); await waitForNextUpdate(); - result.current.postComment(basicCaseId, samplePost, updateCaseCallback); + result.current.postComment({ + caseId: basicCaseId, + data: samplePost, + updateCase: updateCaseCallback, + }); await waitForNextUpdate(); expect(result.current).toEqual({ isLoading: false, @@ -77,7 +110,11 @@ describe('usePostComment', () => { usePostComment() ); await waitForNextUpdate(); - result.current.postComment(basicCaseId, samplePost, updateCaseCallback); + result.current.postComment({ + caseId: basicCaseId, + data: samplePost, + updateCase: updateCaseCallback, + }); expect(result.current.isLoading).toBe(true); }); @@ -94,7 +131,11 @@ describe('usePostComment', () => { usePostComment() ); await waitForNextUpdate(); - result.current.postComment(basicCaseId, samplePost, updateCaseCallback); + result.current.postComment({ + caseId: basicCaseId, + data: samplePost, + updateCase: updateCaseCallback, + }); expect(result.current).toEqual({ isLoading: false, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index f2bd9d3f41f3..8fc8053c14f7 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -42,8 +42,14 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta } }; +interface PostComment { + caseId: string; + data: CommentRequest; + updateCase?: (newCase: Case) => void; + subCaseId?: string; +} export interface UsePostComment extends NewCommentState { - postComment: (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => void; + postComment: (args: PostComment) => void; } export const usePostComment = (): UsePostComment => { @@ -54,13 +60,13 @@ export const usePostComment = (): UsePostComment => { const [, dispatchToaster] = useStateToaster(); const postMyComment = useCallback( - async (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => { + async ({ caseId, data, updateCase, subCaseId }: PostComment) => { let cancel = false; const abortCtrl = new AbortController(); try { dispatch({ type: 'FETCH_INIT' }); - const response = await postComment(data, caseId, abortCtrl.signal); + const response = await postComment(data, caseId, abortCtrl.signal, subCaseId); if (!cancel) { dispatch({ type: 'FETCH_SUCCESS' }); if (updateCase) { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx index 62560244fe9c..0adf2cc0bf92 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx @@ -7,7 +7,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useUpdateCase, UseUpdateCase } from './use_update_case'; -import { basicCase } from './mock'; +import { basicCase, basicSubCaseId } from './mock'; import * as api from './api'; import { UpdateKey } from './types'; @@ -84,7 +84,27 @@ describe('useUpdateCase', () => { isError: false, updateCaseProperty: result.current.updateCaseProperty, }); - expect(fetchCaseUserActions).toBeCalledWith(basicCase.id); + expect(fetchCaseUserActions).toBeCalledWith(basicCase.id, undefined); + expect(updateCase).toBeCalledWith(basicCase); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('patch sub case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useUpdateCase({ caseId: basicCase.id, subCaseId: basicSubCaseId }) + ); + await waitForNextUpdate(); + result.current.updateCaseProperty(sampleUpdate); + await waitForNextUpdate(); + expect(result.current).toEqual({ + updateKey: null, + isLoading: false, + isError: false, + updateCaseProperty: result.current.updateCaseProperty, + }); + expect(fetchCaseUserActions).toBeCalledWith(basicCase.id, basicSubCaseId); expect(updateCase).toBeCalledWith(basicCase); expect(onSuccess).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index b2b919ae1422..23a23caeb71b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; +import { useReducer, useCallback, useEffect, useRef } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; -import { patchCase } from './api'; -import { UpdateKey, UpdateByKey } from './types'; +import { patchCase, patchSubCase } from './api'; +import { UpdateKey, UpdateByKey, CaseStatuses } from './types'; import * as i18n from './translations'; import { createUpdateSuccessToaster } from './utils'; @@ -57,13 +57,21 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => export interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; } -export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => { +export const useUpdateCase = ({ + caseId, + subCaseId, +}: { + caseId: string; + subCaseId?: string; +}): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, updateKey: null, }); const [, dispatchToaster] = useStateToaster(); + const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); const dispatchUpdateCaseProperty = useCallback( async ({ @@ -75,20 +83,27 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => onSuccess, onError, }: UpdateByKey) => { - let cancel = false; - const abortCtrl = new AbortController(); - try { + didCancel.current = false; + abortCtrl.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: updateKey }); - const response = await patchCase( - caseId, - { [updateKey]: updateValue }, - caseData.version, - abortCtrl.signal - ); - if (!cancel) { + const response = await (updateKey === 'status' && subCaseId + ? patchSubCase( + caseId, + subCaseId, + { status: updateValue as CaseStatuses }, + caseData.version, + abortCtrl.current.signal + ) + : patchCase( + caseId, + { [updateKey]: updateValue }, + caseData.version, + abortCtrl.current.signal + )); + if (!didCancel.current) { if (fetchCaseUserActions != null) { - fetchCaseUserActions(caseId); + fetchCaseUserActions(caseId, subCaseId); } if (updateCase != null) { updateCase(response[0]); @@ -104,26 +119,31 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => } } } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (!didCancel.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE' }); if (onError) { onError(); } } } - return () => { - cancel = true; - abortCtrl.abort(); - }; }, // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [caseId, subCaseId] ); + useEffect(() => { + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + }, []); + return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.test.tsx index d7d98879459f..9ff266ad9c98 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.test.tsx @@ -7,7 +7,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useUpdateComment, UseUpdateComment } from './use_update_comment'; -import { basicCase, basicCaseCommentPatch } from './mock'; +import { basicCase, basicCaseCommentPatch, basicSubCaseId } from './mock'; import * as api from './api'; jest.mock('./api'); @@ -43,7 +43,7 @@ describe('useUpdateComment', () => { }); }); - it('calls patchComment with correct arguments', async () => { + it('calls patchComment with correct arguments - case', async () => { const spyOnPatchComment = jest.spyOn(api, 'patchComment'); await act(async () => { @@ -59,7 +59,30 @@ describe('useUpdateComment', () => { basicCase.comments[0].id, 'updated comment', basicCase.comments[0].version, - abortCtrl.signal + abortCtrl.signal, + undefined + ); + }); + }); + + it('calls patchComment with correct arguments - sub case', async () => { + const spyOnPatchComment = jest.spyOn(api, 'patchComment'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useUpdateComment() + ); + await waitForNextUpdate(); + + result.current.patchComment({ ...sampleUpdate, subCaseId: basicSubCaseId }); + await waitForNextUpdate(); + expect(spyOnPatchComment).toBeCalledWith( + basicCase.id, + basicCase.comments[0].id, + 'updated comment', + basicCase.comments[0].version, + abortCtrl.signal, + basicSubCaseId ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx index 6222d993bb79..e36b21823310 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx @@ -57,6 +57,7 @@ interface UpdateComment { commentId: string; commentUpdate: string; fetchUserActions: () => void; + subCaseId?: string; updateCase: (newCase: Case) => void; version: string; } @@ -78,6 +79,7 @@ export const useUpdateComment = (): UseUpdateComment => { commentId, commentUpdate, fetchUserActions, + subCaseId, updateCase, version, }: UpdateComment) => { @@ -90,7 +92,8 @@ export const useUpdateComment = (): UseUpdateComment => { commentId, commentUpdate, version, - abortCtrl.signal + abortCtrl.signal, + subCaseId ); if (!cancel) { updateCase(response); diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index 701ecdf8580f..edb84db89b87 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -21,7 +21,10 @@ import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/call export const CaseDetailsPage = React.memo(() => { const history = useHistory(); const userPermissions = useGetUserSavedObjectPermissions(); - const { detailName: caseId } = useParams<{ detailName?: string }>(); + const { detailName: caseId, subCaseId } = useParams<{ + detailName?: string; + subCaseId?: string; + }>(); const search = useGetUrlSearch(navTabs.case); if (userPermissions != null && !userPermissions.read) { @@ -38,7 +41,11 @@ export const CaseDetailsPage = React.memo(() => { messages={[{ ...savedObjectReadOnlyErrorMessage }]} /> )} - + diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index 32c94e593665..314bdc9bfd11 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -15,7 +15,9 @@ import { ConfigureCasesPage } from './configure_cases'; const casesPagePath = ''; const caseDetailsPagePath = `${casesPagePath}/:detailName`; -const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`; +const subCaseDetailsPagePath = `${caseDetailsPagePath}/sub-cases/:subCaseId`; +const caseDetailsPagePathWithCommentId = `${caseDetailsPagePath}/:commentId`; +const subCaseDetailsPagePathWithCommentId = `${subCaseDetailsPagePath}/:commentId`; const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; @@ -27,7 +29,13 @@ const CaseContainerComponent: React.FC = () => ( - + + + + + + + diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx index 7a7da6f89306..82d8aac904e9 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx @@ -9,19 +9,43 @@ import { appendSearch } from './helpers'; export const getCaseUrl = (search?: string | null) => `${appendSearch(search ?? undefined)}`; -export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) => - `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; +export const getCaseDetailsUrl = ({ + id, + search, + subCaseId, +}: { + id: string; + search?: string | null; + subCaseId?: string; +}) => { + if (subCaseId) { + return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(subCaseId)}${appendSearch( + search ?? undefined + )}`; + } + return `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; +}; export const getCaseDetailsUrlWithCommentId = ({ id, commentId, search, + subCaseId, }: { id: string; commentId: string; search?: string | null; -}) => - `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`; + subCaseId?: string; +}) => { + if (subCaseId) { + return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent( + subCaseId + )}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`; + } + return `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch( + search ?? undefined + )}`; +}; export const getCreateCaseUrl = (search?: string | null) => `/create${appendSearch(search ?? undefined)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 6b4148db2b1e..8e2f57a1a597 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -164,24 +164,25 @@ export const NetworkDetailsLink = React.memo(NetworkDetailsLinkComponent); const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string; + subCaseId?: string; title?: string; -}> = ({ children, detailName, title }) => { +}> = ({ children, detailName, subCaseId, title }) => { const { formatUrl, search } = useFormatUrl(SecurityPageName.case); const { navigateToApp } = useKibana().services.application; const goToCaseDetails = useCallback( (ev) => { ev.preventDefault(); navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCaseDetailsUrl({ id: detailName, search }), + path: getCaseDetailsUrl({ id: detailName, search, subCaseId }), }); }, - [detailName, navigateToApp, search] + [detailName, navigateToApp, search, subCaseId] ); return ( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 143c39daace6..7d577659d66e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -421,7 +421,7 @@ describe('alert actions', () => { ...mockEcsDataWithAlert, timestamp: '2020-03-20T17:59:46.349Z', }; - const result = determineToAndFrom({ ecsData: ecsDataMock }); + const result = determineToAndFrom({ ecs: ecsDataMock }); expect(result.from).toEqual('2020-03-20T17:54:46.349Z'); expect(result.to).toEqual('2020-03-20T17:59:46.349Z'); @@ -431,7 +431,7 @@ describe('alert actions', () => { const { timestamp, ...ecsDataMock } = { ...mockEcsDataWithAlert, }; - const result = determineToAndFrom({ ecsData: ecsDataMock }); + const result = determineToAndFrom({ ecs: ecsDataMock }); expect(result.from).toEqual('2020-03-01T17:54:46.349Z'); expect(result.to).toEqual('2020-03-01T17:59:46.349Z'); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 9d38e2b369fa..14ccae250ac4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -39,6 +39,7 @@ import { } from './helpers'; import { KueryFilterQueryKind } from '../../../common/store'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { esFilters } from '../../../../../../../src/plugins/data/public'; export const getUpdateAlertsQuery = (eventIds: Readonly) => { return { @@ -102,17 +103,32 @@ export const updateAlertStatusAction = async ({ } }; -export const determineToAndFrom = ({ ecsData }: { ecsData: Ecs }) => { +export const determineToAndFrom = ({ ecs }: { ecs: Ecs[] | Ecs }) => { + if (Array.isArray(ecs)) { + const timestamps = ecs.reduce((acc, item) => { + if (item.timestamp != null) { + const dateTimestamp = new Date(item.timestamp); + if (!acc.includes(dateTimestamp.valueOf())) { + return [...acc, dateTimestamp.valueOf()]; + } + } + return acc; + }, []); + return { + from: new Date(Math.min(...timestamps)).toISOString(), + to: new Date(Math.max(...timestamps)).toISOString(), + }; + } + const ecsData = ecs as Ecs; const ellapsedTimeRule = moment.duration( moment().diff( - dateMath.parse(ecsData.signal?.rule?.from != null ? ecsData.signal?.rule?.from[0] : 'now-0s') + dateMath.parse(ecsData?.signal?.rule?.from != null ? ecsData.signal?.rule?.from[0] : 'now-0s') ) ); - - const from = moment(ecsData.timestamp ?? new Date()) + const from = moment(ecsData?.timestamp ?? new Date()) .subtract(ellapsedTimeRule) .toISOString(); - const to = moment(ecsData.timestamp ?? new Date()).toISOString(); + const to = moment(ecsData?.timestamp ?? new Date()).toISOString(); return { to, from }; }; @@ -128,37 +144,41 @@ const getFiltersFromRule = (filters: string[]): Filter[] => }, [] as Filter[]); export const getThresholdAggregationDataProvider = ( - ecsData: Ecs, + ecsData: Ecs | Ecs[], nonEcsData: TimelineNonEcsData[] ): DataProvider[] => { - const aggregationField = ecsData.signal?.rule?.threshold?.field!; - const aggregationValue = - get(aggregationField, ecsData) ?? find(['field', aggregationField], nonEcsData)?.value; - const dataProviderValue = Array.isArray(aggregationValue) - ? aggregationValue[0] - : aggregationValue; + const thresholdEcsData: Ecs[] = Array.isArray(ecsData) ? ecsData : [ecsData]; + return thresholdEcsData.reduce((acc, tresholdData) => { + const aggregationField = tresholdData.signal?.rule?.threshold?.field!; + const aggregationValue = + get(aggregationField, tresholdData) ?? find(['field', aggregationField], nonEcsData)?.value; + const dataProviderValue = Array.isArray(aggregationValue) + ? aggregationValue[0] + : aggregationValue; - if (!dataProviderValue) { - return []; - } + if (!dataProviderValue) { + return acc; + } - const aggregationFieldId = aggregationField.replace('.', '-'); + const aggregationFieldId = aggregationField.replace('.', '-'); - return [ - { - and: [], - id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${aggregationFieldId}-${dataProviderValue}`, - name: aggregationField, - enabled: true, - excluded: false, - kqlQuery: '', - queryMatch: { - field: aggregationField, - value: dataProviderValue, - operator: ':', + return [ + ...acc, + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${aggregationFieldId}-${dataProviderValue}`, + name: aggregationField, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: aggregationField, + value: dataProviderValue, + operator: ':', + }, }, - }, - ]; + ]; + }, []); }; export const isEqlRuleWithGroupId = (ecsData: Ecs) => @@ -169,20 +189,134 @@ export const isEqlRuleWithGroupId = (ecsData: Ecs) => export const isThresholdRule = (ecsData: Ecs) => ecsData.signal?.rule?.type?.length && ecsData.signal?.rule?.type[0] === 'threshold'; +export const buildAlertsKqlFilter = ( + key: '_id' | 'signal.group.id', + alertIds: string[] +): Filter[] => { + return [ + { + query: { + bool: { + filter: { + ids: { + values: alertIds, + }, + }, + }, + }, + meta: { + alias: 'Alert Ids', + negate: false, + disabled: false, + type: 'phrases', + key, + value: alertIds.join(), + params: alertIds, + }, + $state: { + store: esFilters.FilterStateStore.APP_STATE, + }, + }, + ]; +}; + +export const buildTimelineDataProviderOrFilter = ( + alertsIds: string[], + _id: string +): { filters: Filter[]; dataProviders: DataProvider[] } => { + if (!isEmpty(alertsIds)) { + return { + dataProviders: [], + filters: buildAlertsKqlFilter('_id', alertsIds), + }; + } + return { + filters: [], + dataProviders: [ + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${_id}`, + name: _id, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: '_id', + value: _id, + operator: ':' as const, + }, + }, + ], + }; +}; + +export const buildEqlDataProviderOrFilter = ( + alertsIds: string[], + ecs: Ecs[] | Ecs +): { filters: Filter[]; dataProviders: DataProvider[] } => { + if (!isEmpty(alertsIds) && Array.isArray(ecs)) { + return { + dataProviders: [], + filters: buildAlertsKqlFilter( + 'signal.group.id', + ecs.reduce((acc, ecsData) => { + const signalGroupId = ecsData.signal?.group?.id?.length + ? ecsData.signal?.group?.id[0] + : 'unknown-signal-group-id'; + if (!acc.includes(signalGroupId)) { + return [...acc, signalGroupId]; + } + return acc; + }, []) + ), + }; + } else if (!Array.isArray(ecs)) { + const signalGroupId = ecs.signal?.group?.id?.length + ? ecs.signal?.group?.id[0] + : 'unknown-signal-group-id'; + return { + dataProviders: [ + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`, + name: ecs._id, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'signal.group.id', + value: signalGroupId, + operator: ':' as const, + }, + }, + ], + filters: [], + }; + } + return { filters: [], dataProviders: [] }; +}; + export const sendAlertToTimelineAction = async ({ apolloClient, createTimeline, - ecsData, + ecsData: ecs, nonEcsData, updateTimelineIsLoading, searchStrategyClient, }: SendAlertToTimelineActionProps) => { + /* FUTURE DEVELOPER + * We are making an assumption here that if you have an array of ecs data they are all coming from the same rule + * but we still want to determine the filter for each alerts + */ + const ecsData: Ecs = Array.isArray(ecs) && ecs.length > 0 ? ecs[0] : (ecs as Ecs); + const alertIds = Array.isArray(ecs) ? ecs.map((d) => d._id) : []; const noteContent = ecsData.signal?.rule?.note != null ? ecsData.signal?.rule?.note[0] : ''; const timelineId = ecsData.signal?.rule?.timeline_id != null ? ecsData.signal?.rule?.timeline_id[0] : ''; - const { to, from } = determineToAndFrom({ ecsData }); + const { to, from } = determineToAndFrom({ ecs }); - if (!isEmpty(timelineId) && apolloClient != null) { + // For now we do not want to populate the template timeline if we have alertIds + if (!isEmpty(timelineId) && apolloClient != null && isEmpty(alertIds)) { try { updateTimelineIsLoading({ id: TimelineId.active, isLoading: true }); const [responseTimeline, eventDataResp] = await Promise.all([ @@ -275,7 +409,7 @@ export const sendAlertToTimelineAction = async ({ ...timelineDefaults, description: `_id: ${ecsData._id}`, filters: getFiltersFromRule(ecsData.signal?.rule?.filters as string[]), - dataProviders: [...getThresholdAggregationDataProvider(ecsData, nonEcsData)], + dataProviders: [...getThresholdAggregationDataProvider(ecs, nonEcsData)], id: TimelineId.active, indexNames: [], dateRange: { @@ -301,36 +435,11 @@ export const sendAlertToTimelineAction = async ({ ruleNote: noteContent, }); } else { - let dataProviders = [ - { - and: [], - id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`, - name: ecsData._id, - enabled: true, - excluded: false, - kqlQuery: '', - queryMatch: { - field: '_id', - value: ecsData._id, - operator: ':' as const, - }, - }, - ]; + let { dataProviders, filters } = buildTimelineDataProviderOrFilter(alertIds ?? [], ecsData._id); if (isEqlRuleWithGroupId(ecsData)) { - const signalGroupId = ecsData.signal?.group?.id?.length - ? ecsData.signal?.group?.id[0] - : 'unknown-signal-group-id'; - dataProviders = [ - { - ...dataProviders[0], - id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`, - queryMatch: { - field: 'signal.group.id', - value: signalGroupId, - operator: ':' as const, - }, - }, - ]; + const tempEql = buildEqlDataProviderOrFilter(alertIds ?? [], ecs); + dataProviders = tempEql.dataProviders; + filters = tempEql.filters; } return createTimeline({ @@ -346,6 +455,7 @@ export const sendAlertToTimelineAction = async ({ end: to, }, eventType: 'all', + filters, kqlQuery: { filterQuery: { kuery: { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx index d6813fdef8e5..2f0fee980c21 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx @@ -24,14 +24,18 @@ import { } from '../translations'; interface InvestigateInTimelineActionProps { - ariaLabel?: string; - ecsRowData: Ecs; + ecsRowData: Ecs | Ecs[] | null; nonEcsRowData: TimelineNonEcsData[]; + ariaLabel?: string; + alertIds?: string[]; + fetchEcsAlertsData?: (alertIds?: string[]) => Promise; } const InvestigateInTimelineActionComponent: React.FC = ({ ariaLabel = ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL, + alertIds, ecsRowData, + fetchEcsAlertsData, nonEcsRowData, }) => { const { @@ -66,25 +70,42 @@ const InvestigateInTimelineActionComponent: React.FC - sendAlertToTimelineAction({ - apolloClient, - createTimeline, - ecsData: ecsRowData, - nonEcsData: nonEcsRowData, - searchStrategyClient, - updateTimelineIsLoading, - }), - [ - apolloClient, - createTimeline, - ecsRowData, - nonEcsRowData, - searchStrategyClient, - updateTimelineIsLoading, - ] - ); + const investigateInTimelineAlertClick = useCallback(async () => { + try { + if (ecsRowData != null) { + await sendAlertToTimelineAction({ + apolloClient, + createTimeline, + ecsData: ecsRowData, + nonEcsData: nonEcsRowData, + searchStrategyClient, + updateTimelineIsLoading, + }); + } + if (ecsRowData == null && fetchEcsAlertsData) { + const alertsEcsData = await fetchEcsAlertsData(alertIds); + await sendAlertToTimelineAction({ + apolloClient, + createTimeline, + ecsData: alertsEcsData, + nonEcsData: nonEcsRowData, + searchStrategyClient, + updateTimelineIsLoading, + }); + } + } catch { + // TODO show a toaster that something went wrong + } + }, [ + alertIds, + apolloClient, + createTimeline, + ecsRowData, + fetchEcsAlertsData, + nonEcsRowData, + searchStrategyClient, + updateTimelineIsLoading, + ]); return ( ; createTimeline: CreateTimeline; - ecsData: Ecs; + ecsData: Ecs | Ecs[]; nonEcsData: TimelineNonEcsData[]; updateTimelineIsLoading: UpdateTimelineLoading; searchStrategyClient: ISearchStart; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index 475160b78727..8557e1082c1c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import React, { SetStateAction, useEffect, useState } from 'react'; import { fetchQueryAlerts } from './api'; @@ -80,7 +81,9 @@ export const useQueryAlerts = ( } }; - fetchData(); + if (!isEmpty(query)) { + fetchData(); + } return () => { isSubscribed = false; abortCtrl.abort(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index e33ee4d5762a..15261ab5fad0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -172,8 +172,10 @@ export const singleBulkCreate = async ({ logger.debug(buildRuleMessage(`took property says bulk took: ${response.took} milliseconds`)); const createdItems = filteredEvents.hits.hits - .map((doc) => - buildBulkBody({ + .map((doc, index) => ({ + _id: response.items[index].create?._id ?? '', + _index: response.items[index].create?._index ?? '', + ...buildBulkBody({ doc, ruleParams, id, @@ -187,8 +189,8 @@ export const singleBulkCreate = async ({ enabled, tags, throttle, - }) - ) + }), + })) .filter((_, index) => get(response.items[index], 'create.status') === 201); const createdItemsCount = createdItems.length; const duplicateSignalsCount = countBy(response.items, 'create.status')['409']; @@ -263,7 +265,11 @@ export const bulkInsertSignals = async ( const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0; const createdItems = signals - .map((doc) => doc._source) + .map((doc, index) => ({ + ...doc._source, + _id: response.items[index].create?._id ?? '', + _index: response.items[index].create?._index ?? '', + })) .filter((_, index) => get(response.items[index], 'create.status') === 201); logger.debug(`bulk created ${createdItemsCount} signals`); return { bulkCreateDuration: makeFloatString(end - start), createdItems, createdItemsCount }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 344a07e53e3e..a5d4b5d991b4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17250,7 +17250,6 @@ "xpack.securitySolution.case.caseView.reporterLabel": "報告者", "xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "{ externalService }インシデントの更新が必要です", "xpack.securitySolution.case.caseView.sendEmalLinkAria": "クリックすると、{user}に電子メールを送信します", - "xpack.securitySolution.case.caseView.showAlertDeletedTooltip": "アラートが見つかりません", "xpack.securitySolution.case.caseView.showAlertTooltip": "アラートの詳細を表示", "xpack.securitySolution.case.caseView.statusLabel": "ステータス", "xpack.securitySolution.case.caseView.tags": "タグ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 579a06d44e65..f84b993fe563 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17293,7 +17293,6 @@ "xpack.securitySolution.case.caseView.reporterLabel": "报告者", "xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "需要更新 { externalService } 事件", "xpack.securitySolution.case.caseView.sendEmalLinkAria": "单击可向 {user} 发送电子邮件", - "xpack.securitySolution.case.caseView.showAlertDeletedTooltip": "未找到告警", "xpack.securitySolution.case.caseView.showAlertTooltip": "显示告警详情", "xpack.securitySolution.case.caseView.statusLabel": "状态", "xpack.securitySolution.case.caseView.tags": "标签", diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts index 2250b481c372..86b1c3031cbe 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -95,6 +95,10 @@ export default ({ getService }: FtrProviderContext): void => { type: CommentType.alert, alertId: 'test-id', index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, }) .expect(400); }); @@ -110,6 +114,10 @@ export default ({ getService }: FtrProviderContext): void => { type: CommentType.generatedAlert, alerts: [{ _id: 'id1' }], index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, }) .expect(400); }); @@ -167,6 +175,10 @@ export default ({ getService }: FtrProviderContext): void => { type: CommentType.alert, alertId: 'new-id', index: postCommentAlertReq.index, + rule: { + id: 'id', + name: 'name', + }, }) .expect(200); @@ -230,6 +242,10 @@ export default ({ getService }: FtrProviderContext): void => { type: CommentType.alert, alertId: 'test-id', index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, }) .expect(400); }); @@ -302,6 +318,10 @@ export default ({ getService }: FtrProviderContext): void => { type: CommentType.alert, index: 'test-index', alertId: 'test-id', + rule: { + id: 'id', + name: 'name', + }, }; for (const attribute of ['alertId', 'index']) { @@ -341,6 +361,10 @@ export default ({ getService }: FtrProviderContext): void => { type: CommentType.alert, index: 'test-index', alertId: 'test-id', + rule: { + id: 'id', + name: 'name', + }, [attribute]: attribute, }) .expect(400); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index 1ce011985d9e..fb095c117cdf 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -148,6 +148,10 @@ export default ({ getService }: FtrProviderContext): void => { type: CommentType.alert, index: 'test-index', alertId: 'test-id', + rule: { + id: 'id', + name: 'name', + }, }; for (const attribute of ['alertId', 'index']) { @@ -176,6 +180,10 @@ export default ({ getService }: FtrProviderContext): void => { [attribute]: attribute, alertId: 'test-id', index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, }) .expect(400); } @@ -296,6 +304,10 @@ export default ({ getService }: FtrProviderContext): void => { .send({ alertId: alert._id, index: alert._index, + rule: { + id: 'id', + name: 'name', + }, type: CommentType.alert, }) .expect(200); @@ -346,6 +358,10 @@ export default ({ getService }: FtrProviderContext): void => { .send({ alertId: alert._id, index: alert._index, + rule: { + id: 'id', + name: 'name', + }, type: CommentType.alert, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index dcc49152e4db..43d6be196da0 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -402,6 +402,10 @@ export default ({ getService }: FtrProviderContext): void => { .send({ alertId: alert._id, index: alert._index, + rule: { + id: 'id', + name: 'name', + }, type: CommentType.alert, }) .expect(200); @@ -453,6 +457,10 @@ export default ({ getService }: FtrProviderContext): void => { alertId: alert._id, index: alert._index, type: CommentType.alert, + rule: { + id: 'id', + name: 'name', + }, }) .expect(200); @@ -503,6 +511,10 @@ export default ({ getService }: FtrProviderContext): void => { .send({ alertId: alert._id, index: alert._index, + rule: { + id: 'id', + name: 'name', + }, type: CommentType.alert, }) .expect(200); @@ -570,6 +582,10 @@ export default ({ getService }: FtrProviderContext): void => { alertId: alert._id, index: alert._index, type: CommentType.alert, + rule: { + id: 'id', + name: 'name', + }, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 01dd6ed5404c..4812ead2c4c7 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -736,7 +736,12 @@ export default ({ getService }: FtrProviderContext): void => { subAction: 'addComment', subActionParams: { caseId: caseRes.body.id, - comment: { alertId: alert._id, index: alert._index, type: CommentType.alert }, + comment: { + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + rule: { id: 'id', name: 'name' }, + }, }, }; @@ -784,7 +789,12 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); createdActionId = createdAction.id; - const comment = { alertId: 'test-id', index: 'test-index', type: CommentType.alert }; + const comment = { + alertId: 'test-id', + index: 'test-index', + type: CommentType.alert, + rule: { id: 'id', name: 'name' }, + }; const params = { subAction: 'addComment', subActionParams: { @@ -876,7 +886,12 @@ export default ({ getService }: FtrProviderContext): void => { subAction: 'addComment', subActionParams: { caseId: '123', - comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, + comment: { + alertId: 'test-id', + index: 'test-index', + type: CommentType.alert, + rule: { id: 'id', name: 'name' }, + }, }, }; @@ -1017,7 +1032,12 @@ export default ({ getService }: FtrProviderContext): void => { subAction: 'addComment', subActionParams: { caseId: caseRes.body.id, - comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, + comment: { + alertId: 'test-id', + index: 'test-index', + type: CommentType.alert, + rule: { id: 'id', name: 'name' }, + }, }, }; diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index 2f4fa1b30f56..f6fd2b1a6b3b 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -8,6 +8,7 @@ import { CommentSchemaType, ContextTypeGeneratedAlertType, + createAlertsString, isCommentGeneratedAlert, transformConnectorComment, } from '../../../../plugins/case/server/connectors'; @@ -70,12 +71,15 @@ export const postCommentUserReq: CommentRequestUserType = { export const postCommentAlertReq: CommentRequestAlertType = { alertId: 'test-id', index: 'test-index', + rule: { id: 'test-rule-id', name: 'test-index-id' }, type: CommentType.alert, }; export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = { - alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], - index: 'test-index', + alerts: createAlertsString([ + { _id: 'test-id', _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' }, + { _id: 'test-id2', _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' }, + ]), type: CommentType.generatedAlert, };