[Fleet] Add a fleet service to do create and search on .fleet-actions* indices (#158833)

## Summary

This PR adds a fleet service to manage write/read access to
`.fleet-actions` and `.fleet-actions-results` indices so that non-fleet
plugins use this service instead of directly accessing these indices for
create and search.

- [x] create a service.
- [x] substitute it in osquery and endpoint plugins wherever
`.fleet-actions` and `.fleet-actions-results` are accessed
  - [x]  replace create endpoint fleet actions
- [ ] ~replace methods that take `kuery` as param with specific search
methods.~
- [x] Validate kuery methods to allow narrow set of fields 

### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Ashokaditya 2023-06-26 10:34:19 +02:00 committed by GitHub
parent d5bf4be1da
commit b30f8a6ba5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1851 additions and 226 deletions

View file

@ -19,3 +19,6 @@ export class FleetError extends Error {
export class PackagePolicyValidationError extends FleetError {}
export class MessageSigningError extends FleetError {}
export class FleetActionsError extends FleetError {}
export class FleetActionsClientError extends FleetError {}

View file

@ -37,6 +37,9 @@ export * from '../services/artifacts/mocks';
// export all mocks from fleet files client
export * from '../services/files/mocks';
// export all mocks from fleet actions client
export * from '../services/actions/mocks';
export interface MockedFleetAppContext extends FleetAppContext {
elasticsearch: ReturnType<typeof elasticsearchServiceMock.createStart>;
data: ReturnType<typeof dataPluginMock.createStartContract>;

View file

@ -125,6 +125,7 @@ import {
UninstallTokenService,
type UninstallTokenServiceInterface,
} from './services/security/uninstall_token_service';
import { FleetActionsClient, type FleetActionsClientInterface } from './services/actions';
import type { FilesClientFactory } from './services/files/types';
export interface FleetSetupDeps {
@ -229,6 +230,7 @@ export interface FleetStartContract {
messageSigningService: MessageSigningServiceInterface;
uninstallTokenService: UninstallTokenServiceInterface;
createFleetActionsClient: (packageName: string) => FleetActionsClientInterface;
}
export class FleetPlugin
@ -581,6 +583,9 @@ export class FleetPlugin
),
messageSigningService,
uninstallTokenService,
createFleetActionsClient(packageName: string) {
return new FleetActionsClient(core.elasticsearch.client.asInternalUser, packageName);
},
};
}

View file

@ -0,0 +1,346 @@
/*
* 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 { v4 as uuidV4 } from 'uuid';
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import type { estypes } from '@elastic/elasticsearch';
import { ES_SEARCH_LIMIT } from '../../../common/constants';
import { FleetActionsError } from '../../../common/errors';
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../common';
import { auditLoggingService } from '../audit_logging';
import {
validateFilterKueryNode,
allowedFleetActionsFields,
ALLOWED_FLEET_ACTIONS_FIELD_TYPES,
} from './utils';
import type { FleetActionRequest, FleetActionResult, BulkCreateResponse } from './types';
const queryOptions = Object.freeze({
ignore: [404],
});
export const createAction = async (
esClient: ElasticsearchClient,
action: FleetActionRequest
): Promise<FleetActionRequest> => {
try {
const document = {
...action,
action_id: action.action_id || uuidV4(),
'@timestamp': action['@timestamp'] || new Date().toISOString(),
};
await esClient.create(
{
index: AGENT_ACTIONS_INDEX,
// doc id is same as action_id
id: document.action_id,
document,
refresh: 'wait_for',
},
{
meta: true,
}
);
auditLoggingService.writeCustomAuditLog({
message: `User created Fleet action [id=${action.action_id}, user_id=${action.user_id}, input_type=${action.input_type}]`,
});
return document;
} catch (createActionError) {
throw new FleetActionsError(
`Error creating action: ${createActionError.message}`,
createActionError
);
}
};
const getLoggingInfo = ({
id,
actions,
}: {
id: string;
actions: FleetActionRequest[];
}): {
input_type: string;
user_id: string;
} => {
const action = actions.find((item) => item.action_id === id);
return {
input_type: action?.input_type || '',
user_id: action?.user_id || '',
};
};
type BulkCreate = Array<{ create: { _index: string; _id: string } } | FleetActionRequest>;
export const bulkCreateActions = async (
esClient: ElasticsearchClient,
_actions: FleetActionRequest[]
): Promise<BulkCreateResponse> => {
const actions: FleetActionRequest[] = [];
const bulkCreateActionsOperations = _actions.reduce<BulkCreate>((acc, action) => {
// doc id is same as action_id
const actionId = action.action_id || uuidV4();
acc.push({ create: { _index: AGENT_ACTIONS_INDEX, _id: actionId } });
const actionDoc = {
...action,
action_id: actionId,
'@timestamp': action['@timestamp'] || new Date().toISOString(),
};
acc.push(actionDoc);
actions.push(actionDoc);
return acc;
}, []);
try {
const bulkCreateActionsResponse = (await esClient.bulk({
operations: bulkCreateActionsOperations,
refresh: 'wait_for',
})) as unknown as estypes.BulkResponse;
const responseItems = bulkCreateActionsResponse.items;
responseItems.forEach((item) => {
if (!item.create?.error) {
const id = item.create?._id ?? '';
const loggingInfo = getLoggingInfo({ id, actions });
auditLoggingService.writeCustomAuditLog({
message: `User created Fleet action [id=${id}, user_id=${loggingInfo.user_id}, input_type=${loggingInfo.input_type}]`,
});
}
});
const status = responseItems.every((item) => item.create?.error)
? 'failed'
: responseItems.some((item) => item.create?.error)
? 'mixed'
: 'success';
return {
status,
items: responseItems.map((item) => ({
status: item.create?.error ? 'error' : 'success',
id: item.create?._id ?? '',
})),
};
} catch (createBulkActionsError) {
throw new FleetActionsError(
`Error creating bulk actions: ${createBulkActionsError.message}`,
createBulkActionsError
);
}
};
export const getActionsByIds = async (
esClient: ElasticsearchClient,
actionIds: string[]
): Promise<{ items: FleetActionRequest[]; total: number }> => {
try {
const getActionsResponse = await esClient.search(
{
index: AGENT_ACTIONS_INDEX,
from: 0,
size: ES_SEARCH_LIMIT,
query: {
bool: {
filter: [
{
terms: {
action_id: actionIds,
},
},
],
},
},
},
queryOptions
);
const actions = getActionsResponse.hits.hits.reduce<FleetActionRequest[]>((acc, hit) => {
if (hit._source) {
acc.push(hit._source as FleetActionRequest);
}
return acc;
}, []);
return {
items: actions,
total: actions.length,
};
} catch (getActionsByIdError) {
throw new FleetActionsError(
`Error getting action: ${getActionsByIdError.message}`,
getActionsByIdError
);
}
};
export const getActionsWithKuery = async (
esClient: ElasticsearchClient,
kuery: string
): Promise<{ items: FleetActionRequest[]; total: number }> => {
const kueryNode = fromKueryExpression(kuery);
const validationFilterKuery = validateFilterKueryNode({
astFilter: kueryNode,
types: ALLOWED_FLEET_ACTIONS_FIELD_TYPES,
indexMapping: allowedFleetActionsFields,
indexType: 'actions',
});
if (validationFilterKuery.some((obj) => obj.error != null)) {
const errors = validationFilterKuery
.reduce<string[]>((acc, item) => {
if (item.error) {
acc.push(item.error);
}
return acc;
}, [])
.join();
throw new FleetActionsError(`Kuery validation failed: ${errors}`);
}
try {
const query: estypes.QueryDslQueryContainer = toElasticsearchQuery(kueryNode);
const getActionSearchResponse = await esClient.search(
{
index: AGENT_ACTIONS_INDEX,
from: 0,
size: ES_SEARCH_LIMIT,
query,
},
queryOptions
);
const actions = getActionSearchResponse.hits.hits.reduce<FleetActionRequest[]>((acc, hit) => {
if (hit._source) {
acc.push(hit._source as FleetActionRequest);
}
return acc;
}, []);
return {
items: actions,
total: actions.length,
};
} catch (getActionSearchError) {
throw new FleetActionsError(
`Error getting actions with kuery: ${getActionSearchError.message}`,
getActionSearchError
);
}
};
export const getActionResultsByIds = async (
esClient: ElasticsearchClient,
actionIds: string[]
): Promise<{ items: FleetActionResult[]; total: number }> => {
try {
const getActionsResultsResponse = await esClient.search(
{
index: AGENT_ACTIONS_RESULTS_INDEX,
from: 0,
size: ES_SEARCH_LIMIT,
query: {
bool: {
filter: [
{
terms: {
action_id: actionIds,
},
},
],
},
},
},
queryOptions
);
const actionsResults = getActionsResultsResponse.hits.hits.reduce<FleetActionResult[]>(
(acc, hit) => {
if (hit._source) {
acc.push(hit._source as FleetActionResult);
}
return acc;
},
[]
);
return {
items: actionsResults,
total: actionsResults.length,
};
} catch (getActionByIdError) {
throw new FleetActionsError(
`Error getting action results: ${getActionByIdError.message}`,
getActionByIdError
);
}
};
export const getActionResultsWithKuery = async (
esClient: ElasticsearchClient,
kuery: string
): Promise<{ items: FleetActionResult[]; total: number }> => {
const kueryNode = fromKueryExpression(kuery);
const validationFilterKuery = validateFilterKueryNode({
astFilter: kueryNode,
types: ALLOWED_FLEET_ACTIONS_FIELD_TYPES,
indexMapping: allowedFleetActionsFields,
indexType: 'results',
});
if (validationFilterKuery.some((obj) => obj.error != null)) {
const errors = validationFilterKuery
.reduce<string[]>((acc, item) => {
if (item.error) {
acc.push(item.error);
}
return acc;
}, [])
.join();
throw new FleetActionsError(`Kuery validation failed: ${errors}`);
}
try {
const query: estypes.QueryDslQueryContainer = toElasticsearchQuery(kueryNode);
const getActionSearchResponse = await esClient.search(
{
index: AGENT_ACTIONS_INDEX,
from: 0,
size: ES_SEARCH_LIMIT,
query,
},
queryOptions
);
const actionsResults = getActionSearchResponse.hits.hits.reduce<FleetActionResult[]>(
(acc, hit) => {
if (hit._source) {
acc.push(hit._source as FleetActionResult);
}
return acc;
},
[]
);
return {
items: actionsResults,
total: actionsResults.length,
};
} catch (getActionResultsSearchError) {
throw new FleetActionsError(
`Error getting action results with kuery: ${getActionResultsSearchError.message}`,
getActionResultsSearchError
);
}
};

View file

@ -0,0 +1,301 @@
/*
* 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 { v4 as uuidV4 } from 'uuid';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { FleetActionsClientError, FleetActionsError } from '../../../common/errors';
import { createAppContextStartContractMock } from '../../mocks';
import { appContextService } from '../app_context';
import { auditLoggingService } from '../audit_logging';
import {
generateFleetAction,
generateFleetActionResult,
generateFleetActionsBulkCreateESResponse,
generateFleetActionsESResponse,
generateFleetActionsResultsESResponse,
} from './mocks';
import { FleetActionsClient } from './client';
jest.mock('../audit_logging');
const mockedAuditLoggingService = auditLoggingService as jest.Mocked<typeof auditLoggingService>;
describe('actions', () => {
let fleetActionsClient: FleetActionsClient;
let esClientMock: ReturnType<typeof elasticsearchServiceMock.createInternalClient>;
beforeEach(() => {
appContextService.start(createAppContextStartContractMock());
esClientMock = elasticsearchServiceMock.createInternalClient();
fleetActionsClient = new FleetActionsClient(esClientMock, 'foo');
});
afterEach(() => {
appContextService.stop();
});
describe('create()', () => {
afterEach(() => {
mockedAuditLoggingService.writeCustomAuditLog.mockReset();
});
it('should create an action', async () => {
const action = generateFleetAction({ action_id: '1', input_type: 'foo' });
expect(await fleetActionsClient.create(action)).toEqual(action);
expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({
message: `User created Fleet action [id=1, user_id=${action.user_id}, input_type=foo]`,
});
});
it('should throw error when action does not match package name', async () => {
const action = generateFleetAction({ action_id: '1', input_type: 'bar' });
await expect(async () => await fleetActionsClient.create(action)).rejects.toBeInstanceOf(
FleetActionsClientError
);
expect(mockedAuditLoggingService.writeCustomAuditLog).not.toHaveBeenCalled();
});
});
describe('bulkCreate()', () => {
afterEach(() => {
mockedAuditLoggingService.writeCustomAuditLog.mockReset();
});
it('should bulk create actions', async () => {
const actions = [
{
action_id: uuidV4(),
input_type: 'foo',
},
{
action_id: uuidV4(),
input_type: 'foo',
},
].map(generateFleetAction);
esClientMock.bulk.mockResolvedValue(generateFleetActionsBulkCreateESResponse(actions));
expect(await fleetActionsClient.bulkCreate(actions)).toEqual({
status: 'success',
items: actions.map((action) => ({
id: action.action_id,
status: 'success',
})),
});
expect(mockedAuditLoggingService.writeCustomAuditLog).toBeCalledTimes(2);
expect(mockedAuditLoggingService.writeCustomAuditLog).lastCalledWith({
message: `User created Fleet action [id=${actions[1].action_id}, user_id=${actions[1].user_id}, input_type=foo]`,
});
});
it('should report errored documents', async () => {
const successActions = [
{
action_id: uuidV4(),
input_type: 'foo',
},
{
action_id: uuidV4(),
input_type: 'foo',
},
].map(generateFleetAction);
const failedActions = [
{
action_id: uuidV4(),
input_type: 'foo',
},
{
action_id: uuidV4(),
input_type: 'foo',
},
{
action_id: uuidV4(),
input_type: 'foo',
},
].map(generateFleetAction);
esClientMock.bulk.mockResolvedValue(
generateFleetActionsBulkCreateESResponse(successActions, failedActions, true)
);
expect(await fleetActionsClient.bulkCreate([...successActions, ...failedActions])).toEqual({
status: 'mixed',
items: successActions
.map((action) => ({
id: action.action_id,
status: 'success',
}))
.concat(
failedActions.map((action) => ({
id: action.action_id,
status: 'error',
}))
),
});
expect(mockedAuditLoggingService.writeCustomAuditLog).toBeCalledTimes(2);
expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenNthCalledWith(1, {
message: `User created Fleet action [id=${successActions[0].action_id}, user_id=elastic, input_type=foo]`,
});
expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenNthCalledWith(2, {
message: `User created Fleet action [id=${successActions[1].action_id}, user_id=elastic, input_type=foo]`,
});
});
it('should throw error for bulk creation on package mismatch on any given set of actions', async () => {
const actions = [
{
action_id: '1',
input_type: 'foo',
},
{
action_id: '2',
input_type: 'bar',
},
].map(generateFleetAction);
await expect(async () => await fleetActionsClient.bulkCreate(actions)).rejects.toBeInstanceOf(
FleetActionsClientError
);
expect(mockedAuditLoggingService.writeCustomAuditLog).not.toHaveBeenCalled();
});
});
describe('getActionsByIds()', () => {
it('should get an action by id', async () => {
const actions = [generateFleetAction({ action_id: '1', input_type: 'foo' })];
esClientMock.search.mockResponse(generateFleetActionsESResponse(actions));
expect(await fleetActionsClient.getActionsByIds(['1'])).toEqual({
items: actions,
total: actions.length,
});
});
it('should reject when trying to get an action from a different package', async () => {
esClientMock.search.mockResponse(
generateFleetActionsESResponse([generateFleetAction({ action_id: '3', input_type: 'bar' })])
);
await expect(
async () => await fleetActionsClient.getActionsByIds(['3'])
).rejects.toBeInstanceOf(FleetActionsClientError);
});
});
describe('getActionsWithKuery()', () => {
it('should get actions with given kuery', async () => {
const actions = [
{ action_id: '1', agents: ['agent-1'], input_type: 'foo' },
{ action_id: '2', agents: ['agent-2'], input_type: 'foo' },
].map(generateFleetAction);
esClientMock.search.mockResponse(generateFleetActionsESResponse(actions));
expect(
await fleetActionsClient.getActionsWithKuery(
'action_id: "1" or action_id: "2" and input_type: "endpoint" and "@timestamp" <= "now" and "@timestamp" >= "now-2d"'
)
).toEqual({ items: actions, total: actions.length });
});
it('should reject when given kuery results do not match package name', async () => {
const actions = [
{ action_id: '1', agents: ['agent-1'], input_type: 'foo' },
{ action_id: '2', agents: ['agent-2'], input_type: 'bar' },
].map(generateFleetAction);
esClientMock.search.mockResponse(generateFleetActionsESResponse(actions));
await expect(
async () => await fleetActionsClient.getActionsWithKuery('action_id: "1" or action_id: "2"')
).rejects.toBeInstanceOf(FleetActionsClientError);
});
it('should reject when given kuery uses un-allowed fields', async () => {
const actions = [
{ action_id: '1', agents: ['agent-1'], input_type: 'foo' },
{ action_id: '2', agents: ['agent-2'], input_type: 'foo' },
].map(generateFleetAction);
esClientMock.search.mockResponse(generateFleetActionsESResponse(actions));
await expect(
async () =>
await fleetActionsClient.getActionsWithKuery(
'action_id: "1" or expiration: "2023-06-21T10:55:36.481Z"'
)
).rejects.toBeInstanceOf(FleetActionsError);
});
});
describe('getResultsByIds()', () => {
it('should get action results by action ids', async () => {
const results = [
{ action_id: 'action-id-1', agent_id: 'agent-1', action_input_type: 'foo' },
{ action_id: 'action-id-2', agent_id: 'agent-2', action_input_type: 'foo' },
].map(generateFleetActionResult);
esClientMock.search.mockResponse(generateFleetActionsResultsESResponse(results));
expect(await fleetActionsClient.getResultsByIds(['action-id-1', 'action-id-2'])).toEqual({
items: results,
total: 2,
});
});
it('should reject when given package name does not match result', async () => {
const results = [
{ action_id: 'action-id-21', agent_id: 'agent-1', action_input_type: 'foo' },
{ action_id: 'action-id-23', agent_id: 'agent-2', action_input_type: 'bar' },
].map(generateFleetActionResult);
esClientMock.search.mockResponse(generateFleetActionsResultsESResponse(results));
await expect(
async () => await fleetActionsClient.getResultsByIds(['action-id-1', 'action-id-2'])
).rejects.toBeInstanceOf(FleetActionsClientError);
});
});
describe('getResultsWithKuery()', () => {
it('should get action results with kuery', async () => {
const results = [
{ action_id: 'action-id-21', agent_id: 'agent-1', action_input_type: 'foo' },
{ action_id: 'action-id-23', agent_id: 'agent-2', action_input_type: 'foo' },
].map(generateFleetActionResult);
esClientMock.search.mockResponse(generateFleetActionsResultsESResponse(results));
expect(
await fleetActionsClient.getResultsWithKuery(
'action_id: "action-id-21" or action_id: "action-id-23"'
)
).toEqual({ items: results, total: results.length });
});
it('should reject when given package name does not match result', async () => {
const results = [
{ action_id: 'action-id-21', agent_id: 'agent-1', action_input_type: 'foo' },
{ action_id: 'action-id-23', agent_id: 'agent-2', action_input_type: 'bar' },
].map(generateFleetActionResult);
esClientMock.search.mockResponse(generateFleetActionsResultsESResponse(results));
await expect(
async () =>
await fleetActionsClient.getResultsWithKuery(
'action_id: "action-id-21" or action_id: "action-id-23"'
)
).rejects.toBeInstanceOf(FleetActionsClientError);
});
it('should reject when given kuery uses un-allowed fields', async () => {
const results = [
{ action_id: 'action-id-21', agent_id: 'agent-1', action_input_type: 'foo' },
{ action_id: 'action-id-23', agent_id: 'agent-2', action_input_type: 'foo' },
].map(generateFleetActionResult);
esClientMock.search.mockResponse(generateFleetActionsResultsESResponse(results));
await expect(
async () =>
await fleetActionsClient.getResultsWithKuery(
'action_id: "action-id-21" or action_input_type: "osquery"'
)
).rejects.toBeInstanceOf(FleetActionsError);
});
});
});

View file

@ -0,0 +1,104 @@
/*
* 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 { FleetActionsClientError } from '../../../common/errors';
import type {
FleetActionsClientInterface,
FleetActionRequest,
FleetActionResult,
BulkCreateResponse,
} from './types';
import {
createAction,
bulkCreateActions,
getActionsByIds,
getActionsWithKuery,
getActionResultsByIds,
getActionResultsWithKuery,
} from './actions';
export class FleetActionsClient implements FleetActionsClientInterface {
constructor(private esClient: ElasticsearchClient, private packageName: string) {
if (!packageName) {
throw new FleetActionsClientError('packageName is required');
}
}
private _verifyAction(action: FleetActionRequest) {
if (action.input_type !== this.packageName) {
throw new FleetActionsClientError(
`Action package name mismatch. Expected "${this.packageName}" got "${action.input_type}. Action: ${action.action_id}."`
);
}
return action;
}
private _verifyResult(result: FleetActionResult) {
if (result.action_input_type !== this.packageName) {
throw new FleetActionsClientError(
`Action result package name mismatch. Expected "${this.packageName}" got "${result.action_input_type}". Result: ${result.action_id}`
);
}
return result;
}
async create(action: FleetActionRequest): Promise<FleetActionRequest> {
const verifiedAction = this._verifyAction(action);
return createAction(this.esClient, verifiedAction);
}
async bulkCreate(actions: FleetActionRequest[]): Promise<BulkCreateResponse> {
actions.map((action) => this._verifyAction(action));
return bulkCreateActions(this.esClient, actions);
}
async getActionsByIds(ids: string[]): Promise<{
items: FleetActionRequest[];
total: number;
}> {
const actions = await getActionsByIds(this.esClient, ids);
actions.items.every((action) => this._verifyAction(action));
return actions;
}
async getActionsWithKuery(kuery: string): Promise<{
items: FleetActionRequest[];
total: number;
}> {
const actions = await getActionsWithKuery(this.esClient, kuery);
actions.items.every((action) => this._verifyAction(action));
return actions;
}
async getResultsByIds(ids: string[]): Promise<{
items: FleetActionResult[];
total: number;
}> {
const results = await getActionResultsByIds(this.esClient, ids);
results.items.every((result) => this._verifyResult(result));
return results;
}
async getResultsWithKuery(kuery: string): Promise<{
items: FleetActionResult[];
total: number;
}> {
const results = await getActionResultsWithKuery(this.esClient, kuery);
results.items.every((result) => this._verifyResult(result));
return results;
}
}

View file

@ -0,0 +1,9 @@
/*
* 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 { FleetActionsClient } from './client';
export type { FleetActionsClientInterface, FleetActionRequest, FleetActionResult } from './types';

View file

@ -0,0 +1,235 @@
/*
* 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 moment from 'moment';
import { merge } from 'lodash';
import type { DeepPartial } from 'utility-types';
import { v4 as uuidV4 } from 'uuid';
import type {
FleetActionRequest,
FleetActionResult,
FleetActionsClientInterface,
BulkCreateResponse,
} from './types';
export const generateFleetAction = (
overrides: DeepPartial<FleetActionRequest> = {}
): FleetActionRequest => {
return merge(
{
'@timestamp': moment().toISOString(),
action_id: uuidV4(),
agents: [uuidV4()],
expiration: moment().add(1, 'day').toISOString(),
data: {},
input_type: Math.random().toString(36).slice(2),
timeout: 2000,
type: 'INPUT_ACTION',
user_id: 'elastic',
},
overrides as FleetActionRequest
);
};
export const generateFleetActionResult = (
overrides: DeepPartial<FleetActionResult> = {}
): FleetActionResult => {
return merge(
{
'@timestamp': moment().toISOString(),
action_id: uuidV4(),
action_data: {},
action_input_type: Math.random().toString(36).slice(2),
action_response:
overrides.action_input_type === 'endpoint'
? {
endpoint: {
ack: true,
},
}
: undefined,
agent_id: uuidV4(),
completed_at: moment().add(1, 'minute').toISOString(),
error: undefined,
started_at: moment().add(10, 'second').toISOString(),
},
overrides as FleetActionResult
);
};
export const generateFleetActionsESResponse = (actions: Array<Partial<FleetActionRequest>>) => {
const hits = actions.map((action) => ({
_index: '.fleet-actions-7',
_id: action.action_id || uuidV4(),
_score: 1.0,
_source: {
...action,
},
}));
return {
took: 1,
timed_out: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0,
},
hits: {
total: actions.length,
max_score: 1.0,
hits,
},
};
};
export const generateFleetActionsResultsESResponse = (
results: Array<Partial<FleetActionResult>>
) => {
const hits = results.map((result) => ({
_index: '.ds-.fleet-actions-results-2023.05.29-000001',
_id: uuidV4(),
_score: 1.0,
_source: {
...result,
},
}));
return {
took: 1,
timed_out: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0,
},
hits: {
total: results.length,
max_score: 1.0,
hits,
},
};
};
export const generateFleetActionsBulkCreateESResponse = (
successActions: Array<Partial<FleetActionRequest>>,
failedActions: Array<Partial<FleetActionRequest>> = [],
hasErrors = false
) => {
const items = [];
for (let i = 0; i < successActions.length; i++) {
items.push({
create: {
_index: '.fleet-actions-7',
_id: successActions[i].action_id || uuidV4(),
_version: 1,
result: 'created',
_shards: {
total: 2,
successful: 1,
failed: 0,
},
_seq_no: i,
status: 201,
_primary_term: i,
},
});
}
if (hasErrors) {
for (let i = 0; i < failedActions.length; i++) {
items.push({
create: {
_index: '.fleet-actions-7',
_id: failedActions[i].action_id || uuidV4(),
status: 503,
error: {
type: 'some-error-type',
reason: 'some-reason-for-failure',
},
},
});
}
}
return {
took: 23,
errors: hasErrors,
items,
};
};
export const createFleetActionsClientMock = (): jest.Mocked<FleetActionsClientInterface> => {
const createResponse = (action: DeepPartial<FleetActionRequest>): FleetActionRequest =>
generateFleetAction(action);
const bulkCreateResponse = (
actions: Array<DeepPartial<FleetActionRequest>>
): BulkCreateResponse => ({
status: 'success',
items: actions.map((action) => ({ status: 'success', id: action.action_id || uuidV4() })),
});
const actionsRequests = (ids: string[]): FleetActionRequest[] =>
ids.map((id) =>
generateFleetAction({
action_id: id,
input_type: 'foo',
})
);
const actionsResults = (ids: string[]): FleetActionResult[] =>
ids.map((id) =>
generateFleetActionResult({
action_id: id,
action_input_type: 'foo',
})
);
return {
create: jest.fn(async (action) => {
return createResponse(action);
}),
bulkCreate: jest.fn(async (actions) => bulkCreateResponse(actions)),
getActionsByIds: jest.fn(async (ids) => {
const items = actionsRequests(ids);
return {
items,
total: items.length,
};
}),
getActionsWithKuery: jest.fn(async (_) => {
const items = actionsRequests(['action_id_1', 'action_id_2']);
return {
items,
total: items.length,
};
}),
getResultsByIds: jest.fn(async (ids) => {
const items = actionsResults(ids);
return {
items,
total: items.length,
};
}),
getResultsWithKuery: jest.fn(async (_) => {
const items = actionsResults(['action_id_1', 'action_id_2']);
return {
items,
total: items.length,
};
}),
};
};

View file

@ -0,0 +1,125 @@
/*
* 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 interface BulkCreateResponse {
status: 'success' | 'failed' | 'mixed';
items: Array<{
status: 'success' | 'error';
id: string; // same as action_id
}>;
}
export interface FleetActionsClientInterface {
/**
*
* @param action
* @returns {Promise<FleetActionRequest>}
* @throws {FleetActionsError}
* creates a new action document
*/
create(action: FleetActionRequest): Promise<FleetActionRequest>;
/**
*
* @param actions
* @returns {Promise<BulkCreateResponse>}
* @throws {FleetActionsError}
* creates multiple action documents
* return successfully created actions
* logs failed actions documents
*/
bulkCreate(actions: FleetActionRequest[]): Promise<BulkCreateResponse>;
/**
*
* @param actionIds
* @returns {Promise<items: FleetActionRequest[], total: number>}
* @throws {FleetActionsError}
* returns actions by action ids
*/
getActionsByIds(actionIds: string[]): Promise<{
items: FleetActionRequest[];
total: number;
}>;
/**
*
* @param kuery
* @returns {Promise<items: FleetActionRequest[], total: number>}
* @throws {FleetActionsError}
* returns actions by kuery
*/
getActionsWithKuery(kuery: string): Promise<{
items: FleetActionRequest[];
total: number;
}>;
/**
*
* @param actionIds
* @returns {Promise<items: FleetActionResult[], total: number>}
* @throws {FleetActionsError}
* returns action results by action ids
*/
getResultsByIds(actionIds: string[]): Promise<{
items: FleetActionResult[];
total: number;
}>;
/**
*
* @param kuery
* @returns {Promise<items: FleetActionResult[], total: number>}
* @throws {FleetActionsError}
* returns action results by action ids
*/
getResultsWithKuery(kuery: string): Promise<{
items: FleetActionResult[];
total: number;
}>;
}
interface CommonFleetActionResultDocFields {
'@timestamp': string;
action_id: string;
//
data?: {
[k: string]: unknown;
};
}
export interface FleetActionRequest extends CommonFleetActionResultDocFields {
agents: string[];
expiration: string;
input_type: string;
minimum_execution_duration?: number;
rollout_duration_seconds?: number;
// only endpoint uses this for now
signed?: {
data: string;
signature: string;
};
start_time?: string;
timeout: number;
type: string;
user_id: string;
// allow other fields that are not mapped
[k: string]: unknown;
}
export interface FleetActionResult extends CommonFleetActionResultDocFields {
'@timestamp': string;
action_data: object;
action_id: string;
action_input_type: string;
action_response?: Record<string, unknown>;
agent_id: string;
completed_at: string;
error: string;
started_at: string;
}

View file

@ -0,0 +1,187 @@
/*
* 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 * as esKuery from '@kbn/es-query';
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../common';
import {
validateFilterKueryNode,
allowedFleetActionsFields,
allowedFleetActionsResultsFields,
isFieldDefined,
hasFieldKeyError,
type IndexType,
} from './utils';
describe('utils', () => {
describe('#validateFilterKueryNode', () => {
it('should accept only allowed search fields', () => {
const validationObject = validateFilterKueryNode({
astFilter: esKuery.fromKueryExpression(
'action_id: "1" or action_id: "2" and (input_type: "endpoint" and @timestamp <= "now" and @timestamp >= "now-2d")'
),
types: ['keyword', 'date'],
indexMapping: allowedFleetActionsFields,
});
expect(validationObject).toEqual([
{
astPath: 'arguments.0',
error: null,
key: 'action_id',
type: 'keyword',
},
{
astPath: 'arguments.1.arguments.0',
error: null,
key: 'action_id',
type: 'keyword',
},
{
astPath: 'arguments.1.arguments.1.arguments.0',
error: null,
key: 'input_type',
type: 'keyword',
},
{
astPath: 'arguments.1.arguments.1.arguments.1',
error: null,
key: '@timestamp',
type: 'date',
},
{
astPath: 'arguments.1.arguments.1.arguments.2',
error: null,
key: '@timestamp',
type: 'date',
},
]);
});
it('should not accept if any search fields are not allowed', () => {
const validationObject = validateFilterKueryNode({
astFilter: esKuery.fromKueryExpression(
'action_id: "1" and expiration: "2023-06-21T10:55:36.481Z" and (input_type: "endpoint" and @timestamp <= "now" and @timestamp >= "now-2d")'
),
types: ['keyword', 'date'],
indexMapping: allowedFleetActionsFields,
});
expect(validationObject).toEqual([
{
astPath: 'arguments.0',
error: null,
key: 'action_id',
type: 'keyword',
},
{
astPath: 'arguments.1',
error: "This key 'expiration' does not exist in .fleet-actions index mappings",
key: 'expiration',
type: undefined,
},
{
astPath: 'arguments.2.arguments.0',
error: null,
key: 'input_type',
type: 'keyword',
},
{
astPath: 'arguments.2.arguments.1',
error: null,
key: '@timestamp',
type: 'date',
},
{
astPath: 'arguments.2.arguments.2',
error: null,
key: '@timestamp',
type: 'date',
},
]);
});
});
describe('#hasFieldKeyError', () => {
describe.each([
[AGENT_ACTIONS_INDEX, 'actions', ['keyword', 'date']],
[AGENT_ACTIONS_RESULTS_INDEX, 'results', ['keyword']],
])('%s', (indexName, indexType, fieldTypes) => {
it('Return no error if filter key is valid', () => {
const hasError = hasFieldKeyError(
'action_id',
fieldTypes,
indexType === 'actions' ? allowedFleetActionsFields : allowedFleetActionsResultsFields,
indexType as IndexType
);
expect(hasError).toBeNull();
});
it('Return error if filter key is valid but type is not', () => {
const hasError = hasFieldKeyError(
'action_id',
['text', 'integer'],
indexType === 'actions' ? allowedFleetActionsFields : allowedFleetActionsResultsFields,
indexType as IndexType
);
expect(hasError).toEqual(
`This key 'action_id' does not match allowed field types in ${indexName} index mappings`
);
});
it('Return error if key is not defined', () => {
const hasError = hasFieldKeyError(
undefined,
['integer'],
indexType === 'actions' ? allowedFleetActionsFields : allowedFleetActionsResultsFields,
indexType as IndexType
);
const errorMessage =
indexType === 'actions'
? '[action_id,agents,input_type,@timestamp,type,user_id]'
: '[action_id,agent_id]';
expect(hasError).toEqual(`The key is empty and should be one of ${errorMessage}`);
});
it('Return error if key is null', () => {
const hasError = hasFieldKeyError(
null,
['text'],
indexType === 'actions' ? allowedFleetActionsFields : allowedFleetActionsResultsFields,
indexType as IndexType
);
const errorMessage =
indexType === 'actions'
? '[action_id,agents,input_type,@timestamp,type,user_id]'
: '[action_id,agent_id]';
expect(hasError).toEqual(`The key is empty and should be one of ${errorMessage}`);
});
});
});
describe('#isFieldDefined', () => {
it('Return false if kuery is using an non-existing key', () => {
const _isFieldDefined = isFieldDefined(allowedFleetActionsFields, 'not_a_key');
expect(_isFieldDefined).toBeFalsy();
});
it.each(['action_id', 'agents', 'input_type', '@timestamp', 'type', 'user_id'])(
'Return true if kuery is using an existing key %s',
(key) => {
const _isFieldDefined = isFieldDefined(allowedFleetActionsFields, key);
expect(_isFieldDefined).toBeTruthy();
}
);
});
});

View file

@ -0,0 +1,182 @@
/*
* 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 { KueryNode } from '@kbn/es-query';
import { get } from 'lodash';
import { deepFreeze } from '@kbn/std';
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../common';
const getFieldType = (
key: string | undefined | null,
indexMappings: FleetActionsIndexMapping
): string => {
const mappingKey = `properties.${key}.type`;
return key != null ? get(indexMappings, mappingKey) : '';
};
export const isFieldDefined = (
indexMappings: FleetActionsIndexMapping | FleetActionsResultsIndexMapping,
key: string
): boolean => {
const mappingKey = 'properties.' + key;
return typeof get(indexMappings, mappingKey) !== 'undefined';
};
export const hasFieldKeyError = (
key: string | null | undefined,
fieldTypes: Readonly<string[]>,
indexMapping: FleetActionsIndexMapping | FleetActionsResultsIndexMapping,
indexType: IndexType
): string | null => {
const allowedKeys = Object.keys(indexMapping.properties).join();
if (key == null) {
return `The key is empty and should be one of [${allowedKeys}]`;
}
if (key) {
const allowedFieldTypes = fieldTypes.every((type) =>
indexType === 'actions'
? ALLOWED_FLEET_ACTIONS_FIELD_TYPES.includes(type)
: ALLOWED_FLEET_ACTIONS_RESULTS_FIELD_TYPES.includes(type)
);
const indexName = indexType === 'actions' ? AGENT_ACTIONS_INDEX : AGENT_ACTIONS_RESULTS_INDEX;
if (!isFieldDefined(indexMapping, key)) {
return `This key '${key}' does not exist in ${indexName} index mappings`;
}
if (!allowedFieldTypes) {
return `This key '${key}' does not match allowed field types in ${indexName} index mappings`;
}
}
return null;
};
export const ALLOWED_FLEET_ACTIONS_RESULTS_FIELD_TYPES: Readonly<string[]> = ['keyword'];
export const ALLOWED_FLEET_ACTIONS_FIELD_TYPES: Readonly<string[]> = ['keyword', 'date'];
export interface FleetActionsIndexMapping {
properties: {
action_id: {
type: 'keyword';
};
agents: {
type: 'keyword';
};
input_type: {
type: 'keyword';
};
'@timestamp': {
type: 'date';
};
type: {
type: 'keyword';
};
user_id: {
type: 'keyword';
};
};
}
export interface FleetActionsResultsIndexMapping {
properties: {
action_id: {
type: 'keyword';
};
agent_id: {
type: 'keyword';
};
};
}
export const allowedFleetActionsFields: FleetActionsIndexMapping = deepFreeze({
properties: {
action_id: {
type: 'keyword',
},
agents: {
type: 'keyword',
},
input_type: {
type: 'keyword',
},
'@timestamp': {
type: 'date',
},
type: {
type: 'keyword',
},
user_id: {
type: 'keyword',
},
},
});
export const allowedFleetActionsResultsFields: FleetActionsResultsIndexMapping = deepFreeze({
properties: {
action_id: {
type: 'keyword',
},
agent_id: {
type: 'keyword',
},
},
});
interface ValidateFilterKueryNode {
astPath: string;
error: string;
isSavedObjectAttr: boolean;
key: string;
type: string | null;
}
export type IndexType = 'actions' | 'results';
interface ValidateFilterKueryNodeParams {
astFilter: KueryNode;
types: Readonly<string[]>;
indexMapping: FleetActionsIndexMapping;
indexType?: IndexType;
path?: string;
}
export const validateFilterKueryNode = ({
astFilter,
types,
indexMapping,
indexType = 'actions',
path = 'arguments',
}: ValidateFilterKueryNodeParams): ValidateFilterKueryNode[] => {
return astFilter.arguments.reduce((kueryNode: string[], ast: KueryNode, index: number) => {
if (ast.arguments) {
const myPath = `${path}.${index}`;
return [
...kueryNode,
...validateFilterKueryNode({
astFilter: ast,
types,
indexMapping,
path: `${myPath}.arguments`,
}),
];
}
if (index === 0) {
const splitPath = path.split('.');
return [
...kueryNode,
{
astPath: splitPath.slice(0, splitPath.length - 1).join('.'),
error: hasFieldKeyError(ast.value, types, indexMapping, indexType),
key: ast.value,
type: getFieldType(ast.value, indexMapping),
},
];
}
return kueryNode;
}, []);
};

View file

@ -16,6 +16,7 @@ import type {
} from '@kbn/fleet-plugin/server';
import type { PluginStartContract as AlertsPluginStartContract } from '@kbn/alerting-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { FleetActionsClientInterface } from '@kbn/fleet-plugin/server/services/actions/types';
import {
getPackagePolicyCreateCallback,
getPackagePolicyUpdateCallback,
@ -49,6 +50,7 @@ export interface EndpointAppContextServiceSetupContract {
export interface EndpointAppContextServiceStartContract {
fleetAuthzService?: FleetStartContract['authz'];
createFleetFilesClient: FleetStartContract['createFilesClient'];
createFleetActionsClient: FleetStartContract['createFleetActionsClient'];
logger: Logger;
endpointMetadataService: EndpointMetadataService;
endpointFleetServicesFactory: EndpointFleetServicesFactoryInterface;
@ -252,4 +254,12 @@ export class EndpointAppContextService {
return this.startDependencies.createFleetFilesClient.fromHost('endpoint');
}
public async getFleetActionsClient(): Promise<FleetActionsClientInterface> {
if (!this.startDependencies?.createFleetActionsClient) {
throw new EndpointAppContentServicesNotStartedError();
}
return this.startDependencies.createFleetActionsClient('endpoint');
}
}

View file

@ -37,6 +37,7 @@ import {
createMessageSigningServiceMock,
createFleetFromHostFilesClientMock,
createFleetToHostFilesClientMock,
createFleetActionsClientMock,
} from '@kbn/fleet-plugin/server/mocks';
import { createFleetAuthzMock } from '@kbn/fleet-plugin/common/mocks';
import type { RequestFixtureOptions, RouterMock } from '@kbn/core-http-router-server-mocks';
@ -97,6 +98,7 @@ export const createMockEndpointAppContextService = (
const casesClientMock = createCasesClientMock();
const fleetFromHostFilesClientMock = createFleetFromHostFilesClientMock();
const fleetToHostFilesClientMock = createFleetToHostFilesClientMock();
const fleetActionsClientMock = createFleetActionsClientMock();
return {
start: jest.fn(),
@ -117,6 +119,7 @@ export const createMockEndpointAppContextService = (
getFeatureUsageService: jest.fn(),
getExceptionListsClient: jest.fn(),
getMessageSigningService: jest.fn(),
getFleetActionsClient: jest.fn(async (_) => fleetActionsClientMock),
} as unknown as jest.Mocked<EndpointAppContextService>;
};
@ -179,6 +182,7 @@ export const createMockEndpointAppContextServiceStartContract =
);
const casesMock = casesPluginMock.createStartContract();
const fleetActionsClientMock = createFleetActionsClientMock();
return {
endpointMetadataService,
@ -205,6 +209,7 @@ export const createMockEndpointAppContextServiceStartContract =
experimentalFeatures: createMockConfig().experimentalFeatures,
messageSigningService: createMessageSigningServiceMock(),
actionCreateService: undefined,
createFleetActionsClient: jest.fn((_) => fleetActionsClientMock),
};
};

View file

@ -25,7 +25,6 @@ import {
httpServiceMock,
savedObjectsClientMock,
} from '@kbn/core/server/mocks';
import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common';
import type { CasesClientMock } from '@kbn/cases-plugin/server/client/mocks';
import { LicenseService } from '../../../../common/license';
@ -43,12 +42,10 @@ import {
} from '../../../../common/endpoint/constants';
import type {
ActionDetails,
EndpointAction,
ResponseActionApiResponse,
HostMetadata,
LogsEndpointAction,
ResponseActionRequestBody,
ResponseActionsExecuteParameters,
} from '../../../../common/endpoint/types';
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
import type { EndpointAuthz } from '../../../../common/endpoint/types/authz';
@ -69,7 +66,7 @@ import { actionCreateService } from '../../services/actions';
interface CallRouteInterface {
body?: ResponseActionRequestBody;
idxResponse?: any;
indexErrorResponse?: any;
searchResponse?: HostMetadata;
mockUser?: any;
license?: License;
@ -143,7 +140,14 @@ describe('Response actions', () => {
// 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,
{
body,
indexErrorResponse,
searchResponse,
mockUser,
license,
authz = {},
}: CallRouteInterface,
indexExists?: { endpointDsExists: boolean }
): Promise<AwaitedProperties<SecuritySolutionRequestHandlerContextMock>> => {
const asUser = mockUser ? mockUser : superUser;
@ -174,9 +178,9 @@ describe('Response actions', () => {
);
const metadataResponse = docGen.generateHostMetadata();
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
const withErrorResponse = indexErrorResponse ? indexErrorResponse : { statusCode: 201 };
ctx.core.elasticsearch.client.asInternalUser.index.mockResponseImplementation(
() => withIdxResp
() => withErrorResponse
);
ctx.core.elasticsearch.client.asInternalUser.search.mockResponseImplementation(() => {
return {
@ -227,24 +231,6 @@ describe('Response actions', () => {
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();
@ -253,41 +239,55 @@ describe('Response actions', () => {
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, {
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);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
agents: [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, {
const testUser = { username: 'testuser', roles: ['superuser'] };
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'] },
mockUser: testU,
mockUser: testUser,
});
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);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
user_id: testUser.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 comment = "I am isolating this because it's Friday";
await callRoute(ISOLATE_HOST_ROUTE_V2, {
body: { endpoint_ids: ['XYZ'], comment },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.data.comment).toEqual(CommentText);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ comment }),
})
);
});
it('creates an action and returns its ID + ActionDetails', async () => {
@ -295,125 +295,191 @@ describe('Response actions', () => {
const actionDetails = { agents: endpointIds, command: 'isolate' } as ActionDetails;
getActionDetailsByIdSpy.mockResolvedValue(actionDetails);
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
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;
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
action_id: expect.any(String),
})
);
expect(mockResponse.ok).toBeCalled();
expect((mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse).action).toEqual(
actionID
expect.any(String)
);
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, {
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);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 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 agentId = doc.elastic.agent.id;
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
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);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
agents: [agentId],
})
);
});
it('sends the isolate command payload from the isolate route', async () => {
const ctx = await callRoute(ISOLATE_HOST_ROUTE_V2, {
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');
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'isolate',
}),
})
);
});
it('sends the unisolate command payload from the unisolate route', async () => {
const ctx = await callRoute(UNISOLATE_HOST_ROUTE_V2, {
await callRoute(UNISOLATE_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('unisolate');
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'unisolate',
}),
})
);
});
it('sends the kill-process command payload from the kill process route', async () => {
const ctx = await callRoute(KILL_PROCESS_ROUTE, {
await callRoute(KILL_PROCESS_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('kill-process');
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'kill-process',
}),
})
);
});
it('sends the suspend-process command payload from the suspend process route', async () => {
const ctx = await callRoute(SUSPEND_PROCESS_ROUTE, {
await callRoute(SUSPEND_PROCESS_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('suspend-process');
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'suspend-process',
}),
})
);
});
it('sends the running-processes command payload from the running processes route', async () => {
const ctx = await callRoute(GET_PROCESSES_ROUTE, {
await callRoute(GET_PROCESSES_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('running-processes');
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'running-processes',
}),
})
);
});
it('sends the get-file command payload from the get file route', async () => {
const ctx = await callRoute(GET_FILE_ROUTE, {
await callRoute(GET_FILE_ROUTE, {
body: { endpoint_ids: ['XYZ'], parameters: { path: '/one/two/three' } },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.data.command).toEqual('get-file');
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'get-file',
}),
})
);
});
it('sends the `execute` command payload from the execute route', async () => {
const ctx = await callRoute(EXECUTE_ROUTE, {
await callRoute(EXECUTE_ROUTE, {
body: { endpoint_ids: ['XYZ'], parameters: { command: 'ls -al' } },
});
const actionDoc: EndpointAction = (
ctx.core.elasticsearch.client.asInternalUser.index.mock
.calls[0][0] as estypes.IndexRequest<EndpointAction>
).body!;
expect(actionDoc.data.command).toEqual('execute');
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'execute',
}),
})
);
});
describe('With endpoint data streams', () => {
@ -426,19 +492,25 @@ describe('Response actions', () => {
{ endpointDsExists: true }
);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'unisolate',
}),
})
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
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(actionDocs[0].document!.EndpointActions.data.command).toEqual('unisolate');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -453,19 +525,26 @@ describe('Response actions', () => {
},
{ endpointDsExists: true }
);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'isolate',
}),
})
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
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(actionDocs[0].document!.EndpointActions.data.command).toEqual('isolate');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -481,20 +560,28 @@ describe('Response actions', () => {
},
{ endpointDsExists: true }
);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'kill-process',
comment: undefined,
parameters,
}),
})
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
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('kill-process');
expect(actionDocs[1].body!.data.command).toEqual('kill-process');
expect(actionDocs[1].body!.data.parameters).toEqual(parameters);
expect(actionDocs[0].document!.EndpointActions.data.command).toEqual('kill-process');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -510,20 +597,28 @@ describe('Response actions', () => {
},
{ endpointDsExists: true }
);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'suspend-process',
comment: undefined,
parameters,
}),
})
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
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('suspend-process');
expect(actionDocs[1].body!.data.command).toEqual('suspend-process');
expect(actionDocs[1].body!.data.parameters).toEqual(parameters);
expect(actionDocs[0].document!.EndpointActions.data.command).toEqual('suspend-process');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -538,19 +633,26 @@ describe('Response actions', () => {
},
{ endpointDsExists: true }
);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'running-processes',
}),
})
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
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('running-processes');
expect(actionDocs[1].body!.data.command).toEqual('running-processes');
expect(actionDocs[0].document!.EndpointActions.data.command).toEqual('running-processes');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -565,19 +667,28 @@ describe('Response actions', () => {
},
{ endpointDsExists: true }
);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'get-file',
comment: undefined,
parameters: { path: '/one/two/three' },
}),
})
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
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('get-file');
expect(actionDocs[1].body!.data.command).toEqual('get-file');
expect(actionDocs[0].document!.EndpointActions.data.command).toEqual('get-file');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -592,22 +703,29 @@ describe('Response actions', () => {
},
{ 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>,
];
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'execute',
parameters: expect.objectContaining({
command: 'ls -al',
timeout: 1000,
}),
}),
})
);
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
indexDoc.mock.calls[0][0] as estypes.IndexRequest<LogsEndpointAction>,
];
expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX);
expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX);
expect(actionDocs[0].body!.EndpointActions.data.command).toEqual('execute');
const parameters = actionDocs[1].body!.data.parameters as ResponseActionsExecuteParameters;
expect(parameters.command).toEqual('ls -al');
expect(parameters.timeout).toEqual(1000);
expect(actionDocs[1].body!.data.command).toEqual('execute');
expect(actionDocs[0].document!.EndpointActions.data.command).toEqual('execute');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -622,22 +740,31 @@ describe('Response actions', () => {
},
{ endpointDsExists: true }
);
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
command: 'execute',
parameters: expect.objectContaining({
command: 'ls -al',
timeout: 14400,
}),
}),
})
);
// logs-endpoint indexed doc
const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
const actionDocs: [
{ index: string; body?: LogsEndpointAction },
{ index: string; body?: EndpointAction }
] = [
const actionDocs: [{ index: string; document?: LogsEndpointAction }] = [
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('execute');
const parameters = actionDocs[1].body!.data.parameters as ResponseActionsExecuteParameters;
expect(parameters.command).toEqual('ls -al');
expect(parameters.timeout).toEqual(14400); // 4hrs in seconds
expect(actionDocs[1].body!.data.command).toEqual('execute');
expect(actionDocs[0].document!.EndpointActions.data.command).toEqual('execute');
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
@ -645,7 +772,7 @@ describe('Response actions', () => {
});
it('signs the action', async () => {
const ctx = await callRoute(
await callRoute(
ISOLATE_HOST_ROUTE_V2,
{
body: { endpoint_ids: ['XYZ'] },
@ -653,36 +780,30 @@ describe('Response actions', () => {
{ 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[1].index).toEqual(AGENT_ACTIONS_INDEX);
expect(actionDocs[1].body?.signed).toEqual({
data: 'thisisthedata',
signature: 'thisisasignature',
});
expect(mockResponse.ok).toBeCalled();
const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse;
expect(responseBody.action).toBeTruthy();
await expect(
(
await endpointAppContextService.getFleetActionsClient()
).create as jest.Mock
).toHaveBeenCalledWith(
expect.objectContaining({
signed: {
data: 'thisisthedata',
signature: 'thisisasignature',
},
})
);
});
it('handles errors', async () => {
const ErrMessage = 'Uh oh!';
const errMessage = 'Uh oh!';
await callRoute(
UNISOLATE_HOST_ROUTE_V2,
{
body: { endpoint_ids: ['XYZ'] },
idxResponse: {
indexErrorResponse: {
statusCode: 500,
body: {
result: ErrMessage,
result: errMessage,
},
},
},
@ -692,7 +813,7 @@ describe('Response actions', () => {
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);
expect((response.body as Error).message).toEqual(errMessage);
});
});

View file

@ -7,7 +7,6 @@
import { v4 as uuidv4 } from 'uuid';
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { ResponseActionsApiCommandNames } from '../../../../../common/endpoint/service/response_actions/constants';
import type {

View file

@ -7,9 +7,9 @@
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { Logger } from '@kbn/core/server';
import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common';
import moment from 'moment';
import type { LicenseType } from '@kbn/licensing-plugin/common/types';
import type { FleetActionRequest } from '@kbn/fleet-plugin/server/services/actions/types';
import { DEFAULT_EXECUTE_ACTION_TIMEOUT } from '../../../../../common/endpoint/service/response_actions/constants';
import {
ENDPOINT_ACTIONS_DS,
@ -91,7 +91,7 @@ export const writeActionToIndices = async ({
const logsEndpointActionsResult = await esClient.index<LogsEndpointAction>(
{
index: ENDPOINT_ACTIONS_INDEX,
body: {
document: {
...doc,
agent: {
id: payload.endpoint_ids,
@ -124,23 +124,11 @@ export const writeActionToIndices = async ({
data: fleetActionDocSignature.data.toString('base64'),
signature: fleetActionDocSignature.signature,
},
};
} as unknown as FleetActionRequest;
// write actions to .fleet-actions index
try {
const fleetActionIndexResult = await esClient.index<EndpointAction>(
{
index: AGENT_ACTIONS_INDEX,
body: signedFleetActionDoc,
refresh: 'wait_for',
},
{
meta: true,
}
);
if (fleetActionIndexResult.statusCode !== 201) {
throw new Error(fleetActionIndexResult.body.result);
}
const fleetActionsClient = await endpointContext.service.getFleetActionsClient();
await fleetActionsClient.create(signedFleetActionDoc);
} catch (e) {
// create entry in .logs-endpoint.action.responses-default data stream
// when writing to .fleet-actions fails
@ -178,7 +166,7 @@ const createFailedActionResponseEntry = async ({
try {
await esClient.index<LogsEndpointActionResponse>({
index: `${ENDPOINT_ACTION_RESPONSES_DS}-default`,
body: {
document: {
...doc,
error: {
code: failedFleetActionErrorCode,

View file

@ -437,6 +437,7 @@ export class Plugin implements ISecuritySolutionPlugin {
packagePolicyService,
agentPolicyService,
createFilesClient,
createFleetActionsClient,
} =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugins.fleet!;
@ -521,6 +522,7 @@ export class Plugin implements ISecuritySolutionPlugin {
core.elasticsearch.client.asInternalUser,
this.endpointContext
),
createFleetActionsClient,
});
this.telemetryReceiver.start(