mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
517c815c48
commit
0a72738e4c
12 changed files with 642 additions and 408 deletions
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>>;
|
||||
}
|
|
@ -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>>;
|
||||
}
|
|
@ -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'),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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'),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue