diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index c084dd8ca766..4367c0d90af7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -468,14 +468,23 @@ export type HostMetadata = Immutable<{ id: string; status: HostPolicyResponseActionStatus; name: string; + /** The endpoint integration policy revision number in kibana */ endpoint_policy_version: number; version: number; }; }; configuration: { + /** + * Shows whether the endpoint is set up to be isolated. (e.g. a user has isolated a host, + * and the endpoint successfully received that action and applied the setting) + */ isolation?: boolean; }; state: { + /** + * Shows what the current state of the host is. This could differ from `Endpoint.configuration.isolation` + * in some cases, but normally they will match + */ isolation?: boolean; }; }; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index ae2cc59de6ab..d96929ec183d 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -23,6 +23,7 @@ import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { PLUGIN_ID } from '../../../../../fleet/common'; import { APP_ID } from '../../../../common/constants'; import { KibanaContextProvider } from '../../lib/kibana'; +import { MANAGEMENT_APP_ID } from '../../../management/common/constants'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -156,6 +157,8 @@ const createCoreStartMock = (): ReturnType => { return '/app/fleet'; case APP_ID: return '/app/security'; + case MANAGEMENT_APP_ID: + return '/app/security/administration'; default: return `${appId} not mocked!`; } diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts index 9d12efca19ae..2df16fc1e21b 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts @@ -13,7 +13,7 @@ import type { HttpHandler, HttpStart, } from 'kibana/public'; -import { extend } from 'lodash'; +import { merge } from 'lodash'; import { act } from '@testing-library/react'; class ApiRouteNotMocked extends Error {} @@ -159,6 +159,11 @@ export const httpHandlerMockFactory = ['responseProvider'] = mocks.reduce( (providers, routeMock) => { // FIXME: find a way to remove the ignore below. May need to limit the calling signature of `RouteMock['handler']` @@ -195,7 +200,7 @@ export const httpHandlerMockFactory = { const path = isHttpFetchOptionsWithPath(args[0]) ? args[0].path : args[0]; - const routeMock = methodMocks.find((handler) => handler.path === path); + const routeMock = methodMocks.find((handler) => pathMatchesPattern(handler.path, path)); if (routeMock) { markApiCallAsHandled(responseProvider[routeMock.id].mockDelay); @@ -211,6 +216,9 @@ export const httpHandlerMockFactory = { + // No path params - pattern is single path + if (pathPattern === path) { + return true; + } + + // If pathPattern has params (`{value}`), then see if `path` matches it + if (/{.*?}/.test(pathPattern)) { + const pathParts = path.split(/\//); + const patternParts = pathPattern.split(/\//); + + if (pathParts.length !== patternParts.length) { + return false; + } + + return pathParts.every((part, index) => { + return part === patternParts[index] || /{.*?}/.test(patternParts[index]); + }); + } + + return false; +}; + const isHttpFetchOptionsWithPath = ( opt: string | HttpFetchOptions | HttpFetchOptionsWithPath ): opt is HttpFetchOptionsWithPath => { @@ -235,12 +266,14 @@ const isHttpFetchOptionsWithPath = ( * @example * import { composeApiHandlerMocks } from './http_handler_mock_factory'; * import { + * FleetSetupApiMockInterface, * fleetSetupApiMock, + * AgentsSetupApiMockInterface, * agentsSetupApiMock, * } from './setup'; * - * // Create the new interface as an intersection of all other Api Handler Mocks - * type ComposedApiHandlerMocks = ReturnType & ReturnType + * // Create the new interface as an intersection of all other Api Handler Mock's interfaces + * type ComposedApiHandlerMocks = AgentsSetupApiMockInterface & FleetSetupApiMockInterface * * const newComposedHandlerMock = composeApiHandlerMocks< * ComposedApiHandlerMocks @@ -267,7 +300,7 @@ export const composeHttpHandlerMocks = < handlerMocks.forEach((handlerMock) => { const { waitForApi, ...otherInterfaceProps } = handlerMock(http); - extend(mockedApiInterfaces, otherInterfaceProps); + merge(mockedApiInterfaces, otherInterfaceProps); }); return mockedApiInterfaces; diff --git a/x-pack/plugins/security_solution/public/common/store/test_utils.ts b/x-pack/plugins/security_solution/public/common/store/test_utils.ts index 7616dfccddaf..21c8e6c15f82 100644 --- a/x-pack/plugins/security_solution/public/common/store/test_utils.ts +++ b/x-pack/plugins/security_solution/public/common/store/test_utils.ts @@ -89,7 +89,9 @@ export const createSpyMiddleware = < type ResolvedAction = A extends { type: typeof actionType } ? A : never; // Error is defined here so that we get a better stack trace that points to the test from where it was used - const err = new Error(`action '${actionType}' was not dispatched within the allocated time`); + const err = new Error( + `Timeout! Action '${actionType}' was not dispatched within the allocated time` + ); return new Promise((resolve, reject) => { const watch: ActionWatcher = (action) => { @@ -108,7 +110,10 @@ export const createSpyMiddleware = < const timeout = setTimeout(() => { watchers.delete(watch); reject(err); + // TODO: is there a way we can grab the current timeout value from jest? + // For now, this is using the default value (5000ms) - 500. }, 4500); + watchers.add(watch); }); }, diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts index 7f470c199d55..178ae3b0f716 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts @@ -7,6 +7,8 @@ import { isEmpty } from 'lodash/fp'; +export * from './is_endpoint_host_isolated'; + const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; export const isUrlInvalid = (url: string | null | undefined) => { diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts new file mode 100644 index 000000000000..2e96d56c3625 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts @@ -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 { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { HostMetadata } from '../../../../common/endpoint/types'; +import { isEndpointHostIsolated } from './is_endpoint_host_isolated'; + +describe('When using isEndpointHostIsolated()', () => { + const generator = new EndpointDocGenerator(); + + const generateMetadataDoc = (isolation: boolean = true) => { + const metadataDoc = generator.generateHostMetadata() as HostMetadata; + return { + ...metadataDoc, + Endpoint: { + ...metadataDoc.Endpoint, + state: { + ...metadataDoc.Endpoint.state, + isolation, + }, + }, + }; + }; + + it('Returns `true` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc())).toBe(true); + }); + + it('Returns `false` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc(false))).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts new file mode 100644 index 000000000000..6ca187c52475 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts @@ -0,0 +1,17 @@ +/* + * 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 { HostMetadata } from '../../../../common/endpoint/types'; + +/** + * Given an endpoint host metadata record (`HostMetadata`), this utility will validate if + * that host is isolated + * @param endpointMetadata + */ +export const isEndpointHostIsolated = (endpointMetadata: HostMetadata): boolean => { + return Boolean(endpointMetadata.Endpoint.state.isolation); +}; 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 adc6d3a6b054..f7894d476427 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 @@ -11,6 +11,7 @@ import { Maybe } from '../../../../../../observability/common/typings'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { getHostMetadata } from './api'; import { ISOLATION_STATUS_FAILURE } from './translations'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; interface HostIsolationStatusResponse { loading: boolean; @@ -36,7 +37,7 @@ export const useHostIsolationStatus = ({ try { const metadataResponse = await getHostMetadata({ agentId }); if (isMounted) { - setIsIsolated(Boolean(metadataResponse.metadata.Endpoint.state.isolation)); + setIsIsolated(isEndpointHostIsolated(metadataResponse.metadata)); } } catch (error) { addError(error.message, { title: ISOLATION_STATUS_FAILURE }); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 5bafecb8c4ff..93d0642c6b3b 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -66,7 +66,7 @@ export const getEndpointListPath = ( export const getEndpointDetailsPath = ( props: { - name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate'; + name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate' | 'endpointUnIsolate'; } & EndpointIndexUIQueryParams & EndpointDetailsUrlProps, search?: string @@ -79,6 +79,9 @@ export const getEndpointDetailsPath = ( case 'endpointIsolate': queryParams.show = 'isolate'; break; + case 'endpointUnIsolate': + queryParams.show = 'unisolate'; + break; case 'endpointPolicyResponse': queryParams.show = 'policy_response'; break; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts new file mode 100644 index 000000000000..3a3ad47f9f57 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -0,0 +1,128 @@ +/* + * 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 { + composeHttpHandlerMocks, + httpHandlerMockFactory, + ResponseProvidersInterface, +} from '../../../common/mock/endpoint/http_handler_mock_factory'; +import { + HostInfo, + HostPolicyResponse, + HostResultList, + HostStatus, + MetadataQueryStrategyVersions, +} from '../../../../common/endpoint/types'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { + BASE_POLICY_RESPONSE_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; +import { AGENT_POLICY_API_ROUTES, GetAgentPoliciesResponse } from '../../../../../fleet/common'; + +type EndpointMetadataHttpMocksInterface = ResponseProvidersInterface<{ + metadataList: () => HostResultList; + metadataDetails: () => HostInfo; +}>; +export const endpointMetadataHttpMocks = httpHandlerMockFactory( + [ + { + id: 'metadataList', + path: HOST_METADATA_LIST_ROUTE, + method: 'post', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + hosts: Array.from({ length: 10 }, () => { + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }), + total: 10, + request_page_size: 10, + request_page_index: 0, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + { + id: 'metadataDetails', + path: HOST_METADATA_GET_ROUTE, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + ] +); + +type EndpointPolicyResponseHttpMockInterface = ResponseProvidersInterface<{ + policyResponse: () => HostPolicyResponse; +}>; +export const endpointPolicyResponseHttpMock = httpHandlerMockFactory( + [ + { + id: 'policyResponse', + path: BASE_POLICY_RESPONSE_ROUTE, + method: 'get', + handler: () => { + return new EndpointDocGenerator('seed').generatePolicyResponse(); + }, + }, + ] +); + +type FleetApisHttpMockInterface = ResponseProvidersInterface<{ + agentPolicy: () => GetAgentPoliciesResponse; +}>; +export const fleetApisHttpMock = httpHandlerMockFactory([ + { + id: 'agentPolicy', + path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + const endpointMetadata = generator.generateHostMetadata(); + const agentPolicy = generator.generateAgentPolicy(); + + // Make sure that the Agent policy returned from the API has the Integration Policy ID that + // the endpoint metadata is using. This is needed especially when testing the Endpoint Details + // flyout where certain actions might be disabled if we know the endpoint integration policy no + // longer exists. + (agentPolicy.package_policies as string[]).push(endpointMetadata.Endpoint.policy.applied.id); + + return { + items: [agentPolicy], + perPage: 10, + total: 1, + page: 1, + }; + }, + }, +]); + +type EndpointPageHttpMockInterface = EndpointMetadataHttpMocksInterface & + EndpointPolicyResponseHttpMockInterface & + FleetApisHttpMockInterface; +/** + * HTTP Mocks that support the Endpoint List and Details page + */ +export const endpointPageHttpMock = composeHttpHandlerMocks([ + endpointMetadataHttpMocks, + endpointPolicyResponseHttpMock, + fleetApisHttpMock, +]); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 25f2631ef46f..178f27caa108 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -11,6 +11,7 @@ import { HostInfo, GetHostPolicyResponse, HostIsolationRequestBody, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; @@ -137,7 +138,10 @@ export interface ServerFailedToReturnEndpointsTotal { } export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { - payload: HostIsolationRequestBody; + payload: { + type: ISOLATION_ACTIONS; + data: HostIsolationRequestBody; + }; }; export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 04a04bc38996..6548d8a10ce9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -19,6 +19,7 @@ import { HostResultList, HostIsolationResponse, EndpointAction, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -135,10 +136,13 @@ describe('endpoint list middleware', () => { describe('handling of IsolateEndpointHost action', () => { const getKibanaServicesMock = KibanaServices.get as jest.Mock; - const dispatchIsolateEndpointHost = () => { + const dispatchIsolateEndpointHost = (action: ISOLATION_ACTIONS = 'isolate') => { dispatch({ type: 'endpointIsolationRequest', - payload: hostIsolationRequestBodyMock(), + payload: { + type: action, + data: hostIsolationRequestBodyMock(), + }, }); }; let isolateApiResponseHandlers: ReturnType; @@ -161,7 +165,24 @@ describe('endpoint list middleware', () => { it('should call isolate api', async () => { dispatchIsolateEndpointHost(); - expect(fakeHttpServices.post).toHaveBeenCalled(); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.isolateHost).toHaveBeenCalled(); + }); + + it('should call unisolate api', async () => { + dispatchIsolateEndpointHost('unisolate'); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.unIsolateHost).toHaveBeenCalled(); }); it('should set Isolation state to loaded if api is successful', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 911a902bd202..b62663bd7875 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -51,7 +51,7 @@ import { createLoadedResourceState, createLoadingResourceState, } from '../../../state'; -import { isolateHost } from '../../../../common/lib/host_isolation'; +import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; import { AppAction } from '../../../../common/store/actions'; import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; @@ -504,7 +504,13 @@ const handleIsolateEndpointHost = async ( try { // Cast needed below due to the value of payload being `Immutable<>` - const response = await isolateHost(action.payload as HostIsolationRequestBody); + let response: HostIsolationResponse; + + if (action.payload.type === 'unisolate') { + response = await unIsolateHost(action.payload.data as HostIsolationRequestBody); + } else { + response = await isolateHost(action.payload.data as HostIsolationRequestBody); + } dispatch({ type: 'endpointIsolationRequestStateChange', diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 8b6599611ffc..f3848557567e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -32,6 +32,7 @@ import { isLoadingResourceState, } from '../../../state'; import { ServerApiError } from '../../../../common/types'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; export const listData = (state: Immutable) => state.hosts; @@ -204,6 +205,14 @@ export const uiQueryParams: ( 'admin_query', ]; + const allowedShowValues: Array = [ + 'policy_response', + 'details', + 'isolate', + 'unisolate', + 'activity_log', + ]; + for (const key of keys) { const value: string | undefined = typeof query[key] === 'string' @@ -214,13 +223,8 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if ( - value === 'policy_response' || - value === 'details' || - value === 'activity_log' || - value === 'isolate' - ) { - data[key] = value; + if (allowedShowValues.includes(value as EndpointIndexUIQueryParams['show'])) { + data[key] = value as EndpointIndexUIQueryParams['show']; } } else { data[key] = value; @@ -378,3 +382,7 @@ export const getActivityLogError: ( return activityLog.error; } }); + +export const getIsEndpointHostIsolated = createSelector(detailsData, (details) => { + return (details && isEndpointHostIsolated(details)) || false; +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index ac06f98004f5..53ddfaee7aa0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -114,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate' | 'unisolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx new file mode 100644 index 000000000000..ac1b83bdc493 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.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 React, { memo } from 'react'; +import { EuiContextMenuItem, EuiContextMenuItemProps } from '@elastic/eui'; +import { NavigateToAppOptions } from 'kibana/public'; +import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; + +export interface ContextMenuItemNavByRouterProps extends EuiContextMenuItemProps { + navigateAppId: string; + navigateOptions: NavigateToAppOptions; + children: React.ReactNode; +} + +/** + * Just like `EuiContextMenuItem`, but allows for additional props to be defined which will + * allow navigation to a URL path via React Router + */ +export const ContextMenuItemNavByRouter = memo( + ({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { + const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { + ...navigateOptions, + onClick, + }); + + return ( + + {children} + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx index 8110c5f16a89..94303c43cd4d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx @@ -9,37 +9,31 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { EuiButtonIcon, EuiContextMenuPanel, - EuiPopover, - EuiContextMenuItemProps, EuiContextMenuPanelProps, - EuiContextMenuItem, + EuiPopover, EuiPopoverProps, } from '@elastic/eui'; -import { NavigateToAppOptions } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { ContextMenuItemNavByRouter } from './context_menu_item_nav_by_rotuer'; +import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { useEndpointActionItems } from '../hooks'; export interface TableRowActionProps { - items: Array< - Omit & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - key: string; - } - >; + endpointMetadata: HostMetadata; } -export const TableRowActions = memo(({ items }) => { +export const TableRowActions = memo(({ endpointMetadata }) => { const [isOpen, setIsOpen] = useState(false); + const endpointActions = useEndpointActionItems(endpointMetadata); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => { - return items.map((itemProps) => { - return ; + return endpointActions.map((itemProps) => { + return ; }); - }, [handleCloseMenu, items]); + }, [handleCloseMenu, endpointActions]); const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => { return { 'data-test-subj': 'tableRowActionsMenuPanel' }; @@ -69,22 +63,4 @@ export const TableRowActions = memo(({ items }) => { }); TableRowActions.displayName = 'EndpointTableRowActions'; -const EuiContextMenuItemNavByRouter = memo< - EuiContextMenuItemProps & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - } ->(({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { - const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { - ...navigateOptions, - onClick, - }); - - return ( - - {children} - - ); -}); -EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; +ContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx new file mode 100644 index 000000000000..7ecbad54dbbe --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { ActionsMenu } from './actions_menu'; +import React from 'react'; +import { act } from '@testing-library/react'; +import { endpointPageHttpMock } from '../../../mocks'; +import { fireEvent } from '@testing-library/dom'; + +jest.mock('../../../../../../common/lib/kibana'); + +describe('When using the Endpoint Details Actions Menu', () => { + let render: () => Promise>; + let coreStart: AppContextTestRender['coreStart']; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + let renderResult: ReturnType; + let httpMocks: ReturnType; + + const setEndpointMetadataResponse = (isolation: boolean = false) => { + const endpointHost = httpMocks.responseProvider.metadataDetails(); + // Safe to mutate this mocked data + // @ts-ignore + endpointHost.metadata.Endpoint.state.isolation = isolation; + httpMocks.responseProvider.metadataDetails.mockReturnValue(endpointHost); + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + (useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices }); + coreStart = mockedContext.coreStart; + waitForAction = mockedContext.middlewareSpy.waitForAction; + httpMocks = endpointPageHttpMock(mockedContext.coreStart.http); + + act(() => { + mockedContext.history.push( + '/endpoints?selected_endpoint=5fe11314-678c-413e-87a2-b4a3461878ee' + ); + }); + + render = async () => { + renderResult = mockedContext.render(); + + await act(async () => { + await waitForAction('serverReturnedEndpointDetails'); + }); + + act(() => { + fireEvent.click(renderResult.getByTestId('endpointDetailsActionsButton')); + }); + + return renderResult; + }; + }); + + describe('and endpoint host is NOT isolated', () => { + beforeEach(() => setEndpointMetadataResponse()); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])('should display %s action', async (_, dataTestSubj) => { + await render(); + expect(renderResult.getByTestId(dataTestSubj)).not.toBeNull(); + }); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])( + 'should navigate via kibana `navigateToApp()` when %s is clicked', + async (_, dataTestSubj) => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId(dataTestSubj)); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + } + ); + }); + + describe('and endpoint host is isolated', () => { + beforeEach(() => setEndpointMetadataResponse(true)); + + it('should display Unisolate action', async () => { + await render(); + expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull(); + }); + + it('should navigate via router when unisolate is clicked', async () => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId('unIsolateLink')); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx new file mode 100644 index 000000000000..c778f4f2a08e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx @@ -0,0 +1,63 @@ +/* + * 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 { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useEndpointActionItems, useEndpointSelector } from '../../hooks'; +import { detailsData } from '../../../store/selectors'; +import { ContextMenuItemNavByRouter } from '../../components/context_menu_item_nav_by_rotuer'; + +export const ActionsMenu = React.memo<{}>(() => { + const endpointDetails = useEndpointSelector(detailsData); + const menuOptions = useEndpointActionItems(endpointDetails); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopoverHandler = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const takeActionItems = useMemo(() => { + return menuOptions.map((item) => { + return ; + }); + }, [closePopoverHandler, menuOptions]); + + const takeActionButton = useMemo(() => { + return ( + { + setIsPopoverOpen(!isPopoverOpen); + }} + > + + + ); + }, [isPopoverOpen]); + + return ( + + + + ); +}); + +ActionsMenu.displayName = 'ActionMenu'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx index e299a7ec5f97..289c1efeab04 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx @@ -5,17 +5,19 @@ * 2.0. */ -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { i18n } from '@kbn/i18n'; +import { EuiForm } from '@elastic/eui'; import { HostMetadata } from '../../../../../../../common/endpoint/types'; import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader'; import { EndpointIsolatedFormProps, EndpointIsolateForm, EndpointIsolateSuccess, + EndpointUnisolateForm, } from '../../../../../../common/components/endpoint/host_isolation'; import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding'; import { getEndpointDetailsPath } from '../../../../../common/routing'; @@ -25,18 +27,21 @@ import { getIsIsolationRequestPending, getWasIsolationRequestSuccessful, uiQueryParams, + getIsEndpointHostIsolated, } from '../../../store/selectors'; import { AppAction } from '../../../../../../common/store/actions'; -import { useToasts } from '../../../../../../common/lib/kibana'; -export const EndpointIsolateFlyoutPanel = memo<{ +/** + * Component handles both isolate and un-isolate for a given endpoint + */ +export const EndpointIsolationFlyoutPanel = memo<{ hostMeta: HostMetadata; }>(({ hostMeta }) => { const history = useHistory(); const dispatch = useDispatch>(); - const toast = useToasts(); const { show, ...queryParams } = useEndpointSelector(uiQueryParams); + const isCurrentlyIsolated = useEndpointSelector(getIsEndpointHostIsolated); const isPending = useEndpointSelector(getIsIsolationRequestPending); const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful); const isolateError = useEndpointSelector(getIsolationRequestError); @@ -45,6 +50,8 @@ export const EndpointIsolateFlyoutPanel = memo<{ Parameters[0] >({ comment: '' }); + const IsolationForm = isCurrentlyIsolated ? EndpointUnisolateForm : EndpointIsolateForm; + const handleCancel: EndpointIsolatedFormProps['onCancel'] = useCallback(() => { history.push( getEndpointDetailsPath({ @@ -59,11 +66,14 @@ export const EndpointIsolateFlyoutPanel = memo<{ dispatch({ type: 'endpointIsolationRequest', payload: { - endpoint_ids: [hostMeta.agent.id], - comment: formValues.comment, + type: isCurrentlyIsolated ? 'unisolate' : 'isolate', + data: { + endpoint_ids: [hostMeta.agent.id], + comment: formValues.comment, + }, }, }); - }, [dispatch, formValues.comment, hostMeta.agent.id]); + }, [dispatch, formValues.comment, hostMeta.agent.id, isCurrentlyIsolated]); const handleChange: EndpointIsolatedFormProps['onChange'] = useCallback((changes) => { setFormValues((prevState) => { @@ -74,12 +84,6 @@ export const EndpointIsolateFlyoutPanel = memo<{ }); }, []); - useEffect(() => { - if (isolateError) { - toast.addDanger(isolateError.message); - } - }, [isolateError, toast]); - return ( <> @@ -88,6 +92,7 @@ export const EndpointIsolateFlyoutPanel = memo<{ {wasSuccessful ? ( ) : ( - + + + )} ); }); -EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; +EndpointIsolationFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 8d985f3a4cfe..89c0e3e6a3e0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -11,6 +11,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, + EuiFlyoutFooter, EuiLoadingContent, EuiTitle, EuiText, @@ -55,10 +56,11 @@ import { } from './components/endpoint_details_tabs'; import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; -import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; +import { EndpointIsolationFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +import { ActionsMenu } from './components/actions_menu'; const DetailsFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; @@ -128,6 +130,9 @@ export const EndpointDetailsFlyout = memo(() => { }, ]; + const showFlyoutFooter = + show === 'details' || show === 'policy_response' || show === 'activity_log'; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -203,7 +208,15 @@ export const EndpointDetailsFlyout = memo(() => { {show === 'policy_response' && } - {show === 'isolate' && } + {(show === 'isolate' || show === 'unisolate') && ( + + )} + + {showFlyoutFooter && ( + + + + )} )} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts similarity index 89% rename from x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts rename to x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts index 5c8de0c4e0f3..4c00c00e50db 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts @@ -7,13 +7,14 @@ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; -import { EndpointState } from '../types'; +import { EndpointState } from '../../types'; +import { State } from '../../../../../common/store'; import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, -} from '../../../common/constants'; -import { State } from '../../../../common/store'; +} from '../../../../common/constants'; +import { useKibana } from '../../../../../common/lib/kibana'; + export function useEndpointSelector(selector: (state: EndpointState) => TSelected) { return useSelector(function (state: State) { return selector( @@ -38,7 +39,6 @@ export const useIngestUrl = (subpath: string): { url: string; appId: string; app }; }, [services.application, subpath]); }; - /** * Returns an object that contains Fleet app and URL information */ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts new file mode 100644 index 000000000000..a5a22b43e63d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './hooks'; +export * from './use_endpoint_action_items'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx new file mode 100644 index 000000000000..dd498ffbbcac --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -0,0 +1,155 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { MANAGEMENT_APP_ID } from '../../../../common/constants'; +import { APP_ID, SecurityPageName } from '../../../../../../common/constants'; +import { pagePathGetters } from '../../../../../../../fleet/public'; +import { getEndpointDetailsPath } from '../../../../common/routing'; +import { HostMetadata, MaybeImmutable } from '../../../../../../common/endpoint/types'; +import { useFormatUrl } from '../../../../../common/components/link_to'; +import { useEndpointSelector } from './hooks'; +import { agentPolicies, uiQueryParams } from '../../store/selectors'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { ContextMenuItemNavByRouterProps } from '../components/context_menu_item_nav_by_rotuer'; +import { isEndpointHostIsolated } from '../../../../../common/utils/validators/is_endpoint_host_isolated'; + +/** + * Returns a list (array) of actions for an individual endpoint + * @param endpointMetadata + */ +export const useEndpointActionItems = ( + endpointMetadata: MaybeImmutable | undefined +): ContextMenuItemNavByRouterProps[] => { + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + const fleetAgentPolicies = useEndpointSelector(agentPolicies); + const allCurrentUrlParams = useEndpointSelector(uiQueryParams); + const { + services: { + application: { getUrlForApp }, + }, + } = useKibana(); + + return useMemo(() => { + if (endpointMetadata) { + const isIsolated = isEndpointHostIsolated(endpointMetadata); + const endpointId = endpointMetadata.agent.id; + const endpointPolicyId = endpointMetadata.Endpoint.policy.applied.id; + const endpointHostName = endpointMetadata.host.hostname; + const fleetAgentId = endpointMetadata.elastic.agent.id; + const { + show, + selected_endpoint: _selectedEndpoint, + ...currentUrlParams + } = allCurrentUrlParams; + const endpointIsolatePath = getEndpointDetailsPath({ + name: 'endpointIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + const endpointUnIsolatePath = getEndpointDetailsPath({ + name: 'endpointUnIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + + return [ + isIsolated + ? { + 'data-test-subj': 'unIsolateLink', + icon: 'logoSecurity', + key: 'unIsolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointUnIsolatePath, + }, + href: formatUrl(endpointUnIsolatePath), + children: ( + + ), + } + : { + 'data-test-subj': 'isolateLink', + icon: 'logoSecurity', + key: 'isolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointIsolatePath, + }, + href: formatUrl(endpointIsolatePath), + children: ( + + ), + }, + { + 'data-test-subj': 'hostLink', + icon: 'logoSecurity', + key: 'hostDetailsLink', + navigateAppId: APP_ID, + navigateOptions: { path: `hosts/${endpointHostName}` }, + href: `${getUrlForApp('securitySolution')}/hosts/${endpointHostName}`, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentConfigLink', + 'data-test-subj': 'agentPolicyLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + disabled: fleetAgentPolicies[endpointPolicyId] === undefined, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentDetailsLink', + 'data-test-subj': 'agentDetailsLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + children: ( + + ), + }, + ]; + } + + return []; + }, [allCurrentUrlParams, endpointMetadata, fleetAgentPolicies, formatUrl, getUrlForApp]); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index d963682ff005..509bb7b4cf71 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -522,7 +522,7 @@ describe('when on the endpoint list page', () => { const { // eslint-disable-next-line @typescript-eslint/naming-convention host_status, - metadata: { agent, ...details }, + metadata: { agent, Endpoint, ...details }, // eslint-disable-next-line @typescript-eslint/naming-convention query_strategy_version, } = mockEndpointDetailsApiResult(); @@ -531,6 +531,13 @@ describe('when on the endpoint list page', () => { host_status, metadata: { ...details, + Endpoint: { + ...Endpoint, + state: { + ...Endpoint.state, + isolation: false, + }, + }, agent: { ...agent, id: '1', @@ -633,11 +640,10 @@ describe('when on the endpoint list page', () => { jest.clearAllMocks(); }); - it('should show the flyout', async () => { + it('should show the flyout and footer', async () => { const renderResult = await renderAndWaitForData(); - return renderResult.findByTestId('endpointDetailsFlyout').then((flyout) => { - expect(flyout).not.toBeNull(); - }); + await expect(renderResult.findByTestId('endpointDetailsFlyout')).not.toBeNull(); + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).not.toBeNull(); }); it('should display policy name value as a link', async () => { @@ -743,6 +749,11 @@ describe('when on the endpoint list page', () => { ); }); + it('should show the Take Action button', async () => { + const renderResult = await renderAndWaitForData(); + expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull(); + }); + describe('when link to reassignment in Ingest is clicked', () => { beforeEach(async () => { coreStart.application.getUrlForApp.mockReturnValue('/app/fleet'); @@ -993,18 +1004,13 @@ describe('when on the endpoint list page', () => { }); }); - it('should show error toast if isolate fails', async () => { + it('should show error if isolate fails', async () => { isolateApiMock.responseProvider.isolateHost.mockImplementation(() => { throw new Error('oh oh. something went wrong'); }); - - // coreStart.http.post.mockReset(); - // coreStart.http.post.mockRejectedValue(new Error('oh oh. something went wrong')); await confirmIsolateAndWaitForApiResponse('failure'); - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'oh oh. something went wrong' - ); + expect(renderResult.getByText('oh oh. something went wrong')).not.toBeNull(); }); it('should reset isolation state and show form again', async () => { @@ -1031,6 +1037,10 @@ describe('when on the endpoint list page', () => { ) ).toBe(true); }); + + it('should NOT show the flyout footer', async () => { + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).toBeNull(); + }); }); }); @@ -1045,9 +1055,19 @@ describe('when on the endpoint list page', () => { const { hosts, query_strategy_version: queryStrategyVersion } = mockEndpointResultList(); hostInfo = { host_status: hosts[0].host_status, - metadata: hosts[0].metadata, + metadata: { + ...hosts[0].metadata, + Endpoint: { + ...hosts[0].metadata.Endpoint, + state: { + ...hosts[0].metadata.Endpoint.state, + isolation: false, + }, + }, + }, query_strategy_version: queryStrategyVersion, }; + const packagePolicy = docGenerator.generatePolicyPackagePolicy(); packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; const agentPolicy = generator.generateAgentPolicy(); @@ -1098,6 +1118,8 @@ describe('when on the endpoint list page', () => { expect(isolateLink.getAttribute('href')).toEqual( getEndpointDetailsPath({ name: 'endpointIsolate', + page_index: '0', + page_size: '10', selected_endpoint: hostInfo.metadata.agent.id, }) ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 7e5658f7b0cb..cef6acff4e34 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -39,11 +39,7 @@ import { import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { CreateStructuredSelector } from '../../../../common/store'; import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; -import { - DEFAULT_POLL_INTERVAL, - MANAGEMENT_APP_ID, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; +import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; @@ -61,7 +57,6 @@ import { OutOfDate } from './components/out_of_date'; import { AdminSearchBar } from './components/search_bar'; import { AdministrationListPage } from '../../../components/administration_list_page'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { APP_ID } from '../../../../../common/constants'; import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; import { TableRowActions } from './components/table_row_actions'; @@ -120,7 +115,6 @@ export const EndpointList = () => { policyItemsLoading, endpointPackageVersion, endpointsExist, - agentPolicies, autoRefreshInterval, isAutoRefreshEnabled, patternsError, @@ -130,7 +124,6 @@ export const EndpointList = () => { isTransformEnabled, } = useEndpointSelector(selector); const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); - const dispatch = useDispatch<(a: EndpointAction) => void>(); // cap ability to page at 10k records. (max_result_window) const maxPageCount = totalItemCount > MAX_PAGINATED_ITEM ? MAX_PAGINATED_ITEM : totalItemCount; @@ -427,102 +420,15 @@ export const EndpointList = () => { }), actions: [ { + // eslint-disable-next-line react/display-name render: (item: HostInfo) => { - const endpointIsolatePath = getEndpointDetailsPath({ - name: 'endpointIsolate', - selected_endpoint: item.metadata.agent.id, - }); - - return ( - - ), - }, - { - 'data-test-subj': 'hostLink', - icon: 'logoSecurity', - key: 'hostDetailsLink', - navigateAppId: APP_ID, - navigateOptions: { path: `hosts/${item.metadata.host.hostname}` }, - href: `${services?.application?.getUrlForApp('securitySolution')}/hosts/${ - item.metadata.host.hostname - }`, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentConfigLink', - 'data-test-subj': 'agentPolicyLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - disabled: - agentPolicies[item.metadata.Endpoint.policy.applied.id] === undefined, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentDetailsLink', - 'data-test-subj': 'agentDetailsLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - children: ( - - ), - }, - ]} - /> - ); + return ; }, }, ], }, ]; - }, [queryParams, search, formatUrl, PAD_LEFT, services?.application, agentPolicies]); + }, [queryParams, search, formatUrl, PAD_LEFT]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist || areEndpointsEnrolling) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4c927d5094ca..982cf768db07 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20313,9 +20313,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "アプリを初期化できませんでした", "xpack.securitySolution.endpoint.list.actionmenu": "開く", "xpack.securitySolution.endpoint.list.actions": "アクション", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "エージェント詳細を表示", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "エージェントポリシーを表示", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "ホスト詳細を表示", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "エンドポイントを登録しています。進行状況を追跡するには、{agentsLink}してください。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "エージェントを表示", "xpack.securitySolution.endpoint.list.endpointVersion": "バージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 57a1b6a8751f..46f08cbed6c8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20613,9 +20613,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "应用无法初始化", "xpack.securitySolution.endpoint.list.actionmenu": "未结", "xpack.securitySolution.endpoint.list.actions": "操作", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "查看代理详情", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "查看代理策略", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "查看主机详情", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "正在注册终端。{agentsLink}以跟踪进度。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "查看代理", "xpack.securitySolution.endpoint.list.endpointVersion": "版本", diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json index b70a9d5df0eb..22f4afcf99d4 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json @@ -13,7 +13,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", @@ -81,7 +87,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "963b081e-60d1-482c-befd-a5815fa8290f", @@ -152,7 +164,13 @@ "status": "success" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "b3412d6f-b022-4448-8fee-21cc936ea86b",