From 2c3817d090990819f66b3ed40fbe1f7fcd165c52 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Fri, 1 Oct 2021 15:12:44 +0200 Subject: [PATCH 01/10] 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 --- .../common/endpoint/constants.ts | 8 +- .../endpoint/routes/actions/isolation.ts | 123 +++++++++++++++--- 2 files changed, 111 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index c7949299c68db..6e9123da2dd9b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -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-*'; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 45f0e851dfdd1..c88cbc45cd97a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -6,13 +6,18 @@ */ import moment from 'moment'; -import { RequestHandler } from 'src/core/server'; +import { ElasticsearchClient, RequestHandler, Logger } from 'src/core/server'; import uuid from 'uuid'; import { TypeOf } from '@kbn/config-schema'; +import { LogsEndpointAction } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; import { CommentType } from '../../../../../cases/common'; import { CasesByAlertId } from '../../../../../cases/common/api/cases/case'; import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; -import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants'; +import { + ENDPOINT_ACTIONS_DS, + ISOLATE_HOST_ROUTE, + UNISOLATE_HOST_ROUTE, +} from '../../../../common/endpoint/constants'; import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; import { EndpointAction, HostMetadata } from '../../../../common/endpoint/types'; import { @@ -52,6 +57,31 @@ export function registerHostIsolationRoutes( ); } +const doLogsEndpointActionDsExists = async ({ + esClient, + logger, + dataStreamName, +}: { + esClient: ElasticsearchClient; + logger: Logger; + dataStreamName: string; +}): Promise => { + let doesIndexTemplateExist; + try { + doesIndexTemplateExist = await esClient.indices.existsIndexTemplate({ + name: dataStreamName, + }); + } catch (error) { + const errorType = error?.type ?? ''; + if (errorType !== 'resource_not_found_exception') { + logger.error(error); + throw error; + } + return false; + } + return doesIndexTemplateExist.statusCode === 404 ? false : true; +}; + export const isolationRequestHandler = function ( endpointContext: EndpointAppContext, isolate: boolean @@ -106,25 +136,82 @@ export const isolationRequestHandler = function ( caseIDs = [...new Set(caseIDs)]; // create an Action ID and dispatch it to ES & Fleet Server - const esClient = context.core.elasticsearch.client.asCurrentUser; + let esClient = context.core.elasticsearch.client.asInternalUser; const actionID = uuid.v4(); - let result; + + let fleetActionIndexResult; + let logsEndpointActionsResult; + + const agents = endpointData.map((endpoint: HostMetadata) => endpoint.elastic.agent.id); + const doc = { + '@timestamp': moment().toISOString(), + agent: { + id: agents, + }, + EndpointAction: { + action_id: actionID, + expiration: moment().add(2, 'weeks').toISOString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + data: { + command: isolate ? 'isolate' : 'unisolate', + comment: req.body.comment ?? undefined, + }, + } as Omit, + user: { + id: user!.username, + }, + }; + + // if .logs-endpoint.actions data stream exists + // create action request record in .logs-endpoint.actions DS as the user + const doesLogsEndpointActionsDsExist = await doLogsEndpointActionDsExists({ + esClient, + logger: endpointContext.logFactory.get('host-isolation'), + dataStreamName: ENDPOINT_ACTIONS_DS, + }); + if (doesLogsEndpointActionsDsExist) { + esClient = context.core.elasticsearch.client.asCurrentUser; + try { + logsEndpointActionsResult = await esClient.index({ + index: `${ENDPOINT_ACTIONS_DS}-default`, + body: { + ...doc, + }, + }); + } catch (e) { + return res.customError({ + statusCode: 500, + body: { message: e }, + }); + } + + if (logsEndpointActionsResult.statusCode !== 201) { + return res.customError({ + statusCode: 500, + body: { + message: logsEndpointActionsResult.body.result, + }, + }); + } + } + + // create action request record as system user in .fleet-actions try { - result = await esClient.index({ + // we use this check to ensure the user has permission to write to this new index + // and thus allow this action to be added to the fleet index by kibana + if (!doesLogsEndpointActionsDsExist) { + esClient = context.core.elasticsearch.client.asCurrentUser; + } + + fleetActionIndexResult = await esClient.index({ index: AGENT_ACTIONS_INDEX, body: { - action_id: actionID, - '@timestamp': moment().toISOString(), - expiration: moment().add(2, 'weeks').toISOString(), - type: 'INPUT_ACTION', - input_type: 'endpoint', - agents: endpointData.map((endpt: HostMetadata) => endpt.elastic.agent.id), - user_id: user!.username, + ...doc.EndpointAction, + '@timestamp': doc['@timestamp'], + agents, timeout: 300, // 5 minutes - data: { - command: isolate ? 'isolate' : 'unisolate', - comment: req.body.comment ?? undefined, - }, + user_id: doc.user.id, }, }); } catch (e) { @@ -134,11 +221,11 @@ export const isolationRequestHandler = function ( }); } - if (result.statusCode !== 201) { + if (fleetActionIndexResult.statusCode !== 201) { return res.customError({ statusCode: 500, body: { - message: result.body.result, + message: fleetActionIndexResult.body.result, }, }); } From d9a4961a0e0506adf9b91d674ec9628547d06e36 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Tue, 5 Oct 2021 10:05:52 +0200 Subject: [PATCH 02/10] fix legacy tests --- .../server/endpoint/routes/actions/isolation.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index ed5dbbd09d79a..2a1a36629ba9f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -180,7 +180,16 @@ describe('Host Isolation', () => { (startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce( () => asUser ); + const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient); + ctx.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ + body: false, + statusCode: 404, + }) + ); const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 }; ctx.core.elasticsearch.client.asCurrentUser.index = jest .fn() From 7334401e01711c574c655d80d63bf2d492e42fdf Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Tue, 5 Oct 2021 16:45:53 +0200 Subject: [PATCH 03/10] add relevant additional tests --- .../endpoint_action_generator.ts | 46 +------------ .../data_loaders/index_endpoint_actions.ts | 8 +-- .../common/endpoint/types/actions.ts | 44 +++++++++++++ .../endpoint/routes/actions/isolation.test.ts | 66 ++++++++++++++++--- .../endpoint/routes/actions/isolation.ts | 7 +- 5 files changed, 108 insertions(+), 63 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index 0a39e4ea351f0..24be18046c4c4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -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']; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts index bf46214b20f31..764af65465163 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index c6d30825c21c9..f4b80c289d6bd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -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[]; + }; + EndpointAction: EndpointActionFields & ActionRequestFields; + error?: EcsError; + user: { + id: string; + }; +} + +export interface LogsEndpointActionResponse { + '@timestamp': string; + agent: { + id: string | string[]; + }; + EndpointAction: EndpointActionFields & ActionResponseFields; + error?: EcsError; +} + export interface EndpointActionData { command: ISOLATION_ACTIONS; comment?: string; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 2a1a36629ba9f..6d3de02d29b44 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -40,6 +40,7 @@ import { HostIsolationRequestBody, HostIsolationResponse, HostMetadata, + LogsEndpointAction, } from '../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; import { legacyMetadataSearchResponse } from '../metadata/support/test_support'; @@ -109,7 +110,8 @@ describe('Host Isolation', () => { let callRoute: ( routePrefix: string, - opts: CallRouteInterface + opts: CallRouteInterface, + indexExists?: { endpointDsExists: boolean } ) => Promise>; const superUser = { username: 'superuser', @@ -174,7 +176,8 @@ 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> => { const asUser = mockUser ? mockUser : superUser; (startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce( @@ -182,23 +185,32 @@ describe('Host Isolation', () => { ); const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient); + // mock _index_template ctx.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = jest .fn() - .mockImplementationOnce(() => - Promise.resolve({ + .mockImplementationOnce(() => { + if (indexExists) { + return Promise.resolve({ + body: true, + statusCode: 200, + }); + } + return Promise.resolve({ body: false, statusCode: 404, - }) - ); + }); + }); const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 }; - ctx.core.elasticsearch.client.asCurrentUser.index = jest - .fn() - .mockImplementationOnce(() => Promise.resolve(withIdxResp)); - ctx.core.elasticsearch.client.asCurrentUser.search = jest + const mockIndexResponse = jest.fn().mockImplementation(() => Promise.resolve(withIdxResp)); + const mockSearchResponse = jest .fn() .mockImplementation(() => Promise.resolve({ body: legacyMetadataSearchResponse(searchResponse) }) ); + ctx.core.elasticsearch.client.asInternalUser.index = mockIndexResponse; + ctx.core.elasticsearch.client.asCurrentUser.index = mockIndexResponse; + ctx.core.elasticsearch.client.asInternalUser.search = mockSearchResponse; + ctx.core.elasticsearch.client.asCurrentUser.search = mockSearchResponse; const withLicense = license ? license : Platinum; licenseEmitter.next(withLicense); const mockRequest = httpServerMock.createKibanaRequest({ body }); @@ -334,6 +346,40 @@ 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: [LogsEndpointAction, EndpointAction] = [ + (ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0].body, + (ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0].body, + ]; + expect(actionDocs[0].EndpointAction.data.command).toEqual('unisolate'); + expect(actionDocs[1].data.command).toEqual('unisolate'); + }); + + it('handles isolation', async () => { + const ctx = await callRoute( + ISOLATE_HOST_ROUTE, + { + body: { endpoint_ids: ['XYZ'] }, + }, + { endpointDsExists: true } + ); + const actionDocs: [LogsEndpointAction, EndpointAction] = [ + (ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0].body, + (ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0].body, + ]; + expect(actionDocs[0].EndpointAction.data.command).toEqual('isolate'); + expect(actionDocs[1].data.command).toEqual('isolate'); + }); + }); + describe('License Level', () => { it('allows platinum license levels to isolate hosts', async () => { await callRoute(ISOLATE_HOST_ROUTE, { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index c88cbc45cd97a..f1d8fc93dde34 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -9,7 +9,6 @@ import moment from 'moment'; import { ElasticsearchClient, RequestHandler, Logger } from 'src/core/server'; import uuid from 'uuid'; import { TypeOf } from '@kbn/config-schema'; -import { LogsEndpointAction } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; import { CommentType } from '../../../../../cases/common'; import { CasesByAlertId } from '../../../../../cases/common/api/cases/case'; import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; @@ -19,7 +18,11 @@ import { UNISOLATE_HOST_ROUTE, } from '../../../../common/endpoint/constants'; import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; -import { EndpointAction, HostMetadata } from '../../../../common/endpoint/types'; +import { + EndpointAction, + HostMetadata, + LogsEndpointAction, +} from '../../../../common/endpoint/types'; import { SecuritySolutionPluginRouter, SecuritySolutionRequestHandlerContext, From 1dda776b64c6ada4104ef3d96e82009393c67816 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Tue, 5 Oct 2021 16:53:27 +0200 Subject: [PATCH 04/10] remove duplicate test --- .../server/endpoint/routes/actions/isolation.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 6d3de02d29b44..187cee1777139 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -308,11 +308,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; From 1902317dcdaa4e58f915b1589b1b6edc3c415b1e Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Wed, 6 Oct 2021 12:05:28 +0200 Subject: [PATCH 05/10] update tests --- .../endpoint/routes/actions/isolation.test.ts | 62 +++++++++++++++---- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 187cee1777139..91737c6b0ee59 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -34,6 +34,7 @@ import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE, metadataTransformPrefix, + ENDPOINT_ACTIONS_INDEX, } from '../../../../common/endpoint/constants'; import { EndpointAction, @@ -44,7 +45,7 @@ import { } 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 { @@ -207,9 +208,10 @@ describe('Host Isolation', () => { .mockImplementation(() => Promise.resolve({ body: legacyMetadataSearchResponse(searchResponse) }) ); - ctx.core.elasticsearch.client.asInternalUser.index = mockIndexResponse; + if (indexExists) { + ctx.core.elasticsearch.client.asInternalUser.index = mockIndexResponse; + } ctx.core.elasticsearch.client.asCurrentUser.index = mockIndexResponse; - ctx.core.elasticsearch.client.asInternalUser.search = mockSearchResponse; ctx.core.elasticsearch.client.asCurrentUser.search = mockSearchResponse; const withLicense = license ? license : Platinum; licenseEmitter.next(withLicense); @@ -350,12 +352,18 @@ describe('Host Isolation', () => { }, { endpointDsExists: true } ); - const actionDocs: [LogsEndpointAction, EndpointAction] = [ - (ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0].body, - (ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0].body, + 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].EndpointAction.data.command).toEqual('unisolate'); - expect(actionDocs[1].data.command).toEqual('unisolate'); + + expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX); + expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX); + expect(actionDocs[0].body.EndpointAction.data.command).toEqual('unisolate'); + expect(actionDocs[1].body.data.command).toEqual('unisolate'); }); it('handles isolation', async () => { @@ -366,12 +374,40 @@ describe('Host Isolation', () => { }, { endpointDsExists: true } ); - const actionDocs: [LogsEndpointAction, EndpointAction] = [ - (ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0].body, - (ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0].body, + 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].EndpointAction.data.command).toEqual('isolate'); - expect(actionDocs[1].data.command).toEqual('isolate'); + + expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX); + expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX); + expect(actionDocs[0].body.EndpointAction.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); }); }); From e1e3d4e2fc123b1a2dcba7f84dbe7ad23c4bdbff Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Wed, 6 Oct 2021 13:23:31 +0200 Subject: [PATCH 06/10] cleanup review changes refs elastic/security-team/issues/1704 --- .../endpoint/routes/actions/isolation.ts | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index f1d8fc93dde34..734b449b40dce 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -61,19 +61,20 @@ export function registerHostIsolationRoutes( } const doLogsEndpointActionDsExists = async ({ - esClient, + context, logger, dataStreamName, }: { - esClient: ElasticsearchClient; + context: SecuritySolutionRequestHandlerContext; logger: Logger; dataStreamName: string; }): Promise => { - let doesIndexTemplateExist; try { - doesIndexTemplateExist = await esClient.indices.existsIndexTemplate({ + const esClient = context.core.elasticsearch.client.asInternalUser; + const doesIndexTemplateExist = await esClient.indices.existsIndexTemplate({ name: dataStreamName, }); + return doesIndexTemplateExist.statusCode === 404 ? false : true; } catch (error) { const errorType = error?.type ?? ''; if (errorType !== 'resource_not_found_exception') { @@ -82,7 +83,6 @@ const doLogsEndpointActionDsExists = async ({ } return false; } - return doesIndexTemplateExist.statusCode === 404 ? false : true; }; export const isolationRequestHandler = function ( @@ -139,7 +139,6 @@ export const isolationRequestHandler = function ( caseIDs = [...new Set(caseIDs)]; // create an Action ID and dispatch it to ES & Fleet Server - let esClient = context.core.elasticsearch.client.asInternalUser; const actionID = uuid.v4(); let fleetActionIndexResult; @@ -167,44 +166,44 @@ export const isolationRequestHandler = function ( }; // if .logs-endpoint.actions data stream exists - // create action request record in .logs-endpoint.actions DS as the user + // create action request record in .logs-endpoint.actions DS as the current user const doesLogsEndpointActionsDsExist = await doLogsEndpointActionDsExists({ - esClient, + context, logger: endpointContext.logFactory.get('host-isolation'), dataStreamName: ENDPOINT_ACTIONS_DS, }); if (doesLogsEndpointActionsDsExist) { - esClient = context.core.elasticsearch.client.asCurrentUser; try { + const esClient = context.core.elasticsearch.client.asCurrentUser; logsEndpointActionsResult = await esClient.index({ index: `${ENDPOINT_ACTIONS_DS}-default`, body: { ...doc, }, }); + if (logsEndpointActionsResult.statusCode !== 201) { + return res.customError({ + statusCode: 500, + body: { + message: logsEndpointActionsResult.body.result, + }, + }); + } } catch (e) { return res.customError({ statusCode: 500, body: { message: e }, }); } - - if (logsEndpointActionsResult.statusCode !== 201) { - return res.customError({ - statusCode: 500, - body: { - message: logsEndpointActionsResult.body.result, - }, - }); - } } // create action request record as system user in .fleet-actions try { - // we use this check to ensure the user has permission to write to this new index - // and thus allow this action to be added to the fleet index by kibana - if (!doesLogsEndpointActionsDsExist) { - esClient = context.core.elasticsearch.client.asCurrentUser; + // we use this check to ensure the user has permission to write to the new index + // and thus allow this action record to be added to the fleet index specifically by kibana + let esClient = context.core.elasticsearch.client.asCurrentUser; + if (doesLogsEndpointActionsDsExist) { + esClient = context.core.elasticsearch.client.asInternalUser; } fleetActionIndexResult = await esClient.index({ @@ -217,6 +216,14 @@ export const isolationRequestHandler = function ( user_id: doc.user.id, }, }); + if (fleetActionIndexResult.statusCode !== 201) { + return res.customError({ + statusCode: 500, + body: { + message: fleetActionIndexResult.body.result, + }, + }); + } } catch (e) { return res.customError({ statusCode: 500, @@ -224,15 +231,6 @@ export const isolationRequestHandler = function ( }); } - if (fleetActionIndexResult.statusCode !== 201) { - return res.customError({ - statusCode: 500, - body: { - message: fleetActionIndexResult.body.result, - }, - }); - } - // Update all cases with a comment if (caseIDs.length > 0) { const targets = endpointData.map((endpt: HostMetadata) => ({ From 64fcd1fc303cd15bf1ef732f9ab49647fe7123c2 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Wed, 6 Oct 2021 13:51:50 +0200 Subject: [PATCH 07/10] fix lint --- .../server/endpoint/routes/actions/isolation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 734b449b40dce..bf59a6a3a0d19 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -6,7 +6,7 @@ */ import moment from 'moment'; -import { ElasticsearchClient, RequestHandler, Logger } from 'src/core/server'; +import { RequestHandler, Logger } from 'src/core/server'; import uuid from 'uuid'; import { TypeOf } from '@kbn/config-schema'; import { CommentType } from '../../../../../cases/common'; From 25b6a9d34b27b36f8e5bb31b7d5f0750dccf7cc8 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Mon, 11 Oct 2021 14:24:45 +0200 Subject: [PATCH 08/10] Use correct mapping keys when writing to index --- .../data_generators/endpoint_action_generator.ts | 8 ++++---- .../endpoint/data_loaders/index_endpoint_actions.ts | 12 ++++++------ .../common/endpoint/types/actions.ts | 4 ++-- .../server/endpoint/routes/actions/isolation.test.ts | 4 ++-- .../server/endpoint/routes/actions/isolation.ts | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index 24be18046c4c4..dd4eeeab15cce 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -22,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', @@ -42,11 +42,11 @@ export class EndpointActionGenerator extends BaseDataGenerator { } generateIsolateAction(overrides: DeepPartial = {}): LogsEndpointAction { - return merge(this.generate({ EndpointAction: { data: { command: 'isolate' } } }), overrides); + return merge(this.generate({ EndpointActions: { data: { command: 'isolate' } } }), overrides); } generateUnIsolateAction(overrides: DeepPartial = {}): LogsEndpointAction { - return merge(this.generate({ EndpointAction: { data: { command: 'unisolate' } } }), overrides); + return merge(this.generate({ EndpointActions: { data: { command: 'unisolate' } } }), overrides); } /** Generates an endpoint action response */ @@ -61,7 +61,7 @@ export class EndpointActionGenerator extends BaseDataGenerator { agent: { id: this.randomUUID(), }, - EndpointAction: { + EndpointActions: { action_id: this.randomUUID(), completed_at: timeStamp.toISOString(), data: { diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts index 764af65465163..e4379271315dd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts @@ -45,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' }, }, }); @@ -62,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, }, }); @@ -170,7 +170,7 @@ export const deleteIndexedEndpointActions = async ( { terms: { action_id: indexedData.endpointActions.map( - (action) => action.EndpointAction.action_id + (action) => action.EndpointActions.action_id ), }, }, @@ -196,7 +196,7 @@ export const deleteIndexedEndpointActions = async ( { terms: { action_id: indexedData.endpointActionResponses.map( - (action) => action.EndpointAction.action_id + (action) => action.EndpointActions.action_id ), }, }, diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index f4b80c289d6bd..709a34e8e8be2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -38,7 +38,7 @@ export interface LogsEndpointAction { agent: { id: string | string[]; }; - EndpointAction: EndpointActionFields & ActionRequestFields; + EndpointActions: EndpointActionFields & ActionRequestFields; error?: EcsError; user: { id: string; @@ -50,7 +50,7 @@ export interface LogsEndpointActionResponse { agent: { id: string | string[]; }; - EndpointAction: EndpointActionFields & ActionResponseFields; + EndpointActions: EndpointActionFields & ActionResponseFields; error?: EcsError; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index a193346c766a8..ee3bc5e1f21e3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -363,7 +363,7 @@ describe('Host Isolation', () => { expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX); expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX); - expect(actionDocs[0].body.EndpointAction.data.command).toEqual('unisolate'); + expect(actionDocs[0].body.EndpointActions.data.command).toEqual('unisolate'); expect(actionDocs[1].body.data.command).toEqual('unisolate'); }); @@ -385,7 +385,7 @@ describe('Host Isolation', () => { expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX); expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX); - expect(actionDocs[0].body.EndpointAction.data.command).toEqual('isolate'); + expect(actionDocs[0].body.EndpointActions.data.command).toEqual('isolate'); expect(actionDocs[1].body.data.command).toEqual('isolate'); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index bf59a6a3a0d19..e33603798a08a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -150,7 +150,7 @@ export const isolationRequestHandler = function ( agent: { id: agents, }, - EndpointAction: { + EndpointActions: { action_id: actionID, expiration: moment().add(2, 'weeks').toISOString(), type: 'INPUT_ACTION', @@ -209,7 +209,7 @@ export const isolationRequestHandler = function ( fleetActionIndexResult = await esClient.index({ index: AGENT_ACTIONS_INDEX, body: { - ...doc.EndpointAction, + ...doc.EndpointActions, '@timestamp': doc['@timestamp'], agents, timeout: 300, // 5 minutes From 2890fb699d7a3cdcbdf4c6ced6b17aa7043f1eb5 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Tue, 12 Oct 2021 17:02:39 +0200 Subject: [PATCH 09/10] write record on new index when action request fails to write to `.fleet-actions` review comments --- .../common/endpoint/types/actions.ts | 8 +-- .../endpoint/routes/actions/isolation.ts | 63 +++++++++++++++++-- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 709a34e8e8be2..bc46ca2f5b451 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -11,11 +11,11 @@ import { ActionStatusRequestSchema, HostIsolationRequestSchema } from '../schema export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; interface EcsError { - code: string; - id: string; + code?: string; + id?: string; message: string; - stack_trace: string; - type: string; + stack_trace?: string; + type?: string; } interface EndpointActionFields { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index e33603798a08a..df0af269f11dc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -14,6 +14,7 @@ import { CasesByAlertId } from '../../../../../cases/common/api/cases/case'; import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; import { ENDPOINT_ACTIONS_DS, + ENDPOINT_ACTION_RESPONSES_DS, ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE, } from '../../../../common/endpoint/constants'; @@ -22,6 +23,7 @@ import { EndpointAction, HostMetadata, LogsEndpointAction, + LogsEndpointActionResponse, } from '../../../../common/endpoint/types'; import { SecuritySolutionPluginRouter, @@ -60,6 +62,32 @@ export function registerHostIsolationRoutes( ); } +const createFailedActionResponseEntry = async ({ + context, + doc, + logger, +}: { + context: SecuritySolutionRequestHandlerContext; + doc: LogsEndpointActionResponse; + logger: Logger; +}): Promise => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + try { + await esClient.index({ + index: `${ENDPOINT_ACTION_RESPONSES_DS}-default`, + body: { + ...doc, + error: { + code: '424', + message: 'Failed to send action request to agent', + }, + }, + }); + } catch (e) { + logger.error(e); + } +}; + const doLogsEndpointActionDsExists = async ({ context, logger, @@ -166,12 +194,17 @@ export const isolationRequestHandler = function ( }; // if .logs-endpoint.actions data stream exists - // create action request record in .logs-endpoint.actions DS as the current user + // try to create action request record in .logs-endpoint.actions DS as the current user + // (from >= v7.16, use this check to ensure the current user has privileges to write to the new index) + // and allow only users with superuser privileges to write to fleet indices + const logger = endpointContext.logFactory.get('host-isolation'); const doesLogsEndpointActionsDsExist = await doLogsEndpointActionDsExists({ context, - logger: endpointContext.logFactory.get('host-isolation'), + logger, dataStreamName: ENDPOINT_ACTIONS_DS, }); + // if the new endpoint indices/data streams exists + // write the action request to the new index as the current user if (doesLogsEndpointActionsDsExist) { try { const esClient = context.core.elasticsearch.client.asCurrentUser; @@ -197,15 +230,14 @@ export const isolationRequestHandler = function ( } } - // create action request record as system user in .fleet-actions try { - // we use this check to ensure the user has permission to write to the new index - // and thus allow this action record to be added to the fleet index specifically by kibana let esClient = context.core.elasticsearch.client.asCurrentUser; if (doesLogsEndpointActionsDsExist) { + // create action request record as system user with user in .fleet-actions esClient = context.core.elasticsearch.client.asInternalUser; } - + // write as the current user if the new indices do not exist + // ({ index: AGENT_ACTIONS_INDEX, body: { @@ -216,6 +248,7 @@ export const isolationRequestHandler = function ( user_id: doc.user.id, }, }); + if (fleetActionIndexResult.statusCode !== 201) { return res.customError({ statusCode: 500, @@ -225,6 +258,24 @@ export const isolationRequestHandler = function ( }); } } catch (e) { + // create entry in .logs-endpoint.action.responses-default data stream + // when writing to .fleet-actions fails + if (doesLogsEndpointActionsDsExist) { + await createFailedActionResponseEntry({ + context, + doc: { + '@timestamp': moment().toISOString(), + agent: doc.agent, + EndpointActions: { + action_id: doc.EndpointActions.action_id, + completed_at: moment().toISOString(), + started_at: moment().toISOString(), + data: doc.EndpointActions.data, + }, + }, + logger, + }); + } return res.customError({ statusCode: 500, body: { message: e }, From ad0339db26c9f4bc41e9173d50da2410bf475207 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Tue, 12 Oct 2021 18:03:00 +0200 Subject: [PATCH 10/10] better error message review comment --- .../server/endpoint/routes/actions/isolation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index df0af269f11dc..4652630649ffc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -79,7 +79,7 @@ const createFailedActionResponseEntry = async ({ ...doc, error: { code: '424', - message: 'Failed to send action request to agent', + message: 'Failed to deliver action request to fleet', }, }, });