Skip to content

Commit

Permalink
[Security Solution][Endpoint] Add host isolation action to the endpoi…
Browse files Browse the repository at this point in the history
…nt 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
  • Loading branch information
paul-tavares authored May 24, 2021
1 parent 21820e9 commit 093044f
Show file tree
Hide file tree
Showing 26 changed files with 1,031 additions and 275 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}),
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,7 +24,8 @@ export interface EndpointAction {
};
}

export type HostIsolationRequestBody = TypeOf<typeof HostIsolationRequestSchema.body>;

export interface HostIsolationResponse {
action?: string;
message?: string;
action: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
});
});
});
Original file line number Diff line number Diff line change
@@ -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),
});
};
Original file line number Diff line number Diff line change
@@ -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(),
},
]);
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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, {
Expand All @@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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"]}',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +24,7 @@ import {
UpdateAlertStatusProps,
CasesFromAlertsResponse,
} from './types';
import { isolateHost } from '../../../../common/lib/host_isolation';

/**
* Fetch Alerts by providing a query
Expand Down Expand Up @@ -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,
});

/**
Expand Down
Loading

0 comments on commit 093044f

Please sign in to comment.