diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index f4a454456c16a..03f769e34c5d8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -274,12 +274,10 @@ export class EndpointActionGenerator extends BaseDataGenerator { >; uploadActionDetails.parameters = { - file: { - file_id: 'file-x-y-z', - file_name: 'foo.txt', - size: 1234, - sha256: 'file-hash-sha-256', - }, + file_id: 'file-x-y-z', + file_name: 'foo.txt', + file_size: 1234, + file_sha256: 'file-hash-sha-256', }; uploadActionDetails.outputs = { diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 7539b85c3397c..c06e890305793 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -477,12 +477,10 @@ export interface ActionFileInfoApiResponse { * the action's parameters via the API route handler */ export type ResponseActionUploadParameters = UploadActionApiRequestBody['parameters'] & { - file: { - sha256: string; - size: number; - file_name: string; - file_id: string; - }; + file_sha256: string; + file_size: number; + file_name: string; + file_id: string; }; export interface ResponseActionUploadOutputContent { diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx index e7fe639d606a2..3ee32999aeec6 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx @@ -8,6 +8,7 @@ import React, { memo, useMemo } from 'react'; import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiDescriptionList } from '@elastic/eui'; import { css, euiStyled } from '@kbn/kibana-react-plugin/common'; +import { EndpointUploadActionResult } from '../../endpoint_upload_action_result'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { OUTPUT_MESSAGES } from '../translations'; import { getUiCommand } from './hooks'; @@ -16,6 +17,10 @@ import { ResponseActionFileDownloadLink } from '../../response_action_file_downl import { ExecuteActionHostResponse } from '../../endpoint_execute_action'; import { getEmptyValue } from '../../../../common/components/empty_value'; +import type { + ResponseActionUploadOutputContent, + ResponseActionUploadParameters, +} from '../../../../../common/endpoint/types'; import { type ActionDetails, type MaybeImmutable } from '../../../../../common/endpoint/types'; const emptyValue = getEmptyValue(); @@ -75,6 +80,12 @@ const StyledEuiFlexGroup = euiStyled(EuiFlexGroup).attrs({ overflow-y: auto; `; +const isUploadAction = ( + action: MaybeImmutable +): action is ActionDetails => { + return action.command === 'upload'; +}; + const OutputContent = memo<{ action: MaybeImmutable; 'data-test-subj'?: string }>( ({ action, 'data-test-subj': dataTestSubj }) => { const getTestId = useTestIdGenerator(dataTestSubj); @@ -145,6 +156,20 @@ const OutputContent = memo<{ action: MaybeImmutable; 'data-test-s ); } + if (isUploadAction(action)) { + return ( + +

{OUTPUT_MESSAGES.wasSuccessful(command)}

+ + +
+ ); + } + return <>{OUTPUT_MESSAGES.wasSuccessful(command)}; } ); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx index 088809509cd50..d9226436e4f3b 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx @@ -16,8 +16,11 @@ import { } from '../../../../common/mock/endpoint'; import { ResponseActionsLog } from '../response_actions_log'; import type { + ActionDetails, ActionDetailsApiResponse, ActionFileInfoApiResponse, + ResponseActionUploadOutputContent, + ResponseActionUploadParameters, } from '../../../../../common/endpoint/types'; import { MANAGEMENT_PATH } from '../../../../../common/constants'; import { getActionListMock } from '../mocks'; @@ -29,6 +32,8 @@ import { responseActionsHttpMocks } from '../../../mocks/response_actions_http_m import { waitFor } from '@testing-library/react'; import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint/service/authz/mocks'; import { useGetEndpointActionList as _useGetEndpointActionList } from '../../../hooks/response_actions/use_get_endpoint_action_list'; +import { OUTPUT_MESSAGES } from '../translations'; +import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator'; const useGetEndpointActionListMock = _useGetEndpointActionList as jest.Mock; @@ -786,6 +791,84 @@ describe('Response actions history', () => { } ); }); + + describe('`upload` action', () => { + let action: ActionDetails; + + beforeEach(async () => { + action = new EndpointActionGenerator().generateActionDetails< + ResponseActionUploadOutputContent, + ResponseActionUploadParameters + >({ command: 'upload' }); + + const actionListApiResponse = await getActionListMock({ + actionCount: 1, + commands: ['upload'], + }); + + actionListApiResponse.data = [action]; + + useGetEndpointActionListMock.mockReturnValue({ + ...getBaseMockedActionList(), + data: actionListApiResponse, + }); + }); + + it('should display pending output if action is not complete yet', () => { + action.isCompleted = false; + const { getByTestId } = render(); + getByTestId(`${testPrefix}-expand-button`).click(); + + expect(getByTestId(`${testPrefix}-details-tray-output`)).toHaveTextContent( + OUTPUT_MESSAGES.isPending('upload') + ); + }); + + it('should display output for single agent', () => { + const { getByTestId } = render(); + getByTestId(`${testPrefix}-expand-button`).click(); + + expect(getByTestId(`${testPrefix}-uploadDetails`)).toHaveTextContent( + 'upload completed successfully' + + 'File saved to: /path/to/uploaded/file' + + 'Free disk space on drive: 1.18MB' + ); + }); + + it('should display output for multiple agents', () => { + action.agents.push('agent-b'); + action.hosts['agent-b'] = { + name: 'host b', + }; + action.agentState['agent-b'] = { + errors: undefined, + wasSuccessful: true, + isCompleted: true, + completedAt: '2023-05-10T20:09:25.824Z', + }; + (action.outputs = action.outputs ?? {})['agent-b'] = { + type: 'json', + content: { + code: 'ra_upload_file-success', + path: 'some/path/to/file', + disk_free_space: 123445, + }, + }; + + const { getByTestId } = render(); + getByTestId(`${testPrefix}-expand-button`).click(); + + expect(getByTestId(`${testPrefix}-uploadDetails`)).toHaveTextContent( + 'upload completed successfully' + + 'Host: Host-agent-a' + + 'File saved to: /path/to/uploaded/file' + + 'Free disk space on drive: 1.18MB' + + 'Host: host b' + + 'File saved to: some/path/to/file' + + 'Free disk space on drive: 120.55KB' + ); + }); + }); }); describe('Action status ', () => { diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/mocks.tsx index 480239d1eee6a..76ae714eb9555 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/mocks.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/mocks.tsx @@ -50,15 +50,49 @@ export const getActionListMock = async ({ .map(() => uuidv4()); const actionDetails: ActionListApiResponse['data'] = actionIds.map((actionId) => { + const command = (commands?.[0] ?? 'isolate') as ResponseActionsApiCommandNames; return endpointActionGenerator.generateActionDetails({ agents: [id], - command: (commands?.[0] ?? 'isolate') as ResponseActionsApiCommandNames, + command, id: actionId, isCompleted, isExpired, wasSuccessful, status, completedAt: isExpired ? undefined : new Date().toISOString(), + hosts: { + ...(command === 'upload' + ? { + [id]: { name: 'host name' }, + } + : {}), + }, + agentState: { + ...(command === 'upload' + ? { + [id]: { + errors: undefined, + wasSuccessful: true, + isCompleted: true, + completedAt: '2023-05-10T20:09:25.824Z', + }, + } + : {}), + }, + outputs: { + ...(command === 'upload' + ? { + [id]: { + type: 'json', + content: { + code: 'ra_upload_file-success', + path: 'some/path/to/file', + disk_free_space: 123445, + }, + }, + } + : {}), + }, }); }); return actionDetails; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_upload_action_result/endpoint_upload_action_result.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_upload_action_result/endpoint_upload_action_result.tsx index 57b0cb389b65b..ee219ff5652e8 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_upload_action_result/endpoint_upload_action_result.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_upload_action_result/endpoint_upload_action_result.tsx @@ -8,6 +8,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { PropsWithChildren } from 'react'; import React, { memo, useMemo } from 'react'; +import type { EuiTextProps } from '@elastic/eui'; import { EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import numeral from '@elastic/numeral'; @@ -53,11 +54,12 @@ interface EndpointUploadActionResultProps { >; /** The agent id to display the result for. If undefined, the output for ALL agents will be displayed */ agentId?: string; + textSize?: EuiTextProps['size']; 'data-test-subj'?: string; } export const EndpointUploadActionResult = memo( - ({ action: _action, agentId, 'data-test-subj': dataTestSubj }) => { + ({ action: _action, agentId, textSize = 's', 'data-test-subj': dataTestSubj }) => { const action = _action as ActionDetails< ResponseActionUploadOutputContent, ResponseActionUploadParameters @@ -99,7 +101,7 @@ export const EndpointUploadActionResult = memo( } return ( -
+ {outputs.map(({ name, state, result }) => { // Use case: action log if (!state.isCompleted) { @@ -157,7 +159,7 @@ export const EndpointUploadActionResult = memo( ); })} -
+ ); } ); @@ -169,9 +171,8 @@ export interface KeyValueDisplayProps { } const KeyValueDisplay = memo(({ name, value }) => { return ( - (({ name, value }) => { {': '} {value} - + ); }); KeyValueDisplay.displayName = 'KeyValueDisplay'; diff --git a/x-pack/plugins/security_solution/public/management/hooks/response_actions/use_send_upload_endpoint_request.ts b/x-pack/plugins/security_solution/public/management/hooks/response_actions/use_send_upload_endpoint_request.ts index 709bf2afcc0c6..04189a9eee3a1 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/response_actions/use_send_upload_endpoint_request.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/response_actions/use_send_upload_endpoint_request.ts @@ -29,7 +29,7 @@ export const useSendUploadEndpointRequest = ( formData.append('file', file, file.name); for (const [key, value] of Object.entries(payload)) { - formData.append(key, JSON.stringify(value)); + formData.append(key, typeof value !== 'string' ? JSON.stringify(value) : value); } return http.post(UPLOAD_ROUTE, { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.test.ts index 5f165dd02ce87..b34d3ecca9984 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.test.ts @@ -170,12 +170,10 @@ describe('Upload response action create API handler', () => { command: 'upload', endpoint_ids: ['123-456'], parameters: { - file: { - file_id: '123', - file_name: 'test.txt', - sha256: 'abc', - size: 1234, - }, + file_id: '123', + file_name: 'test.txt', + file_sha256: 'abc', + file_size: 1234, overwrite: true, }, user: undefined, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.ts index 0edc36a3497a5..85d0b26a0cb3f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_upload_handler.ts @@ -75,12 +75,10 @@ export const getActionFileUploadHandler = ( const { file: _, parameters: userParams, ...actionPayload } = req.body; const uploadParameters: ResponseActionUploadParameters = { ...userParams, - file: { - file_id: '', - file_name: '', - sha256: '', - size: 0, - }, + file_id: '', + file_name: '', + file_sha256: '', + file_size: 0, }; try { @@ -92,10 +90,10 @@ export const getActionFileUploadHandler = ( maxFileBytes, }); - uploadParameters.file.file_id = createdFile.file.id; - uploadParameters.file.file_name = createdFile.file.name; - uploadParameters.file.sha256 = createdFile.file.hash?.sha256; - uploadParameters.file.size = createdFile.file.size; + uploadParameters.file_id = createdFile.file.id; + uploadParameters.file_name = createdFile.file.name; + uploadParameters.file_sha256 = createdFile.file.hash?.sha256; + uploadParameters.file_size = createdFile.file.size; } catch (err) { return errorHandler(logger, res, err); } @@ -131,10 +129,10 @@ export const getActionFileUploadHandler = ( }, }); } catch (err) { - if (uploadParameters.file.file_id) { + if (uploadParameters.file_id) { // Try to delete the created file since creating the action threw an error try { - await deleteFile(esClient, logger, uploadParameters.file.file_id); + await deleteFile(esClient, logger, uploadParameters.file_id); } catch (e) { logger.error( `Attempt to clean up file (after action creation was unsuccessful) failed; ${e.message}`, diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.test.ts index 629be3be03b2a..7b3db55bc67a2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.test.ts @@ -251,10 +251,10 @@ describe('Action Files service', () => { }); it('should throw an error no `action.parameters.file.file_id` defined', async () => { - action.parameters!.file.file_id = ''; + action.parameters!.file_id = ''; await expect(setFileActionId(esClientMock, loggerMock, action)).rejects.toThrow( - "Action [123] has no 'parameters.file.file_id' defined. Unable to set action id on file record" + "Action [123] has no 'parameters.file_id' defined. Unable to set action id on file metadata record" ); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts index b00b3f62ae5c8..71a25d50b77e3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts @@ -296,13 +296,13 @@ export const setFileActionId = async ( action: ActionDetails ): Promise => { assert( - action.parameters?.file.file_id, - `Action [${action.id}] has no 'parameters.file.file_id' defined. Unable to set action id on file record` + action.parameters?.file_id, + `Action [${action.id}] has no 'parameters.file_id' defined. Unable to set action id on file metadata record` ); const fileClient = getFileClient(esClient, logger); const file = await fileClient.get({ - id: action.parameters?.file.file_id, + id: action.parameters?.file_id, }); await file.update({