From fd5309f6a02bce641c4baf79500acfe797e294f7 Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Tue, 2 May 2023 11:02:06 +0200 Subject: [PATCH] [Defend Workflows][E2E]Endpoint e2e response console (#155605) Depends on https://github.com/elastic/kibana/pull/155519 E2E coverage of `isolate`, `processes`, `kill-process` and `suspend-process` commands on mocked endpoint. E2E coverage of the above but on real endpoint is [here](https://github.com/elastic/kibana/pull/155519). Because these tests are run against mocked data I've decided not to mock `kill-process` and `suspend-process` outcome (whether process is actually killed/suspended) because it would mean testing mocks themselves. What is tested is the outcome the user sees ('Action completed'). --------- Co-authored-by: Patryk Kopycinski --- .../cypress/e2e/mocked_data/isolate.cy.ts | 23 +- .../e2e/mocked_data/response_console.cy.ts | 230 +++++++++++++ .../cypress/support/data_loaders.ts | 23 +- .../management/cypress/tasks/isolate.ts | 15 + .../cypress/tasks/response_console.ts | 14 + .../services/action_responder.ts | 7 +- .../services/endpoint_response_actions.ts | 314 +----------------- .../endpoint/common/response_actions.ts | 311 +++++++++++++++++ 8 files changed, 606 insertions(+), 331 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/response_console.cy.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/response_actions.ts diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/isolate.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/isolate.cy.ts index 4259c3d0b708b..97ba50b3cce9b 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/isolate.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/isolate.cy.ts @@ -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; let isolatedEndpointData: ReturnTypeFromChainable; - + 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'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/response_console.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/response_console.cy.ts new file mode 100644 index 0000000000000..2877a91f6b63c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/response_console.cy.ts @@ -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; + 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; + 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; + 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; + 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; + 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'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts index 4a006fdacc353..bd0438542dc73 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts @@ -8,7 +8,10 @@ // / 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 => { + }): Promise => { 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 ({ diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/isolate.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/isolate.ts index 8a05d790e19bd..cf9ae47b1209d 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/isolate.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/isolate.ts @@ -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); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_console.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_console.ts index 5932bc6abf3ec..9e5d2adc7d6e8 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_console.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_console.ts @@ -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> => { + ['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); + }); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts index 8b91273a7cae7..8686698dfdfc6 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts @@ -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'; /** diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts index 25c2e5f6327be..03443882ad369 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts @@ -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 => { - 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 => { - 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 = Pick< - LogsEndpointActionResponse['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; - - 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; - - case 'execute': - const executeOutput: Partial = { - 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; - - default: - return { output: undefined }; - } -}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/response_actions.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/response_actions.ts new file mode 100644 index 0000000000000..cac110ce0cde1 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/response_actions.ts @@ -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 => { + 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 => { + 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 = Pick< + LogsEndpointActionResponse['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; + + 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; + + case 'execute': + const executeOutput: Partial = { + 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; + + default: + return { output: undefined }; + } +};