[Security Solution] Remove a data fetching hook from the add to timeline action component (#124331)

* Fetch alert ecs data in actions.tsx and not a hook in every table row

* Add error handling and tests for theshold timelines

* Fix bad merge

* Remove unused imports

* Actually remove unused file

* Remove usage of alertIds and dead code from cases

* Add basic sanity tests that ensure no extra network calls are being made

* Remove unused operator

* Remove unused imports

* Remove unused mock
This commit is contained in:
Kevin Qualters 2022-02-08 06:13:06 -05:00 committed by GitHub
parent c299aabcb3
commit e312c36e4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 579 additions and 253 deletions

View file

@ -24,8 +24,6 @@ export const timelineIntegrationMock = {
useInsertTimeline: jest.fn(),
},
ui: {
renderInvestigateInTimelineActionComponent: () =>
mockTimelineComponent('investigate-in-timeline'),
renderTimelineDetailsPanel: () => mockTimelineComponent('timeline-details-panel'),
},
};

View file

@ -356,9 +356,6 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
isLoadingUserActions={isLoadingUserActions}
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
renderInvestigateInTimelineActionComponent={
timelineUi?.renderInvestigateInTimelineActionComponent
}
statusActionButton={
userCanCrud ? (
<StatusActionButton

View file

@ -42,7 +42,6 @@ export interface CasesTimelineIntegration {
) => UseInsertTimelineReturn;
};
ui?: {
renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element;
renderTimelineDetailsPanel?: () => JSX.Element;
};
}

View file

@ -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<string, Ecs>];

View file

@ -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 (
<InvestigateInTimelineAction
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE}
alertIds={alertIds}
key="investigate-in-timeline"
ecsRowData={null}
/>
);
};
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,
},
},

View file

@ -8,7 +8,8 @@
import { CoreStart } from '../../../../../../../src/core/public';
import { StartPlugins } from '../../../types';
type GlobalServices = Pick<CoreStart, 'http' | 'uiSettings'> & Pick<StartPlugins, 'data'>;
type GlobalServices = Pick<CoreStart, 'http' | 'uiSettings' | 'notifications'> &
Pick<StartPlugins, 'data'>;
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;
}

View file

@ -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<ISearchStart>;
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<CreateTimeline>;
updateTimelineIsLoading = jest.fn() as jest.Mocked<UpdateTimelineLoading>;
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();
});
});
});

View file

@ -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<string[]>) => {
return {
@ -177,7 +185,7 @@ export const getThresholdAggregationData = (ecsData: Ecs | Ecs[]): ThresholdAggr
return thresholdEcsData.reduce<ThresholdAggregationData>(
(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<Ecs[]>((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)) {

View file

@ -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<Promise<void>, [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(
<TestProviders>
<InvestigateInTimelineAction {...props} />
</TestProviders>
);
expect(wrapper.getByTestId('send-alert-to-timeline-button')).toBeTruthy();
});
test('it calls sendAlertToTimelineAction once on click, not on mount', () => {
const wrapper = render(
<TestProviders>
<InvestigateInTimelineAction {...props} />
</TestProviders>
);
expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(0);
act(() => {
fireEvent.click(wrapper.getByTestId('send-alert-to-timeline-button'));
});
expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(1);
});
});

View file

@ -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<InvestigateInTimelineActionProps> = ({
ariaLabel = ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL,
alertIds,
ecsRowData,
buttonType,
onInvestigateInTimelineAlertClick,
}) => {
const { investigateInTimelineAlertClick } = useInvestigateInTimeline({
ecsRowData,
alertIds,
onInvestigateInTimelineAlertClick,
});

View file

@ -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<Promise<void>, [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);
});
});
});

View file

@ -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
? [
<EuiContextMenuItem
key="investigate-in-timeline-action-item"
data-test-subj="investigate-in-timeline-action-item"
disabled={ecsRowData == null && isFetchingAlertEcs === true}
onClick={investigateInTimelineAlertClick}
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</EuiContextMenuItem>,
]
: [];
const investigateInTimelineActionItems = useMemo(
() => [
<EuiContextMenuItem
key="investigate-in-timeline-action-item"
data-test-subj="investigate-in-timeline-action-item"
disabled={ecsRowData == null}
onClick={investigateInTimelineAlertClick}
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</EuiContextMenuItem>,
],
[ecsRowData, investigateInTimelineAlertClick]
);
return {
investigateInTimelineActionItems,
investigateInTimelineAlertClick,
showInvestigateInTimelineAction,
};
};

View file

@ -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,
});

View file

@ -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<boolean | null>(null);
const [alertsEcsData, setAlertEcsData] = useState<Ecs[] | null>(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<Ecs[]>(
(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,
};
};

View file

@ -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: [<div />],
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(
<TestProviders>
<EventDetailsFooter {...defaultProps} />
</TestProviders>
);
expect(wrapper.getByTestId('take-action-dropdown-btn')).toBeTruthy();
});
});

View file

@ -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 (
<>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
{ecsData && (
{detailsEcsData && (
<TakeActionDropdown
detailsData={detailsData}
ecsData={ecsData}
ecsData={detailsEcsData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loadingEventDetails}
@ -156,8 +144,8 @@ export const EventDetailsFooterComponent = React.memo(
onConfirm={onAddExceptionConfirm}
/>
)}
{isAddEventFilterModalOpen && ecsData != null && (
<EventFiltersFlyout data={ecsData} onCancel={closeAddEventFilterModal} />
{isAddEventFilterModalOpen && detailsEcsData != null && (
<EventFiltersFlyout data={detailsEcsData} onCancel={closeAddEventFilterModal} />
)}
</>
);

View file

@ -48,7 +48,6 @@ const ActionsComponent: React.FC<ActionProps> = ({
ariaRowindex,
checked,
columnValues,
data,
ecsData,
eventId,
eventIdToNoteIds,
@ -68,7 +67,6 @@ const ActionsComponent: React.FC<ActionProps> = ({
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<ActionProps> = ({
<InvestigateInTimelineAction
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE_FOR_ROW({ ariaRowindex, columnValues })}
key="investigate-in-timeline"
alertIds={alertIds}
ecsRowData={ecsData}
/>
)}

View file

@ -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<HTMLInputElement>) => {
if (!disabled) {
@ -39,7 +35,7 @@ export const RowCheckBox = ({
<EuiLoadingSpinner size="m" data-test-subj="event-loader" />
) : (
<EuiCheckbox
data-test-subj={`select-event select-event-rule-producer-${ruleProducer}`}
data-test-subj={`select-event select-event-${eventId}`}
id={eventId}
checked={checked && !disabled}
disabled={disabled}