mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
d5bf4be1da
commit
b30f8a6ba5
17 changed files with 1851 additions and 226 deletions
|
@ -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 {}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
346
x-pack/plugins/fleet/server/services/actions/actions.ts
Normal file
346
x-pack/plugins/fleet/server/services/actions/actions.ts
Normal 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
|
||||
);
|
||||
}
|
||||
};
|
301
x-pack/plugins/fleet/server/services/actions/client.test.ts
Normal file
301
x-pack/plugins/fleet/server/services/actions/client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
104
x-pack/plugins/fleet/server/services/actions/client.ts
Normal file
104
x-pack/plugins/fleet/server/services/actions/client.ts
Normal 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;
|
||||
}
|
||||
}
|
9
x-pack/plugins/fleet/server/services/actions/index.ts
Normal file
9
x-pack/plugins/fleet/server/services/actions/index.ts
Normal 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';
|
235
x-pack/plugins/fleet/server/services/actions/mocks.ts
Normal file
235
x-pack/plugins/fleet/server/services/actions/mocks.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
125
x-pack/plugins/fleet/server/services/actions/types.ts
Normal file
125
x-pack/plugins/fleet/server/services/actions/types.ts
Normal 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;
|
||||
}
|
187
x-pack/plugins/fleet/server/services/actions/utils.test.ts
Normal file
187
x-pack/plugins/fleet/server/services/actions/utils.test.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
182
x-pack/plugins/fleet/server/services/actions/utils.ts
Normal file
182
x-pack/plugins/fleet/server/services/actions/utils.ts
Normal 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;
|
||||
}, []);
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue