[Security Solution] Add more actions to alerts flyout (#105767)

* add investigate in timeline action to flyout

* close context menu on item clicked

* add investigate in timeline

* add investigat in timeline button

* fix failing tests

* add alerts status actions

* update unit test

* export alerts actions from hook

* add disable props

* add case action items

* clean up

* split alert status hook and hide add to case action

* add useHoseIsolationAction hook

* move out take action dropdown

* refeactor hooks to only manage one thing

* apply hooks to alerts table

* clean up

* fix unit tests

* replace euiCodeBlock

* take actions from case

* fetch ecs in flyout footer

* move fetch alert ecs to container

* add AddExceptionModalWrapperData interface

* fix cypress tests

* update snapshot for json view

* fix cypress test

* update AddEndpointExceptionComponent

* fix data retrieved from event details

* fix host isolation action

* use endpointAlertCheck

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2021-08-04 16:49:42 +01:00 committed by GitHub
parent e69d57cf77
commit 60f8da452f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1723 additions and 713 deletions

View file

@ -469,6 +469,7 @@ export type TimelineExpandedEventType =
params?: {
eventId: string;
indexName: string;
refetch?: () => void;
};
}
| EmptyObject;

View file

@ -47,7 +47,8 @@ describe('Alert details with unmapped fields', () => {
const length = elements.length;
cy.wrap(elements)
.eq(length - expectedUnmappedField.line)
.should('have.text', expectedUnmappedField.text);
.invoke('text')
.should('include', expectedUnmappedField.text);
});
});

View file

@ -89,7 +89,8 @@ describe('CTI Enrichment', () => {
expectedEnrichment.forEach((enrichment) => {
cy.wrap(elements)
.eq(length - enrichment.line)
.should('have.text', enrichment.text);
.invoke('text')
.should('include', enrichment.text);
});
});
});

View file

@ -238,10 +238,9 @@ describe('Custom detection rules deletion and edition', () => {
goToManageAlertsDetectionRules();
waitForAlertsIndexToBeCreated();
createCustomRuleActivated(getNewRule(), 'rule1');
createCustomRuleActivated(getNewRule(), 'rule2');
createCustomRuleActivated(getNewOverrideRule(), 'rule3');
createCustomRuleActivated(getExistingRule(), 'rule4');
createCustomRuleActivated(getNewOverrideRule(), 'rule2');
createCustomRuleActivated(getExistingRule(), 'rule3');
reload();
});
@ -295,7 +294,7 @@ describe('Custom detection rules deletion and edition', () => {
});
cy.get(SHOWING_RULES_TEXT).should(
'have.text',
`Showing ${expectedNumberOfRulesAfterDeletion} rules`
`Showing ${expectedNumberOfRulesAfterDeletion} rule`
);
cy.get(CUSTOM_RULES_BTN).should(
'have.text',

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export const ADD_EXCEPTION_BTN = '[data-test-subj="addExceptionButton"]';
export const ADD_EXCEPTION_BTN = '[data-test-subj="add-exception-menu-item"]';
export const ALERTS = '[data-test-subj="events-viewer-panel"] [data-test-subj="event"]';

View file

@ -9,9 +9,11 @@ export const ALERT_FLYOUT = '[data-test-subj="timeline:details-panel:flyout"]';
export const CELL_TEXT = '.euiText';
export const JSON_VIEW_WRAPPER = '[data-test-subj="jsonViewWrapper"]';
export const JSON_CONTENT = '[data-test-subj="jsonView"]';
export const JSON_LINES = '.ace_line';
export const JSON_LINES = '.euiCodeBlock__line';
export const JSON_VIEW_TAB = '[data-test-subj="jsonViewTab"]';

View file

@ -7,7 +7,7 @@
import {
ENRICHMENT_COUNT_NOTIFICATION,
JSON_CONTENT,
JSON_VIEW_WRAPPER,
JSON_VIEW_TAB,
TABLE_TAB,
} from '../screens/alerts_details';
@ -25,7 +25,7 @@ export const openThreatIndicatorDetails = () => {
};
export const scrollJsonViewToBottom = () => {
cy.get(JSON_CONTENT).click({ force: true });
cy.get(JSON_CONTENT).type('{pagedown}{pagedown}{pagedown}');
cy.get(JSON_CONTENT).should('be.visible');
cy.get(JSON_VIEW_WRAPPER).click({ force: true });
cy.get(JSON_VIEW_WRAPPER).type('{pagedown}{pagedown}{pagedown}');
cy.get(JSON_VIEW_WRAPPER).should('be.visible');
};

View file

@ -7,8 +7,6 @@
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { SearchResponse } from 'elasticsearch';
import { isEmpty } from 'lodash';
import {
getCaseDetailsUrl,
@ -18,18 +16,17 @@ import {
getRuleDetailsUrl,
useFormatUrl,
} from '../../../common/components/link_to';
import { Ecs } from '../../../../common/ecs';
import { Case, CaseViewRefreshPropInterface } from '../../../../../cases/common';
import { TimelineId } from '../../../../common/types/timeline';
import { SecurityPageName } from '../../../app/types';
import { KibanaServices, useKibana } from '../../../common/lib/kibana';
import { APP_ID, DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { APP_ID } from '../../../../common/constants';
import { timelineActions } from '../../../timelines/store/timeline';
import { useSourcererScope } 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 { buildAlertsQuery, formatAlertToEcsSignal, useFetchAlertData } from './helpers';
import { useFetchAlertData } from './helpers';
import { SEND_ALERT_TO_TIMELINE } from './translations';
import { useInsertTimeline } from '../use_insert_timeline';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
@ -70,39 +67,12 @@ const TimelineDetailsPanel = () => {
};
const InvestigateInTimelineActionComponent = (alertIds: string[]) => {
const fetchEcsAlertsData = async (fetchAlertIds?: string[]): Promise<Ecs[]> => {
if (isEmpty(fetchAlertIds)) {
return [];
}
const alertResponse = await KibanaServices.get().http.fetch<
SearchResponse<{ '@timestamp': string; [key: string]: unknown }>
>(DETECTION_ENGINE_QUERY_SIGNALS_URL, {
method: 'POST',
body: JSON.stringify(buildAlertsQuery(fetchAlertIds ?? [])),
});
return (
alertResponse?.hits.hits.reduce<Ecs[]>(
(acc, { _id, _index, _source }) => [
...acc,
{
...formatAlertToEcsSignal(_source as {}),
_id,
_index,
timestamp: _source['@timestamp'],
},
],
[]
) ?? []
);
};
return (
<InvestigateInTimelineAction
ariaLabel={SEND_ALERT_TO_TIMELINE}
alertIds={alertIds}
key="investigate-in-timeline"
ecsRowData={null}
fetchEcsAlertsData={fetchEcsAlertsData}
nonEcsRowData={[]}
/>
);

View file

@ -2,54 +2,50 @@
exports[`JSON View rendering should match snapshot 1`] = `
<styled.div>
<EuiCodeEditor
<EuiCodeBlock
data-test-subj="jsonView"
height="100%"
isReadOnly={true}
mode="javascript"
setOptions={
Object {
"fontSize": "12px",
}
}
value="{
\\"_id\\": \\"pEMaMmkBUV60JmNWmWVi\\",
\\"_index\\": \\"filebeat-8.0.0-2019.02.19-000001\\",
\\"_score\\": 1,
\\"_type\\": \\"_doc\\",
\\"@timestamp\\": \\"2019-02-28T16:50:54.621Z\\",
\\"agent\\": {
\\"ephemeral_id\\": \\"9d391ef2-a734-4787-8891-67031178c641\\",
\\"hostname\\": \\"siem-kibana\\",
\\"id\\": \\"5de03d5f-52f3-482e-91d4-853c7de073c3\\",
\\"type\\": \\"filebeat\\",
\\"version\\": \\"8.0.0\\"
fontSize="m"
isCopyable={true}
language="json"
paddingSize="m"
>
{
"_id": "pEMaMmkBUV60JmNWmWVi",
"_index": "filebeat-8.0.0-2019.02.19-000001",
"_score": 1,
"_type": "_doc",
"@timestamp": "2019-02-28T16:50:54.621Z",
"agent": {
"ephemeral_id": "9d391ef2-a734-4787-8891-67031178c641",
"hostname": "siem-kibana",
"id": "5de03d5f-52f3-482e-91d4-853c7de073c3",
"type": "filebeat",
"version": "8.0.0"
},
\\"cloud\\": {
\\"availability_zone\\": \\"projects/189716325846/zones/us-east1-b\\",
\\"instance\\": {
\\"id\\": \\"5412578377715150143\\",
\\"name\\": \\"siem-kibana\\"
"cloud": {
"availability_zone": "projects/189716325846/zones/us-east1-b",
"instance": {
"id": "5412578377715150143",
"name": "siem-kibana"
},
\\"machine\\": {
\\"type\\": \\"projects/189716325846/machineTypes/n1-standard-1\\"
"machine": {
"type": "projects/189716325846/machineTypes/n1-standard-1"
},
\\"project\\": {
\\"id\\": \\"elastic-beats\\"
"project": {
"id": "elastic-beats"
},
\\"provider\\": \\"gce\\"
"provider": "gce"
},
\\"destination\\": {
\\"bytes\\": 584,
\\"ip\\": \\"10.47.8.200\\",
\\"packets\\": 4,
\\"port\\": 902
"destination": {
"bytes": 584,
"ip": "10.47.8.200",
"packets": 4,
"port": 902
},
\\"event\\": {
\\"kind\\": \\"event\\"
"event": {
"kind": "event"
}
}"
width="100%"
/>
}
</EuiCodeBlock>
</styled.div>
`;

View file

@ -245,7 +245,7 @@ const EventDetailsComponent: React.FC<Props> = ({
content: (
<>
<EuiSpacer size="m" />
<TabContentWrapper>
<TabContentWrapper data-test-subj="jsonViewWrapper">
<JsonView data={data} />
</TabContentWrapper>
</>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiCodeEditor } from '@elastic/eui';
import { EuiCodeBlock } from '@elastic/eui';
import { set } from '@elastic/safer-lodash-set/fp';
import React, { useMemo } from 'react';
import styled from 'styled-components';
@ -23,8 +23,6 @@ const EuiCodeEditorContainer = styled.div`
}
`;
const EDITOR_SET_OPTIONS = { fontSize: '12px' };
export const JsonView = React.memo<Props>(({ data }) => {
const value = useMemo(
() =>
@ -38,15 +36,15 @@ export const JsonView = React.memo<Props>(({ data }) => {
return (
<EuiCodeEditorContainer>
<EuiCodeEditor
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="m"
isCopyable
data-test-subj="jsonView"
isReadOnly
mode="javascript"
setOptions={EDITOR_SET_OPTIONS}
value={value}
width="100%"
height="100%"
/>
>
{value}
</EuiCodeBlock>
</EuiCodeEditorContainer>
);
});

View file

@ -0,0 +1,35 @@
/*
* 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 { EuiContextMenuItem, EuiText } from '@elastic/eui';
import React from 'react';
import * as i18n from '../translations';
interface AddEndpointExceptionProps {
onClick: () => void;
disabled?: boolean;
}
const AddEndpointExceptionComponent: React.FC<AddEndpointExceptionProps> = ({
onClick,
disabled,
}) => {
return (
<EuiContextMenuItem
key="add-endpoint-exception-menu-item"
aria-label={i18n.ACTION_ADD_ENDPOINT_EXCEPTION}
data-test-subj="add-endpoint-exception-menu-item"
id="addEndpointException"
onClick={onClick}
disabled={disabled}
>
<EuiText size="m">{i18n.ACTION_ADD_ENDPOINT_EXCEPTION}</EuiText>
</EuiContextMenuItem>
);
};
export const AddEndpointException = React.memo(AddEndpointExceptionComponent);

View file

@ -0,0 +1,34 @@
/*
* 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 { EuiContextMenuItem, EuiText } from '@elastic/eui';
import React from 'react';
import * as i18n from '../translations';
interface AddEventFilterProps {
onClick: () => void;
disabled?: boolean;
}
const AddEventFilterComponent: React.FC<AddEventFilterProps> = ({ onClick, disabled }) => {
return (
<EuiContextMenuItem
key="add-event-filter-menu-item"
aria-label={i18n.ACTION_ADD_EVENT_FILTER}
data-test-subj="add-event-filter-menu-item"
id="addEventFilter"
onClick={onClick}
disabled={disabled}
>
<EuiText data-test-subj="addEventFilterButton" size="m">
{i18n.ACTION_ADD_EVENT_FILTER}
</EuiText>
</EuiContextMenuItem>
);
};
export const AddEventFilter = React.memo(AddEventFilterComponent);

View file

@ -0,0 +1,34 @@
/*
* 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 { EuiContextMenuItem, EuiText } from '@elastic/eui';
import React from 'react';
import * as i18n from '../translations';
interface AddExceptionProps {
disabled?: boolean;
onClick: () => void;
}
const AddExceptionComponent: React.FC<AddExceptionProps> = ({ disabled, onClick }) => {
return (
<EuiContextMenuItem
key="add-exception-menu-item"
aria-label={i18n.ACTION_ADD_EXCEPTION}
data-test-subj="add-exception-menu-item"
id="addException"
onClick={onClick}
disabled={disabled}
>
<EuiText data-test-subj="addExceptionButton" size="m">
{i18n.ACTION_ADD_EXCEPTION}
</EuiText>
</EuiContextMenuItem>
);
};
export const AddException = React.memo(AddExceptionComponent);

View file

@ -6,53 +6,33 @@
*/
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import {
EuiButtonIcon,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { EuiButtonIcon, EuiContextMenu, EuiPopover, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
import { getOr } from 'lodash/fp';
import { indexOf } from 'lodash';
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
import { get, getOr } from 'lodash/fp';
import { buildGetAlertByIdQuery } from '../../../../common/components/exceptions/helpers';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { TimelineId } from '../../../../../common/types/timeline';
import {
DEFAULT_INDEX_PATTERN,
DEFAULT_INDEX_PATTERN_EXPERIMENTAL,
} from '../../../../../common/constants';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
import { timelineActions } from '../../../../timelines/store/timeline';
import { EventsTdContent } from '../../../../timelines/components/timeline/styles';
import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../../timelines/components/timeline/helpers';
import { FILTER_OPEN, FILTER_CLOSED, FILTER_IN_PROGRESS } from '../alerts_filter_group';
import { updateAlertStatusAction } from '../actions';
import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types';
import { Ecs } from '../../../../../common/ecs';
import {
AddExceptionModal,
AddExceptionModalProps,
} from '../../../../common/components/exceptions/add_exception_modal';
import * as i18nCommon from '../../../../common/translations';
import * as i18n from '../translations';
import {
useStateToaster,
displaySuccessToast,
displayErrorToast,
} from '../../../../common/components/toasters';
import { inputsModel } from '../../../../common/store';
import { useUserData } from '../../user_info';
import { AlertData, EcsHit } from '../../../../common/components/exceptions/types';
import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query';
import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index';
import { EventFiltersModal } from '../../../../management/pages/event_filters/view/components/modal';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useAlertsActions } from './use_alerts_actions';
import { useExceptionModal } from './use_add_exception_modal';
import { useExceptionActions } from './use_add_exception_actions';
import { useEventFilterModal } from './use_event_filter_modal';
import { useEventFilterAction } from './use_event_filter_action';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
interface AlertContextMenuProps {
ariaLabel?: string;
@ -71,56 +51,14 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
onRuleChange,
timelineId,
}) => {
const dispatch = useDispatch();
const [, dispatchToaster] = useStateToaster();
const [isPopoverOpen, setPopover] = useState(false);
const eventId = ecsRowData._id;
const ruleId = useMemo(
(): string | null =>
(ecsRowData.signal?.rule && ecsRowData.signal.rule.id && ecsRowData.signal.rule.id[0]) ??
null,
[ecsRowData]
);
const ruleName = useMemo(
(): string =>
(ecsRowData.signal?.rule && ecsRowData.signal.rule.name && ecsRowData.signal.rule.name[0]) ??
'',
[ecsRowData]
);
// TODO: Steph/ueba remove when past experimental
const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled');
const ruleId = get(0, ecsRowData?.signal?.rule?.id);
const ruleName = get(0, ecsRowData?.signal?.rule?.name);
const alertStatus = get(0, ecsRowData?.signal?.status) as Status;
const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]);
const ruleIndices = useMemo((): string[] => {
if (
ecsRowData.signal?.rule &&
ecsRowData.signal.rule.index &&
ecsRowData.signal.rule.index.length > 0
) {
return ecsRowData.signal.rule.index;
} else {
return uebaEnabled
? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL]
: DEFAULT_INDEX_PATTERN;
}
}, [ecsRowData.signal?.rule, uebaEnabled]);
const { addWarning } = useAppToasts();
const alertStatus = useMemo(() => {
return ecsRowData.signal?.status && (ecsRowData.signal.status[0] as Status);
}, [ecsRowData]);
const onButtonClick = useCallback(() => {
setPopover(!isPopoverOpen);
}, [isPopoverOpen]);
const closePopover = useCallback((): void => {
setPopover(false);
}, []);
const [exceptionModalType, setOpenAddExceptionModal] = useState<ExceptionListType | null>(null);
const [isAddEventFilterModalOpen, setIsAddEventFilterModalOpen] = useState<boolean>(false);
const [{ canUserCRUD, hasIndexWrite, hasIndexMaintenance, hasIndexUpdateDelete }] = useUserData();
const isEndpointAlert = useMemo((): boolean => {
if (ecsRowData == null) {
@ -133,188 +71,14 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
return eventModules.includes('endpoint') && kinds.includes('alert');
}, [ecsRowData]);
const closeAddExceptionModal = useCallback((): void => {
setOpenAddExceptionModal(null);
const onButtonClick = useCallback(() => {
setPopover(!isPopoverOpen);
}, [isPopoverOpen]);
const closePopover = useCallback((): void => {
setPopover(false);
}, []);
const closeAddEventFilterModal = useCallback((): void => {
setIsAddEventFilterModalOpen(false);
}, []);
const onAddExceptionCancel = useCallback(() => {
closeAddExceptionModal();
}, [closeAddExceptionModal]);
const onAddExceptionConfirm = useCallback(
(didCloseAlert: boolean, didBulkCloseAlert) => {
closeAddExceptionModal();
if (timelineId !== TimelineId.active || didBulkCloseAlert) {
refetch();
}
},
[closeAddExceptionModal, timelineId, refetch]
);
const onAlertStatusUpdateSuccess = useCallback(
(updated: number, conflicts: number, newStatus: Status) => {
if (conflicts > 0) {
// Partial failure
addWarning({
title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts),
text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts),
});
} else {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated);
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated);
}
displaySuccessToast(title, dispatchToaster);
}
},
[dispatchToaster, addWarning]
);
const onAlertStatusUpdateFailure = useCallback(
(newStatus: Status, error: Error) => {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_FAILED_TOAST;
break;
case 'open':
title = i18n.OPENED_ALERT_FAILED_TOAST;
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST;
}
displayErrorToast(title, [error.message], dispatchToaster);
},
[dispatchToaster]
);
const setEventsLoading = useCallback(
({ eventIds, isLoading }: SetEventsLoadingProps) => {
dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading }));
},
[dispatch, timelineId]
);
const setEventsDeleted = useCallback(
({ eventIds, isDeleted }: SetEventsDeletedProps) => {
dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted }));
},
[dispatch, timelineId]
);
const openAlertActionOnClick = useCallback(() => {
updateAlertStatusAction({
alertIds: [eventId],
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
selectedStatus: FILTER_OPEN,
});
closePopover();
}, [
closePopover,
eventId,
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
]);
const openAlertActionComponent = useMemo(() => {
return (
<EuiContextMenuItem
key="open-alert"
aria-label="Open alert"
data-test-subj="open-alert-status"
id={FILTER_OPEN}
onClick={openAlertActionOnClick}
disabled={!hasIndexUpdateDelete && !hasIndexMaintenance}
>
<EuiText size="m">{i18n.ACTION_OPEN_ALERT}</EuiText>
</EuiContextMenuItem>
);
}, [openAlertActionOnClick, hasIndexUpdateDelete, hasIndexMaintenance]);
const closeAlertActionClick = useCallback(() => {
updateAlertStatusAction({
alertIds: [eventId],
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
selectedStatus: FILTER_CLOSED,
});
closePopover();
}, [
closePopover,
eventId,
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
]);
const closeAlertActionComponent = useMemo(() => {
return (
<EuiContextMenuItem
key="close-alert"
aria-label="Close alert"
data-test-subj="close-alert-status"
id={FILTER_CLOSED}
onClick={closeAlertActionClick}
disabled={!hasIndexUpdateDelete && !hasIndexMaintenance}
>
<EuiText size="m">{i18n.ACTION_CLOSE_ALERT}</EuiText>
</EuiContextMenuItem>
);
}, [closeAlertActionClick, hasIndexUpdateDelete, hasIndexMaintenance]);
const inProgressAlertActionClick = useCallback(() => {
updateAlertStatusAction({
alertIds: [eventId],
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
selectedStatus: FILTER_IN_PROGRESS,
});
closePopover();
}, [
closePopover,
eventId,
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
]);
const inProgressAlertActionComponent = useMemo(() => {
return (
<EuiContextMenuItem
key="in-progress-alert"
aria-label="Mark alert in progress"
data-test-subj="in-progress-alert-status"
id={FILTER_IN_PROGRESS}
onClick={inProgressAlertActionClick}
disabled={!canUserCRUD || !hasIndexUpdateDelete}
>
<EuiText size="m">{i18n.ACTION_IN_PROGRESS_ALERT}</EuiText>
</EuiContextMenuItem>
);
}, [canUserCRUD, hasIndexUpdateDelete, inProgressAlertActionClick]);
const button = useMemo(() => {
return (
<EuiToolTip position="top" content={i18n.MORE_ACTIONS}>
@ -330,105 +94,61 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
);
}, [disabled, onButtonClick, ariaLabel]);
const handleAddEndpointExceptionClick = useCallback((): void => {
closePopover();
setOpenAddExceptionModal('endpoint');
}, [closePopover]);
const {
exceptionModalType,
onAddExceptionCancel,
onAddExceptionConfirm,
onAddExceptionTypeClick,
ruleIndices,
} = useExceptionModal({
ruleIndex: ecsRowData?.signal?.rule?.index,
refetch,
timelineId,
});
const addEndpointExceptionComponent = useMemo(() => {
return (
<EuiContextMenuItem
key="add-endpoint-exception-menu-item"
aria-label="Add Endpoint Exception"
data-test-subj="add-endpoint-exception-menu-item"
id="addEndpointException"
onClick={handleAddEndpointExceptionClick}
disabled={!canUserCRUD || !hasIndexWrite || !isEndpointAlert}
>
<EuiText size="m">{i18n.ACTION_ADD_ENDPOINT_EXCEPTION}</EuiText>
</EuiContextMenuItem>
);
}, [canUserCRUD, hasIndexWrite, isEndpointAlert, handleAddEndpointExceptionClick]);
const {
closeAddEventFilterModal,
isAddEventFilterModalOpen,
onAddEventFilterClick,
} = useEventFilterModal();
const handleAddExceptionClick = useCallback((): void => {
closePopover();
setOpenAddExceptionModal('detection');
}, [closePopover]);
const { statusActions } = useAlertsActions({
alertStatus,
eventId: ecsRowData?._id,
timelineId,
closePopover,
});
const addExceptionComponent = useMemo(() => {
return (
<EuiContextMenuItem
key="add-exception-menu-item"
aria-label="Add Exception"
data-test-subj="add-exception-menu-item"
id="addException"
onClick={handleAddExceptionClick}
disabled={!canUserCRUD || !hasIndexWrite}
>
<EuiText data-test-subj="addExceptionButton" size="m">
{i18n.ACTION_ADD_EXCEPTION}
</EuiText>
</EuiContextMenuItem>
);
}, [handleAddExceptionClick, canUserCRUD, hasIndexWrite]);
const handleAddEventFilterClick = useCallback((): void => {
closePopover();
setIsAddEventFilterModalOpen(true);
}, [closePopover]);
const addEventFilterComponent = useMemo(
() => (
<EuiContextMenuItem
key="add-event-filter-menu-item"
aria-label="Add event filter"
data-test-subj="add-event-filter-menu-item"
id="addEventFilter"
onClick={handleAddEventFilterClick}
>
<EuiText data-test-subj="addEventFilterButton" size="m">
{i18n.ACTION_ADD_EVENT_FILTER}
</EuiText>
</EuiContextMenuItem>
),
[handleAddEventFilterClick]
const handleOnAddExceptionTypeClick = useCallback(
(type: ExceptionListType) => {
onAddExceptionTypeClick(type);
closePopover();
},
[closePopover, onAddExceptionTypeClick]
);
const statusFilters = useMemo(() => {
if (!alertStatus) {
return [];
}
const handleOnAddEventFilterClick = useCallback(() => {
onAddEventFilterClick();
closePopover();
}, [closePopover, onAddEventFilterClick]);
switch (alertStatus) {
case 'open':
return [inProgressAlertActionComponent, closeAlertActionComponent];
case 'in-progress':
return [openAlertActionComponent, closeAlertActionComponent];
case 'closed':
return [openAlertActionComponent, inProgressAlertActionComponent];
default:
return [];
}
}, [
closeAlertActionComponent,
inProgressAlertActionComponent,
openAlertActionComponent,
alertStatus,
]);
const exceptionActions = useExceptionActions({
isEndpointAlert,
onAddExceptionTypeClick: handleOnAddExceptionTypeClick,
});
const items = useMemo(
() =>
!isEvent && ruleId
? [...statusFilters, addEndpointExceptionComponent, addExceptionComponent]
: [addEventFilterComponent],
[
addEndpointExceptionComponent,
addExceptionComponent,
addEventFilterComponent,
statusFilters,
ruleId,
isEvent,
]
const eventFilterActions = useEventFilterAction({
onAddEventFilterClick: handleOnAddEventFilterClick,
});
const panels = useMemo(
() => [
{
id: 0,
items: !isEvent && ruleId ? [...statusActions, ...exceptionActions] : [eventFilterActions],
},
],
[eventFilterActions, exceptionActions, isEvent, ruleId, statusActions]
);
return (
@ -444,23 +164,26 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
anchorPosition="downLeft"
repositionOnScroll
>
<ContextMenuPanel items={items} />
<EuiContextMenu size="s" initialPanelId={0} panels={panels} />
</EuiPopover>
</EventsTdContent>
</div>
{exceptionModalType != null && ruleId != null && ecsRowData != null && (
<AddExceptionModalWrapper
ruleName={ruleName}
ruleId={ruleId}
ruleIndices={ruleIndices}
exceptionListType={exceptionModalType}
ecsData={ecsRowData}
onCancel={onAddExceptionCancel}
onConfirm={onAddExceptionConfirm}
alertStatus={alertStatus}
onRuleChange={onRuleChange}
/>
)}
{exceptionModalType != null &&
ruleId != null &&
ruleName != null &&
ecsRowData?._id != null && (
<AddExceptionModalWrapper
ruleName={ruleName}
ruleId={ruleId}
ruleIndices={ruleIndices}
exceptionListType={exceptionModalType}
eventId={ecsRowData?._id}
onCancel={onAddExceptionCancel}
onConfirm={onAddExceptionConfirm}
alertStatus={alertStatus}
onRuleChange={onRuleChange}
/>
)}
{isAddEventFilterModalOpen && ecsRowData != null && (
<EventFiltersModal data={ecsRowData} onCancel={closeAddEventFilterModal} />
)}
@ -468,7 +191,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
);
};
const ContextMenuPanel = styled(EuiContextMenuPanel)`
const ContextMenuPanel = styled(EuiContextMenu)`
font-size: ${({ theme }) => theme.eui.euiFontSizeS};
`;
@ -480,7 +203,7 @@ type AddExceptionModalWrapperProps = Omit<
AddExceptionModalProps,
'alertData' | 'isAlertDataLoading'
> & {
ecsData: Ecs;
eventId?: string;
};
/**
@ -488,12 +211,12 @@ type AddExceptionModalWrapperProps = Omit<
* Due to the conditional nature of the modal and how we use the `ecsData` field,
* we cannot use the fetch hook within the modal component itself
*/
const AddExceptionModalWrapper: React.FC<AddExceptionModalWrapperProps> = ({
export const AddExceptionModalWrapper: React.FC<AddExceptionModalWrapperProps> = ({
ruleName,
ruleId,
ruleIndices,
exceptionListType,
ecsData,
eventId,
onCancel,
onConfirm,
alertStatus,
@ -502,7 +225,7 @@ const AddExceptionModalWrapper: React.FC<AddExceptionModalWrapperProps> = ({
const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex();
const { loading: isLoadingAlertData, data } = useQueryAlerts<EcsHit, {}>({
query: buildGetAlertByIdQuery(ecsData?._id),
query: buildGetAlertByIdQuery(eventId),
indexName: signalIndexName,
});

View file

@ -0,0 +1,33 @@
/*
* 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 { EuiContextMenuItem, EuiText } from '@elastic/eui';
import React from 'react';
import { FILTER_CLOSED } from '../../alerts_filter_group';
import * as i18n from '../../translations';
interface CloseAlertActionProps {
onClick: () => void;
disabled?: boolean;
}
const CloseAlertActionComponent: React.FC<CloseAlertActionProps> = ({ onClick, disabled }) => {
return (
<EuiContextMenuItem
key="close-alert"
aria-label={i18n.ACTION_CLOSE_ALERT}
data-test-subj="close-alert-status"
id={FILTER_CLOSED}
onClick={onClick}
disabled={disabled}
>
<EuiText size="m">{i18n.ACTION_CLOSE_ALERT}</EuiText>
</EuiContextMenuItem>
);
};
export const CloseAlertAction = React.memo(CloseAlertActionComponent);

View file

@ -0,0 +1,36 @@
/*
* 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 { EuiContextMenuItem, EuiText } from '@elastic/eui';
import React from 'react';
import { FILTER_IN_PROGRESS } from '../../alerts_filter_group';
import * as i18n from '../../translations';
interface InProgressAlertStatusProps {
onClick: () => void;
disabled?: boolean;
}
const InProgressAlertStatusComponent: React.FC<InProgressAlertStatusProps> = ({
onClick,
disabled,
}) => {
return (
<EuiContextMenuItem
key="in-progress-alert"
aria-label={i18n.ACTION_IN_PROGRESS_ALERT}
data-test-subj="in-progress-alert-status"
id={FILTER_IN_PROGRESS}
onClick={onClick}
disabled={disabled}
>
<EuiText size="m">{i18n.ACTION_IN_PROGRESS_ALERT}</EuiText>
</EuiContextMenuItem>
);
};
export const InProgressAlertStatus = React.memo(InProgressAlertStatusComponent);

View file

@ -0,0 +1,33 @@
/*
* 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 { EuiContextMenuItem, EuiText } from '@elastic/eui';
import React from 'react';
import { FILTER_OPEN } from '../../alerts_filter_group';
import * as i18n from '../../translations';
interface OpenAlertStatusProps {
onClick: () => void;
disabled?: boolean;
}
const OpenAlertStatusComponent: React.FC<OpenAlertStatusProps> = ({ onClick, disabled }) => {
return (
<EuiContextMenuItem
key="open-alert"
aria-label={i18n.ACTION_OPEN_ALERT}
data-test-subj="open-alert-status"
id={FILTER_OPEN}
onClick={onClick}
disabled={disabled}
>
<EuiText size="m">{i18n.ACTION_OPEN_ALERT}</EuiText>
</EuiContextMenuItem>
);
};
export const OpenAlertStatus = React.memo(OpenAlertStatusComponent);

View file

@ -5,102 +5,41 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import React from 'react';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineId } from '../../../../../common/types/timeline';
import { Ecs } from '../../../../../common/ecs';
import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline';
import { timelineActions } from '../../../../timelines/store/timeline';
import { sendAlertToTimelineAction } from '../actions';
import { dispatchUpdateTimeline } from '../../../../timelines/components/open_timeline/helpers';
import { ActionIconItem } from '../../../../timelines/components/timeline/body/actions/action_icon_item';
import { CreateTimelineProps } from '../types';
import {
ACTION_INVESTIGATE_IN_TIMELINE,
ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL,
} from '../translations';
import { useInvestigateInTimeline } from './use_investigate_in_timeline';
interface InvestigateInTimelineActionProps {
ecsRowData: Ecs | Ecs[] | null;
nonEcsRowData: TimelineNonEcsData[];
ecsRowData?: Ecs | Ecs[] | null;
nonEcsRowData?: TimelineNonEcsData[];
ariaLabel?: string;
alertIds?: string[];
fetchEcsAlertsData?: (alertIds?: string[]) => Promise<Ecs[]>;
buttonType?: 'text' | 'icon';
onInvestigateInTimelineAlertClick?: () => void;
}
const InvestigateInTimelineActionComponent: React.FC<InvestigateInTimelineActionProps> = ({
ariaLabel = ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL,
alertIds,
ecsRowData,
fetchEcsAlertsData,
nonEcsRowData,
buttonType,
onInvestigateInTimelineAlertClick,
}) => {
const {
data: { search: searchStrategyClient },
} = useKibana().services;
const dispatch = useDispatch();
const updateTimelineIsLoading = useCallback(
(payload) => dispatch(timelineActions.updateIsLoading(payload)),
[dispatch]
);
const createTimeline = useCallback(
({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => {
updateTimelineIsLoading({ id: TimelineId.active, isLoading: false });
dispatchUpdateTimeline(dispatch)({
duplicate: true,
from: fromTimeline,
id: TimelineId.active,
notes: [],
timeline: {
...timeline,
// by setting as an empty array, it will default to all in the reducer because of the event type
indexNames: [],
show: true,
},
to: toTimeline,
ruleNote,
})();
},
[dispatch, updateTimelineIsLoading]
);
const investigateInTimelineAlertClick = useCallback(async () => {
try {
if (ecsRowData != null) {
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsRowData,
nonEcsData: nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
});
}
if (ecsRowData == null && fetchEcsAlertsData) {
const alertsEcsData = await fetchEcsAlertsData(alertIds);
await sendAlertToTimelineAction({
createTimeline,
ecsData: alertsEcsData,
nonEcsData: nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
});
}
} catch {
// TODO show a toaster that something went wrong
}
}, [
alertIds,
createTimeline,
const { investigateInTimelineAlertClick } = useInvestigateInTimeline({
ecsRowData,
fetchEcsAlertsData,
nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
]);
alertIds,
onInvestigateInTimelineAlertClick,
});
return (
<ActionIconItem
@ -110,6 +49,7 @@ const InvestigateInTimelineActionComponent: React.FC<InvestigateInTimelineAction
iconType="timeline"
onClick={investigateInTimelineAlertClick}
isDisabled={false}
buttonType={buttonType}
/>
);
};

View file

@ -0,0 +1,66 @@
/*
* 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 { useCallback, useMemo } from 'react';
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
import { useUserData } from '../../user_info';
import { ACTION_ADD_ENDPOINT_EXCEPTION, ACTION_ADD_EXCEPTION } from '../translations';
interface UseExceptionActions {
name: string;
onClick: () => void;
disabled: boolean;
}
interface UseExceptionActionProps {
isEndpointAlert: boolean;
onAddExceptionTypeClick: (type: ExceptionListType) => void;
}
export const useExceptionActions = ({
isEndpointAlert,
onAddExceptionTypeClick,
}: UseExceptionActionProps): UseExceptionActions[] => {
const [{ canUserCRUD, hasIndexWrite }] = useUserData();
const handleDetectionExceptionModal = useCallback(() => {
onAddExceptionTypeClick('detection');
}, [onAddExceptionTypeClick]);
const handleEndpointExceptionModal = useCallback(() => {
onAddExceptionTypeClick('endpoint');
}, [onAddExceptionTypeClick]);
const disabledAddEndpointException = !canUserCRUD || !hasIndexWrite || !isEndpointAlert;
const disabledAddException = !canUserCRUD || !hasIndexWrite;
const exceptionActions = useMemo(
() => [
{
name: ACTION_ADD_ENDPOINT_EXCEPTION,
onClick: handleEndpointExceptionModal,
disabled: disabledAddEndpointException,
[`data-test-subj`]: 'add-endpoint-exception-menu-item',
},
{
name: ACTION_ADD_EXCEPTION,
onClick: handleDetectionExceptionModal,
disabled: disabledAddException,
[`data-test-subj`]: 'add-exception-menu-item',
},
],
[
disabledAddEndpointException,
disabledAddException,
handleDetectionExceptionModal,
handleEndpointExceptionModal,
]
);
return exceptionActions;
};

View file

@ -0,0 +1,77 @@
/*
* 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 { useCallback, useMemo, useState } from 'react';
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
import {
DEFAULT_INDEX_PATTERN,
DEFAULT_INDEX_PATTERN_EXPERIMENTAL,
} from '../../../../../common/constants';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { TimelineId } from '../../../../../common/types/timeline';
import { inputsModel } from '../../../../common/store';
interface UseExceptionModalProps {
ruleIndex: string[] | null | undefined;
refetch?: inputsModel.Refetch;
timelineId: string;
}
interface UseExceptionModal {
exceptionModalType: ExceptionListType | null;
onAddExceptionTypeClick: (type: ExceptionListType) => void;
onAddExceptionCancel: () => void;
onAddExceptionConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void;
ruleIndices: string[];
}
export const useExceptionModal = ({
ruleIndex,
refetch,
timelineId,
}: UseExceptionModalProps): UseExceptionModal => {
const [exceptionModalType, setOpenAddExceptionModal] = useState<ExceptionListType | null>(null);
// TODO: Steph/ueba remove when past experimental
const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled');
const ruleIndices = useMemo((): string[] => {
if (ruleIndex != null) {
return ruleIndex;
} else {
return uebaEnabled
? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL]
: DEFAULT_INDEX_PATTERN;
}
}, [ruleIndex, uebaEnabled]);
const onAddExceptionTypeClick = useCallback((exceptionListType: ExceptionListType): void => {
setOpenAddExceptionModal(exceptionListType);
}, []);
const onAddExceptionCancel = useCallback(() => {
setOpenAddExceptionModal(null);
}, []);
const onAddExceptionConfirm = useCallback(
(didCloseAlert: boolean, didBulkCloseAlert) => {
if (refetch && (timelineId !== TimelineId.active || didBulkCloseAlert)) {
refetch();
}
setOpenAddExceptionModal(null);
},
[refetch, timelineId]
);
return {
exceptionModalType,
onAddExceptionTypeClick,
onAddExceptionCancel,
onAddExceptionConfirm,
ruleIndices,
};
};

View file

@ -0,0 +1,217 @@
/*
* 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 { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
import { timelineActions } from '../../../../timelines/store/timeline';
import { FILTER_OPEN, FILTER_CLOSED, FILTER_IN_PROGRESS } from '../alerts_filter_group';
import { updateAlertStatusAction } from '../actions';
import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types';
import * as i18nCommon from '../../../../common/translations';
import * as i18n from '../translations';
import {
useStateToaster,
displaySuccessToast,
displayErrorToast,
} from '../../../../common/components/toasters';
import { useUserData } from '../../user_info';
interface Props {
alertStatus?: string;
closePopover: () => void;
eventId: string | null | undefined;
timelineId: string;
}
export const useAlertsActions = ({ alertStatus, closePopover, eventId, timelineId }: Props) => {
const dispatch = useDispatch();
const [, dispatchToaster] = useStateToaster();
const { addWarning } = useAppToasts();
const [{ canUserCRUD, hasIndexMaintenance, hasIndexUpdateDelete }] = useUserData();
const onAlertStatusUpdateSuccess = useCallback(
(updated: number, conflicts: number, newStatus: Status) => {
if (conflicts > 0) {
// Partial failure
addWarning({
title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts),
text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts),
});
} else {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated);
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated);
}
displaySuccessToast(title, dispatchToaster);
}
},
[addWarning, dispatchToaster]
);
const onAlertStatusUpdateFailure = useCallback(
(newStatus: Status, error: Error) => {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_FAILED_TOAST;
break;
case 'open':
title = i18n.OPENED_ALERT_FAILED_TOAST;
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST;
}
displayErrorToast(title, [error.message], dispatchToaster);
},
[dispatchToaster]
);
const setEventsLoading = useCallback(
({ eventIds, isLoading }: SetEventsLoadingProps) => {
dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading }));
},
[dispatch, timelineId]
);
const setEventsDeleted = useCallback(
({ eventIds, isDeleted }: SetEventsDeletedProps) => {
dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted }));
},
[dispatch, timelineId]
);
const openAlertActionOnClick = useCallback(() => {
if (eventId) {
updateAlertStatusAction({
alertIds: [eventId],
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
selectedStatus: FILTER_OPEN,
});
}
closePopover();
}, [
closePopover,
eventId,
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
]);
const closeAlertActionClick = useCallback(() => {
if (eventId) {
updateAlertStatusAction({
alertIds: [eventId],
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
selectedStatus: FILTER_CLOSED,
});
}
closePopover();
}, [
closePopover,
eventId,
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
]);
const inProgressAlertActionClick = useCallback(() => {
if (eventId) {
updateAlertStatusAction({
alertIds: [eventId],
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
selectedStatus: FILTER_IN_PROGRESS,
});
}
closePopover();
}, [
closePopover,
eventId,
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
]);
const disabledInProgressAlertAction = !canUserCRUD || !hasIndexUpdateDelete;
const inProgressAlertAction = useMemo(() => {
return {
name: i18n.ACTION_IN_PROGRESS_ALERT,
disabled: disabledInProgressAlertAction,
onClick: inProgressAlertActionClick,
[`data-test-subj`]: 'in-progress-alert-status',
};
}, [disabledInProgressAlertAction, inProgressAlertActionClick]);
const disabledCloseAlertAction = !hasIndexUpdateDelete && !hasIndexMaintenance;
const closeAlertAction = useMemo(() => {
return {
name: i18n.ACTION_CLOSE_ALERT,
disabled: disabledCloseAlertAction,
onClick: closeAlertActionClick,
[`data-test-subj`]: 'close-alert-status',
};
}, [disabledCloseAlertAction, closeAlertActionClick]);
const disabledOpenAlertAction = !hasIndexUpdateDelete && !hasIndexMaintenance;
const openAlertAction = useMemo(() => {
return {
name: i18n.ACTION_OPEN_ALERT,
disabled: disabledOpenAlertAction,
onClick: openAlertActionOnClick,
[`data-test-subj`]: 'open-alert-status',
};
}, [disabledOpenAlertAction, openAlertActionOnClick]);
const statusActions = useMemo(() => {
if (!alertStatus) {
return [];
}
switch (alertStatus) {
case 'open':
return [inProgressAlertAction, closeAlertAction];
case 'in-progress':
return [openAlertAction, closeAlertAction];
case 'closed':
return [openAlertAction, inProgressAlertAction];
default:
return [];
}
}, [alertStatus, inProgressAlertAction, closeAlertAction, openAlertAction]);
return {
statusActions,
};
};

View file

@ -0,0 +1,24 @@
/*
* 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 { ACTION_ADD_EVENT_FILTER } from '../translations';
export const useEventFilterAction = ({
onAddEventFilterClick,
}: {
onAddEventFilterClick: () => void;
}) => {
const eventFilterActions = useMemo(
() => ({
name: ACTION_ADD_EVENT_FILTER,
onClick: onAddEventFilterClick,
}),
[onAddEventFilterClick]
);
return eventFilterActions;
};

View file

@ -0,0 +1,21 @@
/*
* 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 { useCallback, useState } from 'react';
export const useEventFilterModal = () => {
const [isAddEventFilterModalOpen, setIsAddEventFilterModalOpen] = useState<boolean>(false);
const onAddEventFilterClick = useCallback((): void => {
setIsAddEventFilterModalOpen(true);
}, []);
const closeAddEventFilterModal = useCallback((): void => {
setIsAddEventFilterModalOpen(false);
}, []);
return { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick };
};

View file

@ -0,0 +1,120 @@
/*
* 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 { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineId } from '../../../../../common/types/timeline';
import { Ecs } from '../../../../../common/ecs';
import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline';
import { timelineActions } from '../../../../timelines/store/timeline';
import { sendAlertToTimelineAction } from '../actions';
import { dispatchUpdateTimeline } from '../../../../timelines/components/open_timeline/helpers';
import { CreateTimelineProps } from '../types';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../translations';
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,
nonEcsRowData,
alertIds,
onInvestigateInTimelineAlertClick,
}: UseInvestigateInTimelineActionProps) => {
const {
data: { search: searchStrategyClient },
} = useKibana().services;
const dispatch = useDispatch();
const updateTimelineIsLoading = useCallback(
(payload) => dispatch(timelineActions.updateIsLoading(payload)),
[dispatch]
);
const createTimeline = useCallback(
({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => {
updateTimelineIsLoading({ id: TimelineId.active, isLoading: false });
dispatchUpdateTimeline(dispatch)({
duplicate: true,
from: fromTimeline,
id: TimelineId.active,
notes: [],
timeline: {
...timeline,
// by setting as an empty array, it will default to all in the reducer because of the event type
indexNames: [],
show: true,
},
to: toTimeline,
ruleNote,
})();
},
[dispatch, updateTimelineIsLoading]
);
const showInvestigateInTimelineAction = alertIds != null;
const { isLoading: isFetchingAlertEcs, alertsEcsData } = useFetchEcsAlertsData({
alertIds,
skip: ecsRowData != null || alertIds == null,
});
const investigateInTimelineAlertClick = useCallback(async () => {
if (onInvestigateInTimelineAlertClick) {
onInvestigateInTimelineAlertClick();
}
if (alertsEcsData != null) {
await sendAlertToTimelineAction({
createTimeline,
ecsData: alertsEcsData,
nonEcsData: nonEcsRowData ?? [],
searchStrategyClient,
updateTimelineIsLoading,
});
}
if (ecsRowData != null) {
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsRowData,
nonEcsData: nonEcsRowData ?? [],
searchStrategyClient,
updateTimelineIsLoading,
});
}
}, [
alertsEcsData,
createTimeline,
ecsRowData,
nonEcsRowData,
onInvestigateInTimelineAlertClick,
searchStrategyClient,
updateTimelineIsLoading,
]);
const investigateInTimelineAction = showInvestigateInTimelineAction
? [
{
name: ACTION_INVESTIGATE_IN_TIMELINE,
onClick: investigateInTimelineAlertClick,
disabled: isFetchingAlertEcs,
},
]
: [];
return {
investigateInTimelineAction,
investigateInTimelineAlertClick,
showInvestigateInTimelineAction,
};
};

View file

@ -178,6 +178,13 @@ export const ACTION_ADD_EVENT_FILTER = i18n.translate(
}
);
export const ACTION_ADD_TO_CASE = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.actions.addToCase',
{
defaultMessage: 'Add to case',
}
);
export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.actions.addEndpointException',
{

View file

@ -0,0 +1,23 @@
/*
* 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 { find } from 'lodash/fp';
import type { TimelineEventsDetailsItem } from '../../../../common';
export const getFieldValue = (
{
category,
field,
}: {
category: string;
field: string;
},
data: TimelineEventsDetailsItem[] | null
) => {
const currentField = find({ category, field }, data)?.values;
return currentField && currentField.length > 0 ? currentField[0] : '';
};

View file

@ -1,82 +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 React, { useState, useCallback, useMemo } from 'react';
import { EuiContextMenuItem, EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui';
import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations';
import { useHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status';
import { HostStatus } from '../../../../common/endpoint/types';
export const TakeActionDropdown = React.memo(
({
onChange,
agentId,
}: {
onChange: (action: 'isolateHost' | 'unisolateHost') => void;
agentId: string;
}) => {
const { loading, isIsolated: isolationStatus, agentStatus } = useHostIsolationStatus({
agentId,
});
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopoverHandler = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const isolateHostHandler = useCallback(() => {
setIsPopoverOpen(false);
if (isolationStatus === false) {
onChange('isolateHost');
} else {
onChange('unisolateHost');
}
}, [onChange, isolationStatus]);
const takeActionButton = useMemo(() => {
return (
<EuiButton
iconSide="right"
fill
iconType="arrowDown"
disabled={loading || agentStatus === HostStatus.UNENROLLED}
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
>
{TAKE_ACTION}
</EuiButton>
);
}, [isPopoverOpen, loading, agentStatus]);
return (
<EuiPopover
id="hostIsolationTakeActionPanel"
button={takeActionButton}
isOpen={isPopoverOpen}
closePopover={closePopoverHandler}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s">
{isolationStatus === false ? (
<EuiContextMenuItem key="isolateHost" onClick={isolateHostHandler}>
{ISOLATE_HOST}
</EuiContextMenuItem>
) : (
<EuiContextMenuItem key="unisolateHost" onClick={isolateHostHandler}>
{UNISOLATE_HOST}
</EuiContextMenuItem>
)}
</EuiContextMenuPanel>
</EuiPopover>
);
}
);
TakeActionDropdown.displayName = 'TakeActionDropdown';

View file

@ -0,0 +1,101 @@
/*
* 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 { useCallback, useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '../../../../common';
import { isIsolationSupported } from '../../../../common/endpoint/service/host_isolation/utils';
import { HostStatus } from '../../../../common/endpoint/types';
import { useIsolationPrivileges } from '../../../common/hooks/endpoint/use_isolate_privileges';
import { endpointAlertCheck } from '../../../common/utils/endpoint_alert_check';
import { useHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status';
import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { getFieldValue } from './helpers';
interface UseHostIsolationActionProps {
closePopover: () => void;
detailsData: TimelineEventsDetailsItem[] | null;
isHostIsolationPanelOpen: boolean;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
}
export const useHostIsolationAction = ({
closePopover,
detailsData,
isHostIsolationPanelOpen,
onAddIsolationStatusClick,
}: UseHostIsolationActionProps) => {
const isEndpointAlert = useMemo(() => {
return endpointAlertCheck({ data: detailsData || [] });
}, [detailsData]);
const agentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData),
[detailsData]
);
const hostOsFamily = useMemo(
() => getFieldValue({ category: 'host', field: 'host.os.name' }, detailsData),
[detailsData]
);
const agentVersion = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.version' }, detailsData),
[detailsData]
);
const isolationSupported = isIsolationSupported({
osName: hostOsFamily,
version: agentVersion,
});
const {
loading: loadingHostIsolationStatus,
isIsolated: isolationStatus,
agentStatus,
} = useHostIsolationStatus({
agentId,
});
const { isAllowed: isIsolationAllowed } = useIsolationPrivileges();
const isolateHostHandler = useCallback(() => {
closePopover();
if (isolationStatus === false) {
onAddIsolationStatusClick('isolateHost');
} else {
onAddIsolationStatusClick('unisolateHost');
}
}, [closePopover, isolationStatus, onAddIsolationStatusClick]);
const isolateHostTitle = isolationStatus === false ? ISOLATE_HOST : UNISOLATE_HOST;
const hostIsolationAction = useMemo(
() =>
isIsolationAllowed &&
isEndpointAlert &&
isolationSupported &&
isHostIsolationPanelOpen === false
? [
{
name: isolateHostTitle,
onClick: isolateHostHandler,
disabled: loadingHostIsolationStatus || agentStatus === HostStatus.UNENROLLED,
},
]
: [],
[
agentStatus,
isEndpointAlert,
isHostIsolationPanelOpen,
isIsolationAllowed,
isolateHostHandler,
isolateHostTitle,
isolationSupported,
loadingHostIsolationStatus,
]
);
return hostIsolationAction;
};

View file

@ -0,0 +1,18 @@
/*
* 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 { ACTION_ADD_TO_CASE } from '../alerts_table/translations';
export const addToCaseActionItem = (timelineId: string | null | undefined) =>
['detections-page', 'detections-rules-details-page', 'timeline-1'].includes(timelineId ?? '')
? [
{
name: ACTION_ADD_TO_CASE,
panel: 2,
},
]
: [];

View file

@ -0,0 +1,239 @@
/*
* 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, { useState, useCallback, useMemo } from 'react';
import { EuiContextMenu, EuiButton, EuiPopover } from '@elastic/eui';
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
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';
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
import {
ACTION_ADD_TO_CASE
} from '../alerts_table/translations';
import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana';
import { useInsertTimeline } from '../../../cases/components/use_insert_timeline';
import { addToCaseActionItem } from './helpers'; */
import { useEventFilterAction } from '../alerts_table/timeline_actions/use_event_filter_action';
import { useHostIsolationAction } from '../host_isolation/use_host_isolation_action';
import { CHANGE_ALERT_STATUS } from './translations';
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';
interface ActionsData {
alertStatus: Status;
eventId: string;
eventKind: string;
ruleId: string;
ruleName: string;
}
export const TakeActionDropdown = React.memo(
({
detailsData,
ecsData,
handleOnEventClosed,
isHostIsolationPanelOpen,
loadingEventDetails,
nonEcsData,
onAddEventFilterClick,
onAddExceptionTypeClick,
onAddIsolationStatusClick,
refetch,
timelineId,
}: {
detailsData: TimelineEventsDetailsItem[] | null;
ecsData?: Ecs;
handleOnEventClosed: () => void;
isHostIsolationPanelOpen: boolean;
loadingEventDetails: boolean;
nonEcsData?: TimelineNonEcsData[];
refetch: (() => void) | undefined;
onAddEventFilterClick: () => void;
onAddExceptionTypeClick: (type: ExceptionListType) => void;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
timelineId: string;
}) => {
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
const casePermissions = useGetUserCasesPermissions();
const { timelines: timelinesUi } = useKibana().services;
const insertTimelineHook = useInsertTimeline;
*/
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const actionsData = useMemo(
() =>
[
{ category: 'signal', field: 'signal.rule.id', name: 'ruleId' },
{ category: 'signal', field: 'signal.rule.name', name: 'ruleName' },
{ category: 'signal', field: 'signal.status', name: 'alertStatus' },
{ category: 'event', field: 'event.kind', name: 'eventKind' },
{ category: '_id', field: '_id', name: 'eventId' },
].reduce<ActionsData>(
(acc, curr) => ({
...acc,
[curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData),
}),
{} as ActionsData
),
[detailsData]
);
const alertIds = useMemo(() => [actionsData.eventId], [actionsData.eventId]);
const isEvent = actionsData.eventKind === 'event';
const isEndpointAlert = useMemo((): boolean => {
if (detailsData == null) {
return false;
}
return endpointAlertCheck({ data: detailsData });
}, [detailsData]);
const togglePopoverHandler = useCallback(() => {
setIsPopoverOpen(!isPopoverOpen);
}, [isPopoverOpen]);
const closePopoverHandler = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const closePopoverAndFlyout = useCallback(() => {
handleOnEventClosed();
setIsPopoverOpen(false);
}, [handleOnEventClosed]);
const handleOnAddIsolationStatusClick = useCallback(
(action: 'isolateHost' | 'unisolateHost') => {
onAddIsolationStatusClick(action);
setIsPopoverOpen(false);
},
[onAddIsolationStatusClick]
);
const hostIsolationAction = useHostIsolationAction({
closePopover: closePopoverHandler,
detailsData,
onAddIsolationStatusClick: handleOnAddIsolationStatusClick,
isHostIsolationPanelOpen,
});
const handleOnAddExceptionTypeClick = useCallback(
(type: ExceptionListType) => {
onAddExceptionTypeClick(type);
setIsPopoverOpen(false);
},
[onAddExceptionTypeClick]
);
const exceptionActions = useExceptionActions({
isEndpointAlert,
onAddExceptionTypeClick: handleOnAddExceptionTypeClick,
});
const handleOnAddEventFilterClick = useCallback(() => {
onAddEventFilterClick();
setIsPopoverOpen(false);
}, [onAddEventFilterClick]);
const eventFilterActions = useEventFilterAction({
onAddEventFilterClick: handleOnAddEventFilterClick,
});
const { statusActions } = useAlertsActions({
alertStatus: actionsData.alertStatus,
eventId: actionsData.eventId,
timelineId,
closePopover: closePopoverAndFlyout,
});
const { investigateInTimelineAction } = useInvestigateInTimeline({
alertIds,
ecsRowData: ecsData,
onInvestigateInTimelineAlertClick: closePopoverHandler,
});
const alertsActionItems = useMemo(
() =>
!isEvent && actionsData.ruleId
? [
{
name: CHANGE_ALERT_STATUS,
panel: 1,
},
...exceptionActions,
]
: [eventFilterActions],
[eventFilterActions, exceptionActions, isEvent, actionsData.ruleId]
);
const panels = useMemo(
() => [
{
id: 0,
items: [
...alertsActionItems,
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
...addToCaseActionItem(timelineId),*/
...hostIsolationAction,
...investigateInTimelineAction,
],
},
{
id: 1,
title: CHANGE_ALERT_STATUS,
items: statusActions,
},
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
{
id: 2,
title: ACTION_ADD_TO_CASE,
content: (
<>
{ecsData &&
timelinesUi.getAddToCaseAction({
ecsRowData: ecsData,
useInsertTimeline: insertTimelineHook,
casePermissions,
showIcon: false,
})}
</>
),
},*/
],
[alertsActionItems, hostIsolationAction, investigateInTimelineAction, statusActions]
);
const takeActionButton = useMemo(() => {
return (
<EuiButton iconSide="right" fill iconType="arrowDown" onClick={togglePopoverHandler}>
{TAKE_ACTION}
</EuiButton>
);
}, [togglePopoverHandler]);
return panels[0].items?.length && !loadingEventDetails ? (
<>
<EuiPopover
id="hostIsolationTakeActionPanel"
button={takeActionButton}
isOpen={isPopoverOpen}
closePopover={closePopoverHandler}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenu size="s" initialPanelId={0} panels={panels} />
</EuiPopover>
</>
) : null;
}
);

View file

@ -0,0 +1,43 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CHANGE_ALERT_STATUS = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.changeAlertStatus',
{
defaultMessage: 'Change alert status',
}
);
export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.addEndpointException',
{
defaultMessage: 'Add Endpoint exception',
}
);
export const ACTION_ADD_EXCEPTION = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.addException',
{
defaultMessage: 'Add rule exception',
}
);
export const ACTION_ADD_EVENT_FILTER = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.addEventFilter',
{
defaultMessage: 'Add Endpoint event filter',
}
);
export const INVESTIGATE_IN_TIMELINE = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.investigateInTimeline',
{
defaultMessage: 'investigate in timeline',
}
);

View file

@ -0,0 +1,86 @@
/*
* 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 { SearchResponse } from 'elasticsearch';
import { isEmpty } from 'lodash';
import {
buildAlertsQuery,
formatAlertToEcsSignal,
} from '../../../../cases/components/case_view/helpers';
import { Ecs } from '../../../../../common/ecs';
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../common/constants';
import { KibanaServices } from '../../../../common/lib/kibana';
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<
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 as {}),
_id,
_index,
timestamp: _source['@timestamp'],
},
],
[]
) ?? []
);
} catch (e) {
if (isSubscribed) {
if (onError) {
onError(e);
}
}
}
if (isSubscribed) {
setIsLoading(false);
}
};
if (!isEmpty(alertIds) && !skip) {
fetchAlert();
}
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, [alertIds, onError, skip]);
return {
isLoading,
alertsEcsData,
};
};

View file

@ -38,6 +38,8 @@ export const useHostIsolationStatus = ({
// isMounted tracks if a component is mounted before changing state
let isMounted = true;
let fleetAgentId: string;
setLoading(true);
const fetchData = async () => {
try {
const metadataResponse = await getHostMetadata({ agentId, signal: abortCtrl.signal });
@ -73,15 +75,9 @@ export const useHostIsolationStatus = ({
}
};
setLoading((prevState) => {
if (prevState) {
return prevState;
}
if (!isEmpty(agentId)) {
fetchData();
}
return true;
});
if (!isEmpty(agentId)) {
fetchData();
}
return () => {
// updates to show component is unmounted
isMounted = false;

View file

@ -261,7 +261,7 @@ Array [
-ms-flex: 1;
flex: 1;
overflow: hidden;
padding: 4px 16px 50px;
padding: 16px;
}
<EuiFlyout
@ -454,6 +454,53 @@ Array [
</div>
</EuiFlyoutBody>
</Styled(EuiFlyoutBody)>
<Memo()
detailsData={null}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddIsolationStatusClick={[Function]}
timelineId="test"
>
<EuiFlyoutFooter>
<div
className="euiFlyoutFooter"
>
<EuiFlexGroup
justifyContent="flexEnd"
>
<div
className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--justifyContentFlexEnd euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<Memo()
detailsData={null}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddEventFilterClick={[Function]}
onAddExceptionTypeClick={[Function]}
onAddIsolationStatusClick={[Function]}
timelineId="test"
/>
</div>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</div>
</EuiFlyoutFooter>
</Memo()>
</EventDetailsPanelComponent>
</div>
</EuiFlyout>,
@ -480,7 +527,7 @@ Array [
-ms-flex: 1;
flex: 1;
overflow: hidden;
padding: 4px 16px 50px;
padding: 16px;
}
<div
@ -667,6 +714,53 @@ Array [
</div>
</EuiFlyoutBody>
</Styled(EuiFlyoutBody)>
<Memo()
detailsData={null}
expandedEvent={
Object {
"eventId": "my-id",
"indexName": "my-index",
}
}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddIsolationStatusClick={[Function]}
timelineId="test"
>
<EuiFlyoutFooter>
<div
className="euiFlyoutFooter"
>
<EuiFlexGroup
justifyContent="flexEnd"
>
<div
className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--justifyContentFlexEnd euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<Memo()
detailsData={null}
handleOnEventClosed={[Function]}
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddEventFilterClick={[Function]}
onAddExceptionTypeClick={[Function]}
onAddIsolationStatusClick={[Function]}
timelineId="test"
/>
</div>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</div>
</EuiFlyoutFooter>
</Memo()>
</EventDetailsPanelComponent>
</div>,
]

View file

@ -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, { useMemo } from 'react';
import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { find, get } 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';
import { AddExceptionModalWrapper } from '../../../../detections/components/alerts_table/timeline_actions/alert_context_menu';
import { EventFiltersModal } from '../../../../management/pages/event_filters/view/components/modal';
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';
interface EventDetailsFooterProps {
detailsData: TimelineEventsDetailsItem[] | null;
expandedEvent: {
eventId: string;
indexName: string;
refetch?: () => void;
};
handleOnEventClosed: () => void;
isHostIsolationPanelOpen: boolean;
loadingEventDetails: boolean;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
timelineId: string;
}
interface AddExceptionModalWrapperData {
alertStatus: Status;
eventId: string;
ruleId: string;
ruleName: string;
}
export const EventDetailsFooter = React.memo(
({
detailsData,
expandedEvent,
handleOnEventClosed,
isHostIsolationPanelOpen,
loadingEventDetails,
onAddIsolationStatusClick,
timelineId,
}: EventDetailsFooterProps) => {
const ruleIndex = useMemo(
() => find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values,
[detailsData]
);
const addExceptionModalWrapperData = useMemo(
() =>
[
{ category: 'signal', field: 'signal.rule.id', name: 'ruleId' },
{ category: 'signal', field: 'signal.rule.name', name: 'ruleName' },
{ category: 'signal', field: 'signal.status', name: 'alertStatus' },
{ category: '_id', field: '_id', name: 'eventId' },
].reduce<AddExceptionModalWrapperData>(
(acc, curr) => ({
...acc,
[curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData),
}),
{} as AddExceptionModalWrapperData
),
[detailsData]
);
const eventIds = useMemo(() => [expandedEvent?.eventId], [expandedEvent?.eventId]);
const {
exceptionModalType,
onAddExceptionTypeClick,
onAddExceptionCancel,
onAddExceptionConfirm,
ruleIndices,
} = useExceptionModal({
ruleIndex,
refetch: expandedEvent?.refetch,
timelineId,
});
const {
closeAddEventFilterModal,
isAddEventFilterModalOpen,
onAddEventFilterClick,
} = useEventFilterModal();
const { alertsEcsData } = useFetchEcsAlertsData({
alertIds: eventIds,
skip: expandedEvent?.eventId == null,
});
const ecsData = get(0, alertsEcsData);
return (
<>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<TakeActionDropdown
detailsData={detailsData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loadingEventDetails}
onAddEventFilterClick={onAddEventFilterClick}
onAddExceptionTypeClick={onAddExceptionTypeClick}
onAddIsolationStatusClick={onAddIsolationStatusClick}
refetch={expandedEvent?.refetch}
timelineId={timelineId}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
{/* This is still wrong to do render flyout/modal inside of the flyout
We need to completely refactor the EventDetails component to be correct
*/}
{exceptionModalType != null &&
addExceptionModalWrapperData.ruleId != null &&
addExceptionModalWrapperData.eventId != null && (
<AddExceptionModalWrapper
{...addExceptionModalWrapperData}
ruleIndices={ruleIndices}
exceptionListType={exceptionModalType}
onCancel={onAddExceptionCancel}
onConfirm={onAddExceptionConfirm}
/>
)}
{isAddEventFilterModalOpen && ecsData != null && (
<EventFiltersModal data={ecsData} onCancel={closeAddEventFilterModal} />
)}
</>
);
}
);

View file

@ -5,14 +5,11 @@
* 2.0.
*/
import { find, some } from 'lodash/fp';
import { some } from 'lodash/fp';
import {
EuiButtonEmpty,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiText,
@ -26,17 +23,16 @@ import { useTimelineEventsDetails } from '../../../containers/details';
import { TimelineTabs } from '../../../../../common/types/timeline';
import { HostIsolationPanel } from '../../../../detections/components/host_isolation';
import { EndpointIsolateSuccess } from '../../../../common/components/endpoint/host_isolation';
import { TakeActionDropdown } from '../../../../detections/components/host_isolation/take_action_dropdown';
import {
ISOLATE_HOST,
UNISOLATE_HOST,
} from '../../../../detections/components/host_isolation/translations';
import { getFieldValue } from '../../../../detections/components/host_isolation/helpers';
import { ALERT_DETAILS } from './translations';
import { useIsolationPrivileges } from '../../../../common/hooks/endpoint/use_isolate_privileges';
import { isIsolationSupported } from '../../../../../common/endpoint/service/host_isolation/utils';
import { endpointAlertCheck } from '../../../../common/utils/endpoint_alert_check';
import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context';
import { TimelineEventsDetailsItem } from '../../../../../common';
import { TimelineNonEcsData } from '../../../../../common';
import { Ecs } from '../../../../../common/ecs';
import { EventDetailsFooter } from './footer';
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflow {
@ -47,29 +43,21 @@ const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflowContent {
flex: 1;
overflow: hidden;
padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 50px`};
padding: ${({ theme }) => `${theme.eui.paddingSizes.m}`};
}
}
`;
const getFieldValue = (
{
category,
field,
}: {
category: string;
field: string;
},
data: TimelineEventsDetailsItem[] | null
) => {
const currentField = find({ category, field }, data)?.values;
return currentField && currentField.length > 0 ? currentField[0] : '';
};
interface EventDetailsPanelProps {
browserFields: BrowserFields;
docValueFields: DocValueFields[];
expandedEvent: { eventId: string; indexName: string };
expandedEvent: {
eventId: string;
indexName: string;
ecsData?: Ecs;
nonEcsData?: TimelineNonEcsData[];
refetch?: () => void;
};
handleOnEventClosed: () => void;
isFlyoutView?: boolean;
tabType: TimelineTabs;
@ -107,7 +95,6 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
setIsIsolateActionSuccessBannerVisible(false);
}, []);
const { isAllowed: isIsolationAllowed } = useIsolationPrivileges();
const showHostIsolationPanel = useCallback((action) => {
if (action === 'isolateHost' || action === 'unisolateHost') {
setIsHostIsolationPanel(true);
@ -117,30 +104,11 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
const isAlert = some({ category: 'signal', field: 'signal.rule.id' }, detailsData);
const isEndpointAlert = useMemo(() => {
return endpointAlertCheck({ data: detailsData || [] });
}, [detailsData]);
const ruleName = useMemo(
() => getFieldValue({ category: 'signal', field: 'signal.rule.name' }, detailsData),
[detailsData]
);
const agentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData),
[detailsData]
);
const hostOsFamily = useMemo(
() => getFieldValue({ category: 'host', field: 'host.os.name' }, detailsData),
[detailsData]
);
const agentVersion = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.version' }, detailsData),
[detailsData]
);
const alertId = useMemo(() => getFieldValue({ category: '_id', field: '_id' }, detailsData), [
detailsData,
]);
@ -150,11 +118,6 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
[detailsData]
);
const isolationSupported = isIsolationSupported({
osName: hostOsFamily,
version: agentVersion,
});
const backToAlertDetailsLink = useMemo(() => {
return (
<>
@ -225,18 +188,16 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
/>
)}
</StyledEuiFlyoutBody>
{isIsolationAllowed &&
isEndpointAlert &&
isolationSupported &&
isHostIsolationPanelOpen === false && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<TakeActionDropdown onChange={showHostIsolationPanel} agentId={agentId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
<EventDetailsFooter
detailsData={detailsData}
expandedEvent={expandedEvent}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loading}
onAddIsolationStatusClick={showHostIsolationPanel}
timelineId={timelineId}
/>
</>
) : (
<>

View file

@ -6,7 +6,7 @@
*/
import React, { MouseEvent } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { EuiContextMenuItem, EuiButtonIcon, EuiToolTip, EuiText } from '@elastic/eui';
import { EventsTdContent } from '../../styles';
import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers';
@ -20,6 +20,7 @@ interface ActionIconItemProps {
isDisabled?: boolean;
onClick?: (event: MouseEvent) => void;
children?: React.ReactNode;
buttonType?: 'text' | 'icon';
}
const ActionIconItemComponent: React.FC<ActionIconItemProps> = ({
@ -31,22 +32,41 @@ const ActionIconItemComponent: React.FC<ActionIconItemProps> = ({
isDisabled = false,
onClick,
children,
buttonType = 'icon',
}) => (
<div>
<EventsTdContent textAlign="center" width={width}>
{children ?? (
<EuiToolTip data-test-subj={`${dataTestSubj}-tool-tip`} content={content}>
<EuiButtonIcon
aria-label={ariaLabel}
data-test-subj={`${dataTestSubj}-button`}
iconType={iconType}
isDisabled={isDisabled}
onClick={onClick}
/>
</EuiToolTip>
)}
</EventsTdContent>
</div>
<>
{buttonType === 'icon' && (
<div>
<EventsTdContent textAlign="center" width={width}>
{children ?? (
<EuiToolTip data-test-subj={`${dataTestSubj}-tool-tip`} content={content}>
<EuiButtonIcon
aria-label={ariaLabel}
data-test-subj={`${dataTestSubj}-button`}
iconType={iconType}
isDisabled={isDisabled}
onClick={onClick}
/>
</EuiToolTip>
)}
</EventsTdContent>
</div>
)}
{buttonType === 'text' && (
<EuiContextMenuItem
aria-label={ariaLabel}
data-test-subj={`${dataTestSubj}-button-menu-item`}
disabled={isDisabled}
onClick={onClick}
color="text"
size="s"
>
<EuiText data-test-subj={`${dataTestSubj}-button`} size="m">
{content}
</EuiText>
</EuiContextMenuItem>
)}
</>
);
ActionIconItemComponent.displayName = 'ActionIconItemComponent';

View file

@ -185,6 +185,7 @@ const StatefulEventComponent: React.FC<Props> = ({
params: {
eventId,
indexName,
refetch,
},
};
@ -199,7 +200,7 @@ const StatefulEventComponent: React.FC<Props> = ({
if (timelineId === TimelineId.active && tabType === TimelineTabs.query) {
activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail });
}
}, [dispatch, event._id, event._index, tabType, timelineId]);
}, [dispatch, event._id, event._index, refetch, tabType, timelineId]);
const associateNote = useCallback(
(noteId: string) => {

View file

@ -68,6 +68,7 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({
describe('Body', () => {
const mount = useMountAppended();
const mockRefetch = jest.fn();
const props: StatefulBodyProps = {
activePage: 0,
browserFields: mockBrowserFields,
@ -80,7 +81,7 @@ describe('Body', () => {
isSelectAllChecked: false,
loadingEventIds: [],
pinnedEventIds: {},
refetch: jest.fn(),
refetch: mockRefetch,
renderCellValue: DefaultCellRenderer,
rowRenderers: defaultRowRenderers,
selectedEventIds: {},
@ -253,6 +254,7 @@ describe('Body', () => {
params: {
eventId: '1',
indexName: undefined,
refetch: mockRefetch,
},
tabType: 'query',
timelineId: 'timeline-test',
@ -277,6 +279,7 @@ describe('Body', () => {
params: {
eventId: '1',
indexName: undefined,
refetch: mockRefetch,
},
tabType: 'pinned',
timelineId: 'timeline-test',
@ -301,6 +304,7 @@ describe('Body', () => {
params: {
eventId: '1',
indexName: undefined,
refetch: mockRefetch,
},
tabType: 'notes',
timelineId: 'timeline-test',