[Security Solution][Endpoint] server-side standard interface for response actions clients (#171755)

## Summary

PR introduces a standard interface for Response Actions clients -
currently only Endpoint, but in the near future, other clients will be
introduced like SentinelOne. This PR is in preperation for that feature
in a post v8.12 release.

Changes include:

- Introduction of `EndpointActionsClient` class (first Actions client
using new standard interface)
- Changed Response Actions API handler to:
    - use new `EndpointActionsClient` for processing response actions
- added support for handling file `upload` response action (previously a
separate handler)
    - now handles all errors using the common HTTP error handler
- Deleted `upload` specific API HTTP handler - no longer needed as
common handler will now also process `upload` response actions

**NOTE:** No changes in functionality as a result of this PR. Just
preparation work needed to support Bi-Directional Response Actions.
This commit is contained in:
Paul Tavares 2023-11-28 09:36:16 -05:00 committed by GitHub
parent 517c815c48
commit 0a72738e4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 642 additions and 408 deletions

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import type { TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
import { UploadActionRequestSchema } from '../..';
import { ExecuteActionRequestSchema } from '../execute_route';
import { EndpointActionGetFileSchema } from '../get_file_route';
import { KillOrSuspendProcessRequestSchema, NoParametersRequestSchema } from './base';
@ -15,4 +17,7 @@ export const ResponseActionBodySchema = schema.oneOf([
KillOrSuspendProcessRequestSchema.body,
EndpointActionGetFileSchema.body,
ExecuteActionRequestSchema.body,
UploadActionRequestSchema.body,
]);
export type ResponseActionsRequestBody = TypeOf<typeof ResponseActionBodySchema>;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { TypeOf } from '@kbn/config-schema';
import { NoParametersRequestSchema } from './common/base';
export const GetProcessesRouteRequestSchema = NoParametersRequestSchema;
export type GetProcessesRequestBody = TypeOf<typeof GetProcessesRouteRequestSchema.body>;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { TypeOf } from '@kbn/config-schema';
import { NoParametersRequestSchema } from './common/base';
export const IsolateRouteRequestSchema = NoParametersRequestSchema;
export type IsolationRouteRequestBody = TypeOf<typeof IsolateRouteRequestSchema.body>;

View file

@ -0,0 +1,86 @@
/*
* 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 type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { CasesClient } from '@kbn/cases-plugin/server';
import type { Logger } from '@kbn/logging';
import type { EndpointAppContext } from '../../types';
import type { ResponseActionsProvider } from './types';
import type {
ActionDetails,
GetProcessesActionOutputContent,
KillOrSuspendProcessRequestBody,
KillProcessActionOutputContent,
ResponseActionExecuteOutputContent,
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
ResponseActionParametersWithPidOrEntityId,
ResponseActionsExecuteParameters,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
SuspendProcessActionOutputContent,
} from '../../../../common/endpoint/types';
import type {
IsolationRouteRequestBody,
ExecuteActionRequestBody,
GetProcessesRequestBody,
ResponseActionGetFileRequestBody,
UploadActionApiRequestBody,
} from '../../../../common/api/endpoint';
export interface BaseActionsProviderOptions {
endpointContext: EndpointAppContext;
esClient: ElasticsearchClient;
casesClient?: CasesClient;
/** Username that will be stored along with the action's ES documents */
username: string;
}
export abstract class BaseResponseActionsClient implements ResponseActionsProvider {
protected readonly log: Logger;
constructor(protected readonly options: BaseActionsProviderOptions) {
this.log = options.endpointContext.logFactory.get(this.constructor.name ?? 'ActionsProvider');
}
// TODO:PT implement a generic way to update cases without relying on the Attachments being endpoint agents
// protected async updateCases(): Promise<void> {
// throw new Error('Method not yet implemented');
// }
public abstract isolate(options: IsolationRouteRequestBody): Promise<ActionDetails>;
public abstract release(options: IsolationRouteRequestBody): Promise<ActionDetails>;
public abstract killProcess(
options: KillOrSuspendProcessRequestBody
): Promise<
ActionDetails<KillProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
>;
public abstract suspendProcess(
options: KillOrSuspendProcessRequestBody
): Promise<
ActionDetails<SuspendProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
>;
public abstract runningProcesses(
options: GetProcessesRequestBody
): Promise<ActionDetails<GetProcessesActionOutputContent>>;
public abstract getFile(
options: ResponseActionGetFileRequestBody
): Promise<ActionDetails<ResponseActionGetFileOutputContent, ResponseActionGetFileParameters>>;
public abstract execute(
options: ExecuteActionRequestBody
): Promise<ActionDetails<ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters>>;
public abstract upload(
options: UploadActionApiRequestBody
): Promise<ActionDetails<ResponseActionUploadOutputContent, ResponseActionUploadParameters>>;
}

View file

@ -0,0 +1,65 @@
/*
* 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 type {
ActionDetails,
KillOrSuspendProcessRequestBody,
KillProcessActionOutputContent,
ResponseActionParametersWithPidOrEntityId,
SuspendProcessActionOutputContent,
GetProcessesActionOutputContent,
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
ResponseActionExecuteOutputContent,
ResponseActionsExecuteParameters,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
} from '../../../../common/endpoint/types';
import type {
IsolationRouteRequestBody,
GetProcessesRequestBody,
ResponseActionGetFileRequestBody,
ExecuteActionRequestBody,
UploadActionApiRequestBody,
} from '../../../../common/api/endpoint';
/**
* The interface required for a Response Actions provider
*/
export interface ResponseActionsProvider {
isolate: (options: IsolationRouteRequestBody) => Promise<ActionDetails>;
release: (options: IsolationRouteRequestBody) => Promise<ActionDetails>;
killProcess: (
options: KillOrSuspendProcessRequestBody
) => Promise<
ActionDetails<KillProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
>;
suspendProcess: (
options: KillOrSuspendProcessRequestBody
) => Promise<
ActionDetails<SuspendProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
>;
runningProcesses: (
options: GetProcessesRequestBody
) => Promise<ActionDetails<GetProcessesActionOutputContent>>;
getFile: (
options: ResponseActionGetFileRequestBody
) => Promise<ActionDetails<ResponseActionGetFileOutputContent, ResponseActionGetFileParameters>>;
execute: (
options: ExecuteActionRequestBody
) => Promise<ActionDetails<ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters>>;
upload: (
options: UploadActionApiRequestBody
) => Promise<ActionDetails<ResponseActionUploadOutputContent, ResponseActionUploadParameters>>;
}

View file

@ -1,201 +0,0 @@
/*
* 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 type { HttpApiTestSetupMock } from '../../mocks';
import { createHttpApiTestSetupMock } from '../../mocks';
import type { UploadActionApiRequestBody } from '../../../../common/api/endpoint';
import type { getActionFileUploadHandler } from './file_upload_handler';
import { registerActionFileUploadRoute } from './file_upload_handler';
import { UPLOAD_ROUTE } from '../../../../common/endpoint/constants';
import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks';
import { EndpointAuthorizationError } from '../../errors';
import type { HapiReadableStream } from '../../../types';
import { createHapiReadableStreamMock } from '../../services/actions/mocks';
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { omit } from 'lodash';
import type { FleetToHostFileClientInterface } from '@kbn/fleet-plugin/server';
describe('Upload response action create API handler', () => {
type UploadHttpApiTestSetupMock = HttpApiTestSetupMock<never, never, UploadActionApiRequestBody>;
let testSetup: UploadHttpApiTestSetupMock;
let httpRequestMock: ReturnType<UploadHttpApiTestSetupMock['createRequestMock']>;
let httpHandlerContextMock: UploadHttpApiTestSetupMock['httpHandlerContextMock'];
let httpResponseMock: UploadHttpApiTestSetupMock['httpResponseMock'];
let fleetFilesClientMock: jest.Mocked<FleetToHostFileClientInterface>;
beforeEach(async () => {
testSetup = createHttpApiTestSetupMock<never, never, UploadActionApiRequestBody>();
({ httpHandlerContextMock, httpResponseMock } = testSetup);
httpRequestMock = testSetup.createRequestMock();
fleetFilesClientMock =
(await testSetup.endpointAppContextMock.service.getFleetToHostFilesClient()) as jest.Mocked<FleetToHostFileClientInterface>;
});
describe('registerActionFileUploadRoute()', () => {
it('should register the route', () => {
registerActionFileUploadRoute(testSetup.routerMock, testSetup.endpointAppContextMock);
expect(
testSetup.getRegisteredVersionedRoute('post', UPLOAD_ROUTE, '2023-10-31')
).toBeDefined();
});
it('should NOT register route if feature flag is false', () => {
// @ts-expect-error
testSetup.endpointAppContextMock.experimentalFeatures.responseActionUploadEnabled = false;
registerActionFileUploadRoute(testSetup.routerMock, testSetup.endpointAppContextMock);
expect(() =>
testSetup.getRegisteredVersionedRoute('post', UPLOAD_ROUTE, '2023-10-31')
).toThrow('No routes registered for [POST /api/endpoint/action/upload]');
});
it('should use maxUploadResponseActionFileBytes config value', () => {
// @ts-expect-error
testSetup.endpointAppContextMock.serverConfig.maxUploadResponseActionFileBytes = 999;
registerActionFileUploadRoute(testSetup.routerMock, testSetup.endpointAppContextMock);
expect(
testSetup.getRegisteredVersionedRoute('post', UPLOAD_ROUTE, '2023-10-31').routeConfig
?.options?.body
).toEqual({
accepts: ['multipart/form-data'],
maxBytes: 999,
output: 'stream',
});
});
it('should error if user has no authz to api', async () => {
(
(await httpHandlerContextMock.securitySolution).getEndpointAuthz as jest.Mock
).mockResolvedValue(getEndpointAuthzInitialStateMock({ canWriteFileOperations: false }));
registerActionFileUploadRoute(testSetup.routerMock, testSetup.endpointAppContextMock);
await testSetup
.getRegisteredVersionedRoute('post', UPLOAD_ROUTE, '2023-10-31')
.routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
expect(httpResponseMock.forbidden).toHaveBeenCalledWith({
body: expect.any(EndpointAuthorizationError),
});
});
});
describe('route request handler', () => {
let callHandler: () => ReturnType<ReturnType<typeof getActionFileUploadHandler>>;
let fileContent: HapiReadableStream;
let createdUploadAction: ActionDetails;
beforeEach(() => {
fileContent = createHapiReadableStreamMock();
const reqBody: UploadActionApiRequestBody = {
file: fileContent,
endpoint_ids: ['123-456'],
parameters: {
overwrite: true,
},
};
httpRequestMock = testSetup.createRequestMock({ body: reqBody });
registerActionFileUploadRoute(testSetup.routerMock, testSetup.endpointAppContextMock);
createdUploadAction = new EndpointActionGenerator('seed').generateActionDetails({
command: 'upload',
});
(
testSetup.endpointAppContextMock.service.getActionCreateService().createAction as jest.Mock
).mockResolvedValue(createdUploadAction);
(testSetup.endpointAppContextMock.service.getEndpointMetadataService as jest.Mock) = jest
.fn()
.mockReturnValue({
getMetadataForEndpoints: jest.fn().mockResolvedValue([
{
elastic: {
agent: {
id: '123-456',
},
},
},
]),
});
const handler: ReturnType<typeof getActionFileUploadHandler> =
testSetup.getRegisteredVersionedRoute('post', UPLOAD_ROUTE, '2023-10-31').routeHandler;
callHandler = () => handler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should create a file', async () => {
await callHandler();
expect(fleetFilesClientMock.create).toHaveBeenCalledWith(fileContent, ['123-456']);
});
it('should create the action using parameters with stored file info', async () => {
await callHandler();
const createActionMock = testSetup.endpointAppContextMock.service.getActionCreateService()
.createAction as jest.Mock;
expect(createActionMock).toHaveBeenCalledWith(
{
command: 'upload',
endpoint_ids: ['123-456'],
parameters: {
file_id: '123-456-789',
file_name: 'foo.txt',
file_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3',
file_size: 45632,
overwrite: true,
},
user: undefined,
},
['123-456']
);
});
it('should delete file if creation of Action fails', async () => {
const createActionMock = testSetup.endpointAppContextMock.service.getActionCreateService()
.createAction as jest.Mock;
createActionMock.mockImplementation(async () => {
throw new CustomHttpRequestError('oh oh');
});
await callHandler();
expect(fleetFilesClientMock.delete).toHaveBeenCalledWith('123-456-789');
});
it('should update file with action id', async () => {
await callHandler();
expect(fleetFilesClientMock.update).toHaveBeenCalledWith('123-456-789', { actionId: '123' });
});
it('should return expected response on success', async () => {
await callHandler();
expect(httpResponseMock.ok).toHaveBeenCalledWith({
body: {
action: createdUploadAction.action,
data: omit(createdUploadAction, 'action'),
},
});
});
});
});

View file

@ -1,158 +0,0 @@
/*
* 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 type { RequestHandler } from '@kbn/core/server';
import type { UploadActionApiRequestBody } from '../../../../common/api/endpoint';
import { UploadActionRequestSchema } from '../../../../common/api/endpoint';
import type { ResponseActionsApiCommandNames } from '../../../../common/endpoint/service/response_actions/constants';
import type {
ResponseActionUploadParameters,
ResponseActionUploadOutputContent,
HostMetadata,
} from '../../../../common/endpoint/types';
import { UPLOAD_ROUTE } from '../../../../common/endpoint/constants';
import { withEndpointAuthz } from '../with_endpoint_authz';
import type {
SecuritySolutionPluginRouter,
SecuritySolutionRequestHandlerContext,
HapiReadableStream,
} from '../../../types';
import type { EndpointAppContext } from '../../types';
import { errorHandler } from '../error_handler';
import { updateCases } from '../../services/actions/create/update_cases';
export const registerActionFileUploadRoute = (
router: SecuritySolutionPluginRouter,
endpointContext: EndpointAppContext
) => {
if (!endpointContext.experimentalFeatures.responseActionUploadEnabled) {
return;
}
const logger = endpointContext.logFactory.get('uploadAction');
router.versioned
.post({
access: 'public',
path: UPLOAD_ROUTE,
options: {
authRequired: true,
tags: ['access:securitySolution'],
body: {
accepts: ['multipart/form-data'],
output: 'stream',
maxBytes: endpointContext.serverConfig.maxUploadResponseActionFileBytes,
},
},
})
.addVersion(
{
version: '2023-10-31',
validate: {
request: UploadActionRequestSchema,
},
},
withEndpointAuthz(
{ all: ['canWriteFileOperations'] },
logger,
getActionFileUploadHandler(endpointContext)
)
);
};
export const getActionFileUploadHandler = (
endpointContext: EndpointAppContext
): RequestHandler<
never,
never,
UploadActionApiRequestBody,
SecuritySolutionRequestHandlerContext
> => {
const logger = endpointContext.logFactory.get('uploadAction');
return async (context, req, res) => {
const fleetFiles = await endpointContext.service.getFleetToHostFilesClient();
const user = endpointContext.service.security?.authc.getCurrentUser(req);
const fileStream = req.body.file as HapiReadableStream;
const { file: _, parameters: userParams, ...actionPayload } = req.body;
const uploadParameters: ResponseActionUploadParameters = {
...userParams,
file_id: '',
file_name: '',
file_sha256: '',
file_size: 0,
};
try {
const createdFile = await fleetFiles.create(fileStream, actionPayload.endpoint_ids);
uploadParameters.file_id = createdFile.id;
uploadParameters.file_name = createdFile.name;
uploadParameters.file_sha256 = createdFile.sha256;
uploadParameters.file_size = createdFile.size;
} catch (err) {
return errorHandler(logger, res, err);
}
const createActionPayload = {
...actionPayload,
parameters: uploadParameters,
command: 'upload' as ResponseActionsApiCommandNames,
user,
};
const esClient = (await context.core).elasticsearch.client.asInternalUser;
const endpointData = await endpointContext.service
.getEndpointMetadataService()
.getMetadataForEndpoints(esClient, [...new Set(createActionPayload.endpoint_ids)]);
const agentIds = endpointData.map((endpoint: HostMetadata) => endpoint.elastic.agent.id);
try {
const casesClient = await endpointContext.service.getCasesClient(req);
const { action: actionId, ...data } = await endpointContext.service
.getActionCreateService()
.createAction<ResponseActionUploadOutputContent, ResponseActionUploadParameters>(
createActionPayload,
agentIds
);
// Update the file meta to include the action id, and if any errors (unlikely),
// then just log them and still allow api to return success since the action has
// already been created and potentially dispatched to Endpoint. Action ID is not
// needed by the Endpoint or fleet-server's API, so no need to fail here
try {
await fleetFiles.update(uploadParameters.file_id, { actionId: data.id });
} catch (e) {
logger.warn(`Attempt to update File meta with Action ID failed: ${e.message}`, e);
}
// update cases
await updateCases({ casesClient, createActionPayload, endpointData });
return res.ok({
body: {
action: actionId,
data,
},
});
} catch (err) {
if (uploadParameters.file_id) {
// Try to delete the created file since creating the action threw an error
try {
await fleetFiles.delete(uploadParameters.file_id);
} catch (e) {
logger.error(
`Attempt to clean up file (after action creation was unsuccessful) failed; ${e.message}`,
e
);
}
}
return errorHandler(logger, res, err);
}
};
};

View file

@ -34,6 +34,7 @@ import {
UNISOLATE_HOST_ROUTE,
GET_FILE_ROUTE,
EXECUTE_ROUTE,
UPLOAD_ROUTE,
} from '../../../../common/endpoint/constants';
import type {
ActionDetails,
@ -46,7 +47,9 @@ import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'
import type { EndpointAuthz } from '../../../../common/endpoint/types/authz';
import type { SecuritySolutionRequestHandlerContextMock } from '../../../lib/detection_engine/routes/__mocks__/request_context';
import { EndpointAppContextService } from '../../endpoint_app_context_services';
import type { HttpApiTestSetupMock } from '../../mocks';
import {
createHttpApiTestSetupMock,
createMockEndpointAppContext,
createMockEndpointAppContextServiceSetupContract,
createMockEndpointAppContextServiceStartContract,
@ -59,6 +62,13 @@ import * as ActionDetailsService from '../../services/actions/action_details_by_
import { CaseStatuses } from '@kbn/cases-components';
import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks';
import { actionCreateService } from '../../services/actions';
import type { UploadActionApiRequestBody } from '../../../../common/api/endpoint';
import type { FleetToHostFileClientInterface } from '@kbn/fleet-plugin/server';
import type { HapiReadableStream, SecuritySolutionRequestHandlerContext } from '../../../types';
import { createHapiReadableStreamMock } from '../../services/actions/mocks';
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import { omit } from 'lodash';
interface CallRouteInterface {
body?: ResponseActionRequestBody;
@ -827,26 +837,26 @@ describe('Response actions', () => {
});
it('handles errors', async () => {
const errMessage = 'Uh oh!';
await callRoute(
UNISOLATE_HOST_ROUTE_V2,
{
body: { endpoint_ids: ['XYZ'] },
version: '2023-10-31',
indexErrorResponse: {
statusCode: 500,
body: {
result: errMessage,
const expectedError = new Error('Uh oh!');
await expect(
callRoute(
UNISOLATE_HOST_ROUTE_V2,
{
body: { endpoint_ids: ['XYZ'] },
version: '2023-10-31',
indexErrorResponse: {
statusCode: 500,
body: {
result: expectedError.message,
},
},
},
},
{ endpointDsExists: true }
);
{ endpointDsExists: true }
)
).rejects.toEqual(expectedError);
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);
});
});
@ -1001,4 +1011,140 @@ describe('Response actions', () => {
});
});
});
describe('Upload response action handler', () => {
type UploadHttpApiTestSetupMock = HttpApiTestSetupMock<
never,
never,
UploadActionApiRequestBody
>;
type UploadRequestHandler = RequestHandler<
never,
never,
UploadActionApiRequestBody,
SecuritySolutionRequestHandlerContext
>;
let testSetup: UploadHttpApiTestSetupMock;
let httpRequestMock: ReturnType<UploadHttpApiTestSetupMock['createRequestMock']>;
let httpHandlerContextMock: UploadHttpApiTestSetupMock['httpHandlerContextMock'];
let httpResponseMock: UploadHttpApiTestSetupMock['httpResponseMock'];
let fleetFilesClientMock: jest.Mocked<FleetToHostFileClientInterface>;
let callHandler: () => ReturnType<UploadRequestHandler>;
let fileContent: HapiReadableStream;
let createdUploadAction: ActionDetails;
beforeEach(async () => {
testSetup = createHttpApiTestSetupMock<never, never, UploadActionApiRequestBody>();
({ httpHandlerContextMock, httpResponseMock } = testSetup);
httpRequestMock = testSetup.createRequestMock();
fleetFilesClientMock =
(await testSetup.endpointAppContextMock.service.getFleetToHostFilesClient()) as jest.Mocked<FleetToHostFileClientInterface>;
fileContent = createHapiReadableStreamMock();
const reqBody: UploadActionApiRequestBody = {
file: fileContent,
endpoint_ids: ['123-456'],
parameters: {
overwrite: true,
},
};
httpRequestMock = testSetup.createRequestMock({ body: reqBody });
registerResponseActionRoutes(testSetup.routerMock, testSetup.endpointAppContextMock);
createdUploadAction = new EndpointActionGenerator('seed').generateActionDetails({
command: 'upload',
});
(
testSetup.endpointAppContextMock.service.getActionCreateService().createAction as jest.Mock
).mockResolvedValue(createdUploadAction);
(testSetup.endpointAppContextMock.service.getEndpointMetadataService as jest.Mock) = jest
.fn()
.mockReturnValue({
getMetadataForEndpoints: jest.fn().mockResolvedValue([
{
elastic: {
agent: {
id: '123-456',
},
},
},
]),
});
const handler = testSetup.getRegisteredVersionedRoute('post', UPLOAD_ROUTE, '2023-10-31')
.routeHandler as UploadRequestHandler;
callHandler = () => handler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should create a file', async () => {
await callHandler();
expect(fleetFilesClientMock.create).toHaveBeenCalledWith(fileContent, ['123-456']);
});
it('should create the action using parameters with stored file info', async () => {
await callHandler();
const createActionMock = testSetup.endpointAppContextMock.service.getActionCreateService()
.createAction as jest.Mock;
expect(createActionMock).toHaveBeenCalledWith(
{
command: 'upload',
endpoint_ids: ['123-456'],
parameters: {
file_id: '123-456-789',
file_name: 'foo.txt',
file_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3',
file_size: 45632,
overwrite: true,
},
user: { username: 'unknown' },
},
['123-456']
);
});
it('should delete file if creation of Action fails', async () => {
const createActionMock = testSetup.endpointAppContextMock.service.getActionCreateService()
.createAction as jest.Mock;
createActionMock.mockImplementation(async () => {
throw new CustomHttpRequestError('oh oh');
});
await callHandler();
expect(fleetFilesClientMock.delete).toHaveBeenCalledWith('123-456-789');
});
it('should update file with action id', async () => {
await callHandler();
expect(fleetFilesClientMock.update).toHaveBeenCalledWith('123-456-789', {
actionId: '123',
});
});
it('should return expected response on success', async () => {
await callHandler();
expect(httpResponseMock.ok).toHaveBeenCalledWith({
body: {
action: createdUploadAction.action,
data: omit(createdUploadAction, 'action'),
},
});
});
});
});

View file

@ -7,9 +7,14 @@
import type { RequestHandler } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import { EndpointActionsClient } from '../../services/actions/clients';
import type {
ResponseActionBodySchema,
NoParametersRequestSchema,
ResponseActionsRequestBody,
ExecuteActionRequestBody,
ResponseActionGetFileRequestBody,
UploadActionApiRequestBody,
} from '../../../../common/api/endpoint';
import {
ExecuteActionRequestSchema,
@ -19,6 +24,7 @@ import {
SuspendProcessRouteRequestSchema,
UnisolateRouteRequestSchema,
GetProcessesRouteRequestSchema,
UploadActionRequestSchema,
} from '../../../../common/api/endpoint';
import {
@ -31,13 +37,14 @@ import {
UNISOLATE_HOST_ROUTE,
GET_FILE_ROUTE,
EXECUTE_ROUTE,
UPLOAD_ROUTE,
} from '../../../../common/endpoint/constants';
import type {
EndpointActionDataParameterTypes,
ResponseActionParametersWithPidOrEntityId,
ResponseActionsExecuteParameters,
ActionDetails,
HostMetadata,
KillOrSuspendProcessRequestBody,
} from '../../../../common/endpoint/types';
import type { ResponseActionsApiCommandNames } from '../../../../common/endpoint/service/response_actions/constants';
import type {
@ -46,8 +53,7 @@ import type {
} from '../../../types';
import type { EndpointAppContext } from '../../types';
import { withEndpointAuthz } from '../with_endpoint_authz';
import { registerActionFileUploadRoute } from './file_upload_handler';
import { updateCases } from '../../services/actions/create/update_cases';
import { errorHandler } from '../error_handler';
export function registerResponseActionRoutes(
router: SecuritySolutionPluginRouter,
@ -243,7 +249,33 @@ export function registerResponseActionRoutes(
)
);
registerActionFileUploadRoute(router, endpointContext);
router.versioned
.post({
access: 'public',
path: UPLOAD_ROUTE,
options: {
authRequired: true,
tags: ['access:securitySolution'],
body: {
accepts: ['multipart/form-data'],
output: 'stream',
maxBytes: endpointContext.serverConfig.maxUploadResponseActionFileBytes,
},
},
})
.addVersion(
{
version: '2023-10-31',
validate: {
request: UploadActionRequestSchema,
},
},
withEndpointAuthz(
{ all: ['canWriteFileOperations'] },
logger,
responseActionRequestHandler<ResponseActionsExecuteParameters>(endpointContext, 'upload')
)
);
}
function responseActionRequestHandler<T extends EndpointActionDataParameterTypes>(
@ -252,43 +284,76 @@ function responseActionRequestHandler<T extends EndpointActionDataParameterTypes
): RequestHandler<
unknown,
unknown,
TypeOf<typeof ResponseActionBodySchema>,
ResponseActionsRequestBody,
SecuritySolutionRequestHandlerContext
> {
const logger = endpointContext.logFactory.get('responseActionsHandler');
return async (context, req, res) => {
const user = endpointContext.service.security?.authc.getCurrentUser(req);
const esClient = (await context.core).elasticsearch.client.asInternalUser;
let action: ActionDetails;
const casesClient = await endpointContext.service.getCasesClient(req);
const actionsClient = new EndpointActionsClient({
esClient,
casesClient,
endpointContext,
username: user?.username ?? 'unknown',
});
try {
const createActionPayload = { ...req.body, command, user };
const endpointData = await endpointContext.service
.getEndpointMetadataService()
.getMetadataForEndpoints(esClient, [...new Set(createActionPayload.endpoint_ids)]);
const agentIds = endpointData.map((endpoint: HostMetadata) => endpoint.elastic.agent.id);
let action: ActionDetails;
action = await endpointContext.service
.getActionCreateService()
.createAction(createActionPayload, agentIds);
switch (command) {
case 'isolate':
action = await actionsClient.isolate(req.body);
break;
// update cases
const casesClient = await endpointContext.service.getCasesClient(req);
await updateCases({ casesClient, createActionPayload, endpointData });
} catch (err) {
return res.customError({
statusCode: 500,
body: err,
case 'unisolate':
action = await actionsClient.release(req.body);
break;
case 'running-processes':
action = await actionsClient.runningProcesses(req.body);
break;
case 'execute':
action = await actionsClient.execute(req.body as ExecuteActionRequestBody);
break;
case 'suspend-process':
action = await actionsClient.suspendProcess(req.body as KillOrSuspendProcessRequestBody);
break;
case 'kill-process':
action = await actionsClient.killProcess(req.body as KillOrSuspendProcessRequestBody);
break;
case 'get-file':
action = await actionsClient.getFile(req.body as ResponseActionGetFileRequestBody);
break;
case 'upload':
action = await actionsClient.upload(req.body as UploadActionApiRequestBody);
break;
default:
throw new CustomHttpRequestError(
`No handler found for response action command: [${command}]`,
501
);
}
const { action: actionId, ...data } = action;
return res.ok({
body: {
action: actionId,
data,
},
});
} catch (err) {
return errorHandler(logger, res, err);
}
const { action: actionId, ...data } = action;
return res.ok({
body: {
action: actionId,
data,
},
});
};
}

View file

@ -0,0 +1,215 @@
/*
* 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 type { HapiReadableStream } from '../../../../types';
import type { ResponseActionsApiCommandNames } from '../../../../../common/endpoint/service/response_actions/constants';
import { updateCases } from '../create/update_cases';
import type { CreateActionPayload } from '../create/types';
import type {
ExecuteActionRequestBody,
GetProcessesRequestBody,
IsolationRouteRequestBody,
ResponseActionGetFileRequestBody,
UploadActionApiRequestBody,
ResponseActionsRequestBody,
} from '../../../../../common/api/endpoint';
import { BaseResponseActionsClient } from '../../../lib/response_actions/base_actions_provider';
import type {
ActionDetails,
HostMetadata,
GetProcessesActionOutputContent,
KillOrSuspendProcessRequestBody,
KillProcessActionOutputContent,
ResponseActionExecuteOutputContent,
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
ResponseActionParametersWithPidOrEntityId,
ResponseActionsExecuteParameters,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
SuspendProcessActionOutputContent,
HostMetadataInterface,
ImmutableObject,
} from '../../../../../common/endpoint/types';
export class EndpointActionsClient extends BaseResponseActionsClient {
private async checkAgentIds(ids: string[]): Promise<{
valid: string[];
invalid: string[];
allValid: boolean;
hosts: HostMetadata[];
}> {
const foundEndpointHosts = await this.options.endpointContext.service
.getEndpointMetadataService()
.getMetadataForEndpoints(this.options.esClient, [...new Set(ids)]);
const validIds = foundEndpointHosts.map((endpoint: HostMetadata) => endpoint.elastic.agent.id);
const invalidIds = ids.filter((id) => !validIds.includes(id));
if (invalidIds.length) {
this.log.debug(`The following agent ids are not valid: ${JSON.stringify(invalidIds)}`);
}
return {
valid: validIds,
invalid: invalidIds,
allValid: invalidIds.length === 0,
hosts: foundEndpointHosts,
};
}
private async handleResponseAction<
TOptions extends ResponseActionsRequestBody = ResponseActionsRequestBody,
TResponse extends ActionDetails = ActionDetails
>(command: ResponseActionsApiCommandNames, options: TOptions): Promise<TResponse> {
const agentIds = await this.checkAgentIds(options.endpoint_ids);
const createPayload: CreateActionPayload = {
...options,
command,
user: { username: this.options.username },
};
const response = await this.options.endpointContext.service
.getActionCreateService()
.createAction(createPayload, agentIds.valid);
await this.updateCases(createPayload, agentIds.hosts);
return response as TResponse;
}
protected async updateCases(
createActionPayload: CreateActionPayload,
endpointData: Array<ImmutableObject<HostMetadataInterface>>
): Promise<void> {
return updateCases({
casesClient: this.options.casesClient,
createActionPayload,
endpointData,
});
}
async isolate(options: IsolationRouteRequestBody): Promise<ActionDetails> {
return this.handleResponseAction<IsolationRouteRequestBody, ActionDetails>('isolate', options);
}
async release(options: IsolationRouteRequestBody): Promise<ActionDetails> {
return this.handleResponseAction<IsolationRouteRequestBody, ActionDetails>(
'unisolate',
options
);
}
async killProcess(
options: KillOrSuspendProcessRequestBody
): Promise<
ActionDetails<KillProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
> {
return this.handleResponseAction<
KillOrSuspendProcessRequestBody,
ActionDetails<KillProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
>('kill-process', options);
}
async suspendProcess(
options: KillOrSuspendProcessRequestBody
): Promise<
ActionDetails<SuspendProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
> {
return this.handleResponseAction<
KillOrSuspendProcessRequestBody,
ActionDetails<SuspendProcessActionOutputContent, ResponseActionParametersWithPidOrEntityId>
>('suspend-process', options);
}
async runningProcesses(
options: GetProcessesRequestBody
): Promise<ActionDetails<GetProcessesActionOutputContent>> {
return this.handleResponseAction<
GetProcessesRequestBody,
ActionDetails<GetProcessesActionOutputContent>
>('running-processes', options);
}
async getFile(
options: ResponseActionGetFileRequestBody
): Promise<ActionDetails<ResponseActionGetFileOutputContent, ResponseActionGetFileParameters>> {
return this.handleResponseAction<
ResponseActionGetFileRequestBody,
ActionDetails<ResponseActionGetFileOutputContent, ResponseActionGetFileParameters>
>('get-file', options);
}
async execute(
options: ExecuteActionRequestBody
): Promise<ActionDetails<ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters>> {
return this.handleResponseAction<
ExecuteActionRequestBody,
ActionDetails<ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters>
>('execute', options);
}
async upload(
options: UploadActionApiRequestBody
): Promise<ActionDetails<ResponseActionUploadOutputContent, ResponseActionUploadParameters>> {
const fleetFiles = await this.options.endpointContext.service.getFleetToHostFilesClient();
const fileStream = options.file as HapiReadableStream;
const { file: _, parameters: userParams, ...actionPayload } = options;
const uploadParameters: ResponseActionUploadParameters = {
...userParams,
file_id: '',
file_name: '',
file_sha256: '',
file_size: 0,
};
const createdFile = await fleetFiles.create(fileStream, actionPayload.endpoint_ids);
uploadParameters.file_id = createdFile.id;
uploadParameters.file_name = createdFile.name;
uploadParameters.file_sha256 = createdFile.sha256;
uploadParameters.file_size = createdFile.size;
const createFileActionOptions = {
...actionPayload,
parameters: uploadParameters,
command: 'upload' as ResponseActionsApiCommandNames,
};
try {
const createdAction = await this.handleResponseAction<
typeof createFileActionOptions,
ActionDetails<ResponseActionUploadOutputContent, ResponseActionUploadParameters>
>('upload', createFileActionOptions);
// Update the file meta to include the action id, and if any errors (unlikely),
// then just log them and still allow api to return success since the action has
// already been created and potentially dispatched to Endpoint. Action ID is not
// needed by the Endpoint or fleet-server's API, so no need to fail here
try {
await fleetFiles.update(uploadParameters.file_id, { actionId: createdAction.id });
} catch (e) {
this.log.warn(`Attempt to update File meta with Action ID failed: ${e.message}`, e);
}
return createdAction;
} catch (err) {
if (uploadParameters.file_id) {
// Try to delete the created file since creating the action threw an error
try {
await fleetFiles.delete(uploadParameters.file_id);
} catch (e) {
this.log.error(
`Attempt to clean up file id [${uploadParameters.file_id}] (after action creation was unsuccessful) failed; ${e.message}`,
e
);
}
}
throw err;
}
}
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { EndpointActionsClient } from './endpoint_actions_client';

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { AuthenticationServiceStart } from '@kbn/security-plugin/server';
import type { LicenseType } from '@kbn/licensing-plugin/server';
import type { TypeOf } from '@kbn/config-schema';
import type { ResponseActionBodySchema } from '../../../../../common/api/endpoint';
@ -17,7 +16,7 @@ import type { ResponseActionsApiCommandNames } from '../../../../../common/endpo
export type CreateActionPayload = TypeOf<typeof ResponseActionBodySchema> & {
command: ResponseActionsApiCommandNames;
user?: ReturnType<AuthenticationServiceStart['getCurrentUser']>;
user?: { username: string } | null | undefined;
rule_id?: string;
rule_name?: string;
error?: string;