mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution] generic response actions API handler (#133977)
This commit is contained in:
parent
f45e09e58b
commit
3819bf12ce
9 changed files with 1062 additions and 12 deletions
|
@ -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';
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 }),
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { FeatureUsageService } from './service';
|
||||
export type { FeatureKeys } from './service';
|
||||
export { createFeatureUsageServiceMock, createMockPolicyData } from './mocks';
|
||||
|
||||
export const featureUsageService = new FeatureUsageService();
|
||||
|
|
|
@ -8,3 +8,4 @@
|
|||
export * from './artifacts';
|
||||
export { getMetadataForEndpoints } from './metadata/metadata';
|
||||
export * from './actions';
|
||||
export type { FeatureKeys } from './feature_usage';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue