Skip to content

Commit

Permalink
[Security Solution][Endpoint] Host Isolation API changes (#113621)
Browse files Browse the repository at this point in the history
* Use the new data stream (if exists) to write action request to
and then the fleet index. Else do as usual.

fixes elastic/security-team/issues/1704

* fix legacy tests

* add relevant additional tests

* remove duplicate test

* update tests

* cleanup

review changes
refs elastic/security-team/issues/1704

* fix lint

* Use correct mapping keys when writing to index

* write record on new index when action request fails to write to `.fleet-actions`

review comments

* better error message

review comment

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
ashokaditya and kibanamachine authored Oct 13, 2021
1 parent 3d75154 commit 1d71d42
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 101 deletions.
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],
];

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

0 comments on commit 1d71d42

Please sign in to comment.