diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts index 5269306424a84..1b4768c13d143 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts @@ -379,7 +379,7 @@ export const getEndpointResponseActionsConsoleCommands = ({ capabilities: endpointCapabilities, privileges: endpointPrivileges, }, - exampleUsage: 'get-file path "/full/path/to/file.txt" --comment "Possible malware"', + exampleUsage: 'get-file --path "/full/path/to/file.txt" --comment "Possible malware"', exampleInstruction: ENTER_OR_ADD_COMMENT_ARG_INSTRUCTION, validate: capabilitiesAndPrivilegesValidator, mustHaveArgs: true, 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 65b40d9a924ea..05b3fec8cd967 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 @@ -7,7 +7,10 @@ import uuid from 'uuid'; import type { ActionListApiResponse } from '../../../../common/endpoint/types'; -import type { ResponseActionStatus } from '../../../../common/endpoint/service/response_actions/constants'; +import type { + ResponseActionsApiCommandNames, + ResponseActionStatus, +} from '../../../../common/endpoint/service/response_actions/constants'; import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; export const getActionListMock = async ({ @@ -49,6 +52,7 @@ export const getActionListMock = async ({ const actionDetails: ActionListApiResponse['data'] = actionIds.map((actionId) => { return endpointActionGenerator.generateActionDetails({ agents: [id], + command: (commands?.[0] ?? 'isolate') as ResponseActionsApiCommandNames, id: actionId, isCompleted, isExpired, diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx index 09d201c171e9e..01d19867d4212 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx @@ -21,6 +21,7 @@ import { getActionListMock } from './mocks'; import { useGetEndpointsList } from '../../hooks/endpoint/use_get_endpoints_list'; import uuid from 'uuid'; import { RESPONSE_ACTION_API_COMMANDS_NAMES } from '../../../../common/endpoint/service/response_actions/constants'; +import { useUserPrivileges as _useUserPrivileges } from '../../../common/components/user_privileges'; let mockUseGetEndpointActionList: { isFetched?: boolean; @@ -113,9 +114,15 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { jest.mock('../../hooks/endpoint/use_get_endpoints_list'); +jest.mock('../../../common/components/user_privileges'); + const mockUseGetEndpointsList = useGetEndpointsList as jest.Mock; describe('Response actions history', () => { + const useUserPrivilegesMock = _useUserPrivileges as jest.Mock< + ReturnType + >; + const testPrefix = 'response-actions-list'; let render: ( @@ -409,6 +416,53 @@ describe('Response actions history', () => { ); }); + it('should contain download link in expanded row for `get-file` action WITH file operation permission', async () => { + mockUseGetEndpointActionList = { + ...baseMockedActionList, + data: await getActionListMock({ actionCount: 1, commands: ['get-file'] }), + }; + + render(); + const { getByTestId } = renderResult; + + const expandButton = getByTestId(`${testPrefix}-expand-button`); + userEvent.click(expandButton); + const downloadLink = getByTestId(`${testPrefix}-getFileDownloadLink`); + expect(downloadLink).toBeTruthy(); + expect(downloadLink.textContent).toEqual( + 'Click here to download(ZIP file passcode: elastic)' + ); + }); + + it('should not contain download link in expanded row for `get-file` action when NO file operation permission', async () => { + const privileges = useUserPrivilegesMock(); + + useUserPrivilegesMock.mockImplementationOnce(() => { + return { + ...privileges, + endpointPrivileges: { + ...privileges.endpointPrivileges, + canWriteFileOperations: false, + }, + }; + }); + + mockUseGetEndpointActionList = { + ...baseMockedActionList, + data: await getActionListMock({ actionCount: 1, commands: ['get-file'] }), + }; + + render(); + const { getByTestId, queryByTestId } = renderResult; + + const expandButton = getByTestId(`${testPrefix}-expand-button`); + userEvent.click(expandButton); + const output = getByTestId(`${testPrefix}-details-tray-output`); + expect(output).toBeTruthy(); + expect(output.textContent).toEqual('get-file completed successfully'); + expect(queryByTestId(`${testPrefix}-getFileDownloadLink`)).toBeNull(); + }); + it('should refresh data when autoRefresh is toggled on', async () => { render(); const { getByTestId } = renderResult; @@ -552,17 +606,22 @@ describe('Response actions history', () => { it('should show a list of actions when opened', () => { render(); - const { getByTestId } = renderResult; + const { getByTestId, getAllByTestId } = renderResult; userEvent.click(getByTestId(`${testPrefix}-${filterPrefix}-popoverButton`)); const filterList = getByTestId(`${testPrefix}-${filterPrefix}-popoverList`); expect(filterList).toBeTruthy(); - expect(filterList.querySelectorAll('ul>li').length).toEqual( + expect(getAllByTestId(`${filterPrefix}-option`).length).toEqual( RESPONSE_ACTION_API_COMMANDS_NAMES.length ); - expect( - Array.from(filterList.querySelectorAll('ul>li')).map((option) => option.textContent) - ).toEqual(['isolate', 'release', 'kill-process', 'suspend-process', 'processes', 'get-file']); + expect(getAllByTestId(`${filterPrefix}-option`).map((option) => option.textContent)).toEqual([ + 'isolate', + 'release', + 'kill-process', + 'suspend-process', + 'processes', + 'get-file', + ]); }); it('should have `clear all` button `disabled` when no selected values', () => { @@ -580,15 +639,17 @@ describe('Response actions history', () => { it('should show a list of statuses when opened', () => { render(); - const { getByTestId } = renderResult; + const { getByTestId, getAllByTestId } = renderResult; userEvent.click(getByTestId(`${testPrefix}-${filterPrefix}-popoverButton`)); const filterList = getByTestId(`${testPrefix}-${filterPrefix}-popoverList`); expect(filterList).toBeTruthy(); - expect(filterList.querySelectorAll('ul>li').length).toEqual(3); - expect( - Array.from(filterList.querySelectorAll('ul>li')).map((option) => option.textContent) - ).toEqual(['Failed', 'Pending', 'Successful']); + expect(getAllByTestId(`${filterPrefix}-option`).length).toEqual(3); + expect(getAllByTestId(`${filterPrefix}-option`).map((option) => option.textContent)).toEqual([ + 'Failed', + 'Pending', + 'Successful', + ]); }); it('should have `clear all` button `disabled` when no selected values', () => { @@ -623,13 +684,13 @@ describe('Response actions history', () => { it('should show a list of host names when opened', () => { render({ showHostNames: true }); - const { getByTestId } = renderResult; + const { getByTestId, getAllByTestId } = renderResult; const popoverButton = getByTestId(`${testPrefix}-${filterPrefix}-popoverButton`); userEvent.click(popoverButton); const filterList = getByTestId(`${testPrefix}-${filterPrefix}-popoverList`); expect(filterList).toBeTruthy(); - expect(filterList.querySelectorAll('ul>li').length).toEqual(9); + expect(getAllByTestId(`${filterPrefix}-option`).length).toEqual(9); expect( getByTestId(`${testPrefix}-${filterPrefix}-popoverButton`).querySelector( '.euiNotificationBadge' @@ -652,16 +713,15 @@ describe('Response actions history', () => { } }); - const filterList = renderResult.getByTestId(`${testPrefix}-${filterPrefix}-popoverList`); - - const selectedFilterOptions = Array.from(filterList.querySelectorAll('ul>li')).reduce< - number[] - >((acc, curr, i) => { - if (curr.getAttribute('aria-checked') === 'true') { - acc.push(i); - } - return acc; - }, []); + const selectedFilterOptions = getAllByTestId(`${filterPrefix}-option`).reduce( + (acc, curr, i) => { + if (curr.getAttribute('aria-checked') === 'true') { + acc.push(i); + } + return acc; + }, + [] + ); expect(selectedFilterOptions).toEqual([1, 3, 5]); }); @@ -686,16 +746,16 @@ describe('Response actions history', () => { // re-open userEvent.click(popoverButton); - const filterList = renderResult.getByTestId(`${testPrefix}-${filterPrefix}-popoverList`); - const selectedFilterOptions = Array.from(filterList.querySelectorAll('ul>li')).reduce< - number[] - >((acc, curr, i) => { - if (curr.getAttribute('aria-checked') === 'true') { - acc.push(i); - } - return acc; - }, []); + const selectedFilterOptions = getAllByTestId(`${filterPrefix}-option`).reduce( + (acc, curr, i) => { + if (curr.getAttribute('aria-checked') === 'true') { + acc.push(i); + } + return acc; + }, + [] + ); expect(selectedFilterOptions).toEqual([0, 1, 2]); }); @@ -730,15 +790,15 @@ describe('Response actions history', () => { } }); - const filterList = renderResult.getByTestId(`${testPrefix}-${filterPrefix}-popoverList`); - const selectedFilterOptions = Array.from(filterList.querySelectorAll('ul>li')).reduce< - number[] - >((acc, curr, i) => { - if (curr.getAttribute('aria-checked') === 'true') { - acc.push(i); - } - return acc; - }, []); + const selectedFilterOptions = getAllByTestId(`${filterPrefix}-option`).reduce( + (acc, curr, i) => { + if (curr.getAttribute('aria-checked') === 'true') { + acc.push(i); + } + return acc; + }, + [] + ); expect(selectedFilterOptions).toEqual([0, 1, 2, 4, 6, 8]); }); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/use_response_actions_log_table.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/use_response_actions_log_table.tsx index 443eac84c6b18..8d38b22508be2 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/use_response_actions_log_table.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/use_response_actions_log_table.tsx @@ -5,7 +5,6 @@ * 2.0. */ import React, { useCallback, useMemo, useState } from 'react'; -import type { HorizontalAlignment } from '@elastic/eui'; import { EuiI18nNumber, @@ -20,6 +19,7 @@ import { EuiScreenReaderOnly, EuiText, EuiToolTip, + type HorizontalAlignment, } from '@elastic/eui'; import { css, euiStyled } from '@kbn/kibana-react-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -33,6 +33,7 @@ import { getEmptyValue } from '../../../common/components/empty_value'; import { StatusBadge } from './components/status_badge'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../common/constants'; +import { ResponseActionFileDownloadLink } from '../response_action_file_download_link'; const emptyValue = getEmptyValue(); @@ -137,6 +138,7 @@ export const useResponseActionsLogTable = ({ : undefined; const command = getUiCommand(_command); + const isGetFileCommand = command === 'get-file'; const dataList = [ { title: OUTPUT_MESSAGES.expandSection.placedAt, @@ -169,6 +171,35 @@ export const useResponseActionsLogTable = ({ }; }); + const getOutputContent = () => { + if (isExpired) { + return OUTPUT_MESSAGES.hasExpired(command); + } + + if (!isCompleted) { + return OUTPUT_MESSAGES.isPending(command); + } + + if (!wasSuccessful) { + return OUTPUT_MESSAGES.hasFailed(command); + } + + if (isGetFileCommand) { + return ( + <> + {OUTPUT_MESSAGES.wasSuccessful(command)} + + + ); + } + + return OUTPUT_MESSAGES.wasSuccessful(command); + }; + const outputList = [ { title: ( @@ -177,13 +208,7 @@ export const useResponseActionsLogTable = ({ description: ( // codeblock for output - {isExpired - ? OUTPUT_MESSAGES.hasExpired(command) - : isCompleted - ? wasSuccessful - ? OUTPUT_MESSAGES.wasSuccessful(command) - : OUTPUT_MESSAGES.hasFailed(command) - : OUTPUT_MESSAGES.isPending(command)} + {getOutputContent()} ), }, diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx index e873a4ce253f7..20701ff555593 100644 --- a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx +++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx @@ -5,9 +5,14 @@ * 2.0. */ -import type { CSSProperties } from 'react'; -import React, { memo, useMemo } from 'react'; -import { EuiButtonEmpty, EuiLoadingContent, EuiText } from '@elastic/eui'; +import React, { memo, useMemo, type CSSProperties } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiLoadingContent, + EuiText, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; @@ -40,6 +45,7 @@ export interface ResponseActionFileDownloadLinkProps { agentId?: string; buttonTitle?: string; 'data-test-subj'?: string; + textSize?: 's' | 'xs'; } /** @@ -49,7 +55,13 @@ export interface ResponseActionFileDownloadLinkProps { * NOTE: Currently displays only the link for the first host in the Action */ export const ResponseActionFileDownloadLink = memo( - ({ action, agentId, buttonTitle = DEFAULT_BUTTON_TITLE, 'data-test-subj': dataTestSubj }) => { + ({ + action, + agentId, + buttonTitle = DEFAULT_BUTTON_TITLE, + 'data-test-subj': dataTestSubj, + textSize = 's', + }) => { const getTestId = useTestIdGenerator(dataTestSubj); const { canWriteFileOperations } = useUserPrivileges().endpointPrivileges; @@ -97,31 +109,32 @@ export const ResponseActionFileDownloadLink = memo - - {buttonTitle} - - - - - + + + + {buttonTitle} + + + + + + + + ); } );