From ff4b05ae79e2aa813cd60e70720b4d7496e4c3de Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 6 Jan 2021 11:40:40 +0200 Subject: [PATCH] [Security Solution][Case] Fix comment content when pushing alerts to external services (#86812) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../cases/components/case_view/index.tsx | 6 ++ .../use_push_to_service/index.test.tsx | 28 ++++++ .../components/use_push_to_service/index.tsx | 6 +- .../public/cases/containers/translations.ts | 2 + .../use_post_push_to_service.test.tsx | 98 +++++++++++++++++-- .../containers/use_post_push_to_service.tsx | 80 +++++++++++++-- .../public/cases/translations.ts | 11 +++ 7 files changed, 212 insertions(+), 19 deletions(-) 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 6007038b33ab7..2425b4c74d12a 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 @@ -90,6 +90,8 @@ interface Signal { rule: { id: string; name: string; + to: string; + from: string; }; } @@ -97,6 +99,7 @@ interface SignalHit { _id: string; _index: string; _source: { + '@timestamp': string; signal: Signal; }; } @@ -104,6 +107,7 @@ interface SignalHit { export type Alert = { _id: string; _index: string; + '@timestamp': string; } & Signal; export const CaseComponent = React.memo( @@ -153,6 +157,7 @@ export const CaseComponent = React.memo( [_id]: { _id, _index, + '@timestamp': _source['@timestamp'], ..._source.signal, }, }), @@ -291,6 +296,7 @@ export const CaseComponent = React.memo( updateCase: handleUpdateCase, userCanCrud, isValidConnector, + alerts, }); const onSubmitConnector = useCallback( diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx index dc361d87bad0a..1709413c7bd7f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx @@ -42,6 +42,7 @@ describe('usePushToService', () => { isLoading: false, postPushToService, }; + const mockConnector = connectorsMock[0]; const actionLicense = actionLicenses[0]; const caseServices = { @@ -53,6 +54,7 @@ describe('usePushToService', () => { hasDataToPush: true, }, }; + const defaultArgs = { connector: { id: mockConnector.id, @@ -67,6 +69,19 @@ describe('usePushToService', () => { updateCase, userCanCrud: true, isValidConnector: true, + alerts: { + 'alert-id-1': { + _id: 'alert-id-1', + _index: 'alert-index-1', + '@timestamp': '2020-11-20T15:35:28.373Z', + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + from: 'now-360s', + to: 'now', + }, + }, + }, }; beforeEach(() => { @@ -98,6 +113,19 @@ describe('usePushToService', () => { type: ConnectorTypes.servicenow, }, updateCase, + alerts: { + 'alert-id-1': { + _id: 'alert-id-1', + _index: 'alert-index-1', + '@timestamp': '2020-11-20T15:35:28.373Z', + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + from: 'now-360s', + to: 'now', + }, + }, + }, }); expect(result.current.pushCallouts).toBeNull(); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx index 15a01406c5724..24b17dcbcc1e6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx @@ -21,6 +21,7 @@ import { CaseServices } from '../../containers/use_get_case_user_actions'; import { LinkAnchor } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; import { ErrorMessage } from '../callout/types'; +import { Alert } from '../case_view'; export interface UsePushToService { caseId: string; @@ -31,6 +32,7 @@ export interface UsePushToService { updateCase: (newCase: Case) => void; userCanCrud: boolean; isValidConnector: boolean; + alerts: Record; } export interface ReturnUsePushToService { @@ -47,6 +49,7 @@ export const usePushToService = ({ updateCase, userCanCrud, isValidConnector, + alerts, }: UsePushToService): ReturnUsePushToService => { const history = useHistory(); const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); @@ -61,9 +64,10 @@ export const usePushToService = ({ caseServices, connector, updateCase, + alerts, }); } - }, [caseId, caseServices, connector, postPushToService, updateCase]); + }, [alerts, caseId, caseServices, connector, postPushToService, updateCase]); const goToConfigureCases = useCallback( (ev) => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/translations.ts b/x-pack/plugins/security_solution/public/cases/containers/translations.ts index b0dafcec97cce..a45fd342a23ea 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/translations.ts @@ -6,6 +6,8 @@ import { i18n } from '@kbn/i18n'; +export * from '../translations'; + export const ERROR_TITLE = i18n.translate('xpack.securitySolution.containers.case.errorTitle', { defaultMessage: 'Error fetching data', }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx index 71711dae69319..c8d00a4c5cf0f 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx @@ -23,10 +23,20 @@ import { CaseServices } from './use_get_case_user_actions'; import { CaseConnector, ConnectorTypes, CommentType } from '../../../../case/common/api'; jest.mock('./api'); +jest.mock('../../common/components/link_to', () => { + const originalModule = jest.requireActual('../../common/components/link_to'); + return { + ...originalModule, + getTimelineTabsUrl: jest.fn(), + useFormatUrl: jest.fn().mockReturnValue({ formatUrl: jest.fn(), search: 'urlSearch' }), + }; +}); describe('usePostPushToService', () => { const abortCtrl = new AbortController(); const updateCase = jest.fn(); + const formatUrl = jest.fn(); + const samplePush = { caseId: pushedCase.id, caseServices: { @@ -45,7 +55,21 @@ describe('usePostPushToService', () => { fields: { issueType: 'Task', priority: 'Low', parent: null }, } as CaseConnector, updateCase, + alerts: { + 'alert-id-1': { + _id: 'alert-id-1', + _index: 'alert-index-1', + '@timestamp': '2020-11-20T15:35:28.373Z', + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + from: 'now-360s', + to: 'now', + }, + }, + }, }; + const sampleServiceRequestData = { savedObjectId: pushedCase.id, createdAt: pushedCase.createdAt, @@ -142,11 +166,13 @@ describe('usePostPushToService', () => { expect(spyOnPushToService).toBeCalledWith( samplePush.connector.id, samplePush.connector.type, - formatServiceRequestData( - basicCase, - samplePush.connector, - sampleCaseServices as CaseServices - ), + formatServiceRequestData({ + myCase: basicCase, + connector: samplePush.connector, + caseServices: sampleCaseServices as CaseServices, + alerts: samplePush.alerts, + formatUrl, + }), abortCtrl.signal ); }); @@ -162,6 +188,7 @@ describe('usePostPushToService', () => { type: ConnectorTypes.none, fields: null, }, + alerts: samplePush.alerts, updateCase, }; const spyOnPushToService = jest.spyOn(api, 'pushToService'); @@ -176,7 +203,13 @@ describe('usePostPushToService', () => { expect(spyOnPushToService).toBeCalledWith( samplePush2.connector.id, samplePush2.connector.type, - formatServiceRequestData(basicCase, samplePush2.connector, {}), + formatServiceRequestData({ + myCase: basicCase, + connector: samplePush2.connector, + caseServices: {}, + alerts: samplePush.alerts, + formatUrl, + }), abortCtrl.signal ); }); @@ -213,7 +246,13 @@ describe('usePostPushToService', () => { it('formatServiceRequestData - current connector', () => { const caseServices = sampleCaseServices; - const result = formatServiceRequestData(pushedCase, samplePush.connector, caseServices); + const result = formatServiceRequestData({ + myCase: pushedCase, + connector: samplePush.connector, + caseServices, + alerts: samplePush.alerts, + formatUrl, + }); expect(result).toEqual(sampleServiceRequestData); }); @@ -225,7 +264,13 @@ describe('usePostPushToService', () => { type: ConnectorTypes.jira, fields: { issueType: 'Task', priority: 'High', parent: 'RJ-01' }, }; - const result = formatServiceRequestData(pushedCase, connector as CaseConnector, caseServices); + const result = formatServiceRequestData({ + myCase: pushedCase, + connector: connector as CaseConnector, + caseServices, + alerts: samplePush.alerts, + formatUrl, + }); expect(result).toEqual({ ...sampleServiceRequestData, ...connector.fields, @@ -237,13 +282,22 @@ describe('usePostPushToService', () => { const caseServices = { '123': sampleCaseServices['123'], }; + const connector = { id: '456', name: 'connector 2', type: ConnectorTypes.jira, fields: { issueType: 'Task', priority: 'High', parent: null }, }; - const result = formatServiceRequestData(pushedCase, connector as CaseConnector, caseServices); + + const result = formatServiceRequestData({ + myCase: pushedCase, + connector: connector as CaseConnector, + caseServices, + alerts: samplePush.alerts, + formatUrl, + }); + expect(result).toEqual({ ...sampleServiceRequestData, ...connector.fields, @@ -251,6 +305,32 @@ describe('usePostPushToService', () => { }); }); + it('formatServiceRequestData - Alert comment content', () => { + formatUrl.mockReturnValue('https://app.com/detections'); + const caseServices = sampleCaseServices; + const result = formatServiceRequestData({ + myCase: { + ...pushedCase, + comments: [ + { + ...pushedCase.comments[0], + type: CommentType.alert, + alertId: 'alert-id-1', + index: 'alert-index-1', + }, + ], + }, + connector: samplePush.connector, + caseServices, + alerts: samplePush.alerts, + formatUrl, + }); + + expect(result.comments![0].comment).toEqual( + '[Alert](https://app.com/detections?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:alert-id-1),type:phrase),query:(match:(_id:(query:alert-id-1,type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%272020-11-20T15:29:28.373Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)),timeline:(linkTo:!(global),timerange:(from:%272020-11-20T15:29:28.373Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)))) added to case.' + ); + }); + it('unhappy path', async () => { const spyOnPushToService = jest.spyOn(api, 'pushToService'); spyOnPushToService.mockImplementation(() => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx index 97fd0c99ffd96..b46840cae60e2 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx @@ -5,6 +5,8 @@ */ import { useReducer, useCallback } from 'react'; +import moment from 'moment'; +import dateMath from '@elastic/datemath'; import { ServiceConnectorCaseResponse, @@ -12,15 +14,18 @@ import { CaseConnector, CommentType, } from '../../../../case/common/api'; +import { SecurityPageName } from '../../app/types'; +import { useFormatUrl, FormatUrl, getRuleDetailsUrl } from '../../common/components/link_to'; import { errorToToaster, useStateToaster, displaySuccessToast, } from '../../common/components/toasters'; +import { Alert } from '../components/case_view'; import { getCase, pushToService, pushCase } from './api'; import * as i18n from './translations'; -import { Case } from './types'; +import { Case, Comment } from './types'; import { CaseServices } from './use_get_case_user_actions'; interface PushToServiceState { @@ -72,6 +77,7 @@ interface PushToServiceRequest { caseId: string; connector: CaseConnector; caseServices: CaseServices; + alerts: Record; updateCase: (newCase: Case) => void; } @@ -80,6 +86,7 @@ export interface UsePostPushToService extends PushToServiceState { caseId, caseServices, connector, + alerts, updateCase, }: PushToServiceRequest) => void; } @@ -92,9 +99,10 @@ export const usePostPushToService = (): UsePostPushToService => { isError: false, }); const [, dispatchToaster] = useStateToaster(); + const { formatUrl } = useFormatUrl(SecurityPageName.detections); const postPushToService = useCallback( - async ({ caseId, caseServices, connector, updateCase }: PushToServiceRequest) => { + async ({ caseId, caseServices, connector, alerts, updateCase }: PushToServiceRequest) => { let cancel = false; const abortCtrl = new AbortController(); try { @@ -103,7 +111,13 @@ export const usePostPushToService = (): UsePostPushToService => { const responseService = await pushToService( connector.id, connector.type, - formatServiceRequestData(casePushData, connector, caseServices), + formatServiceRequestData({ + myCase: casePushData, + connector, + caseServices, + alerts, + formatUrl, + }), abortCtrl.signal ); const responseCase = await pushCase( @@ -148,11 +162,59 @@ export const usePostPushToService = (): UsePostPushToService => { return { ...state, postPushToService }; }; -export const formatServiceRequestData = ( - myCase: Case, - connector: CaseConnector, - caseServices: CaseServices -): ServiceConnectorCaseParams => { +export const determineToAndFrom = (alert: Alert) => { + const ellapsedTimeRule = moment.duration( + moment().diff(dateMath.parse(alert.rule?.from != null ? alert.rule.from : 'now-0s')) + ); + + const from = moment(alert['@timestamp'] ?? new Date()) + .subtract(ellapsedTimeRule) + .toISOString(); + const to = moment(alert['@timestamp'] ?? new Date()).toISOString(); + + return { to, from }; +}; + +const getAlertFilterUrl = (alert: Alert): string => { + const { to, from } = determineToAndFrom(alert); + return `?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:${alert._id}),type:phrase),query:(match:(_id:(query:${alert._id},type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)),timeline:(linkTo:!(global),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)))`; +}; + +const getCommentContent = ( + comment: Comment, + alerts: Record, + formatUrl: FormatUrl +): string => { + if (comment.type === CommentType.user) { + return comment.comment; + } else if (comment.type === CommentType.alert) { + const alert = alerts[comment.alertId]; + const ruleDetailsLink = formatUrl(getRuleDetailsUrl(alert.rule.id), { + absolute: true, + skipSearch: true, + }); + + return `[${i18n.ALERT}](${ruleDetailsLink}${getAlertFilterUrl(alert)}) ${ + i18n.ALERT_ADDED_TO_CASE + }.`; + } + + return ''; +}; + +export const formatServiceRequestData = ({ + myCase, + connector, + caseServices, + alerts, + formatUrl, +}: { + myCase: Case; + connector: CaseConnector; + caseServices: CaseServices; + alerts: Record; + formatUrl: FormatUrl; +}): ServiceConnectorCaseParams => { const { id: caseId, createdAt, @@ -179,7 +241,7 @@ export const formatServiceRequestData = ( ) .map((c) => ({ commentId: c.id, - comment: c.type === CommentType.user ? c.comment : '', + comment: getCommentContent(c, alerts, formatUrl), createdAt: c.createdAt, createdBy: { fullName: c.createdBy.fullName ?? null, diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index fd217457f9e7d..6e3dcd91312de 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -278,3 +278,14 @@ export const SYNC_ALERTS_HELP = i18n.translate( 'Enabling this option will sync the status of alerts in this case with the case status.', } ); + +export const ALERT = i18n.translate('xpack.securitySolution.common.alertLabel', { + defaultMessage: 'Alert', +}); + +export const ALERT_ADDED_TO_CASE = i18n.translate( + 'xpack.securitySolution.common.alertAddedToCase', + { + defaultMessage: 'added to case', + } +);