diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index e7c6464bc1546..6a3d812b1bf5b 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -469,6 +469,7 @@ export type TimelineExpandedEventType = params?: { eventId: string; indexName: string; + refetch?: () => void; }; } | EmptyObject; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index 074aceab63343..afb3211eb2f1a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -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); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts index 4c4810c5daf31..8e82394da1db8 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts @@ -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); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 10ed18eb91d0c..7d833b134ddd7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -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', diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 30fd5bd801182..e0430fb402769 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -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"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index 2449a90f5328c..5307ebc267efc 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -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"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts index f7f7f72e83d41..091a483399ada 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts @@ -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'); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 10a9e27fee1cf..fdb12170309c7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -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 => { - 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( - (acc, { _id, _index, _source }) => [ - ...acc, - { - ...formatAlertToEcsSignal(_source as {}), - _id, - _index, - timestamp: _source['@timestamp'], - }, - ], - [] - ) ?? [] - ); - }; - return ( ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap index 0412b3074e3f1..d4e839c4bef77 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap @@ -2,54 +2,50 @@ exports[`JSON View rendering should match snapshot 1`] = ` - + { + "_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%" - /> +} + `; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 1d639eb9497fc..3b5519825f996 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -245,7 +245,7 @@ const EventDetailsComponent: React.FC = ({ content: ( <> - + diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index c9ca93582cd9a..0614f131bcd10 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -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(({ data }) => { const value = useMemo( () => @@ -38,15 +36,15 @@ export const JsonView = React.memo(({ data }) => { return ( - + > + {value} + ); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx new file mode 100644 index 0000000000000..23709269a4c13 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx @@ -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 = ({ + onClick, + disabled, +}) => { + return ( + + {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} + + ); +}; + +export const AddEndpointException = React.memo(AddEndpointExceptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_event_filter.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_event_filter.tsx new file mode 100644 index 0000000000000..1104b3eb83081 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_event_filter.tsx @@ -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 = ({ onClick, disabled }) => { + return ( + + + {i18n.ACTION_ADD_EVENT_FILTER} + + + ); +}; + +export const AddEventFilter = React.memo(AddEventFilterComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx new file mode 100644 index 0000000000000..030f67c9e708c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx @@ -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 = ({ disabled, onClick }) => { + return ( + + + {i18n.ACTION_ADD_EXCEPTION} + + + ); +}; + +export const AddException = React.memo(AddExceptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index b1881d29ec10d..3a9a4e875369e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -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 = ({ 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 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 ruleId = get(0, ecsRowData?.signal?.rule?.id); + const ruleName = get(0, ecsRowData?.signal?.rule?.name); - const onButtonClick = useCallback(() => { - setPopover(!isPopoverOpen); - }, [isPopoverOpen]); + const alertStatus = get(0, ecsRowData?.signal?.status) as Status; - const closePopover = useCallback((): void => { - setPopover(false); - }, []); - const [exceptionModalType, setOpenAddExceptionModal] = useState(null); - const [isAddEventFilterModalOpen, setIsAddEventFilterModalOpen] = useState(false); - const [{ canUserCRUD, hasIndexWrite, hasIndexMaintenance, hasIndexUpdateDelete }] = useUserData(); + const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); const isEndpointAlert = useMemo((): boolean => { if (ecsRowData == null) { @@ -133,188 +71,14 @@ const AlertContextMenuComponent: React.FC = ({ return eventModules.includes('endpoint') && kinds.includes('alert'); }, [ecsRowData]); - const closeAddExceptionModal = useCallback((): void => { - setOpenAddExceptionModal(null); - }, []); + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); - const closeAddEventFilterModal = useCallback((): void => { - setIsAddEventFilterModalOpen(false); + const closePopover = useCallback((): void => { + setPopover(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 ( - - {i18n.ACTION_OPEN_ALERT} - - ); - }, [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 ( - - {i18n.ACTION_CLOSE_ALERT} - - ); - }, [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 ( - - {i18n.ACTION_IN_PROGRESS_ALERT} - - ); - }, [canUserCRUD, hasIndexUpdateDelete, inProgressAlertActionClick]); - const button = useMemo(() => { return ( @@ -330,105 +94,61 @@ const AlertContextMenuComponent: React.FC = ({ ); }, [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 ( - - {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} - - ); - }, [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 ( - - - {i18n.ACTION_ADD_EXCEPTION} - - - ); - }, [handleAddExceptionClick, canUserCRUD, hasIndexWrite]); + const handleOnAddExceptionTypeClick = useCallback( + (type: ExceptionListType) => { + onAddExceptionTypeClick(type); + closePopover(); + }, + [closePopover, onAddExceptionTypeClick] + ); - const handleAddEventFilterClick = useCallback((): void => { + const handleOnAddEventFilterClick = useCallback(() => { + onAddEventFilterClick(); closePopover(); - setIsAddEventFilterModalOpen(true); - }, [closePopover]); - - const addEventFilterComponent = useMemo( - () => ( - - - {i18n.ACTION_ADD_EVENT_FILTER} - - - ), - [handleAddEventFilterClick] - ); + }, [closePopover, onAddEventFilterClick]); - const statusFilters = useMemo(() => { - if (!alertStatus) { - return []; - } + const exceptionActions = useExceptionActions({ + isEndpointAlert, + onAddExceptionTypeClick: handleOnAddExceptionTypeClick, + }); - 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 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 = ({ anchorPosition="downLeft" repositionOnScroll > - + - {exceptionModalType != null && ruleId != null && ecsRowData != null && ( - - )} + {exceptionModalType != null && + ruleId != null && + ruleName != null && + ecsRowData?._id != null && ( + + )} {isAddEventFilterModalOpen && ecsRowData != null && ( )} @@ -468,7 +191,7 @@ const AlertContextMenuComponent: React.FC = ({ ); }; -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 = ({ +export const AddExceptionModalWrapper: React.FC = ({ ruleName, ruleId, ruleIndices, exceptionListType, - ecsData, + eventId, onCancel, onConfirm, alertStatus, @@ -502,7 +225,7 @@ const AddExceptionModalWrapper: React.FC = ({ const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const { loading: isLoadingAlertData, data } = useQueryAlerts({ - query: buildGetAlertByIdQuery(ecsData?._id), + query: buildGetAlertByIdQuery(eventId), indexName: signalIndexName, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/close_status.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/close_status.tsx new file mode 100644 index 0000000000000..038d58c38a013 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/close_status.tsx @@ -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 = ({ onClick, disabled }) => { + return ( + + {i18n.ACTION_CLOSE_ALERT} + + ); +}; + +export const CloseAlertAction = React.memo(CloseAlertActionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/in_progress_alert_status.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/in_progress_alert_status.tsx new file mode 100644 index 0000000000000..2bca569032827 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/in_progress_alert_status.tsx @@ -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 = ({ + onClick, + disabled, +}) => { + return ( + + {i18n.ACTION_IN_PROGRESS_ALERT} + + ); +}; + +export const InProgressAlertStatus = React.memo(InProgressAlertStatusComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/open_alert_status.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/open_alert_status.tsx new file mode 100644 index 0000000000000..34832ee07ea75 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/open_alert_status.tsx @@ -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 = ({ onClick, disabled }) => { + return ( + + {i18n.ACTION_OPEN_ALERT} + + ); +}; + +export const OpenAlertStatus = React.memo(OpenAlertStatusComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx index 3bf30d57d4a8a..04cba8332553a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx @@ -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; + buttonType?: 'text' | 'icon'; + onInvestigateInTimelineAlertClick?: () => void; } const InvestigateInTimelineActionComponent: React.FC = ({ 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 ( ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx new file mode 100644 index 0000000000000..0f8fa00a3ac40 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx @@ -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; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_modal.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_modal.tsx new file mode 100644 index 0000000000000..e623438213433 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_modal.tsx @@ -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(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, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx new file mode 100644 index 0000000000000..855eb2dd5fef4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -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, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx new file mode 100644 index 0000000000000..c24a1e0223ede --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx @@ -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; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_modal.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_modal.tsx new file mode 100644 index 0000000000000..88917a6428cc8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_modal.tsx @@ -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(false); + + const onAddEventFilterClick = useCallback((): void => { + setIsAddEventFilterModalOpen(true); + }, []); + const closeAddEventFilterModal = useCallback((): void => { + setIsAddEventFilterModalOpen(false); + }, []); + + return { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx new file mode 100644 index 0000000000000..0671101f47a37 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -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, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index c63b4b73ae315..badc077244acd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -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', { diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts new file mode 100644 index 0000000000000..aa08db0a23669 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts @@ -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] : ''; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx deleted file mode 100644 index 1404f7927d6ec..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx +++ /dev/null @@ -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 ( - { - setIsPopoverOpen(!isPopoverOpen); - }} - > - {TAKE_ACTION} - - ); - }, [isPopoverOpen, loading, agentStatus]); - - return ( - - - {isolationStatus === false ? ( - - {ISOLATE_HOST} - - ) : ( - - {UNISOLATE_HOST} - - )} - - - ); - } -); - -TakeActionDropdown.displayName = 'TakeActionDropdown'; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx new file mode 100644 index 0000000000000..d7e54e7f9900b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx @@ -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; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/helpers.ts new file mode 100644 index 0000000000000..22f147494a2d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/helpers.ts @@ -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, + }, + ] + : []; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx new file mode 100644 index 0000000000000..d0f26894bf7d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -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( + (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 ( + + {TAKE_ACTION} + + ); + }, [togglePopoverHandler]); + + return panels[0].items?.length && !loadingEventDetails ? ( + <> + + + + + ) : null; + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/translations.ts b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/translations.ts new file mode 100644 index 0000000000000..f8ddb98a7ed86 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/translations.ts @@ -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', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts new file mode 100644 index 0000000000000..8af4781284924 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts @@ -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(null); + const [alertsEcsData, setAlertEcsData] = useState(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchAlert = async () => { + try { + setIsLoading(true); + const alertResponse = await KibanaServices.get().http.fetch< + SearchResponse<{ '@timestamp': string; [key: string]: unknown }> + >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { + method: 'POST', + body: JSON.stringify(buildAlertsQuery(alertIds ?? [])), + }); + + setAlertEcsData( + alertResponse?.hits.hits.reduce( + (acc, { _id, _index, _source }) => [ + ...acc, + { + ...formatAlertToEcsSignal(_source 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, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx index 6a40898d0a109..4737f13c9c596 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx @@ -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; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 8118555cd64d8..3b365306447b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -261,7 +261,7 @@ Array [ -ms-flex: 1; flex: 1; overflow: hidden; - padding: 4px 16px 50px; + padding: 16px; } + + +
+ +
+ +
+ +
+
+
+
+
+
+
, @@ -480,7 +527,7 @@ Array [ -ms-flex: 1; flex: 1; overflow: hidden; - padding: 4px 16px 50px; + padding: 16px; }
+ + +
+ +
+ +
+ +
+
+
+
+
+
+
, ] diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx new file mode 100644 index 0000000000000..cb8ed537543a0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -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( + (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 ( + <> + + + + + + + + {/* 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 && ( + + )} + {isAddEventFilterModalOpen && ecsData != null && ( + + )} + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 90af9d871e0c7..82e994802c650 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -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 = ({ 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 = ({ 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 = ({ [detailsData] ); - const isolationSupported = isIsolationSupported({ - osName: hostOsFamily, - version: agentVersion, - }); - const backToAlertDetailsLink = useMemo(() => { return ( <> @@ -225,18 +188,16 @@ const EventDetailsPanelComponent: React.FC = ({ /> )} - {isIsolationAllowed && - isEndpointAlert && - isolationSupported && - isHostIsolationPanelOpen === false && ( - - - - - - - - )} + + ) : ( <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx index 4bbcbf4e15981..2e2e912f5abfa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx @@ -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 = ({ @@ -31,22 +32,41 @@ const ActionIconItemComponent: React.FC = ({ isDisabled = false, onClick, children, + buttonType = 'icon', }) => ( -
- - {children ?? ( - - - - )} - -
+ <> + {buttonType === 'icon' && ( +
+ + {children ?? ( + + + + )} + +
+ )} + {buttonType === 'text' && ( + + + {content} + + + )} + ); ActionIconItemComponent.displayName = 'ActionIconItemComponent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 57a1fdc99498c..2936b9a41bfb5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -187,6 +187,7 @@ const StatefulEventComponent: React.FC = ({ params: { eventId, indexName, + refetch, }, }; @@ -201,7 +202,7 @@ const StatefulEventComponent: React.FC = ({ 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) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 19059b5fb4599..87fb4ee762ab0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -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',