[Security Solution] [Bugfix] Fixes broken alert actions (add to case, investigate in timeline) (#109339)

This commit is contained in:
Steph Milovic 2021-08-24 08:44:56 -06:00 committed by GitHub
parent 127100ffed
commit a75db0550b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1938 additions and 143 deletions

View file

@ -22,6 +22,7 @@ import {
import { FlowTarget } from '../../search_strategy/security_solution/network';
import { errorSchema } from '../../detection_engine/schemas/response/error_schema';
import { Direction, Maybe } from '../../search_strategy';
import { Ecs } from '../../ecs';
export * from './actions';
export * from './cells';
@ -481,6 +482,7 @@ export type TimelineExpandedEventType =
eventId: string;
indexName: string;
refetch?: () => void;
ecsData?: Ecs;
};
}
| EmptyObject;

View file

@ -31,13 +31,11 @@ import { useExceptionModal } from './use_add_exception_modal';
import { useExceptionActions } from './use_add_exception_actions';
import { useEventFilterModal } from './use_event_filter_modal';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
import { useInsertTimeline } from '../../../../cases/components/use_insert_timeline';
import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana';
import { useKibana } from '../../../../common/lib/kibana';
import { useInvestigateInResolverContextItem } from './investigate_in_resolver';
import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/timeline/body/translations';
import { TimelineId } from '../../../../../common';
import { APP_ID } from '../../../../../common/constants';
import { useEventFilterAction } from './use_event_filter_action';
import { useAddToCaseActions } from './use_add_to_case_actions';
interface AlertContextMenuProps {
ariaLabel?: string;
@ -68,41 +66,13 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
const ruleId = get(0, ecsRowData?.signal?.rule?.id);
const ruleName = get(0, ecsRowData?.signal?.rule?.name);
const { timelines: timelinesUi } = useKibana().services;
const casePermissions = useGetUserCasesPermissions();
const insertTimelineHook = useInsertTimeline;
const addToCaseActionProps = useMemo(
() => ({
ariaLabel: ATTACH_ALERT_TO_CASE_FOR_ROW({ ariaRowindex, columnValues }),
event: { data: [], ecs: ecsRowData, _id: ecsRowData._id },
useInsertTimeline: insertTimelineHook,
casePermissions,
appId: APP_ID,
onClose: afterItemSelection,
}),
[
ariaRowindex,
columnValues,
ecsRowData,
insertTimelineHook,
casePermissions,
afterItemSelection,
]
);
const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false;
const addToCaseActionItems = useMemo(
() =>
[
TimelineId.detectionsPage,
TimelineId.detectionsRulesDetailsPage,
TimelineId.active,
].includes(timelineId as TimelineId) && hasWritePermissions
? [
timelinesUi.getAddToExistingCaseButton(addToCaseActionProps),
timelinesUi.getAddToNewCaseButton(addToCaseActionProps),
]
: [],
[addToCaseActionProps, hasWritePermissions, timelineId, timelinesUi]
);
const { addToCaseActionProps, addToCaseActionItems } = useAddToCaseActions({
ecsData: ecsRowData,
afterCaseSelection: afterItemSelection,
timelineId,
ariaLabel: ATTACH_ALERT_TO_CASE_FOR_ROW({ ariaRowindex, columnValues }),
});
const alertStatus = get(0, ecsRowData?.signal?.status) as Status;
@ -217,7 +187,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
return (
<>
{timelinesUi.getAddToCaseAction(addToCaseActionProps)}
{addToCaseActionProps && timelinesUi.getAddToCaseAction(addToCaseActionProps)}
{items.length > 0 && (
<div key="actions-context-menu">
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>

View file

@ -0,0 +1,70 @@
/*
* 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 { useMemo } from 'react';
import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana';
import { TimelineId, TimelineNonEcsData } from '../../../../../common';
import { APP_ID } from '../../../../../common/constants';
import { useInsertTimeline } from '../../../../cases/components/use_insert_timeline';
import { Ecs } from '../../../../../common/ecs';
export interface UseAddToCaseActions {
afterCaseSelection: () => void;
ariaLabel?: string;
ecsData?: Ecs;
nonEcsData?: TimelineNonEcsData[];
timelineId: string;
}
export const useAddToCaseActions = ({
afterCaseSelection,
ariaLabel,
ecsData,
nonEcsData,
timelineId,
}: UseAddToCaseActions) => {
const { timelines: timelinesUi } = useKibana().services;
const casePermissions = useGetUserCasesPermissions();
const insertTimelineHook = useInsertTimeline;
const addToCaseActionProps = useMemo(
() =>
ecsData?._id
? {
ariaLabel,
event: { data: nonEcsData ?? [], ecs: ecsData, _id: ecsData?._id },
useInsertTimeline: insertTimelineHook,
casePermissions,
appId: APP_ID,
onClose: afterCaseSelection,
}
: null,
[ecsData, ariaLabel, nonEcsData, insertTimelineHook, casePermissions, afterCaseSelection]
);
const hasWritePermissions = casePermissions?.crud ?? false;
const addToCaseActionItems = useMemo(
() =>
[
TimelineId.detectionsPage,
TimelineId.detectionsRulesDetailsPage,
TimelineId.active,
].includes(timelineId as TimelineId) &&
hasWritePermissions &&
addToCaseActionProps
? [
timelinesUi.getAddToExistingCaseButton(addToCaseActionProps),
timelinesUi.getAddToNewCaseButton(addToCaseActionProps),
]
: [],
[addToCaseActionProps, hasWritePermissions, timelineId, timelinesUi]
);
return {
addToCaseActionItems,
addToCaseActionProps,
};
};

View file

@ -108,7 +108,7 @@ export const useInvestigateInTimeline = ({
<EuiContextMenuItem
key="investigate-in-timeline-action-item"
data-test-subj="investigate-in-timeline-action-item"
disabled={isFetchingAlertEcs === true}
disabled={ecsRowData == null && isFetchingAlertEcs === true}
onClick={investigateInTimelineAlertClick}
>
{ACTION_INVESTIGATE_IN_TIMELINE}

View file

@ -0,0 +1,181 @@
/*
* 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 { mount, ReactWrapper } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { TakeActionDropdown, TakeActionDropdownProps } from '.';
import { mockAlertDetailsData } from '../../../common/components/event_details/__mocks__';
import { mockEcsDataWithAlert } from '../../../common/mock/mock_detection_alerts';
import { TimelineEventsDetailsItem, TimelineId } from '../../../../common';
import { TestProviders } from '../../../common/mock';
import { mockTimelines } from '../../../common/mock/mock_timelines_plugin';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { useKibana } from '../../../common/lib/kibana';
jest.mock('../../../common/hooks/endpoint/use_isolate_privileges', () => ({
useIsolationPrivileges: jest.fn().mockReturnValue({ isAllowed: true }),
}));
jest.mock('../../../common/lib/kibana', () => ({
useKibana: jest.fn(),
useGetUserCasesPermissions: jest.fn().mockReturnValue({ crud: true }),
}));
jest.mock('../../../cases/components/use_insert_timeline');
jest.mock('../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
jest.mock('@kbn/alerts', () => {
return { useGetUserAlertsPermissions: jest.fn().mockReturnValue({ crud: true }) };
});
jest.mock('../../../common/utils/endpoint_alert_check', () => {
return { endpointAlertCheck: jest.fn().mockReturnValue(true) };
});
jest.mock('../../../../common/endpoint/service/host_isolation/utils', () => {
return {
isIsolationSupported: jest.fn().mockReturnValue(true),
};
});
jest.mock('../../containers/detection_engine/alerts/use_host_isolation_status', () => {
return {
useHostIsolationStatus: jest.fn().mockReturnValue({
loading: false,
isIsolated: false,
agentStatus: 'healthy',
}),
};
});
describe('take action dropdown', () => {
const defaultProps: TakeActionDropdownProps = {
detailsData: mockAlertDetailsData as TimelineEventsDetailsItem[],
ecsData: mockEcsDataWithAlert,
handleOnEventClosed: jest.fn(),
indexName: 'index',
isHostIsolationPanelOpen: false,
loadingEventDetails: false,
onAddEventFilterClick: jest.fn(),
onAddExceptionTypeClick: jest.fn(),
onAddIsolationStatusClick: jest.fn(),
refetch: jest.fn(),
timelineId: TimelineId.active,
};
beforeAll(() => {
(useKibana as jest.Mock).mockImplementation(() => {
const mockStartServicesMock = createStartServicesMock();
return {
services: {
...mockStartServicesMock,
timelines: { ...mockTimelines },
application: {
capabilities: { siem: { crud_alerts: true, read_alerts: true } },
},
},
};
});
});
test('should render takeActionButton', () => {
const wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="take-action-dropdown-btn"]').exists()).toBeTruthy();
});
test('should render takeActionButton with correct text', () => {
const wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="take-action-dropdown-btn"]').first().text()).toEqual(
'Take action'
);
});
describe('should render take action items', () => {
const testProps = {
...defaultProps,
};
let wrapper: ReactWrapper;
beforeAll(() => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...testProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
});
test('should render "Add to existing case"', async () => {
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="add-to-existing-case-action"]').first().text()
).toEqual('Add to existing case');
});
});
test('should render "Add to new case"', async () => {
await waitFor(() => {
expect(wrapper.find('[data-test-subj="add-to-new-case-action"]').first().text()).toEqual(
'Add to new case'
);
});
});
test('should render "mark as acknowledge"', async () => {
await waitFor(() => {
expect(wrapper.find('[data-test-subj="acknowledged-alert-status"]').first().text()).toEqual(
'Mark as acknowledged'
);
});
});
test('should render "mark as close"', async () => {
await waitFor(() => {
expect(wrapper.find('[data-test-subj="close-alert-status"]').first().text()).toEqual(
'Mark as closed'
);
});
});
test('should render "Add Endpoint exception"', async () => {
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="add-endpoint-exception-menu-item"]').first().text()
).toEqual('Add Endpoint exception');
});
});
test('should render "Add rule exception"', async () => {
await waitFor(() => {
expect(wrapper.find('[data-test-subj="add-exception-menu-item"]').first().text()).toEqual(
'Add rule exception'
);
});
});
test('should render "Isolate host"', async () => {
await waitFor(() => {
expect(wrapper.find('[data-test-subj="isolate-host-action-item"]').first().text()).toEqual(
'Isolate host'
);
});
});
test('should render "Investigate in timeline"', async () => {
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="investigate-in-timeline-action-item"]').first().text()
).toEqual('Investigate in timeline');
});
});
});
});

View file

@ -8,23 +8,21 @@
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';
import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations';
import { TimelineEventsDetailsItem, TimelineNonEcsData } from '../../../../common';
import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions';
import { useAlertsActions } from '../alerts_table/timeline_actions/use_alerts_actions';
import { useInvestigateInTimeline } from '../alerts_table/timeline_actions/use_investigate_in_timeline';
import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana';
import { useInsertTimeline } from '../../../cases/components/use_insert_timeline';
import { useEventFilterAction } from '../alerts_table/timeline_actions/use_event_filter_action';
import { useHostIsolationAction } from '../host_isolation/use_host_isolation_action';
import { getFieldValue } from '../host_isolation/helpers';
import type { Ecs } from '../../../../common/ecs';
import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { endpointAlertCheck } from '../../../common/utils/endpoint_alert_check';
import { APP_ID } from '../../../../common/constants';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions';
interface ActionsData {
alertStatus: Status;
@ -34,39 +32,36 @@ interface ActionsData {
ruleName: string;
}
export interface TakeActionDropdownProps {
detailsData: TimelineEventsDetailsItem[] | null;
ecsData?: Ecs;
handleOnEventClosed: () => void;
indexName: string;
isHostIsolationPanelOpen: boolean;
loadingEventDetails: boolean;
onAddEventFilterClick: () => void;
onAddExceptionTypeClick: (type: ExceptionListType) => void;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
refetch: (() => void) | undefined;
timelineId: string;
}
export const TakeActionDropdown = React.memo(
({
detailsData,
ecsData,
handleOnEventClosed,
indexName,
isHostIsolationPanelOpen,
loadingEventDetails,
nonEcsData,
onAddEventFilterClick,
onAddExceptionTypeClick,
onAddIsolationStatusClick,
refetch,
indexName,
timelineId,
}: {
detailsData: TimelineEventsDetailsItem[] | null;
ecsData?: Ecs;
handleOnEventClosed: () => void;
isHostIsolationPanelOpen: boolean;
loadingEventDetails: boolean;
nonEcsData?: TimelineNonEcsData[];
refetch: (() => void) | undefined;
indexName: string;
onAddEventFilterClick: () => void;
onAddExceptionTypeClick: (type: ExceptionListType) => void;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
timelineId: string;
}) => {
const casePermissions = useGetUserCasesPermissions();
}: TakeActionDropdownProps) => {
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
const { timelines: timelinesUi } = useKibana().services;
const insertTimelineHook = useInsertTimeline;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const actionsData = useMemo(
@ -87,7 +82,9 @@ export const TakeActionDropdown = React.memo(
[detailsData]
);
const alertIds = useMemo(() => [actionsData.eventId], [actionsData.eventId]);
const alertIds = useMemo(() => (isEmpty(actionsData.eventId) ? null : [actionsData.eventId]), [
actionsData.eventId,
]);
const isEvent = actionsData.eventKind === 'event';
const isEndpointAlert = useMemo((): boolean => {
@ -153,11 +150,11 @@ export const TakeActionDropdown = React.memo(
const { actionItems: statusActionItems } = useAlertsActions({
alertStatus: actionsData.alertStatus,
closePopover: closePopoverAndFlyout,
eventId: actionsData.eventId,
indexName,
timelineId,
refetch,
closePopover: closePopoverAndFlyout,
timelineId,
});
const { investigateInTimelineActionItems } = useInvestigateInTimeline({
@ -174,37 +171,16 @@ export const TakeActionDropdown = React.memo(
[eventFilterActionItems, exceptionActionItems, statusActionItems, isEvent, actionsData.ruleId]
);
const addToCaseProps = useMemo(() => {
if (ecsData) {
return {
event: { data: [], ecs: ecsData, _id: ecsData._id },
useInsertTimeline: insertTimelineHook,
casePermissions,
appId: APP_ID,
onClose: afterCaseSelection,
};
} else {
return null;
}
}, [afterCaseSelection, casePermissions, ecsData, insertTimelineHook]);
const addToCasesActionItems = useMemo(
() =>
addToCaseProps &&
['detections-page', 'detections-rules-details-page', 'timeline-1'].includes(
timelineId ?? ''
)
? [
timelinesUi.getAddToExistingCaseButton(addToCaseProps),
timelinesUi.getAddToNewCaseButton(addToCaseProps),
]
: [],
[timelinesUi, addToCaseProps, timelineId]
);
const { addToCaseActionItems } = useAddToCaseActions({
ecsData,
nonEcsData: detailsData?.map((d) => ({ field: d.field, value: d.values })) ?? [],
afterCaseSelection,
timelineId,
});
const items: React.ReactElement[] = useMemo(
() => [
...(tGridEnabled ? addToCasesActionItems : []),
...(tGridEnabled ? addToCaseActionItems : []),
...alertsActionItems,
...hostIsolationActionItems,
...investigateInTimelineActionItems,
@ -212,7 +188,7 @@ export const TakeActionDropdown = React.memo(
[
tGridEnabled,
alertsActionItems,
addToCasesActionItems,
addToCaseActionItems,
hostIsolationActionItems,
investigateInTimelineActionItems,
]
@ -220,26 +196,30 @@ export const TakeActionDropdown = React.memo(
const takeActionButton = useMemo(() => {
return (
<EuiButton iconSide="right" fill iconType="arrowDown" onClick={togglePopoverHandler}>
<EuiButton
data-test-subj="take-action-dropdown-btn"
fill
iconSide="right"
iconType="arrowDown"
onClick={togglePopoverHandler}
>
{TAKE_ACTION}
</EuiButton>
);
}, [togglePopoverHandler]);
return items.length && !loadingEventDetails ? (
<>
<EuiPopover
id="AlertTakeActionPanel"
button={takeActionButton}
isOpen={isPopoverOpen}
closePopover={closePopoverHandler}
panelPaddingSize="none"
anchorPosition="downLeft"
repositionOnScroll
>
<EuiContextMenuPanel size="s" items={items} />
</EuiPopover>
</>
return items.length && !loadingEventDetails && ecsData ? (
<EuiPopover
id="AlertTakeActionPanel"
button={takeActionButton}
isOpen={isPopoverOpen}
closePopover={closePopoverHandler}
panelPaddingSize="none"
anchorPosition="downLeft"
repositionOnScroll
>
<EuiContextMenuPanel data-test-subj="takeActionPanelMenu" size="s" items={items} />
</EuiPopover>
) : null;
}
);

View file

@ -60,7 +60,7 @@ export const useFetchEcsAlertsData = ({
} catch (e) {
if (isSubscribed) {
if (onError) {
onError(e);
onError(e as Error);
}
}
}

View file

@ -7,7 +7,7 @@
import React, { useMemo } from 'react';
import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { find, get } from 'lodash/fp';
import { find, get, isEmpty } from 'lodash/fp';
import { TakeActionDropdown } from '../../../../detections/components/take_action_dropdown';
import type { TimelineEventsDetailsItem } from '../../../../../common';
import { useExceptionModal } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_modal';
@ -16,8 +16,8 @@ import { EventFiltersModal } from '../../../../management/pages/event_filters/vi
import { useEventFilterModal } from '../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal';
import { getFieldValue } from '../../../../detections/components/host_isolation/helpers';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
import { useFetchEcsAlertsData } from '../../../../detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data';
import { Ecs } from '../../../../../common/ecs';
import { useFetchEcsAlertsData } from '../../../../detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data';
interface EventDetailsFooterProps {
detailsData: TimelineEventsDetailsItem[] | null;
@ -73,7 +73,10 @@ export const EventDetailsFooter = React.memo(
[detailsData]
);
const eventIds = useMemo(() => [expandedEvent?.eventId], [expandedEvent?.eventId]);
const eventIds = useMemo(
() => (isEmpty(expandedEvent?.eventId) ? null : [expandedEvent?.eventId]),
[expandedEvent?.eventId]
);
const {
exceptionModalType,
@ -97,25 +100,27 @@ export const EventDetailsFooter = React.memo(
skip: expandedEvent?.eventId == null,
});
const ecsData = get(0, alertsEcsData);
const ecsData = expandedEvent.ecsData ?? get(0, alertsEcsData);
return (
<>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<TakeActionDropdown
detailsData={detailsData}
ecsData={ecsData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loadingEventDetails}
onAddEventFilterClick={onAddEventFilterClick}
onAddExceptionTypeClick={onAddExceptionTypeClick}
onAddIsolationStatusClick={onAddIsolationStatusClick}
refetch={expandedEvent?.refetch}
indexName={expandedEvent.indexName}
timelineId={timelineId}
/>
{ecsData && (
<TakeActionDropdown
detailsData={detailsData}
ecsData={ecsData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loadingEventDetails}
onAddEventFilterClick={onAddEventFilterClick}
onAddExceptionTypeClick={onAddExceptionTypeClick}
onAddIsolationStatusClick={onAddIsolationStatusClick}
refetch={expandedEvent?.refetch}
indexName={expandedEvent.indexName}
timelineId={timelineId}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>

View file

@ -15,6 +15,7 @@ import {
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
mockEcsDataWithAlert,
} from '../../../common/mock';
import { createStore, State } from '../../../common/store';
import { DetailsPanel } from './index';
@ -69,6 +70,7 @@ describe('Details Panel Component', () => {
params: {
eventId: 'my-id',
indexName: 'my-index',
ecsData: mockEcsDataWithAlert,
},
},
};
@ -149,7 +151,7 @@ describe('Details Panel Component', () => {
describe('DetailsPanel:HostDetails: rendering', () => {
beforeEach(() => {
state.timeline.timelineById.test.expandedDetail = hostExpandedDetail;
state.timeline.timelineById.test.expandedDetail = hostExpandedDetail as TimelineExpandedDetail;
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
});
@ -166,7 +168,7 @@ describe('Details Panel Component', () => {
describe('DetailsPanel:NetworkDetails: rendering', () => {
beforeEach(() => {
state.timeline.timelineById.test.expandedDetail = networkExpandedDetail;
state.timeline.timelineById.test.expandedDetail = networkExpandedDetail as TimelineExpandedDetail;
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
});

View file

@ -15,6 +15,7 @@ import {
PinnedEvent,
} from './pinned_event';
import { Direction, Maybe } from '../../search_strategy';
import { Ecs } from '../../ecs';
export * from './actions';
export * from './cells';
@ -475,6 +476,7 @@ export type TimelineExpandedEventType =
params?: {
eventId: string;
indexName: string;
ecsData?: Ecs;
};
}
| EmptyObject;

View file

@ -127,6 +127,7 @@ const StatefulEventComponent: React.FC<Props> = ({
const updatedExpandedDetail: TimelineExpandedDetailType = {
panelView: 'eventDetail',
params: {
ecsData: event.ecs,
eventId,
indexName,
},
@ -139,7 +140,7 @@ const StatefulEventComponent: React.FC<Props> = ({
timelineId,
})
);
}, [dispatch, event._id, event._index, tabType, timelineId]);
}, [dispatch, event._id, event._index, event.ecs, tabType, timelineId]);
const setEventsLoading = useCallback<SetEventsLoading>(
({ eventIds, isLoading }) => {

View file

@ -85,6 +85,7 @@ const RowActionComponent = ({
params: {
eventId,
indexName: indexName ?? '',
ecsData,
},
};
@ -95,7 +96,7 @@ const RowActionComponent = ({
timelineId,
})
);
}, [dispatch, eventId, indexName, tabType, timelineId]);
}, [dispatch, ecsData, eventId, indexName, tabType, timelineId]);
const Action = controlColumn.rowCellRender;

View file

@ -0,0 +1,71 @@
/*
* 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 { normalizedEventFields } from './use_add_to_case';
import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import { merge } from 'lodash';
const defaultArgs = {
_id: 'test-id',
data: [
{ field: '@timestamp', value: ['2018-11-05T19:03:25.937Z'] },
{ field: ALERT_RULE_UUID, value: ['data-rule-id'] },
{ field: ALERT_RULE_NAME, value: ['data-rule-name'] },
],
ecs: {
_id: 'test-id',
_index: 'test-index',
signal: { rule: { id: ['rule-id'], name: ['rule-name'], false_positives: [] } },
},
};
describe('normalizedEventFields', () => {
it('uses rule data when provided', () => {
const result = normalizedEventFields(defaultArgs);
expect(result).toEqual({
ruleId: 'data-rule-id',
ruleName: 'data-rule-name',
});
});
const makeObj = (s: string, v: string[]) => {
const keys = s.split('.');
return keys
.reverse()
.reduce((prev, current, i) => (i === 0 ? { [current]: v } : { [current]: { ...prev } }), {});
};
it('uses rule/ecs combo Xavier thinks is a thing but Steph has yet to see', () => {
const args = {
...defaultArgs,
data: [],
ecs: {
_id: 'string',
...merge(
makeObj(ALERT_RULE_UUID, ['xavier-rule-id']),
makeObj(ALERT_RULE_NAME, ['xavier-rule-name'])
),
},
};
const result = normalizedEventFields(args);
expect(result).toEqual({
ruleId: 'xavier-rule-id',
ruleName: 'xavier-rule-name',
});
});
it('falls back to use ecs data', () => {
const result = normalizedEventFields({ ...defaultArgs, data: [] });
expect(result).toEqual({
ruleId: 'rule-id',
ruleName: 'rule-name',
});
});
it('returns null when all the data is bad', () => {
const result = normalizedEventFields({ ...defaultArgs, data: [], ecs: { _id: 'bad' } });
expect(result).toEqual({
ruleId: null,
ruleName: null,
});
});
});

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash';
import { get, isEmpty } from 'lodash/fp';
import { useState, useCallback, useMemo, SyntheticEvent } from 'react';
import { useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
@ -16,7 +16,7 @@ import { TimelineItem } from '../../common/';
import { tGridActions } from '../store/t_grid';
import { useDeepEqualSelector } from './use_selector';
import { createUpdateSuccessToaster } from '../components/actions/timeline/cases/helpers';
import { AddToCaseActionProps } from '../components/actions/timeline/cases/add_to_case_action';
import { AddToCaseActionProps } from '../components/actions';
interface UseAddToCase {
addNewCaseClick: () => void;
@ -243,12 +243,24 @@ export const useAddToCase = ({
};
export function normalizedEventFields(event?: TimelineItem) {
const ruleUuid = event && event.data.find(({ field }) => field === ALERT_RULE_UUID);
const ruleName = event && event.data.find(({ field }) => field === ALERT_RULE_NAME);
const ruleUuidValue = ruleUuid && ruleUuid.value && ruleUuid.value[0];
const ruleNameValue = ruleName && ruleName.value && ruleName.value[0];
const ruleUuidData = event && event.data.find(({ field }) => field === ALERT_RULE_UUID);
const ruleNameData = event && event.data.find(({ field }) => field === ALERT_RULE_NAME);
const ruleUuidValueData = ruleUuidData && ruleUuidData.value && ruleUuidData.value[0];
const ruleNameValueData = ruleNameData && ruleNameData.value && ruleNameData.value[0];
const ruleUuid =
ruleUuidValueData ??
get(`ecs.${ALERT_RULE_UUID}[0]`, event) ??
get(`ecs.signal.rule.id[0]`, event) ??
null;
const ruleName =
ruleNameValueData ??
get(`ecs.${ALERT_RULE_NAME}[0]`, event) ??
get(`ecs.signal.rule.name[0]`, event) ??
null;
return {
ruleId: ruleUuidValue ?? null,
ruleName: ruleNameValue ?? null,
ruleId: ruleUuid,
ruleName,
};
}

View file

@ -28,4 +28,8 @@ export const createTGridMocks = () => ({
getUseAddToTimeline: () => useAddToTimeline,
getUseAddToTimelineSensor: () => useAddToTimelineSensor,
getUseDraggableKeyboardWrapper: () => useDraggableKeyboardWrapper,
// eslint-disable-next-line react/display-name
getAddToExistingCaseButton: () => <div data-test-subj="add-to-existing-case" />,
// eslint-disable-next-line react/display-name
getAddToNewCaseButton: () => <div data-test-subj="add-to-new-case" />,
});