Skip to content

Commit

Permalink
[Security Solution] Convert isolate host to standalone flyout (elasti…
Browse files Browse the repository at this point in the history
…c#211853)

## Summary

Ref: elastic#207596

This PR fixed a bug where when user has a alert open in preview, then
clicks isolate/release host, the panel opens in the background. This is
because the isolate host panel was called via `openRightPanel`, which
only replaces the panel and not opens a new flyout. To make the isolate
host flyout consistent with other actions, this PR converts the panel
into a normal EUI flyout.


https://github.com/user-attachments/assets/7da4baa0-61ee-4166-9ff1-57c1078a1547


**How to test**
- Apply license (platinum+)
- To enable the isolate/release host button, you can use this command
`yarn test:generate --fleet --withNewUser=test:changeme`


### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
  • Loading branch information
christineweng authored Feb 25, 2025
1 parent 6d6db2f commit 74e1320
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -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('<IsolateHostPanelContent />', () => {
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(
<IsolateHostPanelContent
isIsolateActionSuccessBannerVisible={true}
hostName="some-host-name"
alertId="some-alert-id"
isolateAction="isolateHost"
dataFormattedForFieldBrowser={endpointAlertDataMock.generateEndpointAlertDetailsItemData()}
showAlertDetails={() => {}}
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(
<IsolateHostPanelContent
isIsolateActionSuccessBannerVisible={false}
hostName="some-host-name"
alertId="some-alert-id"
isolateAction="isolateHost"
dataFormattedForFieldBrowser={endpointAlertDataMock.generateEndpointAlertDetailsItemData()}
showAlertDetails={() => {}}
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(
<IsolateHostPanelContent
isIsolateActionSuccessBannerVisible={true}
hostName="some-host-name"
alertId="some-alert-id"
isolateAction="unisolateHost"
dataFormattedForFieldBrowser={endpointAlertDataMock.generateEndpointAlertDetailsItemData()}
showAlertDetails={() => {}}
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(
<IsolateHostPanelContent
isIsolateActionSuccessBannerVisible={false}
hostName="some-host-name"
alertId="some-alert-id"
isolateAction="unisolateHost"
dataFormattedForFieldBrowser={endpointAlertDataMock.generateEndpointAlertDetailsItemData()}
showAlertDetails={() => {}}
handleIsolationActionSuccess={() => {}}
/>
);
expect(getByTestId(FLYOUT_HOST_ISOLATION_PANEL_TEST_ID)).toBeInTheDocument();
expect(queryByTestId('hostUnisolateSuccessMessage')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -44,7 +46,36 @@ export const PanelContent: FC = () => {
);

return (
<FlyoutBody>
<IsolateHostPanelContent
isIsolateActionSuccessBannerVisible={isIsolateActionSuccessBannerVisible}
hostName={hostName}
alertId={alertId}
isolateAction={isolateAction}
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
showAlertDetails={showAlertDetails}
handleIsolationActionSuccess={handleIsolationActionSuccess}
/>
);
};
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 (
<FlyoutBody data-test-subj={FLYOUT_HOST_ISOLATION_PANEL_TEST_ID}>
{isIsolateActionSuccessBannerVisible && (
<EndpointIsolateSuccess
hostName={hostName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
*/

import React from 'react';
import { render } from '@testing-library/react';
import type { IsolateHostPanelContext } from './context';
import { useIsolateHostPanelContext } from './context';
import { PanelHeader } from './header';
import { PanelHeader, IsolateHostPanelHeader } from './header';
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer, endpointAlertDataMock } from '../../../common/mock/endpoint';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
Expand All @@ -18,11 +19,11 @@ import { ISOLATE_HOST, UNISOLATE_HOST } from '../../../common/components/endpoin
jest.mock('./context');

describe('Isolation Flyout PanelHeader', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderComponent: () => ReturnType<AppContextTestRender['render']>;

const setUseIsolateHostPanelContext = (data: Partial<IsolateHostPanelContext> = {}) => {
const panelContextMock: IsolateHostPanelContext = {
eventId: 'some-even-1',
eventId: 'some-event-1',
indexName: 'some-index-name',
scopeId: 'some-scope-id',
dataFormattedForFieldBrowser: endpointAlertDataMock.generateEndpointAlertDetailsItemData(),
Expand All @@ -41,7 +42,7 @@ describe('Isolation Flyout PanelHeader', () => {
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
});

render = () => appContextMock.render(<PanelHeader />);
renderComponent = () => appContextMock.render(<PanelHeader />);

setUseIsolateHostPanelContext({
isolateAction: 'isolateHost',
Expand Down Expand Up @@ -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('<IsolateHostPanelHeader />', () => {
it('should display correct flyout header title for isolateHost', () => {
const { getByTestId } = render(
<IsolateHostPanelHeader
isolateAction="isolateHost"
data={endpointAlertDataMock.generateEndpointAlertDetailsItemData()}
/>
);
expect(getByTestId('flyoutHostIsolationHeaderTitle')).toHaveTextContent(ISOLATE_HOST);
expect(getByTestId('flyoutHostIsolationHeaderIntegration')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,13 @@ import { ISOLATE_HOST, UNISOLATE_HOST } from '../../../common/components/endpoin
*/
export const PanelHeader: FC = () => {
const { isolateAction, dataFormattedForFieldBrowser: data } = useIsolateHostPanelContext();
return <IsolateHostPanelHeader isolateAction={isolateAction} data={data} />;
};

export const IsolateHostPanelHeader: FC<{
isolateAction: string;
data: TimelineEventsDetailsItem[];
}> = ({ isolateAction, data }) => {
const {
details: { agentType },
} = useAlertResponseActionsSupport(data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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...)
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -174,7 +163,7 @@ export const TakeActionButton: FC = () => {
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
onAddEventFilterClick={onAddEventFilterClick}
onAddExceptionTypeClick={onAddExceptionTypeClick}
onAddIsolationStatusClick={showHostIsolationPanelCallback}
onAddIsolationStatusClick={showHostIsolationPanel}
refetchFlyoutData={refetchFlyoutData}
refetch={refetchAll}
scopeId={scopeId}
Expand Down Expand Up @@ -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
<EuiFlyout onClose={showAlertDetails} size="m" maskProps={flyoutZIndex}>
<IsolateHostPanelHeader
isolateAction={isolateAction}
data={dataFormattedForFieldBrowser}
/>
<IsolateHostPanelContent
isIsolateActionSuccessBannerVisible={isIsolateActionSuccessBannerVisible}
hostName={hostName}
alertId={alertId}
isolateAction={isolateAction}
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
showAlertDetails={showAlertDetails}
handleIsolationActionSuccess={handleIsolationActionSuccess}
/>
</EuiFlyout>
)}
</>
);
};
Expand Down
Loading

0 comments on commit 74e1320

Please sign in to comment.