[8.8] [Defend Workflows][E2E]Endpoint e2e response console (#155605) (#158553)

# Backport

This will backport the following commits from `main` to `8.8`:
- [[Defend Workflows][E2E]Endpoint e2e response console
(#155605)](https://github.com/elastic/kibana/pull/155605)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Konrad
Szwarc","email":"konrad.szwarc@elastic.co"},"sourceCommit":{"committedDate":"2023-05-02T09:02:06Z","message":"[Defend
Workflows][E2E]Endpoint e2e response console (#155605)\n\nDepends on
https://github.com/elastic/kibana/pull/155519\r\n\r\nE2E coverage of
`isolate`, `processes`, `kill-process` and\r\n`suspend-process` commands
on mocked endpoint.\r\n\r\nE2E coverage of the above but on real
endpoint
is\r\n[here](https://github.com/elastic/kibana/pull/155519).\r\n\r\nBecause
these tests are run against mocked data I've decided not to
mock\r\n`kill-process` and `suspend-process` outcome (whether process
is\r\nactually killed/suspended) because it would mean testing
mocks\r\nthemselves. What is tested is the outcome the user sees
('Action\r\ncompleted').\r\n\r\n---------\r\n\r\nCo-authored-by: Patryk
Kopycinski
<contact@patrykkopycinski.com>","sha":"fd5309f6a02bce641c4baf79500acfe797e294f7","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Defend
Workflows","v8.8.0","v8.9.0"],"number":155605,"url":"https://github.com/elastic/kibana/pull/155605","mergeCommit":{"message":"[Defend
Workflows][E2E]Endpoint e2e response console (#155605)\n\nDepends on
https://github.com/elastic/kibana/pull/155519\r\n\r\nE2E coverage of
`isolate`, `processes`, `kill-process` and\r\n`suspend-process` commands
on mocked endpoint.\r\n\r\nE2E coverage of the above but on real
endpoint
is\r\n[here](https://github.com/elastic/kibana/pull/155519).\r\n\r\nBecause
these tests are run against mocked data I've decided not to
mock\r\n`kill-process` and `suspend-process` outcome (whether process
is\r\nactually killed/suspended) because it would mean testing
mocks\r\nthemselves. What is tested is the outcome the user sees
('Action\r\ncompleted').\r\n\r\n---------\r\n\r\nCo-authored-by: Patryk
Kopycinski
<contact@patrykkopycinski.com>","sha":"fd5309f6a02bce641c4baf79500acfe797e294f7"}},"sourceBranch":"main","suggestedTargetBranches":["8.8"],"targetPullRequestStates":[{"branch":"8.8","label":"v8.8.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/155605","number":155605,"mergeCommit":{"message":"[Defend
Workflows][E2E]Endpoint e2e response console (#155605)\n\nDepends on
https://github.com/elastic/kibana/pull/155519\r\n\r\nE2E coverage of
`isolate`, `processes`, `kill-process` and\r\n`suspend-process` commands
on mocked endpoint.\r\n\r\nE2E coverage of the above but on real
endpoint
is\r\n[here](https://github.com/elastic/kibana/pull/155519).\r\n\r\nBecause
these tests are run against mocked data I've decided not to
mock\r\n`kill-process` and `suspend-process` outcome (whether process
is\r\nactually killed/suspended) because it would mean testing
mocks\r\nthemselves. What is tested is the outcome the user sees
('Action\r\ncompleted').\r\n\r\n---------\r\n\r\nCo-authored-by: Patryk
Kopycinski
<contact@patrykkopycinski.com>","sha":"fd5309f6a02bce641c4baf79500acfe797e294f7"}}]}]
BACKPORT-->
This commit is contained in:
Konrad Szwarc 2023-05-26 16:01:22 +02:00 committed by GitHub
parent 41428d0808
commit 5ca00fc51f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 606 additions and 331 deletions

View file

@ -7,7 +7,7 @@
import { getEndpointListPath } from '../../../common/routing';
import {
checkEndpointListForOnlyIsolatedHosts,
checkEndpointIsIsolated,
checkFlyoutEndpointIsolation,
filterOutIsolatedHosts,
interceptActionRequests,
@ -32,7 +32,8 @@ describe('Isolate command', () => {
describe('from Manage', () => {
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
let isolatedEndpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
let isolatedEndpointHostnames: [string, string];
let endpointHostnames: [string, string];
before(() => {
indexEndpointHosts({
count: 2,
@ -40,6 +41,10 @@ describe('Isolate command', () => {
isolation: false,
}).then((indexEndpoints) => {
endpointData = indexEndpoints;
endpointHostnames = [
endpointData.data.hosts[0].host.name,
endpointData.data.hosts[1].host.name,
];
});
indexEndpointHosts({
@ -48,6 +53,10 @@ describe('Isolate command', () => {
isolation: true,
}).then((indexEndpoints) => {
isolatedEndpointData = indexEndpoints;
isolatedEndpointHostnames = [
isolatedEndpointData.data.hosts[0].host.name,
isolatedEndpointData.data.hosts[1].host.name,
];
});
});
@ -67,13 +76,15 @@ describe('Isolate command', () => {
beforeEach(() => {
login();
});
// FLAKY: https://github.com/elastic/security-team/issues/6518
it.skip('should allow filtering endpoint by Isolated status', () => {
it('should allow filtering endpoint by Isolated status', () => {
cy.visit(APP_PATH + getEndpointListPath({ name: 'endpointList' }));
closeAllToasts();
filterOutIsolatedHosts();
cy.contains('Showing 2 endpoints');
checkEndpointListForOnlyIsolatedHosts();
isolatedEndpointHostnames.forEach(checkEndpointIsIsolated);
endpointHostnames.forEach((hostname) => {
cy.contains(hostname).should('not.exist');
});
});
});

View file

@ -0,0 +1,230 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ActionDetails } from '../../../../../common/endpoint/types';
import type { ReturnTypeFromChainable } from '../../types';
import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts';
import {
checkReturnedProcessesTable,
inputConsoleCommand,
openResponseConsoleFromEndpointList,
performCommandInputChecks,
submitCommand,
waitForEndpointListPageToBeLoaded,
} from '../../tasks/response_console';
import {
checkEndpointIsIsolated,
checkEndpointIsNotIsolated,
interceptActionRequests,
sendActionResponse,
} from '../../tasks/isolate';
import { login } from '../../tasks/login';
describe('Response console', () => {
beforeEach(() => {
login();
});
describe('Isolate command', () => {
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
let endpointHostname: string;
let isolateRequestResponse: ActionDetails;
before(() => {
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
(indexEndpoints) => {
endpointData = indexEndpoints;
endpointHostname = endpointData.data.hosts[0].host.name;
}
);
});
after(() => {
if (endpointData) {
endpointData.cleanup();
// @ts-expect-error ignore setting to undefined
endpointData = undefined;
}
});
it('should isolate host from response console', () => {
waitForEndpointListPageToBeLoaded(endpointHostname);
checkEndpointIsNotIsolated(endpointHostname);
openResponseConsoleFromEndpointList();
performCommandInputChecks('isolate');
interceptActionRequests((responseBody) => {
isolateRequestResponse = responseBody;
}, 'isolate');
submitCommand();
cy.contains('Action pending.').should('exist');
cy.wait('@isolate').then(() => {
sendActionResponse(isolateRequestResponse);
});
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
waitForEndpointListPageToBeLoaded(endpointHostname);
checkEndpointIsIsolated(endpointHostname);
});
});
describe('Release command', () => {
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
let endpointHostname: string;
let releaseRequestResponse: ActionDetails;
before(() => {
indexEndpointHosts({ withResponseActions: false, isolation: true }).then((indexEndpoints) => {
endpointData = indexEndpoints;
endpointHostname = endpointData.data.hosts[0].host.name;
});
});
after(() => {
if (endpointData) {
endpointData.cleanup();
// @ts-expect-error ignore setting to undefined
endpointData = undefined;
}
});
it('should release host from response console', () => {
waitForEndpointListPageToBeLoaded(endpointHostname);
checkEndpointIsIsolated(endpointHostname);
openResponseConsoleFromEndpointList();
performCommandInputChecks('release');
interceptActionRequests((responseBody) => {
releaseRequestResponse = responseBody;
}, 'release');
submitCommand();
cy.contains('Action pending.').should('exist');
cy.wait('@release').then(() => {
sendActionResponse(releaseRequestResponse);
});
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
waitForEndpointListPageToBeLoaded(endpointHostname);
checkEndpointIsNotIsolated(endpointHostname);
});
});
describe('Processes command', () => {
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
let endpointHostname: string;
let processesRequestResponse: ActionDetails;
before(() => {
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
(indexEndpoints) => {
endpointData = indexEndpoints;
endpointHostname = endpointData.data.hosts[0].host.name;
}
);
});
after(() => {
if (endpointData) {
endpointData.cleanup();
// @ts-expect-error ignore setting to undefined
endpointData = undefined;
}
});
it('should return processes from response console', () => {
waitForEndpointListPageToBeLoaded(endpointHostname);
openResponseConsoleFromEndpointList();
performCommandInputChecks('processes');
interceptActionRequests((responseBody) => {
processesRequestResponse = responseBody;
}, 'processes');
submitCommand();
cy.contains('Action pending.').should('exist');
cy.wait('@processes').then(() => {
sendActionResponse(processesRequestResponse);
});
cy.getByTestSubj('getProcessesSuccessCallout', { timeout: 120000 }).within(() => {
checkReturnedProcessesTable();
});
});
});
describe('Kill process command', () => {
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
let endpointHostname: string;
let killProcessRequestResponse: ActionDetails;
before(() => {
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
(indexEndpoints) => {
endpointData = indexEndpoints;
endpointHostname = endpointData.data.hosts[0].host.name;
}
);
});
after(() => {
if (endpointData) {
endpointData.cleanup();
// @ts-expect-error ignore setting to undefined
endpointData = undefined;
}
});
it('should kill process from response console', () => {
waitForEndpointListPageToBeLoaded(endpointHostname);
openResponseConsoleFromEndpointList();
inputConsoleCommand(`kill-process --pid 1`);
interceptActionRequests((responseBody) => {
killProcessRequestResponse = responseBody;
}, 'kill-process');
submitCommand();
cy.contains('Action pending.').should('exist');
cy.wait('@kill-process').then(() => {
sendActionResponse(killProcessRequestResponse);
});
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
});
});
describe('Suspend process command', () => {
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
let endpointHostname: string;
let suspendProcessRequestResponse: ActionDetails;
before(() => {
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
(indexEndpoints) => {
endpointData = indexEndpoints;
endpointHostname = endpointData.data.hosts[0].host.name;
}
);
});
after(() => {
if (endpointData) {
endpointData.cleanup();
// @ts-expect-error ignore setting to undefined
endpointData = undefined;
}
});
it('should suspend process from response console', () => {
waitForEndpointListPageToBeLoaded(endpointHostname);
openResponseConsoleFromEndpointList();
inputConsoleCommand(`suspend-process --pid 1`);
interceptActionRequests((responseBody) => {
suspendProcessRequestResponse = responseBody;
}, 'suspend-process');
submitCommand();
cy.contains('Action pending.').should('exist');
cy.wait('@suspend-process').then(() => {
sendActionResponse(suspendProcessRequestResponse);
});
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
});
});
});

View file

@ -8,7 +8,10 @@
// / <reference types="cypress" />
import type { CasePostRequest } from '@kbn/cases-plugin/common/api';
import { sendEndpointActionResponse } from '../../../../scripts/endpoint/agent_emulator/services/endpoint_response_actions';
import {
sendEndpointActionResponse,
sendFleetActionResponse,
} from '../../../../scripts/endpoint/common/response_actions';
import type { DeleteAllEndpointDataResponse } from '../../../../scripts/endpoint/common/delete_all_endpoint_data';
import { deleteAllEndpointData } from '../../../../scripts/endpoint/common/delete_all_endpoint_data';
import { waitForEndpointToStreamData } from '../../../../scripts/endpoint/common/endpoint_metadata_services';
@ -21,11 +24,7 @@ import {
deleteIndexedEndpointPolicyResponse,
indexEndpointPolicyResponse,
} from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response';
import type {
ActionDetails,
HostPolicyResponse,
LogsEndpointActionResponse,
} from '../../../../common/endpoint/types';
import type { ActionDetails, HostPolicyResponse } from '../../../../common/endpoint/types';
import type { IndexEndpointHostsCyTaskOptions } from '../types';
import type {
IndexedEndpointRuleAlerts,
@ -162,9 +161,17 @@ export const dataLoaders = (
sendHostActionResponse: async (data: {
action: ActionDetails;
state: { state?: 'success' | 'failure' };
}): Promise<LogsEndpointActionResponse> => {
}): Promise<null> => {
const { esClient } = await stackServicesPromise;
return sendEndpointActionResponse(esClient, data.action, { state: data.state.state });
const fleetResponse = await sendFleetActionResponse(esClient, data.action, {
state: data.state.state,
});
if (!fleetResponse.error) {
await sendEndpointActionResponse(esClient, data.action, { state: data.state.state });
}
return null;
},
deleteAllEndpointData: async ({

View file

@ -143,3 +143,18 @@ export const checkEndpointListForOnlyUnIsolatedHosts = (): void =>
checkEndpointListForIsolatedHosts(false);
export const checkEndpointListForOnlyIsolatedHosts = (): void =>
checkEndpointListForIsolatedHosts(true);
export const checkEndpointIsolationStatus = (
endpointHostname: string,
expectIsolated: boolean
): void => {
const chainer = expectIsolated ? 'contain.text' : 'not.contain.text';
cy.contains(endpointHostname).parents('td').siblings('td').eq(0).should(chainer, 'Isolated');
};
export const checkEndpointIsIsolated = (endpointHostname: string): void =>
checkEndpointIsolationStatus(endpointHostname, true);
export const checkEndpointIsNotIsolated = (endpointHostname: string): void =>
checkEndpointIsolationStatus(endpointHostname, false);

View file

@ -7,6 +7,7 @@
import { closeAllToasts } from './close_all_toasts';
import { APP_ENDPOINTS_PATH } from '../../../../common/constants';
import Chainable = Cypress.Chainable;
export const waitForEndpointListPageToBeLoaded = (endpointHostname: string): void => {
cy.visit(APP_ENDPOINTS_PATH);
@ -56,3 +57,16 @@ export const performCommandInputChecks = (command: string) => {
selectCommandFromHelpMenu(command);
checkInputForCommandPresence(command);
};
export const checkReturnedProcessesTable = (): Chainable<JQuery<HTMLTableRowElement>> => {
['USER', 'PID', 'ENTITY ID', 'COMMAND'].forEach((header) => {
cy.contains(header);
});
return cy
.get('tbody')
.find('tr')
.then((rows) => {
expect(rows.length).to.be.greaterThan(0);
});
};

View file

@ -9,12 +9,9 @@ import { set } from '@kbn/safer-lodash-set';
import type { Client } from '@elastic/elasticsearch';
import type { ToolingLog } from '@kbn/tooling-log';
import type { KbnClient } from '@kbn/test';
import { sendEndpointActionResponse, sendFleetActionResponse } from '../../common/response_actions';
import { BaseRunningService } from '../../common/base_running_service';
import {
fetchEndpointActionList,
sendEndpointActionResponse,
sendFleetActionResponse,
} from './endpoint_response_actions';
import { fetchEndpointActionList } from './endpoint_response_actions';
import type { ActionDetails } from '../../../../common/endpoint/types';
/**

View file

@ -6,43 +6,9 @@
*/
import type { KbnClient } from '@kbn/test';
import type { Client } from '@elastic/elasticsearch';
import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
import * as cborx from 'cbor-x';
import { basename } from 'path';
import { generateFileMetadataDocumentMock } from '../../../../server/endpoint/services/actions/mocks';
import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id';
import { checkInFleetAgent } from '../../common/fleet_services';
import { sendEndpointMetadataUpdate } from '../../common/endpoint_metadata_services';
import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator';
import {
ENDPOINT_ACTION_RESPONSES_INDEX,
BASE_ENDPOINT_ACTION_ROUTE,
FILE_STORAGE_DATA_INDEX,
FILE_STORAGE_METADATA_INDEX,
} from '../../../../common/endpoint/constants';
import type {
ActionDetails,
ActionListApiResponse,
EndpointActionData,
EndpointActionResponse,
LogsEndpointActionResponse,
GetProcessesActionOutputContent,
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
FileUploadMetadata,
ResponseActionExecuteOutputContent,
} from '../../../../common/endpoint/types';
import { BASE_ENDPOINT_ACTION_ROUTE } from '../../../../common/endpoint/constants';
import type { ActionListApiResponse } from '../../../../common/endpoint/types';
import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions';
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
export const fleetActionGenerator = new FleetActionGenerator();
export const endpointActionGenerator = new EndpointActionGenerator();
export const sleep = (ms: number = 1000) => new Promise((r) => setTimeout(r, ms));
export const fetchEndpointActionList = async (
kbn: KbnClient,
@ -76,279 +42,3 @@ export const fetchEndpointActionList = async (
throw error;
}
};
export const sendFleetActionResponse = async (
esClient: Client,
action: ActionDetails,
{ state }: { state?: 'success' | 'failure' } = {}
): Promise<EndpointActionResponse> => {
const fleetResponse = fleetActionGenerator.generateResponse({
action_id: action.id,
agent_id: action.agents[0],
action_response: {
endpoint: {
ack: true,
},
},
});
// 20% of the time we generate an error
if (state === 'failure' || (!state && fleetActionGenerator.randomFloat() < 0.2)) {
fleetResponse.action_response = {};
fleetResponse.error = 'Agent failed to deliver message to endpoint due to unknown error';
} else {
// show it as success (generator currently always generates a `error`, so delete it)
delete fleetResponse.error;
}
await esClient.index(
{
index: AGENT_ACTIONS_RESULTS_INDEX,
body: fleetResponse,
refresh: 'wait_for',
},
ES_INDEX_OPTIONS
);
return fleetResponse;
};
export const sendEndpointActionResponse = async (
esClient: Client,
action: ActionDetails,
{ state }: { state?: 'success' | 'failure' } = {}
): Promise<LogsEndpointActionResponse> => {
const endpointResponse = endpointActionGenerator.generateResponse({
agent: { id: action.agents[0] },
EndpointActions: {
action_id: action.id,
data: {
command: action.command as EndpointActionData['command'],
comment: '',
...getOutputDataIfNeeded(action),
},
started_at: action.startedAt,
},
});
// 20% of the time we generate an error
if (state === 'failure' || (state !== 'success' && endpointActionGenerator.randomFloat() < 0.2)) {
endpointResponse.error = {
message: 'Endpoint encountered an error and was unable to apply action to host',
};
if (
endpointResponse.EndpointActions.data.command === 'get-file' &&
endpointResponse.EndpointActions.data.output
) {
(
endpointResponse.EndpointActions.data.output.content as ResponseActionGetFileOutputContent
).code = endpointActionGenerator.randomGetFileFailureCode();
}
if (
endpointResponse.EndpointActions.data.command === 'execute' &&
endpointResponse.EndpointActions.data.output
) {
(
endpointResponse.EndpointActions.data.output.content as ResponseActionExecuteOutputContent
).stderr = 'execute command timed out';
}
}
await esClient.index({
index: ENDPOINT_ACTION_RESPONSES_INDEX,
body: endpointResponse,
refresh: 'wait_for',
});
// ------------------------------------------
// Post Action Response tasks
// ------------------------------------------
// For isolate, If the response is not an error, then also send a metadata update
if (action.command === 'isolate' && !endpointResponse.error) {
for (const agentId of action.agents) {
await Promise.all([
sendEndpointMetadataUpdate(esClient, agentId, {
Endpoint: {
state: {
isolation: true,
},
},
}),
checkInFleetAgent(esClient, agentId),
]);
}
}
// For UnIsolate, if response is not an Error, then also send metadata update
if (action.command === 'unisolate' && !endpointResponse.error) {
for (const agentId of action.agents) {
await Promise.all([
sendEndpointMetadataUpdate(esClient, agentId, {
Endpoint: {
state: {
isolation: false,
},
},
}),
checkInFleetAgent(esClient, agentId),
]);
}
}
// For `get-file`, upload a file to ES
if ((action.command === 'execute' || action.command === 'get-file') && !endpointResponse.error) {
const filePath =
action.command === 'execute'
? '/execute/file/path'
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(
action as ActionDetails<
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters
>
)?.parameters?.path!;
const fileName = basename(filePath.replace(/\\/g, '/'));
const fileMetaDoc: FileUploadMetadata = generateFileMetadataDocumentMock({
action_id: action.id,
agent_id: action.agents[0],
contents: [
{
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
file_name: fileName ?? 'bad_file.txt',
path: filePath,
size: 4,
type: 'file',
},
],
file: {
attributes: ['archive', 'compressed'],
ChunkSize: 4194304,
compression: 'deflate',
hash: {
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
},
mime_type: 'application/zip',
name: action.command === 'execute' ? 'full-output.zip' : 'upload.zip',
extension: 'zip',
size: 125,
Status: 'READY',
type: 'file',
},
src: 'endpoint',
});
// Index the file's metadata
const fileMeta = await esClient.index({
index: FILE_STORAGE_METADATA_INDEX,
id: getFileDownloadId(action, action.agents[0]),
body: fileMetaDoc,
refresh: 'wait_for',
});
// Index the file content (just one chunk)
// call to `.index()` copied from File plugin here:
// https://github.com/elastic/kibana/blob/main/src/plugins/files/server/blob_storage_service/adapters/es/content_stream/content_stream.ts#L195
await esClient
.index(
{
index: FILE_STORAGE_DATA_INDEX,
id: `${fileMeta._id}.0`,
document: cborx.encode({
bid: fileMeta._id,
last: true,
data: Buffer.from(
'UEsDBAoACQAAAFZeRFWpAsDLHwAAABMAAAAMABwAYmFkX2ZpbGUudHh0VVQJAANTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAMOcoyEq/Q4VyG02U9O0LRbGlwP/y5SOCfRKqLz1rsBQSwcIqQLAyx8AAAATAAAAUEsBAh4DCgAJAAAAVl5EVakCwMsfAAAAEwAAAAwAGAAAAAAAAQAAAKSBAAAAAGJhZF9maWxlLnR4dFVUBQADU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFIAAAB1AAAAAAA=',
'base64'
),
}),
refresh: 'wait_for',
},
{
headers: {
'content-type': 'application/cbor',
accept: 'application/json',
},
}
)
.then(() => sleep(2000));
}
return endpointResponse;
};
type ResponseOutput<TOutputContent extends object = object> = Pick<
LogsEndpointActionResponse<TOutputContent>['EndpointActions']['data'],
'output'
>;
const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => {
const commentUppercase = (action?.comment ?? '').toUpperCase();
switch (action.command) {
case 'running-processes':
return {
output: {
type: 'json',
content: {
entries: endpointActionGenerator.randomResponseActionProcesses(100),
},
},
} as ResponseOutput<GetProcessesActionOutputContent>;
case 'get-file':
return {
output: {
type: 'json',
content: {
code: 'ra_get-file_success_done',
zip_size: 123,
contents: [
{
type: 'file',
path: (
action as ActionDetails<
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters
>
).parameters?.path,
size: 1234,
file_name: 'bad_file.txt',
sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f',
},
],
},
},
} as ResponseOutput<ResponseActionGetFileOutputContent>;
case 'execute':
const executeOutput: Partial<ResponseActionExecuteOutputContent> = {
output_file_id: getFileDownloadId(action, action.agents[0]),
};
// Error?
if (commentUppercase.indexOf('EXECUTE:FAILURE') > -1) {
executeOutput.stdout = '';
executeOutput.stdout_truncated = false;
executeOutput.output_file_stdout_truncated = false;
} else {
executeOutput.stderr = '';
executeOutput.stderr_truncated = false;
executeOutput.output_file_stderr_truncated = false;
}
return {
output: endpointActionGenerator.generateExecuteActionResponseOutput({
content: executeOutput,
}),
} as ResponseOutput<ResponseActionExecuteOutputContent>;
default:
return { output: undefined };
}
};

View file

@ -0,0 +1,311 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
import { basename } from 'path';
import * as cborx from 'cbor-x';
import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
import { FleetActionGenerator } from '../../../common/endpoint/data_generators/fleet_action_generator';
import { EndpointActionGenerator } from '../../../common/endpoint/data_generators/endpoint_action_generator';
import type {
ActionDetails,
EndpointActionData,
EndpointActionResponse,
FileUploadMetadata,
GetProcessesActionOutputContent,
LogsEndpointActionResponse,
ResponseActionExecuteOutputContent,
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
} from '../../../common/endpoint/types';
import { getFileDownloadId } from '../../../common/endpoint/service/response_actions/get_file_download_id';
import {
ENDPOINT_ACTION_RESPONSES_INDEX,
FILE_STORAGE_DATA_INDEX,
FILE_STORAGE_METADATA_INDEX,
} from '../../../common/endpoint/constants';
import { sendEndpointMetadataUpdate } from './endpoint_metadata_services';
import { checkInFleetAgent } from './fleet_services';
import { generateFileMetadataDocumentMock } from '../../../server/endpoint/services/actions/mocks';
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
export const fleetActionGenerator = new FleetActionGenerator();
export const endpointActionGenerator = new EndpointActionGenerator();
export const sleep = (ms: number = 1000) => new Promise((r) => setTimeout(r, ms));
export const sendFleetActionResponse = async (
esClient: Client,
action: ActionDetails,
{ state }: { state?: 'success' | 'failure' } = {}
): Promise<EndpointActionResponse> => {
const fleetResponse = fleetActionGenerator.generateResponse({
action_id: action.id,
agent_id: action.agents[0],
action_response: {
endpoint: {
ack: true,
},
},
});
// 20% of the time we generate an error
if (state === 'failure' || (!state && fleetActionGenerator.randomFloat() < 0.2)) {
fleetResponse.action_response = {};
fleetResponse.error = 'Agent failed to deliver message to endpoint due to unknown error';
} else {
// show it as success (generator currently always generates a `error`, so delete it)
delete fleetResponse.error;
}
await esClient.index(
{
index: AGENT_ACTIONS_RESULTS_INDEX,
body: fleetResponse,
refresh: 'wait_for',
},
ES_INDEX_OPTIONS
);
return fleetResponse;
};
export const sendEndpointActionResponse = async (
esClient: Client,
action: ActionDetails,
{ state }: { state?: 'success' | 'failure' } = {}
): Promise<LogsEndpointActionResponse> => {
const endpointResponse = endpointActionGenerator.generateResponse({
agent: { id: action.agents[0] },
EndpointActions: {
action_id: action.id,
data: {
command: action.command as EndpointActionData['command'],
comment: '',
...getOutputDataIfNeeded(action),
},
started_at: action.startedAt,
},
});
// 20% of the time we generate an error
if (state === 'failure' || (state !== 'success' && endpointActionGenerator.randomFloat() < 0.2)) {
endpointResponse.error = {
message: 'Endpoint encountered an error and was unable to apply action to host',
};
if (
endpointResponse.EndpointActions.data.command === 'get-file' &&
endpointResponse.EndpointActions.data.output
) {
(
endpointResponse.EndpointActions.data.output.content as ResponseActionGetFileOutputContent
).code = endpointActionGenerator.randomGetFileFailureCode();
}
if (
endpointResponse.EndpointActions.data.command === 'execute' &&
endpointResponse.EndpointActions.data.output
) {
(
endpointResponse.EndpointActions.data.output.content as ResponseActionExecuteOutputContent
).stderr = 'execute command timed out';
}
}
await esClient.index({
index: ENDPOINT_ACTION_RESPONSES_INDEX,
body: endpointResponse,
refresh: 'wait_for',
});
// ------------------------------------------
// Post Action Response tasks
// ------------------------------------------
// For isolate, If the response is not an error, then also send a metadata update
if (action.command === 'isolate' && !endpointResponse.error) {
for (const agentId of action.agents) {
await Promise.all([
sendEndpointMetadataUpdate(esClient, agentId, {
Endpoint: {
state: {
isolation: true,
},
},
}),
checkInFleetAgent(esClient, agentId),
]);
}
}
// For UnIsolate, if response is not an Error, then also send metadata update
if (action.command === 'unisolate' && !endpointResponse.error) {
for (const agentId of action.agents) {
await Promise.all([
sendEndpointMetadataUpdate(esClient, agentId, {
Endpoint: {
state: {
isolation: false,
},
},
}),
checkInFleetAgent(esClient, agentId),
]);
}
}
// For `get-file`, upload a file to ES
if ((action.command === 'execute' || action.command === 'get-file') && !endpointResponse.error) {
const filePath =
action.command === 'execute'
? '/execute/file/path'
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(
action as ActionDetails<
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters
>
)?.parameters?.path!;
const fileName = basename(filePath.replace(/\\/g, '/'));
const fileMetaDoc: FileUploadMetadata = generateFileMetadataDocumentMock({
action_id: action.id,
agent_id: action.agents[0],
contents: [
{
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
file_name: fileName ?? 'bad_file.txt',
path: filePath,
size: 4,
type: 'file',
},
],
file: {
attributes: ['archive', 'compressed'],
ChunkSize: 4194304,
compression: 'deflate',
hash: {
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
},
mime_type: 'application/zip',
name: action.command === 'execute' ? 'full-output.zip' : 'upload.zip',
extension: 'zip',
size: 125,
Status: 'READY',
type: 'file',
},
src: 'endpoint',
});
// Index the file's metadata
const fileMeta = await esClient.index({
index: FILE_STORAGE_METADATA_INDEX,
id: getFileDownloadId(action, action.agents[0]),
body: fileMetaDoc,
refresh: 'wait_for',
});
// Index the file content (just one chunk)
// call to `.index()` copied from File plugin here:
// https://github.com/elastic/kibana/blob/main/src/plugins/files/server/blob_storage_service/adapters/es/content_stream/content_stream.ts#L195
await esClient
.index(
{
index: FILE_STORAGE_DATA_INDEX,
id: `${fileMeta._id}.0`,
document: cborx.encode({
bid: fileMeta._id,
last: true,
data: Buffer.from(
'UEsDBAoACQAAAFZeRFWpAsDLHwAAABMAAAAMABwAYmFkX2ZpbGUudHh0VVQJAANTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAMOcoyEq/Q4VyG02U9O0LRbGlwP/y5SOCfRKqLz1rsBQSwcIqQLAyx8AAAATAAAAUEsBAh4DCgAJAAAAVl5EVakCwMsfAAAAEwAAAAwAGAAAAAAAAQAAAKSBAAAAAGJhZF9maWxlLnR4dFVUBQADU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFIAAAB1AAAAAAA=',
'base64'
),
}),
refresh: 'wait_for',
},
{
headers: {
'content-type': 'application/cbor',
accept: 'application/json',
},
}
)
.then(() => sleep(2000));
}
return endpointResponse;
};
type ResponseOutput<TOutputContent extends object = object> = Pick<
LogsEndpointActionResponse<TOutputContent>['EndpointActions']['data'],
'output'
>;
const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => {
const commentUppercase = (action?.comment ?? '').toUpperCase();
switch (action.command) {
case 'running-processes':
return {
output: {
type: 'json',
content: {
entries: endpointActionGenerator.randomResponseActionProcesses(100),
},
},
} as ResponseOutput<GetProcessesActionOutputContent>;
case 'get-file':
return {
output: {
type: 'json',
content: {
code: 'ra_get-file_success_done',
zip_size: 123,
contents: [
{
type: 'file',
path: (
action as ActionDetails<
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters
>
).parameters?.path,
size: 1234,
file_name: 'bad_file.txt',
sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f',
},
],
},
},
} as ResponseOutput<ResponseActionGetFileOutputContent>;
case 'execute':
const executeOutput: Partial<ResponseActionExecuteOutputContent> = {
output_file_id: getFileDownloadId(action, action.agents[0]),
};
// Error?
if (commentUppercase.indexOf('EXECUTE:FAILURE') > -1) {
executeOutput.stdout = '';
executeOutput.stdout_truncated = false;
executeOutput.output_file_stdout_truncated = false;
} else {
executeOutput.stderr = '';
executeOutput.stderr_truncated = false;
executeOutput.output_file_stderr_truncated = false;
}
return {
output: endpointActionGenerator.generateExecuteActionResponseOutput({
content: executeOutput,
}),
} as ResponseOutput<ResponseActionExecuteOutputContent>;
default:
return { output: undefined };
}
};