[Security Solution][Endpoint] Add host isolation action to the endpoint list (#100240)

* Refactor TableRowAction into separate component and enable menu close on item click
* add `show=isolate` to valid url param string for details panel
* Reusable BackToEndpointDetailsFlyoutSubHeader component
* new FlyoutBodyNoTopPadding compoent + refactor Policy response to use it
* Endpoint Isolate flyout panel
* New Service for doing isolate/unisolate of hosts
* Refactor detection isolate API call to use common method from new service
This commit is contained in:
Paul Tavares 2021-05-24 09:50:21 -04:00 committed by GitHub
parent 21820e9aff
commit 093044f10f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1031 additions and 275 deletions

View file

@ -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()),
}),
};

View file

@ -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<typeof HostIsolationRequestSchema.body>;
export interface HostIsolationResponse {
action?: string;
message?: string;
action: string;
}

View file

@ -70,12 +70,22 @@ export const EndpointIsolateForm = memo<EndpointIsolatedFormProps>(
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel} disabled={isLoading}>
<EuiButtonEmpty
onClick={onCancel}
disabled={isLoading}
data-test-subj="hostIsolateCancelButton"
>
{CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={onConfirm} disabled={isLoading} isLoading={isLoading}>
<EuiButton
fill
onClick={onConfirm}
disabled={isLoading}
isLoading={isLoading}
data-test-subj="hostIsolateConfirmButton"
>
{CONFIRM}
</EuiButton>
</EuiFlexItem>

View file

@ -20,13 +20,22 @@ export const EndpointIsolateSuccess = memo<EndpointIsolateSuccessProps>(
({ hostName, onComplete, completeButtonLabel, additionalInfo }) => {
return (
<>
<EuiCallOut iconType="check" color="success" title={GET_SUCCESS_MESSAGE(hostName)}>
<EuiCallOut
iconType="check"
color="success"
title={GET_SUCCESS_MESSAGE(hostName)}
data-test-subj="hostIsolateSuccessMessage"
>
{additionalInfo}
</EuiCallOut>
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty flush="right" onClick={onComplete}>
<EuiButtonEmpty
flush="right"
onClick={onComplete}
data-test-subj="hostIsolateSuccessCompleteButton"
>
<EuiText size="s">
<p>{completeButtonLabel}</p>
</EuiText>

View file

@ -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),
});
});
});

View file

@ -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<HostIsolationResponse> => {
return KibanaServices.get().http.post<HostIsolationResponse>(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<HostIsolationResponse> => {
return KibanaServices.get().http.post<HostIsolationResponse>(UNISOLATE_HOST_ROUTE, {
body: JSON.stringify(params),
});
};

View file

@ -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<HostIsolationHttpMockProviders>([
{
id: 'isolateHost',
method: 'post',
path: ISOLATE_HOST_ROUTE,
handler: () => hostIsolationResponseMock(),
},
{
id: 'unIsolateHost',
method: 'post',
path: UNISOLATE_HOST_ROUTE,
handler: () => hostIsolationResponseMock(),
},
]);

View file

@ -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<typeof createMemoryHistory>;
coreStart: ReturnType<typeof coreMock.createStart>;
depsStart: Pick<StartPlugins, 'data' | 'fleet'>;
startServices: StartServices;
middlewareSpy: MiddlewareActionSpyHelper;
/**
* A wrapper around `AppRootContext` component. Uses the mocked modules as input to the
@ -87,10 +91,14 @@ const experimentalFeaturesReducer: Reducer<State['app'], UpdateExperimentalFeatu
*/
export const createAppRootMockRenderer = (): AppContextTestRender => {
const history = createMemoryHistory<never>();
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 }) => (
<MockKibanaContextProvider>
<KibanaContextProvider services={startServices}>
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
{children}
</AppRootProvider>
</MockKibanaContextProvider>
</KibanaContextProvider>
);
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<typeof coreMock.createStart> => {
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;
};

View file

@ -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"]}',
});

View file

@ -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<HostIsolationResponse> =>
KibanaServices.get().http.fetch<HostIsolationResponse>(ISOLATE_HOST_ROUTE, {
method: 'POST',
body: JSON.stringify({
agent_ids: [agentId],
comment,
case_ids: caseIds,
}),
isolateHost({
agent_ids: [agentId],
comment,
case_ids: caseIds,
});
/**

View file

@ -43,12 +43,15 @@ const querystringStringify = <ExpectedType, ArgType>(
type EndpointDetailsUrlProps = Omit<EndpointIndexUIQueryParams, 'selected_endpoint'> &
Required<Pick<EndpointIndexUIQueryParams, 'selected_endpoint'>>;
/** URL search params that are only applicable to the list page */
type EndpointListUrlProps = Omit<EndpointIndexUIQueryParams, 'selected_endpoint' | 'show'>;
export const getEndpointListPath = (
props: { name: 'default' | 'endpointList' } & EndpointIndexUIQueryParams,
props: { name: 'default' | 'endpointList' } & EndpointListUrlProps,
search?: string
) => {
const { name, ...queryParams } = props;
const urlQueryParams = querystringStringify<EndpointIndexUIQueryParams, typeof queryParams>(
const urlQueryParams = querystringStringify<EndpointListUrlProps, typeof queryParams>(
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<EndpointDetailsUrlProps, typeof queryParams>(
queryParams
);

View file

@ -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;

View file

@ -65,6 +65,9 @@ describe('EndpointList store concerns', () => {
endpointsTotalError: undefined,
queryStrategyVersion: undefined,
policyVersionInfo: undefined,
isolationRequestState: {
type: 'UninitialisedResourceState',
},
});
});

View file

@ -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<EndpointState>, Immutable<AppAction>>;
describe('endpoint list middleware', () => {
let fakeCoreStart: jest.Mocked<CoreStart>;
let depsStart: DepsStartMock;
let fakeHttpServices: jest.Mocked<HttpSetup>;
type EndpointListStore = Store<Immutable<EndpointState>, Immutable<AppAction>>;
let store: EndpointListStore;
let getState: EndpointListStore['getState'];
let dispatch: EndpointListStore['dispatch'];
let waitForAction: MiddlewareActionSpyHelper['waitForAction'];
let actionSpyMiddleware;
let history: History<never>;
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<typeof hostIsolationHttpMocks>;
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<HostIsolationResponse>).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<HostIsolationResponse>;
expect(failedAction.error).toBe(apiError);
});
});
});

View file

@ -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<EndpointState, AppAction>;
export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState> = (
coreStart,
@ -47,9 +64,11 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
return [indexPattern];
}
// eslint-disable-next-line complexity
return ({ getState, dispatch }) => (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<EndpointState
});
}
}
// Isolate Host
if (action.type === 'endpointIsolationRequest') {
return handleIsolateEndpointHost(store, action);
}
};
};
@ -428,3 +452,36 @@ const doEndpointsExist = async (http: HttpStart): Promise<boolean> => {
}
return false;
};
const handleIsolateEndpointHost = async (
{ getState, dispatch }: EndpointPageStore,
action: Immutable<AppAction & { type: 'endpointIsolationRequest' }>
) => {
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<HostIsolationResponse>(response),
});
} catch (error) {
dispatch({
type: 'endpointIsolationRequestStateChange',
payload: createFailedResourceState<HostIsolationResponse>(error.body ?? error),
});
}
};

View file

@ -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<EndpointState> = {
hosts: [],
@ -44,6 +50,7 @@ export const initialEndpointListState: Immutable<EndpointState> = {
queryStrategyVersion: undefined,
policyVersionInfo: undefined,
hostStatus: undefined,
isolationRequestState: createUninitialisedResourceState(),
};
/* eslint-disable-next-line complexity */
@ -199,6 +206,8 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
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<EndpointState> = {
...state,
@ -209,16 +218,29 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
const isCurrentlyOnDetailsPage = isOnEndpointPage(newState) && hasSelectedEndpoint(newState);
const wasPreviouslyOnDetailsPage = isOnEndpointPage(state) && hasSelectedEndpoint(state);
const stateUpdates: Partial<EndpointState> = {
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<EndpointState, AppAction> = (
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<EndpointState, AppAction> = (
// 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,
};
};

View file

@ -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<EndpointState>) => state.hosts;
@ -171,6 +177,7 @@ export const isOnEndpointPage = (state: Immutable<EndpointState>) => {
);
};
/** Sanitized list of URL query params supported by the Details page */
export const uiQueryParams: (
state: Immutable<EndpointState>
) => Immutable<EndpointIndexUIQueryParams> = 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<EndpointState>) => 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<EndpointState>) => Query = create
return decodedQuery;
}
);
export const getCurrentIsolationRequestState = (
state: Immutable<EndpointState>
): EndpointState['isolationRequestState'] => {
return state.isolationRequestState;
};
export const getIsIsolationRequestPending: (
state: Immutable<EndpointState>
) => boolean = createSelector(getCurrentIsolationRequestState, (isolateHost) =>
isLoadingResourceState(isolateHost)
);
export const getWasIsolationRequestSuccessful: (
state: Immutable<EndpointState>
) => boolean = createSelector(getCurrentIsolationRequestState, (isolateHost) =>
isLoadedResourceState(isolateHost)
);
export const getIsolationRequestError: (
state: Immutable<EndpointState>
) => ServerApiError | undefined = createSelector(getCurrentIsolationRequestState, (isolateHost) => {
if (isFailedResourceState(isolateHost)) {
return isolateHost.error;
}
});

View file

@ -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<HostIsolationResponse>;
}
/**
@ -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;
}

View file

@ -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<EuiContextMenuItemProps, 'onClick'> & {
navigateAppId: string;
navigateOptions: NavigateToAppOptions;
children: React.ReactNode;
key: string;
}
>;
}
export const TableRowActions = memo<TableRowActionProps>(({ 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 <EuiContextMenuItemNavByRouter {...itemProps} onClick={handleCloseMenu} />;
});
}, [handleCloseMenu, items]);
const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => {
return { 'data-test-subj': 'tableRowActionsMenuPanel' };
}, []);
return (
<EuiPopover
anchorPosition="downRight"
panelPaddingSize="none"
panelProps={panelProps}
button={
<EuiButtonIcon
data-test-subj="endpointTableRowActions"
iconType="boxesHorizontal"
onClick={handleToggleMenu}
aria-label={i18n.translate('xpack.securitySolution.endpoint.list.actionmenu', {
defaultMessage: 'Open',
})}
/>
}
isOpen={isOpen}
closePopover={handleCloseMenu}
>
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
);
});
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 (
<EuiContextMenuItem {...otherMenuItemProps} onClick={handleOnClick}>
{children}
</EuiContextMenuItem>
);
});
EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter';

View file

@ -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 (
<FlyoutSubHeader
backButton={backButtonProp}
data-test-subj="endpointDetailsPolicyResponseFlyoutHeader"
/>
);
}
);
BackToEndpointDetailsFlyoutSubHeader.displayName = 'BackToEndpointDetailsFlyoutSubHeader';

View file

@ -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<Dispatch<AppAction>>();
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<EndpointIsolatedFormProps['onChange']>[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 (
<>
<BackToEndpointDetailsFlyoutSubHeader endpointId={hostMeta.agent.id} />
<FlyoutBodyNoTopPadding>
{wasSuccessful ? (
<EndpointIsolateSuccess
hostName={hostMeta.host.name}
completeButtonLabel={i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.successProceedButton',
{ defaultMessage: 'Return to endpoint details' }
)}
onComplete={handleCancel}
/>
) : (
<EndpointIsolateForm
comment={formValues.comment}
isLoading={isPending}
hostName={hostMeta.host.name}
onCancel={handleCancel}
onConfirm={handleConfirm}
onChange={handleChange}
/>
)}
</FlyoutBodyNoTopPadding>
</>
);
});
EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel';

View file

@ -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;
}
`;

View file

@ -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(() => {
)}
</EuiFlyoutHeader>
{details === undefined ? (
<>
<EuiFlyoutBody>
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
</EuiFlyoutBody>
</>
<EuiFlyoutBody>
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
</EuiFlyoutBody>
) : (
<>
{show === 'details' && (
<>
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
<EndpointDetails
details={details}
policyInfo={policyInfo}
hostStatus={hostStatus}
/>
</EuiFlyoutBody>
</>
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
<EndpointDetails details={details} policyInfo={policyInfo} hostStatus={hostStatus} />
</EuiFlyoutBody>
)}
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={details} />}
{show === 'isolate' && <EndpointIsolateFlyoutPanel hostMeta={details} />}
</>
)}
</EuiFlyout>
@ -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 (
<>
<FlyoutSubHeader
backButton={backButtonProp}
data-test-subj="endpointDetailsPolicyResponseFlyoutHeader"
/>
<PolicyResponseFlyoutBody
<BackToEndpointDetailsFlyoutSubHeader endpointId={hostMeta.agent.id} />
<FlyoutBodyNoTopPadding
data-test-subj="endpointDetailsPolicyResponseFlyoutBody"
className="endpointDetailsPolicyResponseFlyoutBody"
>
@ -227,7 +188,7 @@ const PolicyResponseFlyoutPanel = memo<{
responseAttentionCount={responseAttentionCount}
/>
)}
</PolicyResponseFlyoutBody>
</FlyoutBodyNoTopPadding>
</>
);
});

View file

@ -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<AppContextTestRender['render']>;
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<typeof hostIsolationHttpMocks>;
let renderResult: ReturnType<AppContextTestRender['render']>;
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<ReturnType<AppContextTestRender['render']>>;
let renderResult: ReturnType<AppContextTestRender['render']>;
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;
});
renderResult = render();
await middlewareSpy.waitForAction('serverReturnedEndpointList');
await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies');
const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(endpointActionsButton);
});
});
afterEach(() => {
jest.clearAllMocks();
});
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);
});
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}`);
});

View file

@ -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 (
<EuiPopover
anchorPosition="downRight"
panelPaddingSize="none"
button={
<EuiButtonIcon
data-test-subj="endpointTableRowActions"
iconType="boxesHorizontal"
onClick={handleToggleMenu}
aria-label={i18n.translate('xpack.securitySolution.endpoint.list.actionmenu', {
defaultMessage: 'Open',
})}
/>
}
isOpen={isOpen}
closePopover={handleCloseMenu}
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
);
});
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<AgentPolicyDetailsDeployAgentAction>(
'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 (
<TableRowActions
items={[
<EuiContextMenuItemNavByRouter
data-test-subj="hostLink"
icon="logoSecurity"
key="hostDetailsLink"
navigateAppId={APP_ID}
navigateOptions={{ path: `hosts/${item.metadata.host.hostname}` }}
href={`${services?.application?.getUrlForApp('securitySolution')}/hosts/${
{
'data-test-subj': 'isolateLink',
icon: 'logoSecurity',
key: 'isolateHost',
navigateAppId: MANAGEMENT_APP_ID,
navigateOptions: {
path: endpointIsolatePath,
},
href: formatUrl(endpointIsolatePath),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.list.actions.isolateHost"
defaultMessage="Isolate Host"
/>
),
},
{
'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
}`}
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.actions.hostDetails"
defaultMessage="View Host Details"
/>
</EuiContextMenuItemNavByRouter>,
<EuiContextMenuItemNavByRouter
icon="logoObservability"
key="agentConfigLink"
data-test-subj="agentPolicyLink"
navigateAppId="fleet"
navigateOptions={{
}`,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.list.actions.hostDetails"
defaultMessage="View Host Details"
/>
),
},
{
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
}
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.actions.agentPolicy"
defaultMessage="View Agent Policy"
/>
</EuiContextMenuItemNavByRouter>,
<EuiContextMenuItemNavByRouter
icon="logoObservability"
key="agentDetailsLink"
data-test-subj="agentDetailsLink"
navigateAppId="fleet"
navigateOptions={{
})}`,
disabled:
agentPolicies[item.metadata.Endpoint.policy.applied.id] === undefined,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.list.actions.agentPolicy"
defaultMessage="View Agent Policy"
/>
),
},
{
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,
})}`}
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.actions.agentDetails"
defaultMessage="View Agent Details"
/>
</EuiContextMenuItemNavByRouter>,
})}`,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.list.actions.agentDetails"
defaultMessage="View Agent Details"
/>
),
},
]}
/>
);
@ -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 = () => {
</AdministrationListPage>
);
};
const EuiContextMenuItemNavByRouter = memo<
Omit<EuiContextMenuItemProps, 'onClick'> & {
navigateAppId: string;
navigateOptions: NavigateToAppOptions;
children: React.ReactNode;
}
>(({ navigateAppId, navigateOptions, children, ...otherMenuItemProps }) => {
const handleOnClick = useNavigateToAppEventHandler(navigateAppId, navigateOptions);
return (
<EuiContextMenuItem {...otherMenuItemProps} onClick={handleOnClick}>
{children}
</EuiContextMenuItem>
);
});
EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter';

View file

@ -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' } });