diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 8f9a5abe2918b..e8997158cdfad 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -9,10 +9,14 @@ import { schema } from '@kbn/config-schema'; export const HostIsolationRequestSchema = { body: schema.object({ - agent_ids: schema.nullable(schema.arrayOf(schema.string())), - endpoint_ids: schema.nullable(schema.arrayOf(schema.string())), - alert_ids: schema.nullable(schema.arrayOf(schema.string())), - case_ids: schema.nullable(schema.arrayOf(schema.string())), - comment: schema.nullable(schema.string()), + /** A list of Fleet Agent IDs whose hosts will be isolated */ + agent_ids: schema.maybe(schema.arrayOf(schema.string())), + /** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */ + endpoint_ids: schema.maybe(schema.arrayOf(schema.string())), + /** If defined, any case associated with the given IDs will be updated */ + alert_ids: schema.maybe(schema.arrayOf(schema.string())), + /** Case IDs to be updated */ + case_ids: schema.maybe(schema.arrayOf(schema.string())), + comment: schema.maybe(schema.string()), }), }; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index a14d26c16aaaf..99dac5ea5cda6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -5,6 +5,9 @@ * 2.0. */ +import { TypeOf } from '@kbn/config-schema'; +import { HostIsolationRequestSchema } from '../schema/actions'; + export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; export interface EndpointAction { @@ -21,7 +24,8 @@ export interface EndpointAction { }; } +export type HostIsolationRequestBody = TypeOf; + export interface HostIsolationResponse { - action?: string; - message?: string; + action: string; } diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx index dd26f676c1fe3..a66d1d05025cb 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx @@ -70,12 +70,22 @@ export const EndpointIsolateForm = memo( - + {CANCEL} - + {CONFIRM} diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx index ee70a4526f5ac..32ac1177d6e80 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx @@ -20,13 +20,22 @@ export const EndpointIsolateSuccess = memo( ({ hostName, onComplete, completeButtonLabel, additionalInfo }) => { return ( <> - + {additionalInfo} - +

{completeButtonLabel}

diff --git a/x-pack/plugins/security_solution/public/common/lib/host_isolation/index.test.ts b/x-pack/plugins/security_solution/public/common/lib/host_isolation/index.test.ts new file mode 100644 index 0000000000000..690a3e3e196fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/host_isolation/index.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { KibanaServices } from '../kibana'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { isolateHost, unIsolateHost } from './index'; +import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants'; +import { hostIsolationRequestBodyMock } from './mocks'; + +jest.mock('../kibana'); + +describe('When using Host Isolation library', () => { + const mockKibanaServices = KibanaServices.get as jest.Mock; + + beforeEach(() => { + mockKibanaServices.mockReturnValue(coreMock.createStart({ basePath: '/mock' })); + }); + + it('should send an isolate POST request', async () => { + const requestBody = hostIsolationRequestBodyMock(); + await isolateHost(requestBody); + + expect(mockKibanaServices().http.post).toHaveBeenCalledWith(ISOLATE_HOST_ROUTE, { + body: JSON.stringify(requestBody), + }); + }); + + it('should send an un-isolate POST request', async () => { + const requestBody = hostIsolationRequestBodyMock(); + await unIsolateHost(requestBody); + + expect(mockKibanaServices().http.post).toHaveBeenCalledWith(UNISOLATE_HOST_ROUTE, { + body: JSON.stringify(requestBody), + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/host_isolation/index.ts b/x-pack/plugins/security_solution/public/common/lib/host_isolation/index.ts new file mode 100644 index 0000000000000..c3836629bcf08 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/host_isolation/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { HostIsolationRequestBody, HostIsolationResponse } from '../../../../common/endpoint/types'; +import { KibanaServices } from '../kibana'; +import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants'; + +/** Isolates a Host running either elastic endpoint or fleet agent */ +export const isolateHost = async ( + params: HostIsolationRequestBody +): Promise => { + return KibanaServices.get().http.post(ISOLATE_HOST_ROUTE, { + body: JSON.stringify(params), + }); +}; + +/** Un-isolates a Host running either elastic endpoint or fleet agent */ +export const unIsolateHost = async ( + params: HostIsolationRequestBody +): Promise => { + return KibanaServices.get().http.post(UNISOLATE_HOST_ROUTE, { + body: JSON.stringify(params), + }); +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/host_isolation/mocks.ts b/x-pack/plugins/security_solution/public/common/lib/host_isolation/mocks.ts new file mode 100644 index 0000000000000..6198918496316 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/host_isolation/mocks.ts @@ -0,0 +1,49 @@ +/* + * 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 { HostIsolationRequestBody, HostIsolationResponse } from '../../../../common/endpoint/types'; +import { + httpHandlerMockFactory, + ResponseProvidersInterface, +} from '../../mock/endpoint/http_handler_mock_factory'; +import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants'; + +export const hostIsolationRequestBodyMock = (): HostIsolationRequestBody => { + return { + agent_ids: ['fd8a122b-4c54-4c05-b295-111'], + endpoint_ids: ['88c04a90-b19c-11eb-b838-222'], + alert_ids: ['88c04a90-b19c-11eb-b838-333'], + case_ids: ['88c04a90-b19c-11eb-b838-444'], + comment: 'Lock it', + }; +}; + +export const hostIsolationResponseMock = (): HostIsolationResponse => { + return { + action: '111-222-333-444', + }; +}; + +export type HostIsolationHttpMockProviders = ResponseProvidersInterface<{ + isolateHost: () => HostIsolationResponse; + unIsolateHost: () => HostIsolationResponse; +}>; + +export const hostIsolationHttpMocks = httpHandlerMockFactory([ + { + id: 'isolateHost', + method: 'post', + path: ISOLATE_HOST_ROUTE, + handler: () => hostIsolationResponseMock(), + }, + { + id: 'unIsolateHost', + method: 'post', + path: UNISOLATE_HOST_ROUTE, + handler: () => hostIsolationResponseMock(), + }, +]); 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 37e60775a7195..ae2cc59de6abf 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 @@ -10,16 +10,19 @@ import { createMemoryHistory } from 'history'; import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; import { Action, Reducer, Store } from 'redux'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { StartPlugins } from '../../../types'; +import { StartPlugins, StartServices } from '../../../types'; import { depsStartMock } from './dependencies_start_mock'; import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils'; import { kibanaObservable } from '../test_providers'; import { createStore, State } from '../../store'; import { AppRootProvider } from './app_root_provider'; import { managementMiddlewareFactory } from '../../../management/store/middleware'; -import { createKibanaContextProviderMock } from '../../lib/kibana/kibana_react.mock'; +import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock'; import { SUB_PLUGINS_REDUCER, mockGlobalState, createSecuritySolutionStorageMock } from '..'; import { ExperimentalFeatures } from '../../../../common/experimental_features'; +import { PLUGIN_ID } from '../../../../../fleet/common'; +import { APP_ID } from '../../../../common/constants'; +import { KibanaContextProvider } from '../../lib/kibana'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -31,6 +34,7 @@ export interface AppContextTestRender { history: ReturnType; coreStart: ReturnType; depsStart: Pick; + startServices: StartServices; middlewareSpy: MiddlewareActionSpyHelper; /** * A wrapper around `AppRootContext` component. Uses the mocked modules as input to the @@ -87,10 +91,14 @@ const experimentalFeaturesReducer: Reducer { const history = createMemoryHistory(); - const coreStart = coreMock.createStart({ basePath: '/mock' }); + const coreStart = createCoreStartMock(); const depsStart = depsStartMock(); const middlewareSpy = createSpyMiddleware(); const { storage } = createSecuritySolutionStorageMock(); + const startServices: StartServices = { + ...createStartServicesMock(), + ...coreStart, + }; const storeReducer = { ...SUB_PLUGINS_REDUCER, @@ -104,14 +112,12 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { middlewareSpy.actionSpyMiddleware, ]); - const MockKibanaContextProvider = createKibanaContextProviderMock(); - const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( - + {children} - + ); const render: UiRender = (ui, options) => { return reactRender(ui, { @@ -132,9 +138,28 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { history, coreStart, depsStart, + startServices, middlewareSpy, AppWrapper, render, setExperimentalFlag, }; }; + +const createCoreStartMock = (): ReturnType => { + const coreStart = coreMock.createStart({ basePath: '/mock' }); + + // Mock the certain APP Ids returned by `application.getUrlForApp()` + coreStart.application.getUrlForApp.mockImplementation((appId) => { + switch (appId) { + case PLUGIN_ID: + return '/app/fleet'; + case APP_ID: + return '/app/security'; + default: + return `${appId} not mocked!`; + } + }); + + return coreStart; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts index 9aa5cfd229292..0c3159e0719e6 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts @@ -22,13 +22,15 @@ import { createSignalIndex, createHostIsolation, } from './api'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; jest.mock('../../../../common/lib/kibana'); -const fetchMock = jest.fn(); -mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); +const coreStartMock = coreMock.createStart({ basePath: '/mock' }); +mockKibanaServices.mockReturnValue(coreStartMock); +const fetchMock = coreStartMock.http.fetch; describe('Detections Alerts API', () => { describe('fetchQueryAlerts', () => { @@ -167,9 +169,11 @@ describe('Detections Alerts API', () => { }); describe('createHostIsolation', () => { + const postMock = coreStartMock.http.post; + beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(mockHostIsolation); + postMock.mockClear(); + postMock.mockResolvedValue(mockHostIsolation); }); test('check parameter url', async () => { @@ -178,8 +182,7 @@ describe('Detections Alerts API', () => { comment: 'commento', caseIds: ['88c04a90-b19c-11eb-b838-bf3c7840b969'], }); - expect(fetchMock).toHaveBeenCalledWith('/api/endpoint/isolate', { - method: 'POST', + expect(postMock).toHaveBeenCalledWith('/api/endpoint/isolate', { body: '{"agent_ids":["fd8a122b-4c54-4c05-b295-e5f8381fc59d"],"comment":"commento","case_ids":["88c04a90-b19c-11eb-b838-bf3c7840b969"]}', }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 300005b23caaa..28a6076421e83 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -14,7 +14,6 @@ import { DETECTION_ENGINE_INDEX_URL, DETECTION_ENGINE_PRIVILEGES_URL, } from '../../../../../common/constants'; -import { ISOLATE_HOST_ROUTE } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; import { BasicSignals, @@ -25,6 +24,7 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; +import { isolateHost } from '../../../../common/lib/host_isolation'; /** * Fetch Alerts by providing a query @@ -124,13 +124,10 @@ export const createHostIsolation = async ({ comment?: string; caseIds?: string[]; }): Promise => - KibanaServices.get().http.fetch(ISOLATE_HOST_ROUTE, { - method: 'POST', - body: JSON.stringify({ - agent_ids: [agentId], - comment, - case_ids: caseIds, - }), + isolateHost({ + agent_ids: [agentId], + comment, + case_ids: caseIds, }); /** 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 32079d97d24e1..5bafecb8c4ff5 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -43,12 +43,15 @@ const querystringStringify = ( type EndpointDetailsUrlProps = Omit & Required>; +/** URL search params that are only applicable to the list page */ +type EndpointListUrlProps = Omit; + export const getEndpointListPath = ( - props: { name: 'default' | 'endpointList' } & EndpointIndexUIQueryParams, + props: { name: 'default' | 'endpointList' } & EndpointListUrlProps, search?: string ) => { const { name, ...queryParams } = props; - const urlQueryParams = querystringStringify( + const urlQueryParams = querystringStringify( queryParams ); const urlSearch = `${urlQueryParams && !isEmpty(search) ? '&' : ''}${search ?? ''}`; @@ -62,14 +65,25 @@ export const getEndpointListPath = ( }; export const getEndpointDetailsPath = ( - props: { name: 'endpointDetails' | 'endpointPolicyResponse' } & EndpointIndexUIQueryParams & + props: { + name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate'; + } & EndpointIndexUIQueryParams & EndpointDetailsUrlProps, search?: string ) => { - const { name, ...queryParams } = props; - queryParams.show = (props.name === 'endpointPolicyResponse' - ? 'policy_response' - : '') as EndpointIndexUIQueryParams['show']; + const { name, show, ...rest } = props; + + const queryParams: EndpointDetailsUrlProps = { ...rest }; + + switch (props.name) { + case 'endpointIsolate': + queryParams.show = 'isolate'; + break; + case 'endpointPolicyResponse': + queryParams.show = 'policy_response'; + break; + } + const urlQueryParams = querystringStringify( queryParams ); 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 e9161703550b6..9a57b2f4a1cd5 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 @@ -5,10 +5,12 @@ * 2.0. */ +import { Action } from 'redux'; import { HostResultList, HostInfo, GetHostPolicyResponse, + HostIsolationRequestBody, } from '../../../../../common/endpoint/types'; import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; @@ -134,6 +136,14 @@ interface ServerFailedToReturnEndpointsTotal { payload: ServerApiError; } +type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { + payload: HostIsolationRequestBody; +}; + +type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { + payload: EndpointState['isolationRequestState']; +}; + export type EndpointAction = | ServerReturnedEndpointList | ServerFailedToReturnEndpointList @@ -157,4 +167,6 @@ export type EndpointAction = | UserUpdatedEndpointListRefreshOptions | ServerReturnedEndpointsTotal | ServerFailedToReturnAgenstWithEndpointsTotal - | ServerFailedToReturnEndpointsTotal; + | ServerFailedToReturnEndpointsTotal + | EndpointIsolationRequest + | EndpointIsolationRequestStateChange; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 4970392a06076..79f0c5af9bbe3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -65,6 +65,9 @@ describe('EndpointList store concerns', () => { endpointsTotalError: undefined, queryStrategyVersion: undefined, policyVersionInfo: undefined, + isolationRequestState: { + type: 'UninitialisedResourceState', + }, }); }); 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 1bd6e6d4e6415..c52d922001887 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 @@ -9,14 +9,16 @@ import { CoreStart, HttpSetup } from 'kibana/public'; import { applyMiddleware, createStore, Store } from 'redux'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { History, createBrowserHistory } from 'history'; - import { DepsStartMock, depsStartMock } from '../../../../common/mock/endpoint'; - import { createSpyMiddleware, MiddlewareActionSpyHelper, } from '../../../../common/store/test_utils'; -import { Immutable, HostResultList } from '../../../../../common/endpoint/types'; +import { + Immutable, + HostResultList, + HostIsolationResponse, +} from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; import { listData } from './selectors'; @@ -24,6 +26,19 @@ import { EndpointState } from '../types'; import { endpointListReducer } from './reducer'; import { endpointMiddlewareFactory } from './middleware'; import { getEndpointListPath } from '../../../common/routing'; +import { + FailedResourceState, + isFailedResourceState, + isLoadedResourceState, + isLoadingResourceState, + LoadedResourceState, +} from '../../../state'; +import { KibanaServices } from '../../../../common/lib/kibana'; +import { + hostIsolationHttpMocks, + hostIsolationRequestBodyMock, + hostIsolationResponseMock, +} from '../../../../common/lib/host_isolation/mocks'; jest.mock('../../policy/store/services/ingest', () => ({ sendGetAgentConfigList: () => Promise.resolve({ items: [] }), @@ -31,21 +46,25 @@ jest.mock('../../policy/store/services/ingest', () => ({ sendGetEndpointSecurityPackage: () => Promise.resolve({}), })); +jest.mock('../../../../common/lib/kibana'); + +type EndpointListStore = Store, Immutable>; + describe('endpoint list middleware', () => { let fakeCoreStart: jest.Mocked; let depsStart: DepsStartMock; let fakeHttpServices: jest.Mocked; - type EndpointListStore = Store, Immutable>; let store: EndpointListStore; let getState: EndpointListStore['getState']; let dispatch: EndpointListStore['dispatch']; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; let actionSpyMiddleware; - let history: History; + const getEndpointListApiResponse = (): HostResultList => { return mockEndpointResultList({ request_page_size: 1, request_page_index: 1, total: 10 }); }; + beforeEach(() => { fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); depsStart = depsStartMock(); @@ -59,6 +78,7 @@ describe('endpoint list middleware', () => { dispatch = store.dispatch; history = createBrowserHistory(); }); + it('handles `userChangedUrl`', async () => { const apiResponse = getEndpointListApiResponse(); fakeHttpServices.post.mockResolvedValue(apiResponse); @@ -109,4 +129,67 @@ describe('endpoint list middleware', () => { }); expect(listData(getState())).toEqual(apiResponse.hosts); }); + + describe('handling of IsolateEndpointHost action', () => { + const getKibanaServicesMock = KibanaServices.get as jest.Mock; + const dispatchIsolateEndpointHost = () => { + dispatch({ + type: 'endpointIsolationRequest', + payload: hostIsolationRequestBodyMock(), + }); + }; + let isolateApiResponseHandlers: ReturnType; + + beforeEach(() => { + isolateApiResponseHandlers = hostIsolationHttpMocks(fakeHttpServices); + getKibanaServicesMock.mockReturnValue(fakeCoreStart); + }); + + it('should set Isolation state to loading', async () => { + const loadingDispatched = waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadingResourceState(action.payload); + }, + }); + + dispatchIsolateEndpointHost(); + expect(await loadingDispatched).not.toBeFalsy(); + }); + + it('should call isolate api', async () => { + dispatchIsolateEndpointHost(); + expect(fakeHttpServices.post).toHaveBeenCalled(); + }); + + it('should set Isolation state to loaded if api is successful', async () => { + const loadedDispatched = waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + dispatchIsolateEndpointHost(); + expect( + ((await loadedDispatched).payload as LoadedResourceState).data + ).toEqual(hostIsolationResponseMock()); + }); + + it('should set Isolation to Failed if api failed', async () => { + const apiError = new Error('oh oh'); + const failedDispatched = waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isFailedResourceState(action.payload); + }, + }); + + isolateApiResponseHandlers.responseProvider.isolateHost.mockImplementation(() => { + throw apiError; + }); + dispatchIsolateEndpointHost(); + + const failedAction = (await failedDispatched) + .payload as FailedResourceState; + expect(failedAction.error).toBe(apiError); + }); + }); }); 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 a8be2c94b202f..ffde8b0931752 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 @@ -6,9 +6,15 @@ */ import { HttpStart } from 'kibana/public'; -import { HostInfo, HostResultList } from '../../../../../common/endpoint/types'; +import { + HostInfo, + HostIsolationRequestBody, + HostIsolationResponse, + HostResultList, + Immutable, +} from '../../../../../common/endpoint/types'; import { GetPolicyListResponse } from '../../policy/types'; -import { ImmutableMiddlewareFactory } from '../../../../common/store'; +import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../common/store'; import { isOnEndpointPage, hasSelectedEndpoint, @@ -19,6 +25,8 @@ import { patterns, searchBarQuery, isTransformEnabled, + getIsIsolationRequestPending, + getCurrentIsolationRequestState, } from './selectors'; import { EndpointState, PolicyIds } from '../types'; import { @@ -30,6 +38,15 @@ import { import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { metadataCurrentIndexPattern } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; +import { + createFailedResourceState, + createLoadedResourceState, + createLoadingResourceState, +} from '../../../state'; +import { isolateHost } from '../../../../common/lib/host_isolation'; +import { AppAction } from '../../../../common/store/actions'; + +type EndpointPageStore = ImmutableMiddlewareAPI; export const endpointMiddlewareFactory: ImmutableMiddlewareFactory = ( coreStart, @@ -47,9 +64,11 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory (next) => async (action) => { + return (store) => (next) => async (action) => { next(action); + const { getState, dispatch } = store; + // Endpoint list if ( (action.type === 'userChangedUrl' || action.type === 'appRequestedEndpointList') && @@ -328,6 +347,11 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory => { } return false; }; + +const handleIsolateEndpointHost = async ( + { getState, dispatch }: EndpointPageStore, + action: Immutable +) => { + const state = getState(); + + if (getIsIsolationRequestPending(state)) { + return; + } + + dispatch({ + type: 'endpointIsolationRequestStateChange', + // Ignore will be fixed with when AsyncResourceState is refactored (#830) + // @ts-ignore + payload: createLoadingResourceState(getCurrentIsolationRequestState(state)), + }); + + try { + // Cast needed below due to the value of payload being `Immutable<>` + const response = await isolateHost(action.payload as HostIsolationRequestBody); + + dispatch({ + type: 'endpointIsolationRequestStateChange', + payload: createLoadedResourceState(response), + }); + } catch (error) { + dispatch({ + type: 'endpointIsolationRequestStateChange', + payload: createFailedResourceState(error.body ?? error), + }); + } +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 4547ae3b34243..b2b46e6de9842 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -5,12 +5,18 @@ * 2.0. */ -import { isOnEndpointPage, hasSelectedEndpoint } from './selectors'; +import { + isOnEndpointPage, + hasSelectedEndpoint, + uiQueryParams, + getCurrentIsolationRequestState, +} from './selectors'; import { EndpointState } from '../types'; import { AppAction } from '../../../../common/store/actions'; import { ImmutableReducer } from '../../../../common/store'; import { Immutable } from '../../../../../common/endpoint/types'; import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; +import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state'; export const initialEndpointListState: Immutable = { hosts: [], @@ -44,6 +50,7 @@ export const initialEndpointListState: Immutable = { queryStrategyVersion: undefined, policyVersionInfo: undefined, hostStatus: undefined, + isolationRequestState: createUninitialisedResourceState(), }; /* eslint-disable-next-line complexity */ @@ -199,6 +206,8 @@ export const endpointListReducer: ImmutableReducer = ( isAutoRefreshEnabled: action.payload.isAutoRefreshEnabled ?? state.isAutoRefreshEnabled, autoRefreshInterval: action.payload.autoRefreshInterval ?? state.autoRefreshInterval, }; + } else if (action.type === 'endpointIsolationRequestStateChange') { + return handleEndpointIsolationRequestStateChanged(state, action); } else if (action.type === 'userChangedUrl') { const newState: Immutable = { ...state, @@ -209,16 +218,29 @@ export const endpointListReducer: ImmutableReducer = ( const isCurrentlyOnDetailsPage = isOnEndpointPage(newState) && hasSelectedEndpoint(newState); const wasPreviouslyOnDetailsPage = isOnEndpointPage(state) && hasSelectedEndpoint(state); + const stateUpdates: Partial = { + location: action.payload, + error: undefined, + detailsError: undefined, + policyResponseError: undefined, + }; + + // Reset `isolationRequestState` if needed + if ( + uiQueryParams(newState).show !== 'isolate' && + !isUninitialisedResourceState(getCurrentIsolationRequestState(newState)) + ) { + stateUpdates.isolationRequestState = createUninitialisedResourceState(); + } + // if on the endpoint list page for the first time, return new location and load list if (isCurrentlyOnListPage) { if (!wasPreviouslyOnListPage) { return { ...state, - location: action.payload, + ...stateUpdates, loading: true, policyItemsLoading: true, - error: undefined, - detailsError: undefined, }; } } else if (isCurrentlyOnDetailsPage) { @@ -226,24 +248,18 @@ export const endpointListReducer: ImmutableReducer = ( if (wasPreviouslyOnDetailsPage || wasPreviouslyOnListPage) { return { ...state, - location: action.payload, + ...stateUpdates, detailsLoading: true, policyResponseLoading: true, - error: undefined, - detailsError: undefined, - policyResponseError: undefined, }; } else { // if previous page was not endpoint list or endpoint details, load both list and details return { ...state, - location: action.payload, + ...stateUpdates, loading: true, detailsLoading: true, policyResponseLoading: true, - error: undefined, - detailsError: undefined, - policyResponseError: undefined, policyItemsLoading: true, }; } @@ -251,12 +267,20 @@ export const endpointListReducer: ImmutableReducer = ( // otherwise we are not on a endpoint list or details page return { ...state, - location: action.payload, - error: undefined, - detailsError: undefined, - policyResponseError: undefined, + ...stateUpdates, endpointsExist: true, }; } + return state; }; + +const handleEndpointIsolationRequestStateChanged: ImmutableReducer< + EndpointState, + AppAction & { type: 'endpointIsolationRequestStateChange' } +> = (state, action) => { + return { + ...state!, + isolationRequestState: action.payload, + }; +}; 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 cd43d72dea8e2..af95d89fdc10b 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 @@ -26,6 +26,12 @@ import { MANAGEMENT_ROUTING_ENDPOINTS_PATH, } from '../../../common/constants'; import { Query } from '../../../../../../../../src/plugins/data/common/query/types'; +import { + isFailedResourceState, + isLoadedResourceState, + isLoadingResourceState, +} from '../../../state'; +import { ServerApiError } from '../../../../common/types'; export const listData = (state: Immutable) => state.hosts; @@ -171,6 +177,7 @@ export const isOnEndpointPage = (state: Immutable) => { ); }; +/** Sanitized list of URL query params supported by the Details page */ export const uiQueryParams: ( state: Immutable ) => Immutable = createSelector( @@ -202,7 +209,7 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if (value === 'policy_response' || value === 'details') { + if (value === 'policy_response' || value === 'details' || value === 'isolate') { data[key] = value; } } else { @@ -227,12 +234,11 @@ export const hasSelectedEndpoint: (state: Immutable) => boolean = ); /** What policy details panel view to show */ -export const showView: (state: EndpointState) => 'policy_response' | 'details' = createSelector( - uiQueryParams, - (searchParams) => { - return searchParams.show === 'policy_response' ? 'policy_response' : 'details'; - } -); +export const showView: ( + state: EndpointState +) => EndpointIndexUIQueryParams['show'] = createSelector(uiQueryParams, (searchParams) => { + return searchParams.show ?? 'details'; +}); /** * Returns the Host Status which is connected the fleet agent @@ -299,3 +305,29 @@ export const searchBarQuery: (state: Immutable) => Query = create return decodedQuery; } ); + +export const getCurrentIsolationRequestState = ( + state: Immutable +): EndpointState['isolationRequestState'] => { + return state.isolationRequestState; +}; + +export const getIsIsolationRequestPending: ( + state: Immutable +) => boolean = createSelector(getCurrentIsolationRequestState, (isolateHost) => + isLoadingResourceState(isolateHost) +); + +export const getWasIsolationRequestSuccessful: ( + state: Immutable +) => boolean = createSelector(getCurrentIsolationRequestState, (isolateHost) => + isLoadedResourceState(isolateHost) +); + +export const getIsolationRequestError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(getCurrentIsolationRequestState, (isolateHost) => { + if (isFailedResourceState(isolateHost)) { + return isolateHost.error; + } +}); 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 7e989276edeb6..74eee0602722b 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 @@ -14,10 +14,12 @@ import { PolicyData, MetadataQueryStrategyVersions, HostStatus, + HostIsolationResponse, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; import { GetPackagesResponse } from '../../../../../fleet/common'; import { IIndexPattern } from '../../../../../../../src/plugins/data/public'; +import { AsyncResourceState } from '../../state'; export interface EndpointState { /** list of host **/ @@ -80,9 +82,10 @@ export interface EndpointState { queryStrategyVersion?: MetadataQueryStrategyVersions; /** The policy IDs and revision number of the corresponding agent, and endpoint. May be more recent than what's running */ policyVersionInfo?: HostInfo['policy_info']; - /** The status of the host, which is mapped to the Elastic Agent status in Fleet - */ + /** The status of the host, which is mapped to the Elastic Agent status in Fleet */ hostStatus?: HostStatus; + /* Host isolation state */ + isolationRequestState: AsyncResourceState; } /** @@ -105,7 +108,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'details'; + show?: 'policy_response' | 'details' | 'isolate'; /** Query text from search bar*/ admin_query?: string; } 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 new file mode 100644 index 0000000000000..8110c5f16a892 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.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, { memo, useCallback, useMemo, useState } from 'react'; +import { + EuiButtonIcon, + EuiContextMenuPanel, + EuiPopover, + EuiContextMenuItemProps, + EuiContextMenuPanelProps, + EuiContextMenuItem, + 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'; + +export interface TableRowActionProps { + items: Array< + Omit & { + navigateAppId: string; + navigateOptions: NavigateToAppOptions; + children: React.ReactNode; + key: string; + } + >; +} + +export const TableRowActions = memo(({ items }) => { + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => { + return items.map((itemProps) => { + return ; + }); + }, [handleCloseMenu, items]); + + const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => { + return { 'data-test-subj': 'tableRowActionsMenuPanel' }; + }, []); + + return ( + + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + + + ); +}); +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'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/back_to_endpoint_details_flyout_subheader.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/back_to_endpoint_details_flyout_subheader.tsx new file mode 100644 index 0000000000000..7218e794f587a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/back_to_endpoint_details_flyout_subheader.tsx @@ -0,0 +1,53 @@ +/* + * 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, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FlyoutSubHeader, FlyoutSubHeaderProps } from './flyout_sub_header'; +import { getEndpointDetailsPath } from '../../../../../common/routing'; +import { useNavigateByRouterEventHandler } from '../../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; +import { useFormatUrl } from '../../../../../../common/components/link_to'; +import { SecurityPageName } from '../../../../../../../common/constants'; +import { useEndpointSelector } from '../../hooks'; +import { uiQueryParams } from '../../../store/selectors'; + +export const BackToEndpointDetailsFlyoutSubHeader = memo<{ endpointId: string }>( + ({ endpointId }) => { + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + const { show, ...currentUrlQueryParams } = useEndpointSelector(uiQueryParams); + + const detailsRoutePath = useMemo( + () => + getEndpointDetailsPath({ + name: 'endpointDetails', + ...currentUrlQueryParams, + selected_endpoint: endpointId, + }), + [currentUrlQueryParams, endpointId] + ); + + const backToDetailsClickHandler = useNavigateByRouterEventHandler(detailsRoutePath); + + const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => { + return { + title: i18n.translate('xpack.securitySolution.endpoint.policyResponse.backLinkTitle', { + defaultMessage: 'Endpoint Details', + }), + href: formatUrl(detailsRoutePath), + onClick: backToDetailsClickHandler, + }; + }, [backToDetailsClickHandler, detailsRoutePath, formatUrl]); + + return ( + + ); + } +); +BackToEndpointDetailsFlyoutSubHeader.displayName = 'BackToEndpointDetailsFlyoutSubHeader'; 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 new file mode 100644 index 0000000000000..e299a7ec5f973 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx @@ -0,0 +1,111 @@ +/* + * 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, useCallback, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import { i18n } from '@kbn/i18n'; +import { HostMetadata } from '../../../../../../../common/endpoint/types'; +import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader'; +import { + EndpointIsolatedFormProps, + EndpointIsolateForm, + EndpointIsolateSuccess, +} from '../../../../../../common/components/endpoint/host_isolation'; +import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding'; +import { getEndpointDetailsPath } from '../../../../../common/routing'; +import { useEndpointSelector } from '../../hooks'; +import { + getIsolationRequestError, + getIsIsolationRequestPending, + getWasIsolationRequestSuccessful, + uiQueryParams, +} from '../../../store/selectors'; +import { AppAction } from '../../../../../../common/store/actions'; +import { useToasts } from '../../../../../../common/lib/kibana'; + +export const EndpointIsolateFlyoutPanel = memo<{ + hostMeta: HostMetadata; +}>(({ hostMeta }) => { + const history = useHistory(); + const dispatch = useDispatch>(); + const toast = useToasts(); + + const { show, ...queryParams } = useEndpointSelector(uiQueryParams); + const isPending = useEndpointSelector(getIsIsolationRequestPending); + const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful); + const isolateError = useEndpointSelector(getIsolationRequestError); + + const [formValues, setFormValues] = useState< + Parameters[0] + >({ comment: '' }); + + const handleCancel: EndpointIsolatedFormProps['onCancel'] = useCallback(() => { + history.push( + getEndpointDetailsPath({ + name: 'endpointDetails', + ...queryParams, + selected_endpoint: hostMeta.agent.id, + }) + ); + }, [history, hostMeta.agent.id, queryParams]); + + const handleConfirm: EndpointIsolatedFormProps['onConfirm'] = useCallback(() => { + dispatch({ + type: 'endpointIsolationRequest', + payload: { + endpoint_ids: [hostMeta.agent.id], + comment: formValues.comment, + }, + }); + }, [dispatch, formValues.comment, hostMeta.agent.id]); + + const handleChange: EndpointIsolatedFormProps['onChange'] = useCallback((changes) => { + setFormValues((prevState) => { + return { + ...prevState, + ...changes, + }; + }); + }, []); + + useEffect(() => { + if (isolateError) { + toast.addDanger(isolateError.message); + } + }, [isolateError, toast]); + + return ( + <> + + + + {wasSuccessful ? ( + + ) : ( + + )} + + + ); +}); +EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/flyout_body_no_top_padding.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/flyout_body_no_top_padding.tsx new file mode 100644 index 0000000000000..58bb361a0d88b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/flyout_body_no_top_padding.tsx @@ -0,0 +1,19 @@ +/* + * 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 styled from 'styled-components'; +import { EuiFlyoutBody } from '@elastic/eui'; + +/** + * Removes the `padding-top` from the `EuiFlyoutBody` component. Normally done when there is a + * sub-header present above the flyout body. + */ +export const FlyoutBodyNoTopPadding = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflowContent { + padding-top: 0; + } +`; 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 e136b63579359..09b1bbceef21d 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, memo, useMemo } from 'react'; +import React, { useCallback, useEffect, memo } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -20,10 +20,8 @@ import { import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; import { useToasts } from '../../../../../common/lib/kibana'; import { useEndpointSelector } from '../hooks'; -import { urlFromQueryParams } from '../url_from_query_params'; import { uiQueryParams, detailsData, @@ -43,12 +41,11 @@ import { import { EndpointDetails } from './endpoint_details'; import { PolicyResponse } from './policy_response'; import { HostMetadata } from '../../../../../../common/endpoint/types'; -import { FlyoutSubHeader, FlyoutSubHeaderProps } from './components/flyout_sub_header'; -import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; -import { getEndpointListPath } from '../../../../common/routing'; -import { SecurityPageName } from '../../../../../app/types'; -import { useFormatUrl } from '../../../../../common/components/link_to'; import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; +import { EndpointIsolateFlyoutPanel } 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'; export const EndpointDetailsFlyout = memo(() => { const history = useHistory(); @@ -66,7 +63,13 @@ export const EndpointDetailsFlyout = memo(() => { const show = useEndpointSelector(showView); const handleFlyoutClose = useCallback(() => { - history.push(urlFromQueryParams(queryParamsWithoutSelectedEndpoint)); + const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; + history.push( + getEndpointListPath({ + name: 'endpointList', + ...urlSearchParams, + }) + ); }, [history, queryParamsWithoutSelectedEndpoint]); useEffect(() => { @@ -106,25 +109,20 @@ export const EndpointDetailsFlyout = memo(() => { )} {details === undefined ? ( - <> - - - - + + + ) : ( <> {show === 'details' && ( - <> - - - - + + + )} + {show === 'policy_response' && } + + {show === 'isolate' && } )} @@ -133,59 +131,22 @@ export const EndpointDetailsFlyout = memo(() => { EndpointDetailsFlyout.displayName = 'EndpointDetailsFlyout'; -const PolicyResponseFlyoutBody = styled(EuiFlyoutBody)` - .euiFlyoutBody__overflowContent { - padding-top: 0; - } -`; - const PolicyResponseFlyoutPanel = memo<{ hostMeta: HostMetadata; }>(({ hostMeta }) => { - const { show, ...queryParams } = useEndpointSelector(uiQueryParams); const responseConfig = useEndpointSelector(policyResponseConfigurations); const responseActions = useEndpointSelector(policyResponseActions); const responseAttentionCount = useEndpointSelector(policyResponseFailedOrWarningActionCount); const loading = useEndpointSelector(policyResponseLoading); const error = useEndpointSelector(policyResponseError); - const { formatUrl } = useFormatUrl(SecurityPageName.administration); const responseTimestamp = useEndpointSelector(policyResponseTimestamp); const responsePolicyRevisionNumber = useEndpointSelector(policyResponseAppliedRevision); - const [detailsUri, detailsRoutePath] = useMemo( - () => [ - formatUrl( - getEndpointListPath({ - name: 'endpointList', - ...queryParams, - selected_endpoint: hostMeta.agent.id, - }) - ), - getEndpointListPath({ - name: 'endpointList', - ...queryParams, - selected_endpoint: hostMeta.agent.id, - }), - ], - [hostMeta.agent.id, formatUrl, queryParams] - ); - const backToDetailsClickHandler = useNavigateByRouterEventHandler(detailsRoutePath); - const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => { - return { - title: i18n.translate('xpack.securitySolution.endpoint.policyResponse.backLinkTitle', { - defaultMessage: 'Endpoint Details', - }), - href: detailsUri, - onClick: backToDetailsClickHandler, - }; - }, [backToDetailsClickHandler, detailsUri]); return ( <> - - + + @@ -227,7 +188,7 @@ const PolicyResponseFlyoutPanel = memo<{ responseAttentionCount={responseAttentionCount} /> )} - + ); }); 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 304d67d8b6d6f..d963682ff005d 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 @@ -27,6 +27,16 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { POLICY_STATUS_TO_TEXT } from './host_constants'; import { mockPolicyResultList } from '../../policy/store/test_mock_utils'; +import { getEndpointDetailsPath } from '../../../common/routing'; +import { KibanaServices, useKibana, useToasts } from '../../../../common/lib/kibana'; +import { hostIsolationHttpMocks } from '../../../../common/lib/host_isolation/mocks'; +import { fireEvent } from '@testing-library/dom'; +import { + isFailedResourceState, + isLoadedResourceState, + isUninitialisedResourceState, +} from '../../../state'; +import { getCurrentIsolationRequestState } from '../store/selectors'; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; // but sure enough it needs to be inline in this one file @@ -47,8 +57,13 @@ jest.mock('../../policy/store/services/ingest', () => { sendGetEndpointSecurityPackage: () => Promise.resolve({}), }; }); + +jest.mock('../../../../common/lib/kibana'); + describe('when on the endpoint list page', () => { const docGenerator = new EndpointDocGenerator(); + const act = reactTestingLibrary.act; + let render: () => ReturnType; let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; @@ -71,6 +86,11 @@ describe('when on the endpoint list page', () => { reactTestingLibrary.act(() => { history.push('/endpoints'); }); + + // Because `.../common/lib/kibana` was mocked, we need to alter these hooks (which are jest.MockFunctions) + // to use services that we have in our test `mockedContext` + (useToasts as jest.Mock).mockReturnValue(coreStart.notifications.toasts); + (useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices }); }); it('should NOT display timeline', async () => { @@ -608,6 +628,7 @@ describe('when on the endpoint list page', () => { return renderResult; }; }); + afterEach(() => { jest.clearAllMocks(); }); @@ -873,14 +894,152 @@ describe('when on the endpoint list page', () => { expect(renderResult.getByText('A New Unknown Action')).not.toBeNull(); }); }); + + describe('when showing the Host Isolate panel', () => { + const getKibanaServicesMock = KibanaServices.get as jest.Mock; + const confirmIsolateAndWaitForApiResponse = async ( + typeOfResponse: 'success' | 'failure' = 'success' + ) => { + const isolateResponseAction = middlewareSpy.waitForAction( + 'endpointIsolationRequestStateChange', + { + validate(action) { + if (typeOfResponse === 'failure') { + return isFailedResourceState(action.payload); + } + + return isLoadedResourceState(action.payload); + }, + } + ); + + await act(async () => { + fireEvent.click(renderResult.getByTestId('hostIsolateConfirmButton')); + await isolateResponseAction; + }); + }; + + let isolateApiMock: ReturnType; + let renderResult: ReturnType; + + beforeEach(async () => { + getKibanaServicesMock.mockReturnValue(coreStart); + reactTestingLibrary.act(() => { + history.push('/endpoints?selected_endpoint=1&show=isolate'); + }); + renderResult = await renderAndWaitForData(); + coreStart.http.post.mockReset(); + isolateApiMock = hostIsolationHttpMocks(coreStart.http); + }); + + it('should show the isolate form', () => { + expect(renderResult.getByTestId('host_isolation_comment')).not.toBeNull(); + }); + + it('should take you back to details when back link below the flyout header is clicked', async () => { + const backButtonLink = renderResult.getByTestId('flyoutSubHeaderBackButton'); + + expect(backButtonLink.getAttribute('href')).toEqual( + getEndpointDetailsPath({ + name: 'endpointDetails', + page_index: '0', + page_size: '10', + selected_endpoint: '1', + }) + ); + + const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl'); + + act(() => { + fireEvent.click(backButtonLink); + }); + + expect((await changeUrlAction).payload).toMatchObject({ + pathname: '/endpoints', + search: '?page_index=0&page_size=10&selected_endpoint=1', + }); + }); + + it('take you back to details when Cancel button is clicked', async () => { + const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl'); + + act(() => { + fireEvent.click(renderResult.getByTestId('hostIsolateCancelButton')); + }); + + expect((await changeUrlAction).payload).toMatchObject({ + pathname: '/endpoints', + search: '?page_index=0&page_size=10&selected_endpoint=1', + }); + }); + + it('should isolate endpoint host when confirm is clicked', async () => { + await confirmIsolateAndWaitForApiResponse(); + expect(renderResult.getByTestId('hostIsolateSuccessMessage')).not.toBeNull(); + }); + + it('should navigate to details when the Complete button on success message is clicked', async () => { + await confirmIsolateAndWaitForApiResponse(); + + const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl'); + + act(() => { + fireEvent.click(renderResult.getByTestId('hostIsolateSuccessCompleteButton')); + }); + + expect((await changeUrlAction).payload).toMatchObject({ + pathname: '/endpoints', + search: '?page_index=0&page_size=10&selected_endpoint=1', + }); + }); + + it('should show error toast 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' + ); + }); + + it('should reset isolation state and show form again', async () => { + // ensures that after the host isolation has been successful, if user navigates away from the panel + // (`show` is NOT `isolate`), then the state should be reset so that the form show up again the next + // time `isolate host` is clicked + await confirmIsolateAndWaitForApiResponse(); + expect(renderResult.getByTestId('hostIsolateSuccessMessage')).not.toBeNull(); + + // Close flyout + const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl'); + act(() => { + fireEvent.click(renderResult.getByTestId('euiFlyoutCloseButton')); + }); + + expect((await changeUrlAction).payload).toMatchObject({ + pathname: '/endpoints', + search: '?page_index=0&page_size=10', + }); + + expect( + isUninitialisedResourceState( + getCurrentIsolationRequestState(store.getState().management.endpoints) + ) + ).toBe(true); + }); + }); }); describe('when the more actions column is opened', () => { + const generator = new EndpointDocGenerator('seed'); let hostInfo: HostInfo; let agentId: string; let agentPolicyId: string; - const generator = new EndpointDocGenerator('seed'); - let renderAndWaitForData: () => Promise>; + let renderResult: ReturnType; const mockEndpointListApi = () => { const { hosts, query_strategy_version: queryStrategyVersion } = mockEndpointResultList(); @@ -902,20 +1061,13 @@ describe('when on the endpoint list page', () => { }); }; - beforeEach(() => { + beforeEach(async () => { mockEndpointListApi(); reactTestingLibrary.act(() => { history.push('/endpoints'); }); - renderAndWaitForData = async () => { - const renderResult = render(); - await middlewareSpy.waitForAction('serverReturnedEndpointList'); - await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies'); - return renderResult; - }; - coreStart.application.getUrlForApp.mockImplementation((appName) => { switch (appName) { case 'securitySolution': @@ -925,42 +1077,43 @@ describe('when on the endpoint list page', () => { } return appName; }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); + renderResult = render(); + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies'); - it('navigates to the Security Solution Host Details page', async () => { - const renderResult = await renderAndWaitForData(); - // open the endpoint actions menu const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); + reactTestingLibrary.act(() => { reactTestingLibrary.fireEvent.click(endpointActionsButton); }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to the Host Details Isolate flyout', async () => { + const isolateLink = await renderResult.findByTestId('isolateLink'); + expect(isolateLink.getAttribute('href')).toEqual( + getEndpointDetailsPath({ + name: 'endpointIsolate', + selected_endpoint: hostInfo.metadata.agent.id, + }) + ); + }); + + it('navigates to the Security Solution Host Details page', async () => { const hostLink = await renderResult.findByTestId('hostLink'); expect(hostLink.getAttribute('href')).toEqual( `/app/security/hosts/${hostInfo.metadata.host.hostname}` ); }); it('navigates to the Ingest Agent Policy page', async () => { - const renderResult = await renderAndWaitForData(); - const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); - reactTestingLibrary.act(() => { - reactTestingLibrary.fireEvent.click(endpointActionsButton); - }); - const agentPolicyLink = await renderResult.findByTestId('agentPolicyLink'); expect(agentPolicyLink.getAttribute('href')).toEqual(`/app/fleet#/policies/${agentPolicyId}`); }); it('navigates to the Ingest Agent Details page', async () => { - const renderResult = await renderAndWaitForData(); - const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); - reactTestingLibrary.act(() => { - reactTestingLibrary.fireEvent.click(endpointActionsButton); - }); - const agentDetailsLink = await renderResult.findByTestId('agentDetailsLink'); expect(agentDetailsLink.getAttribute('href')).toEqual(`/app/fleet#/fleet/agents/${agentId}`); }); 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 f654efdd89ce1..7e5658f7b0cba 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo, useCallback, memo, useState, useContext } from 'react'; +import React, { useMemo, useCallback, memo, useContext } from 'react'; import { EuiHorizontalRule, EuiBasicTable, @@ -17,11 +17,6 @@ import { EuiSelectableProps, EuiSuperDatePicker, EuiSpacer, - EuiPopover, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiContextMenuPanelProps, - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiCallOut, @@ -31,8 +26,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { createStructuredSelector } from 'reselect'; import { useDispatch } from 'react-redux'; -import { EuiContextMenuItemProps } from '@elastic/eui/src/components/context_menu/context_menu_item'; -import { NavigateToAppOptions } from 'kibana/public'; import { ThemeContext } from 'styled-components'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; @@ -46,7 +39,11 @@ 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_PAGE_SIZE_OPTIONS } from '../../../common/constants'; +import { + DEFAULT_POLL_INTERVAL, + MANAGEMENT_APP_ID, + 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'; @@ -66,6 +63,7 @@ import { AdministrationListPage } from '../../../components/administration_list_ 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'; const MAX_PAGINATED_ITEM = 9999; @@ -104,36 +102,6 @@ const EndpointListNavLink = memo<{ }); EndpointListNavLink.displayName = 'EndpointListNavLink'; -const TableRowActions = memo<{ - items: EuiContextMenuPanelProps['items']; -}>(({ items }) => { - const [isOpen, setIsOpen] = useState(false); - const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); - const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); - - return ( - - } - isOpen={isOpen} - closePopover={handleCloseMenu} - > - - - ); -}); -TableRowActions.displayName = 'EndpointTableRowActions'; - const selector = (createStructuredSelector as CreateStructuredSelector)(selectors); export const EndpointList = () => { const history = useHistory(); @@ -234,7 +202,7 @@ export const EndpointList = () => { const NOOP = useCallback(() => {}, []); - const PAD_LEFT: React.CSSProperties = { paddingLeft: '6px' }; + const PAD_LEFT: React.CSSProperties = useMemo(() => ({ paddingLeft: '6px' }), []); const handleDeployEndpointsClick = useNavigateToAppEventHandler( 'fleet', @@ -459,71 +427,93 @@ 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 - }`} - > - - , - + ), + }, + { + 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( + }, + 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 - } - > - - , - + ), + }, + { + 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( + }, + href: `${services?.application?.getUrlForApp( 'fleet' )}#${pagePathGetters.fleet_agent_details({ agentId: item.metadata.elastic.agent.id, - })}`} - > - - , + })}`, + children: ( + + ), + }, ]} /> ); @@ -532,8 +522,7 @@ export const EndpointList = () => { ], }, ]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [formatUrl, queryParams, search, agentPolicies, services?.application?.getUrlForApp]); + }, [queryParams, search, formatUrl, PAD_LEFT, services?.application, agentPolicies]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist || areEndpointsEnrolling) { @@ -697,20 +686,3 @@ export const EndpointList = () => { ); }; - -const EuiContextMenuItemNavByRouter = memo< - Omit & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - } ->(({ navigateAppId, navigateOptions, children, ...otherMenuItemProps }) => { - const handleOnClick = useNavigateToAppEventHandler(navigateAppId, navigateOptions); - - return ( - - {children} - - ); -}); -EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 3340ef38d73cb..ad41f1678fbf6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -173,7 +173,7 @@ describe('Host Isolation', () => { expect(mockResponse.ok).not.toBeCalled(); const response = mockResponse.customError.mock.calls[0][0]; expect(response.statusCode).toEqual(500); - expect((response.body as HostIsolationResponse).message).toEqual(ErrMessage); + expect((response.body as Error).message).toEqual(ErrMessage); }); it('accepts a comment field', async () => { await callRoute(ISOLATE_HOST_ROUTE, { body: { agent_ids: ['XYZ'], comment: 'XYZ' } });