From 6cdb590f63af284195d9c9c9f2fb72e202e7336f Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 26 Feb 2025 07:05:55 +1100 Subject: [PATCH] [8.x] [Security Solution] Convert isolate host to standalone flyout (#211853) (#212434) # Backport This will backport the following commits from `main` to `8.x`: - [[Security Solution] Convert isolate host to standalone flyout (#211853)](https://github.com/elastic/kibana/pull/211853) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) Co-authored-by: christineweng <18648970+christineweng@users.noreply.github.com> --- .../isolate_host/content.test.tsx | 90 +++++++++++++++++++ .../document_details/isolate_host/content.tsx | 33 ++++++- .../isolate_host/header.test.tsx | 24 +++-- .../document_details/isolate_host/header.tsx | 8 ++ .../document_details/isolate_host/test_ids.ts | 1 + .../shared/components/take_action_button.tsx | 66 ++++++++------ .../shared/hooks/use_host_isolation.tsx | 37 +++++++- 7 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.test.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.test.tsx new file mode 100644 index 0000000000000..ab535cf5338ad --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer, endpointAlertDataMock } from '../../../common/mock/endpoint'; +import { FLYOUT_HOST_ISOLATION_PANEL_TEST_ID } from './test_ids'; +import { IsolateHostPanelContent } from './content'; + +describe('', () => { + let appContextMock: AppContextTestRender; + + beforeEach(() => { + jest.clearAllMocks(); + appContextMock = createAppRootMockRenderer(); + + appContextMock.setExperimentalFlag({ + responseActionsSentinelOneV1Enabled: true, + responseActionsCrowdstrikeManualHostIsolationEnabled: true, + }); + }); + + it('should display content with success banner when action is isolateHost', () => { + const { getByTestId } = appContextMock.render( + {}} + handleIsolationActionSuccess={() => {}} + /> + ); + expect(getByTestId(FLYOUT_HOST_ISOLATION_PANEL_TEST_ID)).toBeInTheDocument(); + expect(getByTestId('hostIsolateSuccessMessage')).toBeInTheDocument(); + }); + + it('should display content without success banner when action is isolateHost and success banner is not visible', () => { + const { getByTestId, queryByTestId } = appContextMock.render( + {}} + handleIsolationActionSuccess={() => {}} + /> + ); + expect(getByTestId(FLYOUT_HOST_ISOLATION_PANEL_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId('hostIsolateSuccessMessage')).not.toBeInTheDocument(); + }); + + it('should display content with success banner when action is unisolateHost', () => { + const { getByTestId } = appContextMock.render( + {}} + handleIsolationActionSuccess={() => {}} + /> + ); + expect(getByTestId(FLYOUT_HOST_ISOLATION_PANEL_TEST_ID)).toBeInTheDocument(); + expect(getByTestId('hostUnisolateSuccessMessage')).toBeInTheDocument(); + }); + + it('should display content without success banner when action is unisolateHost', () => { + const { getByTestId, queryByTestId } = appContextMock.render( + {}} + handleIsolationActionSuccess={() => {}} + /> + ); + expect(getByTestId(FLYOUT_HOST_ISOLATION_PANEL_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId('hostUnisolateSuccessMessage')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx index 0c9f05391d82a..16a0a58cb0a28 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/content.tsx @@ -8,6 +8,7 @@ import type { FC } from 'react'; import React, { useCallback } from 'react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import { DocumentDetailsRightPanelKey } from '../shared/constants/panel_keys'; import { useBasicDataFromDetailsData } from '../shared/hooks/use_basic_data_from_details_data'; import { @@ -17,6 +18,7 @@ import { import { useHostIsolation } from '../shared/hooks/use_host_isolation'; import { useIsolateHostPanelContext } from './context'; import { FlyoutBody } from '../../shared/components/flyout_body'; +import { FLYOUT_HOST_ISOLATION_PANEL_TEST_ID } from './test_ids'; /** * Document details expandable flyout section content for the isolate host component, displaying the form or the success banner @@ -44,7 +46,36 @@ export const PanelContent: FC = () => { ); return ( - + + ); +}; +export const IsolateHostPanelContent: FC<{ + isIsolateActionSuccessBannerVisible: boolean; + hostName: string; + alertId: string; + isolateAction: 'isolateHost' | 'unisolateHost'; + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[]; + showAlertDetails: () => void; + handleIsolationActionSuccess: () => void; +}> = ({ + isIsolateActionSuccessBannerVisible, + hostName, + alertId, + isolateAction, + dataFormattedForFieldBrowser, + showAlertDetails, + handleIsolationActionSuccess, +}) => { + return ( + {isIsolateActionSuccessBannerVisible && ( { - let render: () => ReturnType; + let renderComponent: () => ReturnType; const setUseIsolateHostPanelContext = (data: Partial = {}) => { const panelContextMock: IsolateHostPanelContext = { - eventId: 'some-even-1', + eventId: 'some-event-1', indexName: 'some-index-name', scopeId: 'some-scope-id', dataFormattedForFieldBrowser: endpointAlertDataMock.generateEndpointAlertDetailsItemData(), @@ -41,7 +42,7 @@ describe('Isolation Flyout PanelHeader', () => { responseActionsCrowdstrikeManualHostIsolationEnabled: true, }); - render = () => appContextMock.render(); + renderComponent = () => appContextMock.render(); setUseIsolateHostPanelContext({ isolateAction: 'isolateHost', @@ -79,10 +80,23 @@ describe('Isolation Flyout PanelHeader', () => { dataFormattedForFieldBrowser: endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType), }); - const { getByTestId } = render(); + const { getByTestId } = renderComponent(); expect(getByTestId('flyoutHostIsolationHeaderTitle')).toHaveTextContent(title); expect(getByTestId('flyoutHostIsolationHeaderIntegration')); } ); }); + +describe('', () => { + it('should display correct flyout header title for isolateHost', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('flyoutHostIsolationHeaderTitle')).toHaveTextContent(ISOLATE_HOST); + expect(getByTestId('flyoutHostIsolationHeaderIntegration')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx index c0f4174cff95a..135260cf9ac7c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; import type { FC } from 'react'; import React from 'react'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import { AgentTypeIntegration } from '../../../common/components/endpoint/agents/agent_type_integration'; import { useAlertResponseActionsSupport } from '../../../common/hooks/endpoint/use_alert_response_actions_support'; import { useIsolateHostPanelContext } from './context'; @@ -20,6 +21,13 @@ import { ISOLATE_HOST, UNISOLATE_HOST } from '../../../common/components/endpoin */ export const PanelHeader: FC = () => { const { isolateAction, dataFormattedForFieldBrowser: data } = useIsolateHostPanelContext(); + return ; +}; + +export const IsolateHostPanelHeader: FC<{ + isolateAction: string; + data: TimelineEventsDetailsItem[]; +}> = ({ isolateAction, data }) => { const { details: { agentType }, } = useAlertResponseActionsSupport(data); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/test_ids.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/test_ids.ts index b3b18c76b4333..5f5a5748ec038 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/test_ids.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/isolate_host/test_ids.ts @@ -8,3 +8,4 @@ import { PREFIX } from '../../shared/test_ids'; export const FLYOUT_HEADER_TITLE_TEST_ID = `${PREFIX}HeaderTitle` as const; +export const FLYOUT_HOST_ISOLATION_PANEL_TEST_ID = `${PREFIX}HostIsolationPanel` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_button.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_button.tsx index 10595f732fcda..914f98f8772ce 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_button.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_button.tsx @@ -8,8 +8,9 @@ import type { FC } from 'react'; import React, { useCallback, useMemo, useState } from 'react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { useEuiTheme } from '@elastic/eui'; +import { useEuiTheme, EuiFlyout } from '@elastic/eui'; import { find } from 'lodash/fp'; +import { useBasicDataFromDetailsData } from '../hooks/use_basic_data_from_details_data'; import type { Status } from '../../../../../common/api/detection_engine'; import { getAlertDetailsFieldValue } from '../../../../common/lib/endpoint/utils/get_event_details_field_values'; import { TakeActionDropdown } from './take_action_dropdown'; @@ -18,12 +19,12 @@ import { EventFiltersFlyout } from '../../../../management/pages/event_filters/v import { OsqueryFlyout } from '../../../../detections/components/osquery/osquery_flyout'; import { useDocumentDetailsContext } from '../context'; import { useHostIsolation } from '../hooks/use_host_isolation'; -import { DocumentDetailsIsolateHostPanelKey } from '../constants/panel_keys'; import { useRefetchByScope } from '../../right/hooks/use_refetch_by_scope'; import { useExceptionFlyout } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_flyout'; import { isActiveTimeline } from '../../../../helpers'; import { useEventFilterModal } from '../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; - +import { IsolateHostPanelHeader } from '../../isolate_host/header'; +import { IsolateHostPanelContent } from '../../isolate_host/content'; interface AlertSummaryData { /** * Status of the alert (open, closed...) @@ -58,33 +59,21 @@ export const TakeActionButton: FC = () => { [euiTheme] ); - const { closeFlyout, openRightPanel } = useExpandableFlyoutApi(); - const { - eventId, - indexName, - dataFormattedForFieldBrowser, - dataAsNestedObject, - refetchFlyoutData, - scopeId, - } = useDocumentDetailsContext(); + const { closeFlyout } = useExpandableFlyoutApi(); + const { dataFormattedForFieldBrowser, dataAsNestedObject, refetchFlyoutData, scopeId } = + useDocumentDetailsContext(); // host isolation interaction - const { isHostIsolationPanelOpen, showHostIsolationPanel } = useHostIsolation(); - const showHostIsolationPanelCallback = useCallback( - (action: 'isolateHost' | 'unisolateHost' | undefined) => { - showHostIsolationPanel(action); - openRightPanel({ - id: DocumentDetailsIsolateHostPanelKey, - params: { - id: eventId, - indexName, - scopeId, - isolateAction: action, - }, - }); - }, - [eventId, indexName, openRightPanel, scopeId, showHostIsolationPanel] - ); + const { + isolateAction, + isHostIsolationPanelOpen, + showHostIsolationPanel, + isIsolateActionSuccessBannerVisible, + handleIsolationActionSuccess, + showAlertDetails, + } = useHostIsolation(); + + const { hostName } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); const { refetch: refetchAll } = useRefetchByScope({ scopeId }); @@ -174,7 +163,7 @@ export const TakeActionButton: FC = () => { isHostIsolationPanelOpen={isHostIsolationPanelOpen} onAddEventFilterClick={onAddEventFilterClick} onAddExceptionTypeClick={onAddExceptionTypeClick} - onAddIsolationStatusClick={showHostIsolationPanelCallback} + onAddIsolationStatusClick={showHostIsolationPanel} refetchFlyoutData={refetchFlyoutData} refetch={refetchAll} scopeId={scopeId} @@ -213,6 +202,25 @@ export const TakeActionButton: FC = () => { ecsData={dataAsNestedObject} /> )} + + {isHostIsolationPanelOpen && alertId && ( + // EUI TODO: This z-index override of EuiOverlayMask is a workaround, and ideally should be resolved with a cleaner UI/UX flow long-term + + + + + )} ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_host_isolation.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_host_isolation.tsx index d4882572157b5..2b6aabe2ef5ec 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_host_isolation.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_host_isolation.tsx @@ -9,11 +9,13 @@ import { useCallback, useMemo, useReducer } from 'react'; import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint'; interface State { + isolateAction: 'isolateHost' | 'unisolateHost'; isHostIsolationPanelOpen: boolean; isIsolateActionSuccessBannerVisible: boolean; } const initialState: State = { + isolateAction: 'isolateHost', isHostIsolationPanelOpen: false, isIsolateActionSuccessBannerVisible: false, }; @@ -23,6 +25,10 @@ type HostIsolationActions = type: 'setIsHostIsolationPanel'; isHostIsolationPanelOpen: boolean; } + | { + type: 'setIsolateAction'; + isolateAction: 'isolateHost' | 'unisolateHost'; + } | { type: 'setIsIsolateActionSuccessBannerVisible'; isIsolateActionSuccessBannerVisible: boolean; @@ -30,6 +36,8 @@ type HostIsolationActions = function reducer(state: State, action: HostIsolationActions) { switch (action.type) { + case 'setIsolateAction': + return { ...state, isolateAction: action.isolateAction }; case 'setIsHostIsolationPanel': return { ...state, isHostIsolationPanelOpen: action.isHostIsolationPanelOpen }; case 'setIsIsolateActionSuccessBannerVisible': @@ -43,6 +51,10 @@ function reducer(state: State, action: HostIsolationActions) { } export interface UseHostIsolationResult { + /** + * The action to take on the host + */ + isolateAction: 'isolateHost' | 'unisolateHost'; /** * True if the host isolation panel is open in the flyout */ @@ -59,21 +71,34 @@ export interface UseHostIsolationResult { * Callback to show the host isolation panel in the flyout */ showHostIsolationPanel: (action: 'isolateHost' | 'unisolateHost' | undefined) => void; + /** + * Callback to show the alert details in the flyout + */ + showAlertDetails: () => void; } /** * Hook that returns the information for a parent to render the host isolation panel in the flyout */ export const useHostIsolation = (): UseHostIsolationResult => { - const [{ isHostIsolationPanelOpen, isIsolateActionSuccessBannerVisible }, dispatch] = useReducer( - reducer, - initialState - ); + const [ + { isolateAction, isHostIsolationPanelOpen, isIsolateActionSuccessBannerVisible }, + dispatch, + ] = useReducer(reducer, initialState); + + const showAlertDetails = useCallback(() => { + dispatch({ type: 'setIsHostIsolationPanel', isHostIsolationPanelOpen: false }); + dispatch({ + type: 'setIsIsolateActionSuccessBannerVisible', + isIsolateActionSuccessBannerVisible: false, + }); + }, []); const showHostIsolationPanel = useCallback( (action: 'isolateHost' | 'unisolateHost' | undefined) => { if (action === 'isolateHost' || action === 'unisolateHost') { dispatch({ type: 'setIsHostIsolationPanel', isHostIsolationPanelOpen: true }); + dispatch({ type: 'setIsolateAction', isolateAction: action }); } }, [] @@ -95,15 +120,19 @@ export const useHostIsolation = (): UseHostIsolationResult => { return useMemo( () => ({ + isolateAction, isHostIsolationPanelOpen, isIsolateActionSuccessBannerVisible, handleIsolationActionSuccess, + showAlertDetails, showHostIsolationPanel, }), [ + isolateAction, isHostIsolationPanelOpen, isIsolateActionSuccessBannerVisible, handleIsolationActionSuccess, + showAlertDetails, showHostIsolationPanel, ] );