diff --git a/x-pack/plugins/cases/public/components/__mock__/timeline.tsx b/x-pack/plugins/cases/public/components/__mock__/timeline.tsx index 0aeda0f08302d0..d576b0ef1732cd 100644 --- a/x-pack/plugins/cases/public/components/__mock__/timeline.tsx +++ b/x-pack/plugins/cases/public/components/__mock__/timeline.tsx @@ -24,8 +24,6 @@ export const timelineIntegrationMock = { useInsertTimeline: jest.fn(), }, ui: { - renderInvestigateInTimelineActionComponent: () => - mockTimelineComponent('investigate-in-timeline'), renderTimelineDetailsPanel: () => mockTimelineComponent('timeline-details-panel'), }, }; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index c0dc4fa4d95e9b..235c8eabc9e595 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -356,9 +356,6 @@ export const CaseViewPage = React.memo( isLoadingUserActions={isLoadingUserActions} onShowAlertDetails={onShowAlertDetails} onUpdateField={onUpdateField} - renderInvestigateInTimelineActionComponent={ - timelineUi?.renderInvestigateInTimelineActionComponent - } statusActionButton={ userCanCrud ? ( UseInsertTimelineReturn; }; ui?: { - renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; renderTimelineDetailsPanel?: () => JSX.Element; }; } diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts index 80657cc90cba93..dece59ec1eb427 100644 --- a/x-pack/plugins/cases/public/components/user_actions/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -28,7 +28,6 @@ export interface UserActionTreeProps { onRuleDetailsClick?: RuleDetailsNavigation['onClick']; onShowAlertDetails: (alertId: string, index: string) => void; onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; - renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; statusActionButton: JSX.Element | null; updateCase: (newCase: Case) => void; useFetchAlertData: (alertIds: string[]) => [boolean, Record]; 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 08878b56152573..1f02ff88b19bd2 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -12,7 +12,6 @@ import { TimelineId } from '../../../common/types/timeline'; import { getRuleDetailsUrl, useFormatUrl } from '../../common/components/link_to'; -import * as i18n from './translations'; import { useGetUserCasesPermissions, useKibana, useNavigation } from '../../common/lib/kibana'; import { APP_ID, CASES_PATH, SecurityPageName } from '../../../common/constants'; import { timelineActions } from '../../timelines/store/timeline'; @@ -25,7 +24,6 @@ import { SpyRoute } from '../../common/utils/route/spy_routes'; import { useInsertTimeline } from '../components/use_insert_timeline'; import * as timelineMarkdownPlugin from '../../common/components/markdown_editor/plugins/timeline'; import { DetailsPanel } from '../../timelines/components/side_panel'; -import { InvestigateInTimelineAction } from '../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action'; import { useFetchAlertData } from './use_fetch_alert_data'; const TimelineDetailsPanel = () => { @@ -44,17 +42,6 @@ const TimelineDetailsPanel = () => { ); }; -const InvestigateInTimelineActionComponent = (alertIds: string[]) => { - return ( - - ); -}; - const CaseContainerComponent: React.FC = () => { const { cases: casesUi } = useKibana().services; const { getAppUrl, navigateTo } = useNavigation(); @@ -163,7 +150,6 @@ const CaseContainerComponent: React.FC = () => { useInsertTimeline, }, ui: { - renderInvestigateInTimelineActionComponent: InvestigateInTimelineActionComponent, renderTimelineDetailsPanel: TimelineDetailsPanel, }, }, diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts index 364eea3f8d98b2..30bd3d6e598965 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts @@ -8,7 +8,8 @@ import { CoreStart } from '../../../../../../../src/core/public'; import { StartPlugins } from '../../../types'; -type GlobalServices = Pick & Pick; +type GlobalServices = Pick & + Pick; export class KibanaServices { private static kibanaVersion?: string; @@ -19,8 +20,9 @@ export class KibanaServices { data, kibanaVersion, uiSettings, + notifications, }: GlobalServices & { kibanaVersion: string }) { - this.services = { data, http, uiSettings }; + this.services = { data, http, uiSettings, notifications }; this.kibanaVersion = kibanaVersion; } 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 ad95f89c850f6d..b1226e5b591900 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 @@ -29,11 +29,18 @@ import type { ISearchStart } from '../../../../../../../src/plugins/data/public' import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { getTimelineTemplate } from '../../../timelines/containers/api'; import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers'; +import { KibanaServices } from '../../../common/lib/kibana'; +import { + DEFAULT_FROM_MOMENT, + DEFAULT_TO_MOMENT, +} from '../../../common/utils/default_date_settings'; jest.mock('../../../timelines/containers/api', () => ({ getTimelineTemplate: jest.fn(), })); +jest.mock('../../../common/lib/kibana'); + describe('alert actions', () => { const anchor = '2020-03-01T17:59:46.349Z'; const unix = moment(anchor).valueOf(); @@ -41,6 +48,9 @@ describe('alert actions', () => { let updateTimelineIsLoading: UpdateTimelineLoading; let searchStrategyClient: jest.Mocked; let clock: sinon.SinonFakeTimers; + let mockKibanaServices: jest.Mock; + let fetchMock: jest.Mock; + let toastMock: jest.Mock; beforeEach(() => { // jest carries state between mocked implementations when using @@ -52,6 +62,14 @@ describe('alert actions', () => { createTimeline = jest.fn() as jest.Mocked; updateTimelineIsLoading = jest.fn() as jest.Mocked; + mockKibanaServices = KibanaServices.get as jest.Mock; + + fetchMock = jest.fn(); + toastMock = jest.fn(); + mockKibanaServices.mockReturnValue({ + http: { fetch: fetchMock }, + notifications: { toasts: { addError: toastMock } }, + }); searchStrategyClient = { ...dataPluginMock.createStartContract().search, @@ -418,6 +436,59 @@ describe('alert actions', () => { }); describe('determineToAndFrom', () => { + const ecsDataMockWithNoTemplateTimeline = getThresholdDetectionAlertAADMock({ + ...mockAADEcsDataWithAlert, + kibana: { + alert: { + ...mockAADEcsDataWithAlert.kibana?.alert, + rule: { + ...mockAADEcsDataWithAlert.kibana?.alert?.rule, + parameters: { + ...mockAADEcsDataWithAlert.kibana?.alert?.rule?.parameters, + threshold: { + field: ['destination.ip'], + value: 1, + }, + }, + name: ['mock threshold rule'], + saved_id: [], + type: ['threshold'], + uuid: ['c5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'], + timeline_id: undefined, + timeline_title: undefined, + }, + threshold_result: { + count: 99, + from: '2021-01-10T21:11:45.839Z', + cardinality: [ + { + field: 'source.ip', + value: 1, + }, + ], + terms: [ + { + field: 'destination.ip', + value: 1, + }, + ], + }, + }, + }, + }); + beforeEach(() => { + fetchMock.mockResolvedValue({ + hits: { + hits: [ + { + _id: ecsDataMockWithNoTemplateTimeline[0]._id, + _index: 'mock', + _source: ecsDataMockWithNoTemplateTimeline[0], + }, + ], + }, + }); + }); test('it uses ecs.Data.timestamp if one is provided', () => { const ecsDataMock: Ecs = { ...mockEcsDataWithAlert, @@ -438,47 +509,6 @@ describe('alert actions', () => { }); test('it uses original_time and threshold_result.from for threshold alerts', async () => { - const ecsDataMockWithNoTemplateTimeline = getThresholdDetectionAlertAADMock({ - ...mockAADEcsDataWithAlert, - kibana: { - alert: { - ...mockAADEcsDataWithAlert.kibana?.alert, - rule: { - ...mockAADEcsDataWithAlert.kibana?.alert?.rule, - parameters: { - ...mockAADEcsDataWithAlert.kibana?.alert?.rule?.parameters, - threshold: { - field: ['destination.ip'], - value: 1, - }, - }, - name: ['mock threshold rule'], - saved_id: [], - type: ['threshold'], - uuid: ['c5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'], - timeline_id: undefined, - timeline_title: undefined, - }, - threshold_result: { - count: 99, - from: '2021-01-10T21:11:45.839Z', - cardinality: [ - { - field: 'source.ip', - value: 1, - }, - ], - terms: [ - { - field: 'destination.ip', - value: 1, - }, - ], - }, - }, - }, - }); - const expectedFrom = '2021-01-10T21:11:45.839Z'; const expectedTo = '2021-01-10T21:12:45.839Z'; @@ -525,4 +555,86 @@ describe('alert actions', () => { }); }); }); + + describe('show toasts when data is malformed', () => { + const ecsDataMockWithNoTemplateTimeline = getThresholdDetectionAlertAADMock({ + ...mockAADEcsDataWithAlert, + kibana: { + alert: { + ...mockAADEcsDataWithAlert.kibana?.alert, + rule: { + ...mockAADEcsDataWithAlert.kibana?.alert?.rule, + parameters: { + ...mockAADEcsDataWithAlert.kibana?.alert?.rule?.parameters, + threshold: { + field: ['destination.ip'], + value: 1, + }, + }, + name: ['mock threshold rule'], + saved_id: [], + type: ['threshold'], + uuid: ['c5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'], + timeline_id: undefined, + timeline_title: undefined, + }, + threshold_result: { + count: 99, + from: '2021-01-10T21:11:45.839Z', + cardinality: [ + { + field: 'source.ip', + value: 1, + }, + ], + terms: [ + { + field: 'destination.ip', + value: 1, + }, + ], + }, + }, + }, + }); + beforeEach(() => { + fetchMock.mockResolvedValue({ + hits: 'not correctly formed doc', + }); + }); + test('renders a toast and calls create timeline with basic defaults', async () => { + const expectedFrom = DEFAULT_FROM_MOMENT.toISOString(); + const expectedTo = DEFAULT_TO_MOMENT.toISOString(); + const timelineProps = { + ...defaultTimelineProps, + timeline: { + ...defaultTimelineProps.timeline, + dataProviders: [], + dateRange: { + start: expectedFrom, + end: expectedTo, + }, + description: '', + kqlQuery: { + filterQuery: null, + }, + resolveTimelineConfig: undefined, + }, + from: expectedFrom, + to: expectedTo, + }; + + delete timelineProps.ruleNote; + + await sendAlertToTimelineAction({ + createTimeline, + ecsData: ecsDataMockWithNoTemplateTimeline, + updateTimelineIsLoading, + searchStrategyClient, + }); + expect(createTimeline).toHaveBeenCalledTimes(1); + expect(createTimeline).toHaveBeenCalledWith(timelineProps); + expect(toastMock).toHaveBeenCalled(); + }); + }); }); 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 c12133089e02af..46e439d38f81ea 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 @@ -21,6 +21,7 @@ import { ALERT_RULE_PARAMETERS, } from '@kbn/rule-data-utils'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ALERT_ORIGINAL_TIME, ALERT_GROUP_ID, @@ -64,6 +65,13 @@ import { QueryOperator, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { getTimelineTemplate } from '../../../timelines/containers/api'; +import { KibanaServices } from '../../../common/lib/kibana'; +import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../common/constants'; +import { buildAlertsQuery, formatAlertToEcsSignal } from '../../../common/utils/alerts'; +import { + DEFAULT_FROM_MOMENT, + DEFAULT_TO_MOMENT, +} from '../../../common/utils/default_date_settings'; export const getUpdateAlertsQuery = (eventIds: Readonly) => { return { @@ -177,7 +185,7 @@ export const getThresholdAggregationData = (ecsData: Ecs | Ecs[]): ThresholdAggr return thresholdEcsData.reduce( (outerAcc, thresholdData) => { const threshold = - getField(thresholdData, ALERT_RULE_PARAMETERS).threshold ?? + getField(thresholdData, `${ALERT_RULE_PARAMETERS}.threshold`) ?? thresholdData.signal?.rule?.threshold; const thresholdResult: { @@ -384,47 +392,102 @@ const buildEqlDataProviderOrFilter = ( return { filters: [], dataProviders: [] }; }; -const createThresholdTimeline = ( +const createThresholdTimeline = async ( ecsData: Ecs, createTimeline: ({ from, timeline, to }: CreateTimelineProps) => void, noteContent: string, templateValues: { filters?: Filter[]; query?: string; dataProviders?: DataProvider[] } ) => { - const { thresholdFrom, thresholdTo, dataProviders } = getThresholdAggregationData(ecsData); - const params = getField(ecsData, ALERT_RULE_PARAMETERS); - const filters = getFiltersFromRule(params.filters ?? ecsData.signal?.rule?.filters) ?? []; - const language = params.language ?? ecsData.signal?.rule?.language ?? 'kuery'; - const query = params.query ?? ecsData.signal?.rule?.query ?? ''; - const indexNames = params.index ?? ecsData.signal?.rule?.index ?? []; - - return createTimeline({ - from: thresholdFrom, - notes: null, - timeline: { - ...timelineDefaults, - description: `_id: ${ecsData._id}`, - filters: templateValues.filters ?? filters, - dataProviders: templateValues.dataProviders ?? dataProviders, - id: TimelineId.active, - indexNames, - dateRange: { - start: thresholdFrom, - end: thresholdTo, - }, - eventType: 'all', - kqlQuery: { - filterQuery: { - kuery: { - kind: language, - expression: templateValues.query ?? query, + try { + const alertResponse = await KibanaServices.get().http.fetch< + estypes.SearchResponse<{ '@timestamp': string; [key: string]: unknown }> + >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { + method: 'POST', + body: JSON.stringify(buildAlertsQuery([ecsData._id])), + }); + const formattedAlertData = + alertResponse?.hits.hits.reduce((acc, { _id, _index, _source = {} }) => { + return [ + ...acc, + { + ...formatAlertToEcsSignal(_source), + _id, + _index, + timestamp: _source['@timestamp'], + }, + ]; + }, []) ?? []; + const alertDoc = formattedAlertData[0]; + const params = getField(alertDoc, ALERT_RULE_PARAMETERS); + const filters = getFiltersFromRule(params.filters ?? alertDoc.signal?.rule?.filters) ?? []; + const language = params.language ?? alertDoc.signal?.rule?.language ?? 'kuery'; + const query = params.query ?? alertDoc.signal?.rule?.query ?? ''; + const indexNames = params.index ?? alertDoc.signal?.rule?.index ?? []; + + const { thresholdFrom, thresholdTo, dataProviders } = getThresholdAggregationData(alertDoc); + return createTimeline({ + from: thresholdFrom, + notes: null, + timeline: { + ...timelineDefaults, + description: `_id: ${alertDoc._id}`, + filters: templateValues.filters ?? filters, + dataProviders: templateValues.dataProviders ?? dataProviders, + id: TimelineId.active, + indexNames, + dateRange: { + start: thresholdFrom, + end: thresholdTo, + }, + eventType: 'all', + kqlQuery: { + filterQuery: { + kuery: { + kind: language, + expression: templateValues.query ?? query, + }, + serializedQuery: templateValues.query ?? query, }, - serializedQuery: templateValues.query ?? query, }, }, - }, - to: thresholdTo, - ruleNote: noteContent, - }); + to: thresholdTo, + ruleNote: noteContent, + }); + } catch (error) { + const { toasts } = KibanaServices.get().notifications; + toasts.addError(error, { + toastMessage: i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.createThresholdTimelineFailure', + { + defaultMessage: 'Failed to create timeline for document _id: {id}', + values: { id: ecsData._id }, + } + ), + title: i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.createThresholdTimelineFailureTitle', + { + defaultMessage: 'Failed to create theshold alert timeline', + } + ), + }); + const from = DEFAULT_FROM_MOMENT.toISOString(); + const to = DEFAULT_TO_MOMENT.toISOString(); + return createTimeline({ + from, + notes: null, + timeline: { + ...timelineDefaults, + id: TimelineId.active, + indexNames: [], + dateRange: { + start: from, + end: to, + }, + eventType: 'all', + }, + to, + }); + } }; export const sendAlertToTimelineAction = async ({ @@ -492,7 +555,7 @@ export const sendAlertToTimelineAction = async ({ ); // threshold with template if (isThresholdRule(ecsData)) { - createThresholdTimeline(ecsData, createTimeline, noteContent, { + return createThresholdTimeline(ecsData, createTimeline, noteContent, { filters, query, dataProviders, @@ -550,7 +613,7 @@ export const sendAlertToTimelineAction = async ({ }); } } else if (isThresholdRule(ecsData)) { - createThresholdTimeline(ecsData, createTimeline, noteContent, {}); + return createThresholdTimeline(ecsData, createTimeline, noteContent, {}); } else { let { dataProviders, filters } = buildTimelineDataProviderOrFilter(alertIds ?? [], ecsData._id); if (isEqlRuleWithGroupId(ecsData)) { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.test.tsx new file mode 100644 index 00000000000000..24433e2f2ca99c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.test.tsx @@ -0,0 +1,81 @@ +/* + * 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 { fireEvent, render, act } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { KibanaServices, useKibana } from '../../../../common/lib/kibana'; +import { Ecs } from '../../../../../common/ecs'; +import * as actions from '../actions'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import type { SendAlertToTimelineActionProps } from '../types'; +import { InvestigateInTimelineAction } from './investigate_in_timeline_action'; + +const ecsRowData: Ecs = { + _id: '1', + agent: { type: ['blah'] }, + kibana: { + alert: { + workflow_status: ['open'], + rule: { + parameters: {}, + uuid: ['testId'], + }, + }, + }, +}; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../actions'); + +const props = { + ecsRowData, + onInvestigateInTimelineAlertClick: () => {}, + ariaLabel: 'test', +}; + +describe('use investigate in timeline hook', () => { + let mockSendAlertToTimeline: jest.SpyInstance, [SendAlertToTimelineActionProps]>; + + beforeEach(() => { + const coreStartMock = coreMock.createStart(); + (KibanaServices.get as jest.Mock).mockReturnValue(coreStartMock); + mockSendAlertToTimeline = jest.spyOn(actions, 'sendAlertToTimelineAction'); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: { + searchStrategyClient: jest.fn(), + }, + query: jest.fn(), + }, + }, + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + test('it creates a component and click handler', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('send-alert-to-timeline-button')).toBeTruthy(); + }); + test('it calls sendAlertToTimelineAction once on click, not on mount', () => { + const wrapper = render( + + + + ); + expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(0); + act(() => { + fireEvent.click(wrapper.getByTestId('send-alert-to-timeline-button')); + }); + expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(1); + }); +}); 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 bca04dcf37a5b9..b8d8232cb613c3 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 @@ -19,21 +19,18 @@ import { useInvestigateInTimeline } from './use_investigate_in_timeline'; interface InvestigateInTimelineActionProps { ecsRowData?: Ecs | Ecs[] | null; ariaLabel?: string; - alertIds?: string[]; buttonType?: 'text' | 'icon'; onInvestigateInTimelineAlertClick?: () => void; } const InvestigateInTimelineActionComponent: React.FC = ({ ariaLabel = ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL, - alertIds, ecsRowData, buttonType, onInvestigateInTimelineAlertClick, }) => { const { investigateInTimelineAlertClick } = useInvestigateInTimeline({ ecsRowData, - alertIds, onInvestigateInTimelineAlertClick, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx new file mode 100644 index 00000000000000..fc413a6f4f8147 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { fireEvent, render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { KibanaServices, useKibana } from '../../../../common/lib/kibana'; +import { Ecs } from '../../../../../common/ecs'; +import { useInvestigateInTimeline } from './use_investigate_in_timeline'; +import * as actions from '../actions'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import type { SendAlertToTimelineActionProps } from '../types'; + +const ecsRowData: Ecs = { + _id: '1', + agent: { type: ['blah'] }, + kibana: { + alert: { + workflow_status: ['open'], + rule: { + parameters: {}, + uuid: ['testId'], + }, + }, + }, +}; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../actions'); + +const props = { + ecsRowData, + onInvestigateInTimelineAlertClick: () => {}, +}; + +describe('use investigate in timeline hook', () => { + let mockSendAlertToTimeline: jest.SpyInstance, [SendAlertToTimelineActionProps]>; + + beforeEach(() => { + const coreStartMock = coreMock.createStart(); + (KibanaServices.get as jest.Mock).mockReturnValue(coreStartMock); + mockSendAlertToTimeline = jest.spyOn(actions, 'sendAlertToTimelineAction'); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: { + searchStrategyClient: jest.fn(), + }, + query: jest.fn(), + }, + }, + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + test('it creates a component and click handler', () => { + const { result } = renderHook(() => useInvestigateInTimeline(props), { + wrapper: TestProviders, + }); + expect(result.current.investigateInTimelineActionItems).toBeTruthy(); + expect(typeof result.current.investigateInTimelineAlertClick).toBe('function'); + }); + + describe('the click handler calls createTimeline once and only once', () => { + test('runs 0 times on render, once on click', async () => { + const { result } = renderHook(() => useInvestigateInTimeline(props), { + wrapper: TestProviders, + }); + const component = result.current.investigateInTimelineActionItems[0]; + const { getByTestId } = render(component); + expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(0); + act(() => { + fireEvent.click(getByTestId('investigate-in-timeline-action-item')); + }); + expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx index c1cbe657415a6d..301395eb5b963f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -6,32 +6,27 @@ */ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { isEmpty } from 'lodash'; import { EuiContextMenuItem } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineId } from '../../../../../common/types/timeline'; +import { TimelineId, TimelineType } from '../../../../../common/types/timeline'; import { Ecs } from '../../../../../common/ecs'; -import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { sendAlertToTimelineAction } from '../actions'; import { dispatchUpdateTimeline } from '../../../../timelines/components/open_timeline/helpers'; +import { useCreateTimeline } from '../../../../timelines/components/timeline/properties/use_create_timeline'; import { CreateTimelineProps } from '../types'; import { ACTION_INVESTIGATE_IN_TIMELINE } from '../translations'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { useFetchEcsAlertsData } from '../../../containers/detection_engine/alerts/use_fetch_ecs_alerts_data'; interface UseInvestigateInTimelineActionProps { ecsRowData?: Ecs | Ecs[] | null; - nonEcsRowData?: TimelineNonEcsData[]; - alertIds?: string[] | null | undefined; onInvestigateInTimelineAlertClick?: () => void; } export const useInvestigateInTimeline = ({ ecsRowData, - alertIds, onInvestigateInTimelineAlertClick, }: UseInvestigateInTimelineActionProps) => { const { @@ -54,8 +49,14 @@ export const useInvestigateInTimeline = ({ [dispatch] ); + const clearActiveTimeline = useCreateTimeline({ + timelineId: TimelineId.active, + timelineType: TimelineType.default, + }); + const createTimeline = useCallback( ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => { + clearActiveTimeline(); updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); dispatchUpdateTimeline(dispatch)({ duplicate: true, @@ -72,27 +73,14 @@ export const useInvestigateInTimeline = ({ ruleNote, })(); }, - [dispatch, filterManager, updateTimelineIsLoading] + [dispatch, filterManager, updateTimelineIsLoading, clearActiveTimeline] ); - const showInvestigateInTimelineAction = alertIds != null; - const { isLoading: isFetchingAlertEcs, alertsEcsData } = useFetchEcsAlertsData({ - alertIds, - skip: alertIds == null, - }); - const investigateInTimelineAlertClick = useCallback(async () => { if (onInvestigateInTimelineAlertClick) { onInvestigateInTimelineAlertClick(); } - if (!isEmpty(alertsEcsData) && alertsEcsData !== null) { - await sendAlertToTimelineAction({ - createTimeline, - ecsData: alertsEcsData, - searchStrategyClient, - updateTimelineIsLoading, - }); - } else if (ecsRowData != null) { + if (ecsRowData != null) { await sendAlertToTimelineAction({ createTimeline, ecsData: ecsRowData, @@ -101,7 +89,6 @@ export const useInvestigateInTimeline = ({ }); } }, [ - alertsEcsData, createTimeline, ecsRowData, onInvestigateInTimelineAlertClick, @@ -109,22 +96,22 @@ export const useInvestigateInTimeline = ({ updateTimelineIsLoading, ]); - const investigateInTimelineActionItems = showInvestigateInTimelineAction - ? [ - - {ACTION_INVESTIGATE_IN_TIMELINE} - , - ] - : []; + const investigateInTimelineActionItems = useMemo( + () => [ + + {ACTION_INVESTIGATE_IN_TIMELINE} + , + ], + [ecsRowData, investigateInTimelineAlertClick] + ); return { investigateInTimelineActionItems, investigateInTimelineAlertClick, - showInvestigateInTimelineAction, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index d04f6c5d7d5102..8ad76c70247bf8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -8,7 +8,6 @@ import React, { useState, useCallback, useMemo } from 'react'; import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; -import { isEmpty } from 'lodash/fp'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations'; import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions'; @@ -81,10 +80,6 @@ export const TakeActionDropdown = React.memo( [detailsData] ); - const alertIds = useMemo( - () => (isEmpty(actionsData.eventId) ? null : [actionsData.eventId]), - [actionsData.eventId] - ); const isEvent = actionsData.eventKind === 'event'; const isAgentEndpoint = useMemo(() => ecsData?.agent?.type?.includes('endpoint'), [ecsData]); @@ -156,7 +151,6 @@ export const TakeActionDropdown = React.memo( }); const { investigateInTimelineActionItems } = useInvestigateInTimeline({ - alertIds, ecsRowData: ecsData, onInvestigateInTimelineAlertClick: closePopoverHandler, }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts deleted file mode 100644 index c459fab89a25e8..00000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 { useEffect, useState } from 'react'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { isEmpty } from 'lodash'; - -import { Ecs } from '../../../../../common/ecs'; - -import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../common/constants'; -import { KibanaServices } from '../../../../common/lib/kibana'; -import { buildAlertsQuery, formatAlertToEcsSignal } from '../../../../common/utils/alerts'; - -export const useFetchEcsAlertsData = ({ - alertIds, - skip, - onError, -}: { - alertIds?: string[] | null | undefined; - skip?: boolean; - onError?: (e: Error) => void; -}): { isLoading: boolean | null; alertsEcsData: Ecs[] | null } => { - const [isLoading, setIsLoading] = useState(null); - const [alertsEcsData, setAlertEcsData] = useState(null); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const fetchAlert = async () => { - try { - setIsLoading(true); - const alertResponse = await KibanaServices.get().http.fetch< - estypes.SearchResponse<{ '@timestamp': string; [key: string]: unknown }> - >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { - method: 'POST', - body: JSON.stringify(buildAlertsQuery(alertIds ?? [])), - }); - - setAlertEcsData( - alertResponse?.hits.hits.reduce( - (acc, { _id, _index, _source = {} }) => [ - ...acc, - { - ...formatAlertToEcsSignal(_source), - _id, - _index, - timestamp: _source['@timestamp'], - }, - ], - [] - ) ?? [] - ); - } catch (e) { - if (isSubscribed) { - if (onError) { - onError(e as Error); - } - } - } - if (isSubscribed) { - setIsLoading(false); - } - }; - - if (!isEmpty(alertIds) && !skip) { - fetchAlert(); - } - - return (): void => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [alertIds, onError, skip]); - - return { - isLoading, - alertsEcsData, - }; -}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx new file mode 100644 index 00000000000000..71d6f6253010d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { render } from '@testing-library/react'; +import { EventDetailsFooter } from './footer'; +import '../../../../common/mock/match_media'; +import { TestProviders } from '../../../../common/mock'; +import { TimelineId } from '../../../../../common/types/timeline'; +import { Ecs } from '../../../../../common/ecs'; +import { mockAlertDetailsData } from '../../../../common/components/event_details/__mocks__'; +import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; +import { KibanaServices, useKibana } from '../../../../common/lib/kibana'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +const ecsData: Ecs = { + _id: '1', + agent: { type: ['blah'] }, + kibana: { + alert: { + workflow_status: ['open'], + rule: { + parameters: {}, + uuid: ['testId'], + }, + }, + }, +}; + +const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => { + return { + ...detail, + isObjectArray: false, + }; +}) as TimelineEventsDetailsItem[]; + +jest.mock('../../../../../common/endpoint/service/host_isolation/utils', () => { + return { + isIsolationSupported: jest.fn().mockReturnValue(true), + }; +}); + +jest.mock( + '../../../../detections/containers/detection_engine/alerts/use_host_isolation_status', + () => { + return { + useHostIsolationStatus: jest.fn().mockReturnValue({ + loading: false, + isIsolated: false, + agentStatus: 'healthy', + }), + }; + } +); + +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +jest.mock('../../../../detections/components/user_info', () => ({ + useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), +})); +jest.mock('../../../../common/lib/kibana'); +jest.mock( + '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', + () => ({ + useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }), + }) +); +jest.mock('../../../../cases/components/use_insert_timeline'); + +jest.mock('../../../../common/utils/endpoint_alert_check', () => { + return { + isAlertFromEndpointAlert: jest.fn().mockReturnValue(true), + isAlertFromEndpointEvent: jest.fn().mockReturnValue(true), + }; +}); +jest.mock( + '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline', + () => { + return { + useInvestigateInTimeline: jest.fn().mockReturnValue({ + investigateInTimelineActionItems: [
], + investigateInTimelineAlertClick: () => {}, + }), + }; + } +); +jest.mock('../../../../detections/components/alerts_table/actions'); + +const defaultProps = { + timelineId: TimelineId.test, + loadingEventDetails: false, + detailsEcsData: ecsData, + isHostIsolationPanelOpen: false, + handleOnEventClosed: jest.fn(), + onAddIsolationStatusClick: jest.fn(), + expandedEvent: { eventId: ecsData._id, indexName: '' }, + detailsData: mockAlertDetailsDataWithIsObject, +}; + +describe('event details footer component', () => { + beforeEach(() => { + const coreStartMock = coreMock.createStart(); + (KibanaServices.get as jest.Mock).mockReturnValue(coreStartMock); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: { + searchStrategyClient: jest.fn(), + }, + query: jest.fn(), + }, + }, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('it renders the take action dropdown', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('take-action-dropdown-btn')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index 6f461116568715..86b23594c947a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { find, get, isEmpty } from 'lodash/fp'; +import { find } from 'lodash/fp'; import { connect, ConnectedProps } from 'react-redux'; import { TakeActionDropdown } from '../../../../detections/components/take_action_dropdown'; import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; @@ -19,7 +19,6 @@ import { useEventFilterModal } from '../../../../detections/components/alerts_ta import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { Ecs } from '../../../../../common/ecs'; -import { useFetchEcsAlertsData } from '../../../../detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data'; import { inputsModel, inputsSelectors, State } from '../../../../common/store'; interface EventDetailsFooterProps { @@ -82,11 +81,6 @@ export const EventDetailsFooterComponent = React.memo( [detailsData] ); - const eventIds = useMemo( - () => (isEmpty(expandedEvent?.eventId) ? null : [expandedEvent?.eventId]), - [expandedEvent?.eventId] - ); - const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); }; @@ -113,21 +107,15 @@ export const EventDetailsFooterComponent = React.memo( const { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick } = useEventFilterModal(); - const { alertsEcsData } = useFetchEcsAlertsData({ - alertIds: eventIds, - skip: expandedEvent?.eventId == null, - }); - - const ecsData = detailsEcsData ?? get(0, alertsEcsData); return ( <> - {ecsData && ( + {detailsEcsData && ( )} - {isAddEventFilterModalOpen && ecsData != null && ( - + {isAddEventFilterModalOpen && detailsEcsData != null && ( + )} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 75ca399bf52d40..8be6200d1e84a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -48,7 +48,6 @@ const ActionsComponent: React.FC = ({ ariaRowindex, checked, columnValues, - data, ecsData, eventId, eventIdToNoteIds, @@ -68,7 +67,6 @@ const ActionsComponent: React.FC = ({ const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); const emptyNotes: string[] = []; const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const alertIds = useMemo(() => [ecsData._id], [ecsData]); const onPinEvent: OnPinEvent = useCallback( (evtId) => dispatch(timelineActions.pinEvent({ id: timelineId, eventId: evtId })), @@ -167,7 +165,6 @@ const ActionsComponent: React.FC = ({ )} diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx index 77a761edebd49d..b1a16cf5b3abde 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx @@ -7,7 +7,6 @@ import { EuiCheckbox, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback } from 'react'; -import { ALERT_RULE_PRODUCER } from '@kbn/rule-data-utils'; import type { ActionProps, HeaderActionProps } from '../../../../../common/types'; import * as i18n from './translations'; @@ -19,10 +18,7 @@ export const RowCheckBox = ({ columnValues, disabled, loadingEventIds, - data, }: ActionProps) => { - const ruleProducers = data.find((d) => d.field === ALERT_RULE_PRODUCER)?.value ?? []; - const ruleProducer = ruleProducers[0]; const handleSelectEvent = useCallback( (event: React.ChangeEvent) => { if (!disabled) { @@ -39,7 +35,7 @@ export const RowCheckBox = ({ ) : (