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

* 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>
This commit is contained in:
Ashokaditya 2021-10-13 09:25:20 +02:00 committed by GitHub
parent 3d75154368
commit 1d71d42a7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 326 additions and 101 deletions

View file

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

View file

@ -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'];
@ -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',
@ -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 */
@ -105,7 +61,7 @@ export class EndpointActionGenerator extends BaseDataGenerator {
agent: {
id: this.randomUUID(),
},
EndpointAction: {
EndpointActions: {
action_id: this.randomUUID(),
completed_at: timeStamp.toISOString(),
data: {

View file

@ -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';
@ -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' },
},
});
@ -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,
},
});
@ -174,7 +170,7 @@ export const deleteIndexedEndpointActions = async (
{
terms: {
action_id: indexedData.endpointActions.map(
(action) => action.EndpointAction.action_id
(action) => action.EndpointActions.action_id
),
},
},
@ -200,7 +196,7 @@ export const deleteIndexedEndpointActions = async (
{
terms: {
action_id: indexedData.endpointActionResponses.map(
(action) => action.EndpointAction.action_id
(action) => action.EndpointActions.action_id
),
},
},

View file

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

View file

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

View file

@ -6,15 +6,25 @@
*/
import moment from 'moment';
import { RequestHandler } 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';
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,
ENDPOINT_ACTION_RESPONSES_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 {
EndpointAction,
HostMetadata,
LogsEndpointAction,
LogsEndpointActionResponse,
} from '../../../../common/endpoint/types';
import {
SecuritySolutionPluginRouter,
SecuritySolutionRequestHandlerContext,
@ -52,6 +62,57 @@ export function registerHostIsolationRoutes(
);
}
const createFailedActionResponseEntry = async ({
context,
doc,
logger,
}: {
context: SecuritySolutionRequestHandlerContext;
doc: LogsEndpointActionResponse;
logger: Logger;
}): Promise<void> => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
try {
await esClient.index<LogsEndpointActionResponse>({
index: `${ENDPOINT_ACTION_RESPONSES_DS}-default`,
body: {
...doc,
error: {
code: '424',
message: 'Failed to deliver action request to fleet',
},
},
});
} catch (e) {
logger.error(e);
}
};
const doLogsEndpointActionDsExists = async ({
context,
logger,
dataStreamName,
}: {
context: SecuritySolutionRequestHandlerContext;
logger: Logger;
dataStreamName: string;
}): Promise<boolean> => {
try {
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') {
logger.error(error);
throw error;
}
return false;
}
};
export const isolationRequestHandler = function (
endpointContext: EndpointAppContext,
isolate: boolean
@ -106,43 +167,121 @@ 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;
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,
},
EndpointActions: {
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<EndpointAction, 'agents' | 'user_id'>,
user: {
id: user!.username,
},
};
// if .logs-endpoint.actions data stream exists
// 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,
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;
logsEndpointActionsResult = await esClient.index<LogsEndpointAction>({
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 },
});
}
}
try {
result = await esClient.index<EndpointAction>({
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
// <v7.16 requires the current user to be super user
fleetActionIndexResult = await esClient.index<EndpointAction>({
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.EndpointActions,
'@timestamp': doc['@timestamp'],
agents,
timeout: 300, // 5 minutes
data: {
command: isolate ? 'isolate' : 'unisolate',
comment: req.body.comment ?? undefined,
},
user_id: doc.user.id,
},
});
if (fleetActionIndexResult.statusCode !== 201) {
return res.customError({
statusCode: 500,
body: {
message: fleetActionIndexResult.body.result,
},
});
}
} 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 },
});
}
if (result.statusCode !== 201) {
return res.customError({
statusCode: 500,
body: {
message: result.body.result,
},
});
}
// Update all cases with a comment
if (caseIDs.length > 0) {
const targets = endpointData.map((endpt: HostMetadata) => ({