mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[8.0] [Security Solution] Remove a data fetching hook from the add to timeline action component (#124331) (#125810)
* [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
(cherry picked from commit e312c36e4c
)
# Conflicts:
# x-pack/plugins/cases/public/components/case_view/case_view_page.tsx
# x-pack/plugins/cases/public/components/user_actions/types.ts
# x-pack/plugins/security_solution/public/cases/pages/index.tsx
# x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx
* Fix types
* Fix failing tests
This commit is contained in:
parent
090f7aab59
commit
0dbedb5434
23 changed files with 633 additions and 585 deletions
|
@ -24,8 +24,6 @@ export const timelineIntegrationMock = {
|
|||
useInsertTimeline: jest.fn(),
|
||||
},
|
||||
ui: {
|
||||
renderInvestigateInTimelineActionComponent: () =>
|
||||
mockTimelineComponent('investigate-in-timeline'),
|
||||
renderTimelineDetailsPanel: () => mockTimelineComponent('timeline-details-panel'),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -435,9 +435,6 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
isLoadingUserActions={isLoadingUserActions}
|
||||
onShowAlertDetails={onShowAlertDetails}
|
||||
onUpdateField={onUpdateField}
|
||||
renderInvestigateInTimelineActionComponent={
|
||||
timelineUi?.renderInvestigateInTimelineActionComponent
|
||||
}
|
||||
statusActionButton={
|
||||
caseData.type !== CaseType.collection && userCanCrud ? (
|
||||
<StatusActionButton
|
||||
|
|
|
@ -42,7 +42,6 @@ export interface CasesTimelineIntegration {
|
|||
) => UseInsertTimelineReturn;
|
||||
};
|
||||
ui?: {
|
||||
renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element;
|
||||
renderTimelineDetailsPanel?: () => JSX.Element;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -24,9 +24,7 @@ import { timelineActions } from '../../../timelines/store/timeline';
|
|||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { DetailsPanel } from '../../../timelines/components/side_panel';
|
||||
import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action';
|
||||
import { useFetchAlertData } from './helpers';
|
||||
import { SEND_ALERT_TO_TIMELINE } from './translations';
|
||||
import { useInsertTimeline } from '../use_insert_timeline';
|
||||
import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline';
|
||||
import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context';
|
||||
|
@ -68,17 +66,6 @@ const TimelineDetailsPanel = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const InvestigateInTimelineActionComponent = (alertIds: string[]) => {
|
||||
return (
|
||||
<InvestigateInTimelineAction
|
||||
ariaLabel={SEND_ALERT_TO_TIMELINE}
|
||||
alertIds={alertIds}
|
||||
key="investigate-in-timeline"
|
||||
ecsRowData={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CaseView = React.memo(
|
||||
({ caseId, subCaseId, userCanCrud, onCaseDataSuccess }: Props) => {
|
||||
const {
|
||||
|
@ -227,7 +214,6 @@ export const CaseView = React.memo(
|
|||
useInsertTimeline,
|
||||
},
|
||||
ui: {
|
||||
renderInvestigateInTimelineActionComponent: InvestigateInTimelineActionComponent,
|
||||
renderTimelineDetailsPanel: TimelineDetailsPanel,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,6 +21,7 @@ import {
|
|||
ALERT_RULE_PARAMETERS,
|
||||
} from '@kbn/rule-data-utils/technical_field_names';
|
||||
|
||||
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)) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -1063,6 +1063,7 @@ Array [
|
|||
</Styled(EuiFlyoutBody)>
|
||||
<Connect(Component)
|
||||
detailsData={null}
|
||||
detailsEcsData={null}
|
||||
expandedEvent={
|
||||
Object {
|
||||
"ecsData": Object {
|
||||
|
@ -1226,6 +1227,7 @@ Array [
|
|||
>
|
||||
<Memo()
|
||||
detailsData={null}
|
||||
detailsEcsData={null}
|
||||
dispatch={[Function]}
|
||||
expandedEvent={
|
||||
Object {
|
||||
|
@ -1414,171 +1416,7 @@ Array [
|
|||
>
|
||||
<div
|
||||
className="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<Memo()
|
||||
detailsData={null}
|
||||
ecsData={
|
||||
Object {
|
||||
"_id": "1",
|
||||
"destination": Object {
|
||||
"ip": Array [
|
||||
"192.168.0.3",
|
||||
],
|
||||
"port": Array [
|
||||
6343,
|
||||
],
|
||||
},
|
||||
"event": Object {
|
||||
"action": Array [
|
||||
"Action",
|
||||
],
|
||||
"category": Array [
|
||||
"Access",
|
||||
],
|
||||
"id": Array [
|
||||
"1",
|
||||
],
|
||||
"module": Array [
|
||||
"nginx",
|
||||
],
|
||||
"severity": Array [
|
||||
3,
|
||||
],
|
||||
},
|
||||
"geo": Object {
|
||||
"country_iso_code": Array [
|
||||
"xx",
|
||||
],
|
||||
"region_name": Array [
|
||||
"xx",
|
||||
],
|
||||
},
|
||||
"host": Object {
|
||||
"ip": Array [
|
||||
"192.168.0.1",
|
||||
],
|
||||
"name": Array [
|
||||
"apache",
|
||||
],
|
||||
},
|
||||
"signal": Object {
|
||||
"rule": Object {
|
||||
"created_at": Array [
|
||||
"2020-01-10T21:11:45.839Z",
|
||||
],
|
||||
"created_by": Array [
|
||||
"elastic",
|
||||
],
|
||||
"description": Array [
|
||||
"24/7",
|
||||
],
|
||||
"enabled": Array [
|
||||
true,
|
||||
],
|
||||
"false_positives": Array [
|
||||
"test-1",
|
||||
],
|
||||
"filters": Array [],
|
||||
"from": Array [
|
||||
"now-300s",
|
||||
],
|
||||
"id": Array [
|
||||
"b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea",
|
||||
],
|
||||
"immutable": Array [
|
||||
false,
|
||||
],
|
||||
"index": Array [
|
||||
"auditbeat-*",
|
||||
],
|
||||
"interval": Array [
|
||||
"5m",
|
||||
],
|
||||
"language": Array [
|
||||
"kuery",
|
||||
],
|
||||
"max_signals": Array [
|
||||
100,
|
||||
],
|
||||
"note": Array [
|
||||
"# this is some markdown documentation",
|
||||
],
|
||||
"output_index": Array [
|
||||
".siem-signals-default",
|
||||
],
|
||||
"query": Array [
|
||||
"user.name: root or user.name: admin",
|
||||
],
|
||||
"references": Array [
|
||||
"www.test.co",
|
||||
],
|
||||
"risk_score": Array [
|
||||
"21",
|
||||
],
|
||||
"rule_id": Array [
|
||||
"rule-id-1",
|
||||
],
|
||||
"saved_id": Array [
|
||||
"Garrett's IP",
|
||||
],
|
||||
"severity": Array [
|
||||
"low",
|
||||
],
|
||||
"tags": Array [],
|
||||
"threat": Array [],
|
||||
"timeline_id": Array [
|
||||
"1234-2136-11ea-9864-ebc8cc1cb8c2",
|
||||
],
|
||||
"timeline_title": Array [
|
||||
"Untitled timeline",
|
||||
],
|
||||
"to": Array [
|
||||
"now",
|
||||
],
|
||||
"type": Array [
|
||||
"saved_query",
|
||||
],
|
||||
"updated_at": Array [
|
||||
"2020-01-10T21:11:45.839Z",
|
||||
],
|
||||
"updated_by": Array [
|
||||
"elastic",
|
||||
],
|
||||
"version": Array [
|
||||
"1",
|
||||
],
|
||||
},
|
||||
},
|
||||
"source": Object {
|
||||
"ip": Array [
|
||||
"192.168.0.1",
|
||||
],
|
||||
"port": Array [
|
||||
80,
|
||||
],
|
||||
},
|
||||
"timestamp": "2018-11-05T19:03:25.937Z",
|
||||
"user": Object {
|
||||
"id": Array [
|
||||
"1",
|
||||
],
|
||||
"name": Array [
|
||||
"john.dee",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
handleOnEventClosed={[Function]}
|
||||
indexName="my-index"
|
||||
isHostIsolationPanelOpen={false}
|
||||
loadingEventDetails={true}
|
||||
onAddEventFilterClick={[Function]}
|
||||
onAddExceptionTypeClick={[Function]}
|
||||
onAddIsolationStatusClick={[Function]}
|
||||
refetch={[Function]}
|
||||
timelineId="test"
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</div>
|
||||
</EuiFlexGroup>
|
||||
|
@ -2103,6 +1941,7 @@ Array [
|
|||
</Styled(EuiFlyoutBody)>
|
||||
<Connect(Component)
|
||||
detailsData={null}
|
||||
detailsEcsData={null}
|
||||
expandedEvent={
|
||||
Object {
|
||||
"ecsData": Object {
|
||||
|
@ -2266,6 +2105,7 @@ Array [
|
|||
>
|
||||
<Memo()
|
||||
detailsData={null}
|
||||
detailsEcsData={null}
|
||||
dispatch={[Function]}
|
||||
expandedEvent={
|
||||
Object {
|
||||
|
@ -2454,171 +2294,7 @@ Array [
|
|||
>
|
||||
<div
|
||||
className="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<Memo()
|
||||
detailsData={null}
|
||||
ecsData={
|
||||
Object {
|
||||
"_id": "1",
|
||||
"destination": Object {
|
||||
"ip": Array [
|
||||
"192.168.0.3",
|
||||
],
|
||||
"port": Array [
|
||||
6343,
|
||||
],
|
||||
},
|
||||
"event": Object {
|
||||
"action": Array [
|
||||
"Action",
|
||||
],
|
||||
"category": Array [
|
||||
"Access",
|
||||
],
|
||||
"id": Array [
|
||||
"1",
|
||||
],
|
||||
"module": Array [
|
||||
"nginx",
|
||||
],
|
||||
"severity": Array [
|
||||
3,
|
||||
],
|
||||
},
|
||||
"geo": Object {
|
||||
"country_iso_code": Array [
|
||||
"xx",
|
||||
],
|
||||
"region_name": Array [
|
||||
"xx",
|
||||
],
|
||||
},
|
||||
"host": Object {
|
||||
"ip": Array [
|
||||
"192.168.0.1",
|
||||
],
|
||||
"name": Array [
|
||||
"apache",
|
||||
],
|
||||
},
|
||||
"signal": Object {
|
||||
"rule": Object {
|
||||
"created_at": Array [
|
||||
"2020-01-10T21:11:45.839Z",
|
||||
],
|
||||
"created_by": Array [
|
||||
"elastic",
|
||||
],
|
||||
"description": Array [
|
||||
"24/7",
|
||||
],
|
||||
"enabled": Array [
|
||||
true,
|
||||
],
|
||||
"false_positives": Array [
|
||||
"test-1",
|
||||
],
|
||||
"filters": Array [],
|
||||
"from": Array [
|
||||
"now-300s",
|
||||
],
|
||||
"id": Array [
|
||||
"b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea",
|
||||
],
|
||||
"immutable": Array [
|
||||
false,
|
||||
],
|
||||
"index": Array [
|
||||
"auditbeat-*",
|
||||
],
|
||||
"interval": Array [
|
||||
"5m",
|
||||
],
|
||||
"language": Array [
|
||||
"kuery",
|
||||
],
|
||||
"max_signals": Array [
|
||||
100,
|
||||
],
|
||||
"note": Array [
|
||||
"# this is some markdown documentation",
|
||||
],
|
||||
"output_index": Array [
|
||||
".siem-signals-default",
|
||||
],
|
||||
"query": Array [
|
||||
"user.name: root or user.name: admin",
|
||||
],
|
||||
"references": Array [
|
||||
"www.test.co",
|
||||
],
|
||||
"risk_score": Array [
|
||||
"21",
|
||||
],
|
||||
"rule_id": Array [
|
||||
"rule-id-1",
|
||||
],
|
||||
"saved_id": Array [
|
||||
"Garrett's IP",
|
||||
],
|
||||
"severity": Array [
|
||||
"low",
|
||||
],
|
||||
"tags": Array [],
|
||||
"threat": Array [],
|
||||
"timeline_id": Array [
|
||||
"1234-2136-11ea-9864-ebc8cc1cb8c2",
|
||||
],
|
||||
"timeline_title": Array [
|
||||
"Untitled timeline",
|
||||
],
|
||||
"to": Array [
|
||||
"now",
|
||||
],
|
||||
"type": Array [
|
||||
"saved_query",
|
||||
],
|
||||
"updated_at": Array [
|
||||
"2020-01-10T21:11:45.839Z",
|
||||
],
|
||||
"updated_by": Array [
|
||||
"elastic",
|
||||
],
|
||||
"version": Array [
|
||||
"1",
|
||||
],
|
||||
},
|
||||
},
|
||||
"source": Object {
|
||||
"ip": Array [
|
||||
"192.168.0.1",
|
||||
],
|
||||
"port": Array [
|
||||
80,
|
||||
],
|
||||
},
|
||||
"timestamp": "2018-11-05T19:03:25.937Z",
|
||||
"user": Object {
|
||||
"id": Array [
|
||||
"1",
|
||||
],
|
||||
"name": Array [
|
||||
"john.dee",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
handleOnEventClosed={[Function]}
|
||||
indexName="my-index"
|
||||
isHostIsolationPanelOpen={false}
|
||||
loadingEventDetails={true}
|
||||
onAddEventFilterClick={[Function]}
|
||||
onAddExceptionTypeClick={[Function]}
|
||||
onAddIsolationStatusClick={[Function]}
|
||||
refetch={[Function]}
|
||||
timelineId="test"
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</div>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* 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('../../../../common/hooks/endpoint/use_isolate_privileges', () => ({
|
||||
isAllowed: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../detections/components/host_isolation/use_host_isolation_action', () => ({
|
||||
useHostIsolationAction: jest.fn().mockReturnValue([() => <div />]),
|
||||
}));
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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,11 +19,11 @@ 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 {
|
||||
detailsData: TimelineEventsDetailsItem[] | null;
|
||||
detailsEcsData: Ecs | null;
|
||||
expandedEvent: {
|
||||
eventId: string;
|
||||
indexName: string;
|
||||
|
@ -47,6 +47,7 @@ interface AddExceptionModalWrapperData {
|
|||
export const EventDetailsFooterComponent = React.memo(
|
||||
({
|
||||
detailsData,
|
||||
detailsEcsData,
|
||||
expandedEvent,
|
||||
handleOnEventClosed,
|
||||
isHostIsolationPanelOpen,
|
||||
|
@ -81,11 +82,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)());
|
||||
};
|
||||
|
@ -112,21 +108,15 @@ export const EventDetailsFooterComponent = React.memo(
|
|||
const { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick } =
|
||||
useEventFilterModal();
|
||||
|
||||
const { alertsEcsData } = useFetchEcsAlertsData({
|
||||
alertIds: eventIds,
|
||||
skip: expandedEvent?.eventId == null,
|
||||
});
|
||||
|
||||
const ecsData = expandedEvent.ecsData ?? 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}
|
||||
|
@ -155,8 +145,8 @@ export const EventDetailsFooterComponent = React.memo(
|
|||
onConfirm={onAddExceptionConfirm}
|
||||
/>
|
||||
)}
|
||||
{isAddEventFilterModalOpen && ecsData != null && (
|
||||
<EventFiltersModal data={ecsData} onCancel={closeAddEventFilterModal} />
|
||||
{isAddEventFilterModalOpen && detailsEcsData != null && (
|
||||
<EventFiltersModal data={detailsEcsData} onCancel={closeAddEventFilterModal} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -82,7 +82,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
tabType,
|
||||
timelineId,
|
||||
}) => {
|
||||
const [loading, detailsData, rawEventData] = useTimelineEventsDetails({
|
||||
const [loading, detailsData, rawEventData, detailsEcsData] = useTimelineEventsDetails({
|
||||
docValueFields,
|
||||
entityType,
|
||||
indexName: expandedEvent.indexName ?? '',
|
||||
|
@ -209,6 +209,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
|
||||
<EventDetailsFooter
|
||||
detailsData={detailsData}
|
||||
detailsEcsData={detailsEcsData}
|
||||
expandedEvent={expandedEvent}
|
||||
handleOnEventClosed={handleOnEventClosed}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -24,9 +24,11 @@ import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/pl
|
|||
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
|
||||
import * as i18n from './translations';
|
||||
import { EntityType } from '../../../../../timelines/common';
|
||||
import { Ecs } from '../../../../common/ecs';
|
||||
|
||||
export interface EventsArgs {
|
||||
detailsData: TimelineEventsDetailsItem[] | null;
|
||||
ecs: Ecs | null;
|
||||
}
|
||||
|
||||
export interface UseTimelineEventsDetailsProps {
|
||||
|
@ -45,7 +47,12 @@ export const useTimelineEventsDetails = ({
|
|||
eventId,
|
||||
runtimeMappings,
|
||||
skip,
|
||||
}: UseTimelineEventsDetailsProps): [boolean, EventsArgs['detailsData'], object | undefined] => {
|
||||
}: UseTimelineEventsDetailsProps): [
|
||||
boolean,
|
||||
EventsArgs['detailsData'],
|
||||
object | undefined,
|
||||
EventsArgs['ecs']
|
||||
] => {
|
||||
const { data } = useKibana().services;
|
||||
const refetch = useRef<inputsModel.Refetch>(noop);
|
||||
const abortCtrl = useRef(new AbortController());
|
||||
|
@ -57,6 +64,7 @@ export const useTimelineEventsDetails = ({
|
|||
|
||||
const [timelineDetailsResponse, setTimelineDetailsResponse] =
|
||||
useState<EventsArgs['detailsData']>(null);
|
||||
const [ecsData, setEcsData] = useState<EventsArgs['ecs']>(null);
|
||||
|
||||
const [rawEventData, setRawEventData] = useState<object | undefined>(undefined);
|
||||
|
||||
|
@ -84,6 +92,7 @@ export const useTimelineEventsDetails = ({
|
|||
setLoading(false);
|
||||
setTimelineDetailsResponse(response.data || []);
|
||||
setRawEventData(response.rawResponse.hits.hits[0]);
|
||||
setEcsData(response.ecs || null);
|
||||
searchSubscription$.current.unsubscribe();
|
||||
} else if (isErrorResponse(response)) {
|
||||
setLoading(false);
|
||||
|
@ -132,5 +141,5 @@ export const useTimelineEventsDetails = ({
|
|||
};
|
||||
}, [timelineDetailsRequest, timelineDetailsSearch]);
|
||||
|
||||
return [loading, timelineDetailsResponse, rawEventData];
|
||||
return [loading, timelineDetailsResponse, rawEventData, ecsData];
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ import { JsonObject } from '@kbn/utility-types';
|
|||
import type { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common';
|
||||
import { Inspect, Maybe } from '../../../common';
|
||||
import { TimelineRequestOptionsPaginated } from '../..';
|
||||
import { Ecs } from '../../../../../common/ecs';
|
||||
|
||||
export interface TimelineEventsDetailsItem {
|
||||
ariaRowindex?: Maybe<number>;
|
||||
|
@ -23,6 +24,7 @@ export interface TimelineEventsDetailsItem {
|
|||
|
||||
export interface TimelineEventsDetailsStrategyResponse extends IEsSearchResponse {
|
||||
data?: Maybe<TimelineEventsDetailsItem[]>;
|
||||
ecs?: Maybe<Ecs>;
|
||||
inspect?: Maybe<Inspect>;
|
||||
rawEventData?: Maybe<object>;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -181,3 +181,24 @@ const mergeTimelineFieldsWithHit = async <T>(
|
|||
return flattenedFields;
|
||||
}
|
||||
};
|
||||
|
||||
const ECS_METADATA_FIELDS = ['_id', '_index', '_type', '_score'];
|
||||
|
||||
export const buildEcsObjects = (hit: EventHit): Ecs => {
|
||||
const ecsFields = [...TIMELINE_EVENTS_FIELDS];
|
||||
return ecsFields.reduce(
|
||||
(acc, field) => {
|
||||
const nestedParentPath = getNestedParentPath(field, hit.fields);
|
||||
if (
|
||||
nestedParentPath != null ||
|
||||
has(field, hit._source) ||
|
||||
has(field, hit.fields) ||
|
||||
ECS_METADATA_FIELDS.includes(field)
|
||||
) {
|
||||
return merge(acc, buildObjectForFieldPath(field, hit));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ _id: hit._id, timestamp: getTimestamp(hit), _index: hit._index }
|
||||
);
|
||||
};
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
getDataFromSourceHits,
|
||||
getDataSafety,
|
||||
} from '../../../../../../common/utils/field_formatters';
|
||||
import { buildEcsObjects } from '../all/helpers';
|
||||
|
||||
export const timelineEventsDetails: TimelineFactory<TimelineEventsQueries.details> = {
|
||||
buildDsl: ({ authFilter, ...options }: TimelineEventsDetailsRequestOptions) => {
|
||||
|
@ -69,10 +70,12 @@ export const timelineEventsDetails: TimelineFactory<TimelineEventsQueries.detail
|
|||
const data = unionBy('field', fieldsData, sourceData);
|
||||
|
||||
const rawEventData = response.rawResponse.hits.hits[0];
|
||||
const ecs = buildEcsObjects(rawEventData as EventHit);
|
||||
|
||||
return {
|
||||
...response,
|
||||
data,
|
||||
ecs,
|
||||
inspect,
|
||||
rawEventData,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue