[Security Solution] generic response actions API handler (#133977)

This commit is contained in:
Joey F. Poon 2022-06-14 11:31:54 -05:00 committed by GitHub
parent f45e09e58b
commit 3819bf12ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1062 additions and 12 deletions

View file

@ -54,10 +54,19 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`;
export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`;
export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`;
const BASE_ENDPOINT_ACTION_ROUTE = `${BASE_ENDPOINT_ROUTE}/action`;
/** Action Response Routes */
export const ISOLATE_HOST_ROUTE_V2 = `${BASE_ENDPOINT_ACTION_ROUTE}/isolate`;
export const RELEASE_HOST_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/release`;
export const GET_RUNNING_PROCESSES_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/running_procs`;
export const KILL_PROCESS_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/kill_process`;
export const SUSPEND_PROCESS_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/suspend_process`;
/** Endpoint Actions Routes */
export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`;
export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`;
export const ACTION_DETAILS_ROUTE = `/api/endpoint/action/{action_id}`;
export const ENDPOINT_ACTION_LOG_ROUTE = `${BASE_ENDPOINT_ROUTE}/action_log/{agent_id}`;
export const ACTION_STATUS_ROUTE = `${BASE_ENDPOINT_ROUTE}/action_status`;
export const ACTION_DETAILS_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{action_id}`;
export const failedFleetActionErrorCode = '424';

View file

@ -0,0 +1,117 @@
/*
* 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 { HostIsolationRequestSchema, KillProcessRequestSchema } from './actions';
describe('actions schemas', () => {
describe('HostIsolationRequestSchema', () => {
it('should require at least 1 Endpoint ID', () => {
expect(() => {
HostIsolationRequestSchema.body.validate({});
}).toThrow();
});
it('should accept an Endpoint ID as the only required field', () => {
expect(() => {
HostIsolationRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
});
}).not.toThrow();
});
it('should accept a comment', () => {
expect(() => {
HostIsolationRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
comment: 'a user comment',
});
}).not.toThrow();
});
it('should accept alert IDs', () => {
expect(() => {
HostIsolationRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
alert_ids: ['0000000-000-00'],
});
}).not.toThrow();
});
it('should accept case IDs', () => {
expect(() => {
HostIsolationRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
case_ids: ['000000000-000-000'],
});
}).not.toThrow();
});
});
describe('KillProcessRequestSchema', () => {
it('should require at least 1 Endpoint ID', () => {
expect(() => {
HostIsolationRequestSchema.body.validate({});
}).toThrow();
});
it('should accept pid', () => {
expect(() => {
KillProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
parameters: {
pid: 1234,
},
});
}).not.toThrow();
});
it('should accept entity_id', () => {
expect(() => {
KillProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
parameters: {
entity_id: 5678,
},
});
}).not.toThrow();
});
it('should reject pid and entity_id together', () => {
expect(() => {
KillProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
parameters: {
pid: 1234,
entity_id: 5678,
},
});
}).toThrow();
});
it('should reject if no pid or entity_id', () => {
expect(() => {
KillProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
comment: 'a user comment',
parameters: {},
});
}).toThrow();
});
it('should accept a comment', () => {
expect(() => {
KillProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
comment: 'a user comment',
parameters: {
pid: 1234,
},
});
}).not.toThrow();
});
});
});

View file

@ -7,18 +7,36 @@
import { schema, TypeOf } from '@kbn/config-schema';
const BaseActionRequestSchema = {
/** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */
endpoint_ids: schema.arrayOf(schema.string(), { minSize: 1 }),
/** 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()),
parameters: schema.maybe(schema.object({})),
};
export const HostIsolationRequestSchema = {
body: schema.object({ ...BaseActionRequestSchema }),
};
export const KillProcessRequestSchema = {
body: schema.object({
/** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */
endpoint_ids: schema.arrayOf(schema.string(), { minSize: 1 }),
/** 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()),
...BaseActionRequestSchema,
parameters: schema.oneOf([
schema.object({ pid: schema.number({ min: 1 }) }),
schema.object({ entity_id: schema.number({ min: 1 }) }),
]),
}),
};
export const responseActionBodySchemas = schema.oneOf([
HostIsolationRequestSchema.body,
KillProcessRequestSchema.body,
]);
export const EndpointActionLogRequestSchema = {
query: schema.object({
page: schema.number({ defaultValue: 1, min: 1 }),

View file

@ -10,6 +10,8 @@ import { ActionStatusRequestSchema, HostIsolationRequestSchema } from '../schema
export type ISOLATION_ACTIONS = 'isolate' | 'unisolate';
export type ResponseActions = ISOLATION_ACTIONS;
export const ActivityLogItemTypes = {
ACTION: 'action' as const,
RESPONSE: 'response' as const,
@ -70,9 +72,24 @@ export interface LogsEndpointActionResponse {
error?: EcsError;
}
export interface EndpointActionData {
command: ISOLATION_ACTIONS;
interface KillProcessWithPid {
pid: number;
entity_id?: never;
}
interface KillProcessWithEntityId {
pid?: never;
entity_id: number;
}
export type KillProcessParameters = KillProcessWithPid | KillProcessWithEntityId;
export type EndpointActionDataParameterTypes = undefined | KillProcessParameters;
export interface EndpointActionData<T extends EndpointActionDataParameterTypes = undefined> {
command: ResponseActions;
comment?: string;
parameters?: T;
}
export interface FleetActionResponseData {
@ -173,6 +190,11 @@ export interface HostIsolationResponse {
action: string;
}
export interface ResponseActionApiResponse {
action?: string;
data: ActionDetails;
}
export interface EndpointPendingActions {
agent_id: string;
pending_actions: {

View file

@ -11,6 +11,7 @@ import { EndpointAppContext } from '../../types';
import { registerHostIsolationRoutes } from './isolation';
import { registerActionStatusRoutes } from './status';
import { registerActionAuditLogRoutes } from './audit_log';
import { registerResponseActionRoutes } from './response_actions';
export * from './isolation';
@ -24,4 +25,5 @@ export function registerActionRoutes(
registerActionStatusRoutes(router, endpointContext);
registerActionAuditLogRoutes(router, endpointContext);
registerActionDetailsRoutes(router, endpointContext);
registerResponseActionRoutes(router, endpointContext);
}

View file

@ -0,0 +1,561 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Subject } from 'rxjs';
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ILicense } from '@kbn/licensing-plugin/common/types';
import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock';
import { License } from '@kbn/licensing-plugin/common/license';
import { AwaitedProperties } from '@kbn/utility-types';
import {
KibanaRequest,
KibanaResponseFactory,
RequestHandler,
RouteConfig,
} from '@kbn/core/server';
import {
elasticsearchServiceMock,
httpServerMock,
httpServiceMock,
loggingSystemMock,
savedObjectsClientMock,
} from '@kbn/core/server/mocks';
import type { PackageClient } from '@kbn/fleet-plugin/server';
import { createMockPackageService } from '@kbn/fleet-plugin/server/mocks';
import { AGENT_ACTIONS_INDEX, ElasticsearchAssetType } from '@kbn/fleet-plugin/common';
import { CasesClientMock } from '@kbn/cases-plugin/server/client/mocks';
import { parseExperimentalConfigValue } from '../../../../common/experimental_features';
import { LicenseService } from '../../../../common/license';
import {
ISOLATE_HOST_ROUTE_V2,
RELEASE_HOST_ROUTE,
metadataTransformPrefix,
ENDPOINT_ACTIONS_INDEX,
} from '../../../../common/endpoint/constants';
import {
ActionDetails,
EndpointAction,
HostIsolationRequestBody,
ResponseActionApiResponse,
HostMetadata,
LogsEndpointAction,
} from '../../../../common/endpoint/types';
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
import { EndpointAuthz } from '../../../../common/endpoint/types/authz';
import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__';
import { SecuritySolutionRequestHandlerContextMock } from '../../../lib/detection_engine/routes/__mocks__/request_context';
import { EndpointAppContextService } from '../../endpoint_app_context_services';
import {
createMockEndpointAppContextServiceSetupContract,
createMockEndpointAppContextServiceStartContract,
createRouteHandlerContext,
} from '../../mocks';
import { legacyMetadataSearchResponseMock } from '../metadata/support/test_support';
import { registerResponseActionRoutes } from './response_actions';
import * as ActionDetailsService from '../../services/actions/action_details_by_id';
interface CallRouteInterface {
body?: HostIsolationRequestBody;
idxResponse?: any;
searchResponse?: HostMetadata;
mockUser?: any;
license?: License;
authz?: Partial<EndpointAuthz>;
}
const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } });
const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } });
describe('Response actions', () => {
describe('handler', () => {
let endpointAppContextService: EndpointAppContextService;
let mockResponse: jest.Mocked<KibanaResponseFactory>;
let licenseService: LicenseService;
let licenseEmitter: Subject<ILicense>;
let getActionDetailsByIdSpy: jest.SpyInstance;
let callRoute: (
routePrefix: string,
opts: CallRouteInterface,
indexExists?: { endpointDsExists: boolean }
) => Promise<AwaitedProperties<SecuritySolutionRequestHandlerContextMock>>;
const superUser = {
username: 'superuser',
roles: ['superuser'],
};
const docGen = new EndpointDocGenerator();
beforeEach(() => {
// instantiate... everything
const mockScopedClient = elasticsearchServiceMock.createScopedClusterClient();
const mockClusterClient = elasticsearchServiceMock.createClusterClient();
mockClusterClient.asScoped.mockReturnValue(mockScopedClient);
const routerMock = httpServiceMock.createRouter();
mockResponse = httpServerMock.createResponseFactory();
const startContract = createMockEndpointAppContextServiceStartContract();
endpointAppContextService = new EndpointAppContextService();
const mockSavedObjectClient = savedObjectsClientMock.create();
const mockPackageService = createMockPackageService();
const mockedPackageClient = mockPackageService.asInternalUser as jest.Mocked<PackageClient>;
mockedPackageClient.getInstallation.mockResolvedValue({
installed_kibana: [],
package_assets: [],
es_index_patterns: {},
name: '',
version: '',
install_status: 'installed',
install_version: '',
install_started_at: '',
install_source: 'registry',
installed_es: [
{
id: 'logs-endpoint.events.security',
type: ElasticsearchAssetType.indexTemplate,
},
{
id: `${metadataTransformPrefix}-0.16.0-dev.0`,
type: ElasticsearchAssetType.transform,
},
],
keep_policies_up_to_date: false,
});
licenseEmitter = new Subject();
licenseService = new LicenseService();
licenseService.start(licenseEmitter);
endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract());
endpointAppContextService.start({
...startContract,
licenseService,
packageService: mockPackageService,
});
// add the host isolation route handlers to routerMock
registerResponseActionRoutes(routerMock, {
logFactory: loggingSystemMock.create(),
service: endpointAppContextService,
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
});
getActionDetailsByIdSpy = jest
.spyOn(ActionDetailsService, 'getActionDetailsById')
.mockResolvedValue({} as ActionDetails);
// define a convenience function to execute an API call for a given route, body, and mocked response from ES
// 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, authz = {} }: CallRouteInterface,
indexExists?: { endpointDsExists: boolean }
): Promise<AwaitedProperties<SecuritySolutionRequestHandlerContextMock>> => {
const asUser = mockUser ? mockUser : superUser;
(startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce(
() => asUser
);
const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient);
ctx.securitySolution.endpointAuthz = {
...ctx.securitySolution.endpointAuthz,
...authz,
};
// mock _index_template
ctx.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate.mockResponseImplementationOnce(
() => {
if (indexExists) {
return {
body: true,
statusCode: 200,
};
}
return {
body: false,
statusCode: 404,
};
}
);
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
ctx.core.elasticsearch.client.asInternalUser.index.mockResponseImplementation(
() => withIdxResp
);
ctx.core.elasticsearch.client.asCurrentUser.search.mockResponseImplementation(() => {
return {
body: legacyMetadataSearchResponseMock(searchResponse),
};
});
const withLicense = license ? license : Platinum;
licenseEmitter.next(withLicense);
const mockRequest = httpServerMock.createKibanaRequest({ body });
const [, routeHandler]: [
RouteConfig<any, any, any, any>,
RequestHandler<any, any, any, any>
] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(routePrefix))!;
await routeHandler(ctx, mockRequest, mockResponse);
return ctx;
};
});
afterEach(() => {
endpointAppContextService.stop();
licenseService.stop();
licenseEmitter.complete();
getActionDetailsByIdSpy.mockClear();
});
it('succeeds when an endpoint ID is provided', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, { body: { endpoint_ids: ['XYZ'] } });
expect(mockResponse.ok).toBeCalled();
});
it('reports elasticsearch errors creating an action', async () => {
const ErrMessage = 'something went wrong?';
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
idxResponse: {
statusCode: 500,
body: {
result: ErrMessage,
},
},
});
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);
});
it('accepts a comment field', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, { body: { endpoint_ids: ['XYZ'], comment: 'XYZ' } });
expect(mockResponse.ok).toBeCalled();
});
it('sends the action to the requested agent', async () => {
const metadataResponse = docGen.generateHostMetadata();
const AgentID = metadataResponse.elastic.agent.id;
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['ABC-XYZ-000'] },
searchResponse: metadataResponse,
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.agents).toContain(AgentID);
});
it('records the user who performed the action to the action record', async () => {
const testU = { username: 'testuser', roles: ['superuser'] };
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
mockUser: testU,
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.user_id).toEqual(testU.username);
});
it('records the comment in the action payload', async () => {
const CommentText = "I am isolating this because it's Friday";
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'], comment: CommentText },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.data.comment).toEqual(CommentText);
});
it('creates an action and returns its ID + ActionDetails', async () => {
const endpointIds = ['XYZ'];
const actionDetails = { agents: endpointIds, command: 'isolate' } as ActionDetails;
getActionDetailsByIdSpy.mockResolvedValue(actionDetails);
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: endpointIds, comment: 'XYZ' },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
const actionID = actionDoc.action_id;
expect(mockResponse.ok).toBeCalled();
expect((mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse).action).toEqual(
actionID
);
expect(getActionDetailsByIdSpy).toHaveBeenCalledTimes(1);
expect((mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse).data).toEqual(
actionDetails
);
});
it('records the timeout in the action payload', async () => {
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.timeout).toEqual(300);
});
it('sends the action to the correct agent when endpoint ID is given', async () => {
const doc = docGen.generateHostMetadata();
const AgentID = doc.elastic.agent.id;
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
searchResponse: doc,
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.agents).toContain(AgentID);
});
it('sends the isolate command payload from the isolate route', async () => {
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.data.command).toEqual('isolate');
});
it('sends the unisolate command payload from the unisolate route', async () => {
const ctx = await callRoute(RELEASE_HOST_ROUTE, {
body: { endpoint_ids: ['XYZ'] },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.data.command).toEqual('unisolate');
});
describe('With endpoint data streams', () => {
it('handles unisolation', async () => {
const ctx = await callRoute(
RELEASE_HOST_ROUTE,
{
body: { endpoint_ids: ['XYZ'] },
},
{ endpointDsExists: true }
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
indexDoc.mock.calls[0][0] as estypes.IndexRequest<LogsEndpointAction>,
indexDoc.mock.calls[1][0] as estypes.IndexRequest<EndpointAction>,
];
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');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
expect(responseBody.action).toBeTruthy();
});
it('handles isolation', async () => {
const ctx = await callRoute(
ISOLATE_HOST_ROUTE_V2,
{
body: { endpoint_ids: ['XYZ'] },
},
{ endpointDsExists: true }
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
indexDoc.mock.calls[0][0] as estypes.IndexRequest<LogsEndpointAction>,
indexDoc.mock.calls[1][0] as estypes.IndexRequest<EndpointAction>,
];
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');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
expect(responseBody.action).toBeTruthy();
});
it('handles errors', async () => {
const ErrMessage = 'Uh oh!';
await callRoute(
RELEASE_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_V2, {
body: { endpoint_ids: ['XYZ'] },
license: Platinum,
});
expect(mockResponse.ok).toBeCalled();
});
it('prohibits isolating hosts if no authz for it', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
authz: { canIsolateHost: false },
license: Gold,
});
expect(mockResponse.forbidden).toBeCalled();
});
it('allows any license level to unisolate', async () => {
licenseEmitter.next(Gold);
await callRoute(RELEASE_HOST_ROUTE, {
body: { endpoint_ids: ['XYZ'] },
license: Gold,
});
expect(mockResponse.ok).toBeCalled();
});
});
describe('User Authorization Level', () => {
it('allows user to perform isolation when canIsolateHost is true', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
});
expect(mockResponse.ok).toBeCalled();
});
it('allows user to perform unisolation when canUnIsolateHost is true', async () => {
await callRoute(RELEASE_HOST_ROUTE, {
body: { endpoint_ids: ['XYZ'] },
});
expect(mockResponse.ok).toBeCalled();
});
it('prohibits user from performing isolation if canIsolateHost is false', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
authz: { canIsolateHost: false },
});
expect(mockResponse.forbidden).toBeCalled();
});
it('prohibits user from performing un-isolation if canUnIsolateHost is false', async () => {
await callRoute(RELEASE_HOST_ROUTE, {
body: { endpoint_ids: ['XYZ'] },
authz: { canUnIsolateHost: false },
});
expect(mockResponse.forbidden).toBeCalled();
});
});
describe('Cases', () => {
let casesClient: CasesClientMock;
const getCaseIdsFromAttachmentAddService = () => {
return casesClient.attachments.add.mock.calls.map(([addArgs]) => addArgs.caseId);
};
beforeEach(async () => {
casesClient = (await endpointAppContextService.getCasesClient(
{} as KibanaRequest
)) as CasesClientMock;
let counter = 1;
casesClient.cases.getCasesByAlertID.mockImplementation(async () => {
return [
{
id: `case-${counter++}`,
title: 'case',
},
];
});
});
it('logs a comment to the provided cases', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'], case_ids: ['one', 'two'] },
});
expect(casesClient.attachments.add).toHaveBeenCalledTimes(2);
expect(getCaseIdsFromAttachmentAddService()).toEqual(
expect.arrayContaining(['one', 'two'])
);
});
it('logs a comment to any cases associated with the given alerts', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'], alert_ids: ['one', 'two'] },
});
expect(getCaseIdsFromAttachmentAddService()).toEqual(
expect.arrayContaining(['case-1', 'case-2'])
);
});
it('logs a comment to any cases provided on input along with cases associated with the given alerts', async () => {
await callRoute(ISOLATE_HOST_ROUTE_V2, {
// 'case-1` provided on `case_ids` should be dedupped
body: {
endpoint_ids: ['XYZ'],
case_ids: ['ONE', 'TWO', 'case-1'],
alert_ids: ['one', 'two'],
},
});
expect(casesClient.attachments.add).toHaveBeenCalledTimes(4);
expect(getCaseIdsFromAttachmentAddService()).toEqual(
expect.arrayContaining(['ONE', 'TWO', 'case-1', 'case-2'])
);
});
});
});
});

View file

@ -0,0 +1,319 @@
/*
* 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 uuid from 'uuid';
import moment from 'moment';
import { RequestHandler, Logger } from '@kbn/core/server';
import { TypeOf } from '@kbn/config-schema';
import { CasesByAlertId } from '@kbn/cases-plugin/common/api/cases/case';
import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common';
import { CommentType } from '@kbn/cases-plugin/common';
import {
HostIsolationRequestSchema,
responseActionBodySchemas,
} from '../../../../common/endpoint/schema/actions';
import { APP_ID } from '../../../../common/constants';
import {
ISOLATE_HOST_ROUTE_V2,
RELEASE_HOST_ROUTE,
ENDPOINT_ACTIONS_DS,
ENDPOINT_ACTION_RESPONSES_DS,
failedFleetActionErrorCode,
} from '../../../../common/endpoint/constants';
import type {
EndpointAction,
EndpointActionData,
EndpointActionDataParameterTypes,
HostMetadata,
LogsEndpointAction,
LogsEndpointActionResponse,
ResponseActions,
} from '../../../../common/endpoint/types';
import type {
SecuritySolutionPluginRouter,
SecuritySolutionRequestHandlerContext,
} from '../../../types';
import { EndpointAppContext } from '../../types';
import { getMetadataForEndpoints, FeatureKeys, getActionDetailsById } from '../../services';
import { doLogsEndpointActionDsExists } from '../../utils';
import { withEndpointAuthz } from '../with_endpoint_authz';
export function registerResponseActionRoutes(
router: SecuritySolutionPluginRouter,
endpointContext: EndpointAppContext
) {
const logger = endpointContext.logFactory.get('hostIsolation');
router.post(
{
path: ISOLATE_HOST_ROUTE_V2,
validate: HostIsolationRequestSchema,
options: { authRequired: true, tags: ['access:securitySolution'] },
},
withEndpointAuthz(
{ all: ['canIsolateHost'] },
logger,
responseActionRequestHandler(endpointContext, 'isolate')
)
);
router.post(
{
path: RELEASE_HOST_ROUTE,
validate: HostIsolationRequestSchema,
options: { authRequired: true, tags: ['access:securitySolution'] },
},
withEndpointAuthz(
{ all: ['canUnIsolateHost'] },
logger,
responseActionRequestHandler(endpointContext, 'unisolate')
)
);
}
const commandToFeatureKeyMap = new Map<ResponseActions, FeatureKeys>([
['isolate', 'HOST_ISOLATION'],
['unisolate', 'HOST_ISOLATION'],
]);
const endpointActionParametersTypesMap: Map<ResponseActions, EndpointActionDataParameterTypes> =
new Map([
['isolate', undefined],
['unisolate', undefined],
]);
const returnActionIdCommands = ['isolate', 'unisolate'] as const;
function responseActionRequestHandler(
endpointContext: EndpointAppContext,
command: ResponseActions
): RequestHandler<
unknown,
unknown,
TypeOf<typeof responseActionBodySchemas>,
SecuritySolutionRequestHandlerContext
> {
return async (context, req, res) => {
const featureKey = commandToFeatureKeyMap.get(command) as FeatureKeys;
if (featureKey) {
endpointContext.service.getFeatureUsageService().notifyUsage(featureKey);
}
const user = endpointContext.service.security?.authc.getCurrentUser(req);
// fetch the Agent IDs to send the commands to
const endpointIDs = [...new Set(req.body.endpoint_ids)]; // dedupe
const endpointData = await getMetadataForEndpoints(endpointIDs, context);
const casesClient = await endpointContext.service.getCasesClient(req);
// convert any alert IDs into cases
let caseIDs: string[] = req.body.case_ids?.slice() || [];
if (req.body.alert_ids && req.body.alert_ids.length > 0) {
const newIDs: string[][] = await Promise.all(
req.body.alert_ids.map(async (a: string) => {
const cases: CasesByAlertId = await casesClient.cases.getCasesByAlertID({
alertID: a,
options: { owner: APP_ID },
});
return cases.map((caseInfo): string => {
return caseInfo.id;
});
})
);
caseIDs = caseIDs.concat(...newIDs);
}
caseIDs = [...new Set(caseIDs)];
// create an Action ID and dispatch it to ES & Fleet Server
const actionID = uuid.v4();
let fleetActionIndexResult;
let logsEndpointActionsResult;
const agents = endpointData.map((endpoint: HostMetadata) => endpoint.elastic.agent.id);
const parametersType = endpointActionParametersTypesMap.get(command);
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,
comment: req.body.comment ?? undefined,
parameters: req.body.parameters ?? undefined,
} as EndpointActionData<typeof parametersType>,
} as Omit<EndpointAction, 'agents' | 'user_id' | '@timestamp'>,
user: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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,
});
// 8.0+ requires internal user to write to system indices
const esClient = (await context.core).elasticsearch.client.asInternalUser;
// if the new endpoint indices/data streams exists
// write the action request to the new endpoint index
if (doesLogsEndpointActionsDsExist) {
try {
logsEndpointActionsResult = await esClient.index<LogsEndpointAction>(
{
index: `${ENDPOINT_ACTIONS_DS}-default`,
body: {
...doc,
},
refresh: 'wait_for',
},
{ meta: true }
);
if (logsEndpointActionsResult.statusCode !== 201) {
return res.customError({
statusCode: 500,
body: {
message: logsEndpointActionsResult.body.result,
},
});
}
} catch (e) {
return res.customError({
statusCode: 500,
body: { message: e },
});
}
}
// write actions to .fleet-actions index
try {
fleetActionIndexResult = await esClient.index<EndpointAction>(
{
index: AGENT_ACTIONS_INDEX,
body: {
...doc.EndpointActions,
'@timestamp': doc['@timestamp'],
agents,
timeout: 300, // 5 minutes
user_id: doc.user.id,
},
refresh: 'wait_for',
},
{ meta: true }
);
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 },
});
}
// Update all cases with a comment
if (caseIDs.length > 0) {
const targets = endpointData.map((endpt: HostMetadata) => ({
hostname: endpt.host.hostname,
endpointId: endpt.agent.id,
}));
await Promise.all(
caseIDs.map((caseId) =>
casesClient.attachments.add({
caseId,
comment: {
type: CommentType.actions,
comment: req.body.comment || '',
actions: {
targets,
type: command,
},
owner: APP_ID,
},
})
)
);
}
const body = returnActionIdCommands.includes(command) ? { action: actionID } : {};
const data = await getActionDetailsById(esClient, actionID);
return res.ok({
body: {
...body,
data,
},
});
};
}
const createFailedActionResponseEntry = async ({
context,
doc,
logger,
}: {
context: SecuritySolutionRequestHandlerContext;
doc: LogsEndpointActionResponse;
logger: Logger;
}): Promise<void> => {
// 8.0+ requires internal user to write to system indices
const esClient = (await context.core).elasticsearch.client.asInternalUser;
try {
await esClient.index<LogsEndpointActionResponse>({
index: `${ENDPOINT_ACTION_RESPONSES_DS}-default`,
body: {
...doc,
error: {
code: failedFleetActionErrorCode,
message: 'Failed to deliver action request to fleet',
},
},
});
} catch (e) {
logger.error(e);
}
};

View file

@ -6,6 +6,7 @@
*/
import { FeatureUsageService } from './service';
export type { FeatureKeys } from './service';
export { createFeatureUsageServiceMock, createMockPolicyData } from './mocks';
export const featureUsageService = new FeatureUsageService();

View file

@ -8,3 +8,4 @@
export * from './artifacts';
export { getMetadataForEndpoints } from './metadata/metadata';
export * from './actions';
export type { FeatureKeys } from './feature_usage';