Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Endpoint] Host Isolation API changes #113621

Merged
merged 18 commits into from
Oct 13, 2021
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions x-pack/plugins/security_solution/common/endpoint/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
* 2.0.
*/

export const ENDPOINT_ACTIONS_INDEX = '.logs-endpoint.actions-default';
export const ENDPOINT_ACTION_RESPONSES_INDEX = '.logs-endpoint.action.responses-default';
/** endpoint data streams that are used for host isolation */
/** for index patterns `.logs-endpoint.actions-* and .logs-endpoint.action.responses-*`*/
export const ENDPOINT_ACTIONS_DS = '.logs-endpoint.actions';
export const ENDPOINT_ACTIONS_INDEX = `${ENDPOINT_ACTIONS_DS}-default`;
export const ENDPOINT_ACTION_RESPONSES_DS = '.logs-endpoint.action.responses';
export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTIONS_DS}-default`;

export const eventsIndexPattern = 'logs-endpoint.events.*';
export const alertsIndexPattern = 'logs-endpoint.alerts-*';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,51 +8,7 @@
import { DeepPartial } from 'utility-types';
import { merge } from 'lodash';
import { BaseDataGenerator } from './base_data_generator';
import { EndpointActionData, ISOLATION_ACTIONS } from '../types';

interface EcsError {
code: string;
id: string;
message: string;
stack_trace: string;
type: string;
}

interface EndpointActionFields {
action_id: string;
data: EndpointActionData;
}

interface ActionRequestFields {
expiration: string;
type: 'INPUT_ACTION';
input_type: 'endpoint';
}

interface ActionResponseFields {
completed_at: string;
started_at: string;
}
export interface LogsEndpointAction {
'@timestamp': string;
agent: {
id: string | string[];
};
EndpointAction: EndpointActionFields & ActionRequestFields;
error?: EcsError;
user: {
id: string;
};
}

export interface LogsEndpointActionResponse {
'@timestamp': string;
agent: {
id: string | string[];
};
EndpointAction: EndpointActionFields & ActionResponseFields;
error?: EcsError;
}
import { ISOLATION_ACTIONS, LogsEndpointAction, LogsEndpointActionResponse } from '../types';

const ISOLATION_COMMANDS: ISOLATION_ACTIONS[] = ['isolate', 'unisolate'];

Expand All @@ -66,7 +22,7 @@ export class EndpointActionGenerator extends BaseDataGenerator {
agent: {
id: [this.randomUUID()],
},
EndpointAction: {
EndpointActions: {
action_id: this.randomUUID(),
expiration: this.randomFutureDate(timeStamp),
type: 'INPUT_ACTION',
Expand All @@ -86,11 +42,11 @@ export class EndpointActionGenerator extends BaseDataGenerator {
}

generateIsolateAction(overrides: DeepPartial<LogsEndpointAction> = {}): LogsEndpointAction {
return merge(this.generate({ EndpointAction: { data: { command: 'isolate' } } }), overrides);
return merge(this.generate({ EndpointActions: { data: { command: 'isolate' } } }), overrides);
}

generateUnIsolateAction(overrides: DeepPartial<LogsEndpointAction> = {}): LogsEndpointAction {
return merge(this.generate({ EndpointAction: { data: { command: 'unisolate' } } }), overrides);
return merge(this.generate({ EndpointActions: { data: { command: 'unisolate' } } }), overrides);
}

/** Generates an endpoint action response */
Expand All @@ -105,7 +61,7 @@ export class EndpointActionGenerator extends BaseDataGenerator {
agent: {
id: this.randomUUID(),
},
EndpointAction: {
EndpointActions: {
action_id: this.randomUUID(),
completed_at: timeStamp.toISOString(),
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@

import { Client } from '@elastic/elasticsearch';
import { DeleteByQueryResponse } from '@elastic/elasticsearch/api/types';
import { HostMetadata } from '../types';
import {
EndpointActionGenerator,
LogsEndpointAction,
LogsEndpointActionResponse,
} from '../data_generators/endpoint_action_generator';
import { HostMetadata, LogsEndpointAction, LogsEndpointActionResponse } from '../types';
import { EndpointActionGenerator } from '../data_generators/endpoint_action_generator';
import { wrapErrorAndRejectPromise } from './utils';
import { ENDPOINT_ACTIONS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX } from '../constants';

Expand Down Expand Up @@ -49,7 +45,7 @@ export const indexEndpointActionsForHost = async (
for (let i = 0; i < total; i++) {
// create an action
const action = endpointActionGenerator.generate({
EndpointAction: {
EndpointActions: {
data: { comment: 'data generator: this host is same as bad' },
},
});
Expand All @@ -66,9 +62,9 @@ export const indexEndpointActionsForHost = async (
// Create an action response for the above
const actionResponse = endpointActionGenerator.generateResponse({
agent: { id: agentId },
EndpointAction: {
action_id: action.EndpointAction.action_id,
data: action.EndpointAction.data,
EndpointActions: {
action_id: action.EndpointActions.action_id,
data: action.EndpointActions.data,
},
});

Expand Down Expand Up @@ -174,7 +170,7 @@ export const deleteIndexedEndpointActions = async (
{
terms: {
action_id: indexedData.endpointActions.map(
(action) => action.EndpointAction.action_id
(action) => action.EndpointActions.action_id
),
},
},
Expand All @@ -200,7 +196,7 @@ export const deleteIndexedEndpointActions = async (
{
terms: {
action_id: indexedData.endpointActionResponses.map(
(action) => action.EndpointAction.action_id
(action) => action.EndpointActions.action_id
),
},
},
Expand Down
44 changes: 44 additions & 0 deletions x-pack/plugins/security_solution/common/endpoint/types/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,50 @@ import { ActionStatusRequestSchema, HostIsolationRequestSchema } from '../schema

export type ISOLATION_ACTIONS = 'isolate' | 'unisolate';

interface EcsError {
code?: string;
id?: string;
message: string;
stack_trace?: string;
type?: string;
}

interface EndpointActionFields {
action_id: string;
data: EndpointActionData;
}

interface ActionRequestFields {
expiration: string;
type: 'INPUT_ACTION';
input_type: 'endpoint';
}

interface ActionResponseFields {
completed_at: string;
started_at: string;
}
export interface LogsEndpointAction {
'@timestamp': string;
agent: {
id: string | string[];
};
EndpointActions: EndpointActionFields & ActionRequestFields;
error?: EcsError;
user: {
id: string;
};
}

export interface LogsEndpointActionResponse {
'@timestamp': string;
agent: {
id: string | string[];
};
EndpointActions: EndpointActionFields & ActionResponseFields;
error?: EcsError;
}

export interface EndpointActionData {
command: ISOLATION_ACTIONS;
comment?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ import {
ISOLATE_HOST_ROUTE,
UNISOLATE_HOST_ROUTE,
metadataTransformPrefix,
ENDPOINT_ACTIONS_INDEX,
} from '../../../../common/endpoint/constants';
import {
EndpointAction,
HostIsolationRequestBody,
HostIsolationResponse,
HostMetadata,
LogsEndpointAction,
} from '../../../../common/endpoint/types';
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
import { legacyMetadataSearchResponse } from '../metadata/support/test_support';
import { ElasticsearchAssetType } from '../../../../../fleet/common';
import { AGENT_ACTIONS_INDEX, ElasticsearchAssetType } from '../../../../../fleet/common';
import { CasesClientMock } from '../../../../../cases/server/client/mocks';

interface CallRouteInterface {
Expand Down Expand Up @@ -109,7 +111,8 @@ describe('Host Isolation', () => {

let callRoute: (
routePrefix: string,
opts: CallRouteInterface
opts: CallRouteInterface,
indexExists?: { endpointDsExists: boolean }
) => Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>>;
const superUser = {
username: 'superuser',
Expand Down Expand Up @@ -175,22 +178,42 @@ describe('Host Isolation', () => {
// it returns the requestContext mock used in the call, to assert internal calls (e.g. the indexed document)
callRoute = async (
routePrefix: string,
{ body, idxResponse, searchResponse, mockUser, license }: CallRouteInterface
{ body, idxResponse, searchResponse, mockUser, license }: CallRouteInterface,
indexExists?: { endpointDsExists: boolean }
): Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>> => {
const asUser = mockUser ? mockUser : superUser;
(startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce(
() => asUser
);

const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient);
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
ctx.core.elasticsearch.client.asCurrentUser.index = jest
// mock _index_template
ctx.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = jest
.fn()
.mockImplementationOnce(() => Promise.resolve(withIdxResp));
ctx.core.elasticsearch.client.asCurrentUser.search = jest
.mockImplementationOnce(() => {
if (indexExists) {
return Promise.resolve({
body: true,
statusCode: 200,
});
}
return Promise.resolve({
body: false,
statusCode: 404,
});
});
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
const mockIndexResponse = jest.fn().mockImplementation(() => Promise.resolve(withIdxResp));
const mockSearchResponse = jest
.fn()
.mockImplementation(() =>
Promise.resolve({ body: legacyMetadataSearchResponse(searchResponse) })
);
if (indexExists) {
ctx.core.elasticsearch.client.asInternalUser.index = mockIndexResponse;
}
ctx.core.elasticsearch.client.asCurrentUser.index = mockIndexResponse;
ctx.core.elasticsearch.client.asCurrentUser.search = mockSearchResponse;
const withLicense = license ? license : Platinum;
licenseEmitter.next(withLicense);
const mockRequest = httpServerMock.createKibanaRequest({ body });
Expand Down Expand Up @@ -288,11 +311,6 @@ describe('Host Isolation', () => {
).mock.calls[0][0].body;
expect(actionDoc.timeout).toEqual(300);
});

it('succeeds when just an endpoint ID is provided', async () => {
await callRoute(ISOLATE_HOST_ROUTE, { body: { endpoint_ids: ['XYZ'] } });
expect(mockResponse.ok).toBeCalled();
});
it('sends the action to the correct agent when endpoint ID is given', async () => {
const doc = docGen.generateHostMetadata();
const AgentID = doc.elastic.agent.id;
Expand Down Expand Up @@ -326,6 +344,74 @@ describe('Host Isolation', () => {
expect(actionDoc.data.command).toEqual('unisolate');
});

describe('With endpoint data streams', () => {
it('handles unisolation', async () => {
const ctx = await callRoute(
UNISOLATE_HOST_ROUTE,
{
body: { endpoint_ids: ['XYZ'] },
},
{ endpointDsExists: true }
);
const actionDocs: [
{ index: string; body: LogsEndpointAction },
{ index: string; body: EndpointAction }
] = [
(ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0],
(ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is getting the first argument of the second call to asInternalUser.index(). What's the first thing we index as internal user via this path? Are we guaranteeing that it will always happen in that order?

Copy link
Member Author

@ashokaditya ashokaditya Oct 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first thing that is indexed is .logs-endpoint.actions and then .fleet-actions. This is because of the way I mocked the client to use internalUser when the index exists.

];

expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX);
expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX);
expect(actionDocs[0].body.EndpointActions.data.command).toEqual('unisolate');
expect(actionDocs[1].body.data.command).toEqual('unisolate');
});

it('handles isolation', async () => {
const ctx = await callRoute(
ISOLATE_HOST_ROUTE,
{
body: { endpoint_ids: ['XYZ'] },
},
{ endpointDsExists: true }
);
const actionDocs: [
{ index: string; body: LogsEndpointAction },
{ index: string; body: EndpointAction }
] = [
(ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0],
(ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0],
];

expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX);
expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX);
expect(actionDocs[0].body.EndpointActions.data.command).toEqual('isolate');
expect(actionDocs[1].body.data.command).toEqual('isolate');
});

it('handles errors', async () => {
const ErrMessage = 'Uh oh!';
await callRoute(
UNISOLATE_HOST_ROUTE,
{
body: { endpoint_ids: ['XYZ'] },
idxResponse: {
statusCode: 500,
body: {
result: ErrMessage,
},
},
},
{ endpointDsExists: true }
);

expect(mockResponse.ok).not.toBeCalled();
const response = mockResponse.customError.mock.calls[0][0];
expect(response.statusCode).toEqual(500);
expect((response.body as Error).message).toEqual(ErrMessage);
});
});

describe('License Level', () => {
it('allows platinum license levels to isolate hosts', async () => {
await callRoute(ISOLATE_HOST_ROUTE, {
Expand Down
Loading