From 4cf9d1818882e7dbba5679b0e9e34979ebc72f39 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:19:55 -0400 Subject: [PATCH] [Security Solution][Endpoint] Add authz to file/download apis in support of SentinelOne `processes` response action (#189127) ## Summary The following changes were done for Response actions: - The file access APIs (file info + file download) were refactored to ensure they properly validate the required authz for each type of action - Now that these APIs are being used for different response actions, we need to add logic that ensures that a user with access to one response action (ex. `running-processes` for SentinelOne) is not allowed to access file information for a different type of action (ex. `get-file`) - The Response Actions History Log was updated so that the output of a `processes` response action is now displayed to the user when the response action row in the table is expanded - Note that for SentinelOne hosts, the output of the `processes` response action is a file download - **which will only be visible if user has authz to Processes operations** - The height of the expandable row was also increased in order to provide a larger viewing area for the content that is displayed inside of it (the action results) --- .../endpoint_action_generator.ts | 50 ++- .../common/endpoint/service/authz/mocks.ts | 4 + .../service/response_actions/type_guards.ts | 7 + .../get_processes_action.tsx | 171 +-------- .../get_processes_action.test.tsx | 23 +- .../components/action_log_expanded_tray.tsx | 46 +-- .../response_actions_log.test.tsx | 31 +- .../running_processes_action_results/index.ts | 8 + .../running_processes_action_results.test.tsx | 99 ++++++ .../running_processes_action_results.tsx | 285 +++++++++++++++ .../response_console/process_operations.cy.ts | 2 +- .../cypress/tasks/response_actions.ts | 4 +- .../endpoint/common/response_actions.ts | 329 +++++++++--------- .../server/endpoint/mocks/mocks.ts | 11 +- .../actions/file_download_handler.test.ts | 1 + .../routes/actions/file_download_handler.ts | 6 +- .../routes/actions/file_info_handler.test.ts | 1 + .../routes/actions/file_info_handler.ts | 6 +- .../endpoint/routes/actions/utils.test.ts | 80 +++++ .../server/endpoint/routes/actions/utils.ts | 135 +++++++ .../server/endpoint/routes/error_handler.ts | 6 +- .../routes/with_endpoint_authz.test.ts | 35 +- .../endpoint/routes/with_endpoint_authz.ts | 20 +- 23 files changed, 964 insertions(+), 396 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/running_processes_action_results/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.tsx create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.ts 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 87a3aee66a884f..91de8579426ea7 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 @@ -8,8 +8,10 @@ import type { DeepPartial } from 'utility-types'; import { merge } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { isProcessesAction } from '../service/response_actions/type_guards'; import { ENDPOINT_ACTION_RESPONSES_DS, ENDPOINT_ACTIONS_DS } from '../constants'; import { BaseDataGenerator } from './base_data_generator'; +import type { GetProcessesActionOutputContent } from '../types'; import { type ActionDetails, type ActionResponseOutput, @@ -211,16 +213,27 @@ export class EndpointActionGenerator extends BaseDataGenerator { generateActionDetails< TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes - >( - overrides: DeepPartial> = {} - ): ActionDetails { + >({ + agents: overrideAgents, + command: overrideCommand, + ...overrides + }: DeepPartial> = {}): ActionDetails< + TOutputContent, + TParameters + > { + const agents = overrideAgents ? [...(overrideAgents as string[])] : ['agent-a']; + const command = overrideCommand ?? 'isolate'; + const details: WithAllKeys = { action: '123', - agents: ['agent-a'], + agents, agentType: 'endpoint', - command: 'isolate', + command, completedAt: '2022-04-30T16:08:47.449Z', - hosts: { 'agent-a': { name: 'Host-agent-a' } }, + hosts: agents.reduce((acc, agentId) => { + acc[agentId] = { name: `Host-${agentId}` }; + return acc; + }, {} as ActionDetails['hosts']), id: '123', isCompleted: true, isExpired: false, @@ -232,21 +245,20 @@ export class EndpointActionGenerator extends BaseDataGenerator { createdBy: 'auserid', parameters: undefined, outputs: undefined, - agentState: { - 'agent-a': { + agentState: agents.reduce((acc, agentId) => { + acc[agentId] = { errors: undefined, isCompleted: true, completedAt: '2022-04-30T16:08:47.449Z', wasSuccessful: true, - }, - }, + }; + return acc; + }, {} as ActionDetails['agentState']), alertIds: undefined, ruleId: undefined, ruleName: undefined, }; - const command = overrides.command ?? details.command; - if (command === 'get-file') { if (!details.parameters) { ( @@ -391,6 +403,20 @@ export class EndpointActionGenerator extends BaseDataGenerator { }, {}); } + if (isProcessesAction(details)) { + details.outputs = agents.reduce((acc, agentId) => { + acc[agentId] = { + type: 'json', + content: { + code: 'success', + entries: this.randomResponseActionProcesses(), + }, + }; + + return acc; + }, {} as Required>['outputs']); + } + return merge(details, overrides as ActionDetails) as unknown as ActionDetails< TOutputContent, TParameters diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/mocks.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/mocks.ts index b8f5e5cb3ef4d2..cf9cc8ef94629a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/mocks.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/mocks.ts @@ -8,6 +8,10 @@ import type { EndpointAuthz } from '../../types/authz'; import { getEndpointAuthzInitialState } from './authz'; +/** + * Returns the Endpoint Authz values all set to `true` (authorized) + * @param overrides + */ export const getEndpointAuthzInitialStateMock = ( overrides: Partial = {} ): EndpointAuthz => { diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/type_guards.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/type_guards.ts index e147e726e21901..707be0a4d1e65f 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/type_guards.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/type_guards.ts @@ -14,6 +14,7 @@ import type { ResponseActionsExecuteParameters, ResponseActionUploadOutputContent, ResponseActionUploadParameters, + GetProcessesActionOutputContent, } from '../../types'; import { RESPONSE_ACTION_AGENT_TYPE, RESPONSE_ACTION_TYPE } from './constants'; @@ -40,6 +41,12 @@ export const isGetFileAction = ( return action.command === 'get-file'; }; +export const isProcessesAction = ( + action: MaybeImmutable +): action is ActionDetails => { + return action.command === 'running-processes'; +}; + // type guards to ensure only the matching string values are attached to the types filter type export const isAgentType = (type: string): type is (typeof RESPONSE_ACTION_AGENT_TYPE)[number] => RESPONSE_ACTION_AGENT_TYPE.includes(type as (typeof RESPONSE_ACTION_AGENT_TYPE)[number]); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/get_processes_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/get_processes_action.tsx index bc11fc4653ad00..54dce8a6e4addd 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/get_processes_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/get_processes_action.tsx @@ -6,47 +6,15 @@ */ import React, { memo, useMemo } from 'react'; -import styled from 'styled-components'; -import { EuiBasicTable, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { ResponseActionFileDownloadLink } from '../../response_action_file_download_link'; -import { KeyValueDisplay } from '../../key_value_display'; +import { RunningProcessesActionResults } from '../../running_processes_action_results'; import { useConsoleActionSubmitter } from '../hooks/use_console_action_submitter'; import type { - ActionDetails, GetProcessesActionOutputContent, - MaybeImmutable, ProcessesRequestBody, } from '../../../../../common/endpoint/types'; import { useSendGetEndpointProcessesRequest } from '../../../hooks/response_actions/use_send_get_endpoint_processes_request'; import type { ActionRequestComponentProps } from '../types'; -// @ts-expect-error TS2769 -const StyledEuiBasicTable = styled(EuiBasicTable)` - table { - background-color: transparent; - } - - .euiTableHeaderCell { - border-bottom: ${(props) => props.theme.eui.euiBorderThin}; - - .euiTableCellContent__text { - font-weight: ${(props) => props.theme.eui.euiFontWeightRegular}; - } - } - - .euiTableRow { - &:hover { - background-color: ${({ theme: { eui } }) => eui.euiColorEmptyShade} !important; - } - - .euiTableRowCell { - border-top: none !important; - border-bottom: none !important; - } - } -`; - export const GetProcessesActionResult = memo( ({ command, setStore, store, status, setStatus, ResultComponent }) => { const { endpointId, agentType } = command.commandDefinition?.meta ?? {}; @@ -84,141 +52,12 @@ export const GetProcessesActionResult = memo( // Show results return ( - {agentType === 'sentinel_one' ? ( - - ) : ( - - )} + ); } ); GetProcessesActionResult.displayName = 'GetProcessesActionResult'; - -interface EndpointRunningProcessesResultsProps { - action: MaybeImmutable>; - /** If defined, only the results for the given agent id will be displayed. Else, all agents output will be displayed */ - agentId?: string; -} - -const EndpointRunningProcessesResults = memo( - ({ action, agentId }) => { - const agentIds: string[] = agentId ? [agentId] : [...action.agents]; - const columns = useMemo( - () => [ - { - field: 'user', - 'data-test-subj': 'process_list_user', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.user', - { defaultMessage: 'USER' } - ), - width: '10%', - }, - { - field: 'pid', - 'data-test-subj': 'process_list_pid', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.pid', - { defaultMessage: 'PID' } - ), - width: '5%', - }, - { - field: 'entity_id', - 'data-test-subj': 'process_list_entity_id', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.enityId', - { defaultMessage: 'ENTITY ID' } - ), - width: '30%', - }, - - { - field: 'command', - 'data-test-subj': 'process_list_command', - name: i18n.translate( - 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.command', - { defaultMessage: 'COMMAND' } - ), - width: '55%', - }, - ], - [] - ); - - return ( - <> - {agentIds.length > 1 ? ( - agentIds.map((id) => { - const hostName = action.hosts[id].name; - - return ( -
- - } - /> - -
- ); - }) - ) : ( - - )} - - ); - } -); -EndpointRunningProcessesResults.displayName = 'EndpointRunningProcessesResults'; - -interface SentinelOneRunningProcessesResultsProps { - action: MaybeImmutable>; - /** - * If defined, the results will only be displayed for the given agent id. - * If undefined, then responses for all agents are displayed - */ - agentId?: string; -} - -const SentinelOneRunningProcessesResults = memo( - ({ action, agentId }) => { - const agentIds = agentId ? [agentId] : action.agents; - - return ( - <> - {agentIds.length === 1 ? ( - - ) : ( - agentIds.map((id) => { - return ( -
- - } - /> -
- ); - }) - )} - - ); - } -); -SentinelOneRunningProcessesResults.displayName = 'SentinelOneRunningProcessesResults'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx index bf19e565bac9c8..9897319a249001 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx @@ -24,6 +24,11 @@ import type { import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants'; import { UPGRADE_AGENT_FOR_RESPONDER } from '../../../../../common/translations'; import type { CommandDefinition } from '../../../console'; +import { useUserPrivileges as _useUserPrivileges } from '../../../../../common/components/user_privileges'; + +jest.mock('../../../../../common/components/user_privileges'); + +const useUserPrivilegesMock = _useUserPrivileges as jest.Mock; describe('When using processes action from response actions console', () => { let mockedContext: AppContextTestRender; @@ -35,6 +40,7 @@ describe('When using processes action from response actions console', () => { >; let consoleSelectors: ReturnType; let consoleCommands: CommandDefinition[]; + let userAuthzMock: ReturnType; const setConsoleCommands = ( capabilities: EndpointCapabilities[] = [...ENDPOINT_CAPABILITIES], @@ -56,6 +62,7 @@ describe('When using processes action from response actions console', () => { beforeEach(() => { mockedContext = createAppRootMockRenderer(); + userAuthzMock = mockedContext.getUserPrivilegesMockSetter(useUserPrivilegesMock); apiMocks = responseActionsHttpMocks(mockedContext.coreStart.http); setConsoleCommands(); @@ -245,6 +252,20 @@ describe('When using processes action from response actions console', () => { beforeEach(() => { mockedContext.setExperimentalFlag({ responseActionsSentinelOneProcessesEnabled: true }); setConsoleCommands([], 'sentinel_one'); + + const processesResponse = apiMocks.responseProvider.processes(); + processesResponse.data.agentType = 'sentinel_one'; + apiMocks.responseProvider.processes.mockReturnValue(processesResponse); + apiMocks.responseProvider.processes.mockClear(); + + const actionDetails = apiMocks.responseProvider.actionDetails({ + path: '/api/endpoint/action/1.2.3', + }); + actionDetails.data.agentType = 'sentinel_one'; + apiMocks.responseProvider.actionDetails.mockReturnValue(actionDetails); + apiMocks.responseProvider.actionDetails.mockClear(); + + userAuthzMock.set({ canGetRunningProcesses: true }); }); it('should display processes command --help', async () => { @@ -293,7 +314,7 @@ describe('When using processes action from response actions console', () => { await waitFor(() => { expect(renderResult.getByTestId('getProcessesSuccessCallout').textContent).toEqual( - 'Click here to download(ZIP file passcode: elastic).' + + 'Click here to download(ZIP file passcode: Elastic@123).' + 'Files are periodically deleted to clear storage space. Download and save file locally if needed.' ); }); 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 6d1a20a5d28f1c..cba1d0aee41b41 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 @@ -16,12 +16,13 @@ import { import { css, euiStyled } from '@kbn/kibana-react-plugin/common'; import { reduce } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { RunningProcessesActionResults } from '../../running_processes_action_results'; import { getAgentTypeName } from '../../../../common/translations'; import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP } from '../../../../../common/endpoint/service/response_actions/constants'; import { isExecuteAction, isGetFileAction, + isProcessesAction, isUploadAction, } from '../../../../../common/endpoint/service/response_actions/type_guards'; import { EndpointUploadActionResult } from '../../endpoint_upload_action_result'; @@ -86,7 +87,8 @@ const StyledEuiFlexGroup = euiStyled(EuiFlexGroup).attrs({ className: 'eui-yScrollWithShadows', gutterSize: 's', })` - max-height: 270px; + max-height: 40vh; + min-height: 270px; overflow-y: auto; `; @@ -193,9 +195,24 @@ const OutputContent = memo<{ ); } + if (isProcessesAction(action)) { + return ( + +

{OUTPUT_MESSAGES.wasSuccessful(command)}

+ + +
+ ); + } + if (action.agentType === 'crowdstrike') { return <>{OUTPUT_MESSAGES.submittedSuccessfully(command)}; } + return <>{OUTPUT_MESSAGES.wasSuccessful(command)}; }); @@ -209,10 +226,6 @@ export const ActionsLogExpandedTray = memo<{ }>(({ action, fromAlertWorkaround = false, 'data-test-subj': dataTestSubj }) => { const getTestId = useTestIdGenerator(dataTestSubj); - const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled( - 'responseActionsSentinelOneV1Enabled' - ); - const { hosts, startedAt, @@ -277,14 +290,11 @@ export const ActionsLogExpandedTray = memo<{ [] as string[] ).join(', ') || emptyValue, }, - ]; - - if (isSentinelOneV1Enabled) { - list.push({ + { title: OUTPUT_MESSAGES.expandSection.agentType, description: getAgentTypeName(agentType) || emptyValue, - }); - } + }, + ]; return list.map(({ title, description }) => { return { @@ -296,17 +306,7 @@ export const ActionsLogExpandedTray = memo<{ ), }; }); - }, [ - agentType, - command, - comment, - completedAt, - getTestId, - hosts, - isSentinelOneV1Enabled, - parametersList, - startedAt, - ]); + }, [agentType, command, comment, completedAt, getTestId, hosts, parametersList, startedAt]); const outputList = useMemo( () => [ 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 48cc21f4f5a553..b764880257b084 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 @@ -353,16 +353,15 @@ describe('Response actions history', () => { }); it('should show multiple hostnames correctly', async () => { - const data = await getActionListMock({ actionCount: 1 }); - data.data[0] = { - ...data.data[0], + const data = await getActionListMock({ + actionCount: 1, hosts: { - ...data.data[0].hosts, 'agent-b': { name: 'Host-agent-b' }, 'agent-c': { name: '' }, 'agent-d': { name: 'Host-agent-d' }, }, - }; + agentIds: ['agent-a', 'agent-b', 'agent-c', 'agent-d'], + }); useGetEndpointActionListMock.mockReturnValue({ ...getBaseMockedActionList(), @@ -376,14 +375,11 @@ describe('Response actions history', () => { }); it('should show display host is unenrolled for a single agent action when metadata host name is empty', async () => { - const data = await getActionListMock({ actionCount: 1 }); - data.data[0] = { - ...data.data[0], - hosts: { - ...data.data[0].hosts, - 'agent-a': { name: '' }, - }, - }; + const data = await getActionListMock({ + actionCount: 1, + agentIds: ['agent-a'], + hosts: { 'agent-a': { name: '' } }, + }); useGetEndpointActionListMock.mockReturnValue({ ...getBaseMockedActionList(), @@ -397,16 +393,15 @@ describe('Response actions history', () => { }); it('should show display host is unenrolled for a single agent action when metadata host names are empty', async () => { - const data = await getActionListMock({ actionCount: 1 }); - data.data[0] = { - ...data.data[0], + const data = await getActionListMock({ + actionCount: 1, + agentIds: ['agent-a', 'agent-b', 'agent-c'], hosts: { - ...data.data[0].hosts, 'agent-a': { name: '' }, 'agent-b': { name: '' }, 'agent-c': { name: '' }, }, - }; + }); useGetEndpointActionListMock.mockReturnValue({ ...getBaseMockedActionList(), diff --git a/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/index.ts b/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/index.ts new file mode 100644 index 00000000000000..b741431299ea35 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './running_processes_action_results'; diff --git a/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.test.tsx b/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.test.tsx new file mode 100644 index 00000000000000..2f4a25ec0cf4a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.test.tsx @@ -0,0 +1,99 @@ +/* + * 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 React from 'react'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import type { + ActionDetails, + GetProcessesActionOutputContent, +} from '../../../../common/endpoint/types'; +import { RunningProcessesActionResults } from './running_processes_action_results'; +import { useUserPrivileges as _useUserPrivileges } from '../../../common/components/user_privileges'; +import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks'; +import { waitFor } from '@testing-library/react'; + +jest.mock('../../../common/components/user_privileges'); + +const useUserPrivilegesMock = _useUserPrivileges as jest.Mock; + +describe('Running Processes Action Results component', () => { + let appTestContext: AppContextTestRender; + let renderResult: ReturnType; + let render: () => ReturnType; + let setUserPrivileges: ReturnType; + let action: ActionDetails; + let agentId: string | undefined; + + beforeEach(() => { + action = new EndpointActionGenerator('seed').generateActionDetails({ + agents: ['agent-a', 'agent-b'], + command: 'running-processes', + }); + + agentId = 'agent-b'; + appTestContext = createAppRootMockRenderer(); + setUserPrivileges = appTestContext.getUserPrivilegesMockSetter(useUserPrivilegesMock); + setUserPrivileges.set({ canGetRunningProcesses: true }); + + responseActionsHttpMocks(appTestContext.coreStart.http); + + render = () => { + renderResult = appTestContext.render( + + ); + + return renderResult; + }; + }); + + afterEach(() => { + setUserPrivileges.reset(); + }); + + it('should display output content for endpoint agent', () => { + render(); + + expect( + Array.from(renderResult.getByTestId('test-processListTable').querySelectorAll('th')).map( + ($th) => $th.textContent + ) + ).toEqual(['USER', 'PID', 'ENTITY ID', 'COMMAND']); + }); + + it('should display output content sentinelone agent type', async () => { + action.agentType = 'sentinel_one'; + render(); + + await waitFor(() => { + expect(renderResult.getByTestId('test-download')); + }); + }); + + it('should display nothing if agent type does not support processes', () => { + action.agentType = 'crowdstrike'; + render(); + + expect(renderResult.queryByTestId('test')).toBeNull(); + }); + + it('should display output for actions sent to multiple agents', () => { + agentId = undefined; + render(); + + expect(renderResult.queryAllByTestId('test-processListTable')).toHaveLength(2); + }); + + it('should display nothing for SentinelOne when user has no authz', () => { + setUserPrivileges.set({ canGetRunningProcesses: false }); + action.agentType = 'sentinel_one'; + render(); + + expect(renderResult.queryByTestId('test')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.tsx b/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.tsx new file mode 100644 index 00000000000000..e35fe1fff1a08e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/running_processes_action_results/running_processes_action_results.tsx @@ -0,0 +1,285 @@ +/* + * 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 React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { EuiAccordionProps, EuiTextProps } from '@elastic/eui'; +import { EuiAccordion, EuiBasicTable, EuiSpacer, EuiText, useGeneratedHtmlId } from '@elastic/eui'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/css'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { ResponseActionFileDownloadLink } from '../response_action_file_download_link'; +import type { + ActionDetails, + GetProcessesActionOutputContent, + MaybeImmutable, +} from '../../../../common/endpoint/types'; + +export interface RunningProcessesActionResultsProps { + action: MaybeImmutable>; + /** + * If defined, the results will only be displayed for the given agent id. + * If undefined, then responses for all agents are displayed + */ + agentId?: string; + textSize?: EuiTextProps['size']; + 'data-test-subj'?: string; +} + +export const RunningProcessesActionResults = memo( + ({ action, agentId, textSize = 's', 'data-test-subj': dataTestSubj }) => { + return ( + + {action.agentType === 'endpoint' ? ( + + ) : action.agentType === 'sentinel_one' ? ( + + ) : null} + + ); + } +); +RunningProcessesActionResults.displayName = 'RunningProcessesActionResults'; + +// @ts-expect-error TS2769 +const StyledEuiBasicTable = styled(EuiBasicTable)` + table { + background-color: transparent; + font-size: inherit; + } + + .euiTableHeaderCell { + border-bottom: ${(props) => props.theme.eui.euiBorderThin}; + + .euiTableCellContent__text { + font-weight: ${(props) => props.theme.eui.euiFontWeightRegular}; + } + } + + .euiTableRow { + &:hover { + background-color: ${({ theme: { eui } }) => eui.euiColorEmptyShade} !important; + } + + .euiTableRowCell { + border-top: none !important; + border-bottom: none !important; + } + } +`; + +interface EndpointRunningProcessesResultsProps { + action: MaybeImmutable>; + /** If defined, only the results for the given agent id will be displayed. Else, all agents output will be displayed */ + agentId?: string; + 'data-test-subj'?: string; +} + +/** @private */ +const EndpointRunningProcessesResults = memo( + ({ action, agentId, 'data-test-subj': dataTestSubj }) => { + const testId = useTestIdGenerator(dataTestSubj); + const agentIds: string[] = agentId ? [agentId] : [...action.agents]; + const columns = useMemo( + () => [ + { + field: 'user', + 'data-test-subj': testId('user'), + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.user', + { defaultMessage: 'USER' } + ), + width: '10%', + }, + { + field: 'pid', + 'data-test-subj': testId('pid'), + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.pid', + { defaultMessage: 'PID' } + ), + width: '5%', + }, + { + field: 'entity_id', + 'data-test-subj': testId('entity_id'), + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.enityId', + { defaultMessage: 'ENTITY ID' } + ), + width: '30%', + }, + + { + field: 'command', + 'data-test-subj': testId('command'), + name: i18n.translate( + 'xpack.securitySolution.endpointResponseActions.getProcesses.table.header.command', + { defaultMessage: 'COMMAND' } + ), + width: '55%', + }, + ], + [testId] + ); + + const wrappingClassname = useMemo(() => { + return css({ + '.accordion-host-name-button-content': { + 'font-size': 'inherit', + }, + }); + }, []); + + return ( +
+ {agentIds.length > 1 ? ( + agentIds.map((id) => { + const hostName = action.hosts[id].name; + + return ( +
+ } + data-test-subj={testId('hostOutput')} + > + + + + +
+ ); + }) + ) : ( + + )} +
+ ); + } +); +EndpointRunningProcessesResults.displayName = 'EndpointRunningProcessesResults'; + +interface SentinelOneRunningProcessesResultsProps { + action: MaybeImmutable>; + /** + * If defined, the results will only be displayed for the given agent id. + * If undefined, then responses for all agents are displayed + */ + agentId?: string; + 'data-test-subj'?: string; +} + +/** @private */ +const SentinelOneRunningProcessesResults = memo( + ({ action, agentId, 'data-test-subj': dataTestSubj }) => { + const testId = useTestIdGenerator(dataTestSubj); + const agentIds = agentId ? [agentId] : action.agents; + const { canGetRunningProcesses } = useUserPrivileges().endpointPrivileges; + + // If user is not allowed to execute the running processes response action (but may still have + // access to the Response Actions history log), then we don't show any results because user + // does not have access to the file download apis. + if (!canGetRunningProcesses) { + return null; + } + + return ( +
+ {agentIds.length === 1 ? ( + + ) : ( + agentIds.map((id) => { + const hostName = action.hosts[id].name; + + return ( +
+ } + data-test-subj={testId('hostOutput')} + > + + + + +
+ ); + }) + )} +
+ ); + } +); +SentinelOneRunningProcessesResults.displayName = 'SentinelOneRunningProcessesResults'; + +interface HostNameHeaderProps { + hostName: string; +} + +const HostNameHeader = memo(({ hostName }) => { + return ( + + ); +}); +HostNameHeader.displayName = 'HostNameHeader'; + +interface HostProcessesAccordionProps { + buttonContent: EuiAccordionProps['buttonContent']; + children: React.ReactNode; + 'data-test-subj'?: string; +} + +const HostProcessesAccordion = memo( + ({ buttonContent, 'data-test-subj': dataTestSubj, children }) => { + const htmlId = useGeneratedHtmlId(); + + // FYI: Class name used below is defined at the top-level - under component `RunningProcessesActionResults` + return ( + + {children} + + ); + } +); +HostProcessesAccordion.displayName = 'HostProcessesAccordion'; diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts index 66c6bf2af75542..e09aa8dc9fc859 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/response_console/process_operations.cy.ts @@ -76,7 +76,7 @@ describe('Response console', { tags: ['@ess', '@serverless', '@skipInServerlessM cy.contains('Action pending.').should('exist'); // on success - cy.getByTestSubj('getProcessListTable', { timeout: 120000 }).within(() => { + cy.getByTestSubj('processesOutput-processListTable', { timeout: 120000 }).within(() => { ['USER', 'PID', 'ENTITY ID', 'COMMAND'].forEach((header) => { cy.contains(header); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts index a55e385b4b1d01..0e46b99c40d723 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts @@ -85,8 +85,8 @@ export const getRunningProcesses = (command: string): Cypress.Chainable // find pid of process // traverse back from last column to the second column that has pid return cy - .getByTestSubj('getProcessListTable', { timeout: 120000 }) - .findByTestSubj('process_list_command') + .getByTestSubj('processesOutput-processListTable', { timeout: 120000 }) + .findByTestSubj('processesOutput-command') .contains(command) .parents('td') .siblings('td') 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 index 528078ca5d417c..dc9290e5a4b9ac 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/response_actions.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/response_actions.ts @@ -48,34 +48,35 @@ export const sendFleetActionResponse = async ( 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, - }, - }, - }); + let fleetResponse: EndpointActionResponse; - // 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; - } + for (const agentId of action.agents) { + fleetResponse = fleetActionGenerator.generateResponse({ + action_id: action.id, + agent_id: agentId, + action_response: { endpoint: { ack: true } }, + }); - await esClient.index( - { - index: AGENT_ACTIONS_RESULTS_INDEX, - body: fleetResponse, - refresh: 'wait_for', - }, - ES_INDEX_OPTIONS - ); + // 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 + ); + } + + // @ts-expect-error return fleetResponse; }; export const sendEndpointActionResponse = async ( @@ -83,9 +84,11 @@ export const sendEndpointActionResponse = async ( action: ActionDetails, { state }: { state?: 'success' | 'failure' } = {} ): Promise => { - const endpointResponse = - endpointActionGenerator.generateResponse({ - agent: { id: action.agents[0] }, + let endpointResponse: LogsEndpointActionResponse; + + for (const actionAgentId of action.agents) { + endpointResponse = endpointActionGenerator.generateResponse({ + agent: { id: actionAgentId }, EndpointActions: { action_id: action.id, data: { @@ -97,175 +100,173 @@ export const sendEndpointActionResponse = async ( }, }); - // 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', - }; - + // 20% of the time we generate an error if ( - endpointResponse.EndpointActions.data.command === 'get-file' && - endpointResponse.EndpointActions.data.output + 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 - .content as unknown as ResponseActionGetFileOutputContent - ).code = endpointActionGenerator.randomGetFileFailureCode(); - } + ) { + ( + endpointResponse.EndpointActions.data.output + .content as unknown as ResponseActionGetFileOutputContent + ).code = endpointActionGenerator.randomGetFileFailureCode(); + } - if ( - endpointResponse.EndpointActions.data.command === 'scan' && - endpointResponse.EndpointActions.data.output - ) { - ( + if ( + endpointResponse.EndpointActions.data.command === 'scan' && endpointResponse.EndpointActions.data.output - .content as unknown as ResponseActionScanOutputContent - ).code = endpointActionGenerator.randomScanFailureCode(); - } + ) { + ( + endpointResponse.EndpointActions.data.output + .content as unknown as ResponseActionScanOutputContent + ).code = endpointActionGenerator.randomScanFailureCode(); + } - if ( - endpointResponse.EndpointActions.data.command === 'execute' && - endpointResponse.EndpointActions.data.output - ) { - ( + if ( + endpointResponse.EndpointActions.data.command === 'execute' && endpointResponse.EndpointActions.data.output - .content as unknown as ResponseActionExecuteOutputContent - ).stderr = 'execute command timed out'; + ) { + ( + endpointResponse.EndpointActions.data.output + .content as unknown as ResponseActionExecuteOutputContent + ).stderr = 'execute command timed out'; + } } - } - await esClient.index({ - index: ENDPOINT_ACTION_RESPONSES_INDEX, - body: endpointResponse, - refresh: 'wait_for', - }); + await esClient.index({ + index: ENDPOINT_ACTION_RESPONSES_INDEX, + body: endpointResponse, + refresh: 'wait_for', + }); - // ------------------------------------------ - // Post Action Response tasks - // ------------------------------------------ + // ------------------------------------------ + // 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) { + // For isolate, If the response is not an error, then also send a metadata update + if (action.command === 'isolate' && !endpointResponse.error) { await Promise.all([ - sendEndpointMetadataUpdate(esClient, agentId, { - Endpoint: { - state: { - isolation: true, - }, - }, + sendEndpointMetadataUpdate(esClient, actionAgentId, { + Endpoint: { state: { isolation: true } }, }), - checkInFleetAgent(esClient, agentId), + checkInFleetAgent(esClient, actionAgentId), ]); } - } - // 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) { + // For UnIsolate, if response is not an Error, then also send metadata update + if (action.command === 'unisolate' && !endpointResponse.error) { await Promise.all([ - sendEndpointMetadataUpdate(esClient, agentId, { - Endpoint: { - state: { - isolation: false, - }, - }, + sendEndpointMetadataUpdate(esClient, actionAgentId, { + Endpoint: { state: { isolation: false } }, }), - checkInFleetAgent(esClient, agentId), + checkInFleetAgent(esClient, actionAgentId), ]); } - } - // 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 unknown 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], - upload_start: Date.now(), - contents: [ - { - sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28', - file_name: fileName ?? 'bad_file.txt', - path: filePath, - size: 4, + // 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 unknown as ActionDetails< + ResponseActionGetFileOutputContent, + ResponseActionGetFileParameters + > + )?.parameters?.path!; + + const fileName = basename(filePath.replace(/\\/g, '/')); + const fileMetaDoc: FileUploadMetadata = generateFileMetadataDocumentMock({ + action_id: action.id, + agent_id: actionAgentId, + upload_start: Date.now(), + 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', }, - ], - 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]), - op_type: 'create', - refresh: 'wait_for', - body: fileMetaDoc, - }); - - // 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, - '@timestamp': new Date().toISOString(), - data: Buffer.from( - 'UEsDBAoACQAAAFZeRFWpAsDLHwAAABMAAAAMABwAYmFkX2ZpbGUudHh0VVQJAANTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAMOcoyEq/Q4VyG02U9O0LRbGlwP/y5SOCfRKqLz1rsBQSwcIqQLAyx8AAAATAAAAUEsBAh4DCgAJAAAAVl5EVakCwMsfAAAAEwAAAAwAGAAAAAAAAQAAAKSBAAAAAGJhZF9maWxlLnR4dFVUBQADU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFIAAAB1AAAAAAA=', - 'base64' - ), - }), - refresh: 'wait_for', - op_type: 'create', - }, - { - headers: { - 'content-type': 'application/cbor', - accept: 'application/json', + src: 'endpoint', + }); + + // Index the file's metadata + const fileMeta = await esClient.index({ + index: FILE_STORAGE_METADATA_INDEX, + id: getFileDownloadId(action, actionAgentId), + op_type: 'create', + refresh: 'wait_for', + body: fileMetaDoc, + }); + + // 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, + '@timestamp': new Date().toISOString(), + data: Buffer.from( + 'UEsDBAoACQAAAFZeRFWpAsDLHwAAABMAAAAMABwAYmFkX2ZpbGUudHh0VVQJAANTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAMOcoyEq/Q4VyG02U9O0LRbGlwP/y5SOCfRKqLz1rsBQSwcIqQLAyx8AAAATAAAAUEsBAh4DCgAJAAAAVl5EVakCwMsfAAAAEwAAAAwAGAAAAAAAAQAAAKSBAAAAAGJhZF9maWxlLnR4dFVUBQADU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFIAAAB1AAAAAAA=', + 'base64' + ), + }), + refresh: 'wait_for', + op_type: 'create', }, - } - ) - .then(() => sleep(2000)); + { + headers: { + 'content-type': 'application/cbor', + accept: 'application/json', + }, + } + ) + .then(() => sleep(2000)); + } } + // @ts-expect-error return endpointResponse as unknown as LogsEndpointActionResponse; }; + type ResponseOutput< TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput > = Pick['EndpointActions']['data'], 'output'>; + const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => { const commentUppercase = (action?.comment ?? '').toUpperCase(); diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts index 9b6f001934910f..141a5ebb440f6d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts @@ -267,6 +267,8 @@ export interface HttpApiTestSetupMock

{ getRegisteredRouteHandler: (method: RouterMethod, path: string) => RequestHandler; /** Retrieves the route handler configuration that was registered with the router */ getRegisteredRouteConfig: (method: RouterMethod, path: string) => RouteConfig; + /** Sets endpoint authz overrides on the data returned by `EndpointAppContext.services.getEndpointAuthz()` */ + setEndpointAuthz: (overrides: Partial) => void; /** Get a registered versioned route */ getRegisteredVersionedRoute: ( method: RouterMethod, @@ -287,8 +289,9 @@ export const createHttpApiTestSetupMock =

(): HttpApi const endpointAppContextMock = createMockEndpointAppContext(); const scopedEsClusterClientMock = elasticsearchServiceMock.createScopedClusterClient(); const savedObjectClientMock = savedObjectsClientMock.create(); + const endpointAuthz = getEndpointAuthzInitialStateMock(); const httpHandlerContextMock = requestContextMock.convertContext( - createRouteHandlerContext(scopedEsClusterClientMock, savedObjectClientMock) + createRouteHandlerContext(scopedEsClusterClientMock, savedObjectClientMock, { endpointAuthz }) ); const httpResponseMock = httpServerMock.createResponseFactory(); const getRegisteredRouteHandler: HttpApiTestSetupMock['getRegisteredRouteHandler'] = ( @@ -321,6 +324,11 @@ export const createHttpApiTestSetupMock =

(): HttpApi return handler[0]; }; + const setEndpointAuthz = (overrides: Partial) => { + Object.assign(endpointAuthz, overrides); + }; + + (endpointAppContextMock.service.getEndpointAuthz as jest.Mock).mockResolvedValue(endpointAuthz); return { routerMock, @@ -348,6 +356,7 @@ export const createHttpApiTestSetupMock =

(): HttpApi getRegisteredRouteHandler, getRegisteredRouteConfig, + setEndpointAuthz, getRegisteredVersionedRoute: getRegisteredVersionedRouteMock.bind(null, routerMock), }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.test.ts index 2cc6d8efd199ed..050de9019f21ed 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.test.ts @@ -46,6 +46,7 @@ describe('Response Actions file download API', () => { const actionRequestEsSearchResponse = createActionRequestsEsSearchResultsMock(); actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.action_id = '321-654'; + actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.data.command = 'get-file'; applyEsClientSearchMock({ esClientMock, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts index 7095b7d87a50c8..2e16c57886f7d6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts @@ -6,6 +6,7 @@ */ import type { RequestHandler } from '@kbn/core/server'; +import { ensureUserHasAuthzToFilesForAction } from './utils'; import type { EndpointActionFileDownloadParams } from '../../../../common/api/endpoint'; import { EndpointActionFileDownloadSchema } from '../../../../common/api/endpoint'; import type { ResponseActionsClient } from '../../services'; @@ -47,9 +48,10 @@ export const registerActionFileDownloadRoutes = ( }, }, withEndpointAuthz( - { any: ['canWriteFileOperations', 'canWriteExecuteOperations'] }, + { any: ['canWriteFileOperations', 'canWriteExecuteOperations', 'canGetRunningProcesses'] }, logger, - getActionFileDownloadRouteHandler(endpointContext) + getActionFileDownloadRouteHandler(endpointContext), + ensureUserHasAuthzToFilesForAction ) ); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts index e9914dc4232d97..b2866f7cca263b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts @@ -42,6 +42,7 @@ describe('Response Action file info API', () => { const actionRequestEsSearchResponse = createActionRequestsEsSearchResultsMock(); actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.action_id = '321-654'; + actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.data.command = 'get-file'; applyEsClientSearchMock({ esClientMock, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts index a84f3b3a8bf6f5..1cb4e95e1eaf1c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts @@ -6,6 +6,7 @@ */ import type { RequestHandler } from '@kbn/core/server'; +import { ensureUserHasAuthzToFilesForAction } from './utils'; import { stringify } from '../../utils/stringify'; import type { EndpointActionFileInfoParams } from '../../../../common/api/endpoint'; import { EndpointActionFileInfoSchema } from '../../../../common/api/endpoint'; @@ -83,9 +84,10 @@ export const registerActionFileInfoRoute = ( }, }, withEndpointAuthz( - { any: ['canWriteFileOperations', 'canWriteExecuteOperations'] }, + { any: ['canWriteFileOperations', 'canWriteExecuteOperations', 'canGetRunningProcesses'] }, endpointContext.logFactory.get('actionFileInfo'), - getActionFileInfoRouteHandler(endpointContext) + getActionFileInfoRouteHandler(endpointContext), + ensureUserHasAuthzToFilesForAction ) ); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.test.ts new file mode 100644 index 00000000000000..eaf05e972943c6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { HttpApiTestSetupMock } from '../../mocks'; +import { createHttpApiTestSetupMock } from '../../mocks'; +import type { LogsEndpointAction } from '../../../../common/endpoint/types'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { applyEsClientSearchMock } from '../../mocks/utils.mock'; +import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants'; +import { ensureUserHasAuthzToFilesForAction } from './utils'; +import type { Mutable } from 'utility-types'; +import type { KibanaRequest } from '@kbn/core-http-server'; + +describe('Route utilities', () => { + describe('#ensureUserHasAuthzToFilesForAction()', () => { + let testSetupMock: HttpApiTestSetupMock; + let actionRequestMock: LogsEndpointAction; + let httpRequestMock: Mutable>; + + beforeEach(() => { + const endpointGenerator = new EndpointActionGenerator('seed'); + + actionRequestMock = endpointGenerator.generate(); + testSetupMock = createHttpApiTestSetupMock(); + + httpRequestMock = testSetupMock.createRequestMock({ + params: { action_id: actionRequestMock.EndpointActions.action_id }, + }); + + applyEsClientSearchMock({ + esClientMock: testSetupMock.getEsClientMock(), + index: ENDPOINT_ACTIONS_INDEX, + response: endpointGenerator.toEsSearchResponse([ + endpointGenerator.toEsSearchHit(actionRequestMock), + ]), + }); + }); + + it.each` + command | authzKey | agentType + ${'get-file'} | ${'canWriteFileOperations'} | ${'endpoint'} + ${'execute'} | ${'canWriteExecuteOperations'} | ${'endpoint'} + ${'running-processes'} | ${'canGetRunningProcesses'} | ${'sentinel_one'} + `( + 'should throw when user is not authorized to `$command` for $agentType', + async ({ command, authzKey, agentType }) => { + testSetupMock.setEndpointAuthz({ [authzKey]: false }); + actionRequestMock.EndpointActions.data.command = command; + actionRequestMock.EndpointActions.input_type = agentType; + + await expect(() => + ensureUserHasAuthzToFilesForAction(testSetupMock.httpHandlerContextMock, httpRequestMock) + ).rejects.toThrow('Endpoint authorization failure'); + } + ); + + it('should throw when response action is not supported by agent type', async () => { + actionRequestMock.EndpointActions.input_type = 'sentinel_one'; + actionRequestMock.EndpointActions.data.command = 'execute'; + + await expect(() => + ensureUserHasAuthzToFilesForAction(testSetupMock.httpHandlerContextMock, httpRequestMock) + ).rejects.toThrow('Response action [execute] not supported for agent type [sentinel_one]'); + }); + + it('should throw when response action does not support access to files', async () => { + actionRequestMock.EndpointActions.data.command = 'running-processes'; + + await expect(() => + ensureUserHasAuthzToFilesForAction(testSetupMock.httpHandlerContextMock, httpRequestMock) + ).rejects.toThrow( + 'Response action [running-processes] for agent type [endpoint] does not support file downloads' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.ts new file mode 100644 index 00000000000000..92033801e71b61 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/utils.ts @@ -0,0 +1,135 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import { deepFreeze } from '@kbn/std'; +import { get } from 'lodash'; +import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; +import { isActionSupportedByAgentType } from '../../../../common/endpoint/service/response_actions/is_response_action_supported'; +import { EndpointAuthorizationError } from '../../errors'; +import { fetchActionRequestById } from '../../services/actions/utils/fetch_action_request_by_id'; +import type { SecuritySolutionRequestHandlerContext } from '../../../types'; +import type { + ResponseActionAgentType, + ResponseActionsApiCommandNames, +} from '../../../../common/endpoint/service/response_actions/constants'; + +type CommandsWithFileAccess = Readonly< + Record>> +>; + +// FYI: this object here should help to quickly catch instances where we might forget to update the +// authz on the file info/download apis when a response action needs to support file downloads. +const COMMANDS_WITH_ACCESS_TO_FILES: CommandsWithFileAccess = deepFreeze({ + 'get-file': { + endpoint: true, + sentinel_one: true, + crowdstrike: false, + }, + execute: { + endpoint: true, + sentinel_one: false, + crowdstrike: false, + }, + 'running-processes': { + endpoint: false, + sentinel_one: true, + crowdstrike: false, + }, + upload: { + endpoint: false, + sentinel_one: false, + crowdstrike: false, + }, + scan: { + endpoint: false, + sentinel_one: false, + crowdstrike: false, + }, + isolate: { + endpoint: false, + sentinel_one: false, + crowdstrike: false, + }, + unisolate: { + endpoint: false, + sentinel_one: false, + crowdstrike: false, + }, + 'kill-process': { + endpoint: false, + sentinel_one: false, + crowdstrike: false, + }, + 'suspend-process': { + endpoint: false, + sentinel_one: false, + crowdstrike: false, + }, +}); + +/** + * Checks to ensure that the user has the correct authz for the response action associated with the action id. + * + * FYI: Additional check is needed because the File info and download APIs are used by multiple response actions, + * thus we want to ensure that we don't allow access to file associated with response actions the user does + * not have authz to. + * + * @param context + * @param request + */ +export const ensureUserHasAuthzToFilesForAction = async ( + context: SecuritySolutionRequestHandlerContext, + request: KibanaRequest +): Promise => { + const userAuthz = await (await context.securitySolution).getEndpointAuthz(); + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + const { action_id: actionId } = request.params as { action_id: string }; + const { + EndpointActions: { + data: { command }, + input_type: agentType, + }, + } = await fetchActionRequestById(esClient, actionId); + + // Check if command is supported by the agent type + if (!isActionSupportedByAgentType(agentType, command, 'manual')) { + throw new CustomHttpRequestError( + `Response action [${command}] not supported for agent type [${agentType}]`, + 400 + ); + } + + // Check if the command is marked as having access to files + if (!get(COMMANDS_WITH_ACCESS_TO_FILES, `${command}.${agentType}`, false)) { + throw new CustomHttpRequestError( + `Response action [${command}] for agent type [${agentType}] does not support file downloads`, + 400 + ); + } + + let hasAuthzToCommand = false; + + switch (command) { + case 'get-file': + hasAuthzToCommand = userAuthz.canWriteFileOperations; + break; + + case 'execute': + hasAuthzToCommand = userAuthz.canWriteExecuteOperations; + break; + + case 'running-processes': + hasAuthzToCommand = userAuthz.canGetRunningProcesses; + break; + } + + if (!hasAuthzToCommand) { + throw new EndpointAuthorizationError(); + } +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts index a14303e0004eec..ca8602e0969d17 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts @@ -8,7 +8,7 @@ import type { IKibanaResponse, KibanaResponseFactory, Logger } from '@kbn/core/server'; import { FleetFileNotFound } from '@kbn/fleet-plugin/server/errors'; import { CustomHttpRequestError } from '../../utils/custom_http_request_error'; -import { NotFoundError } from '../errors'; +import { EndpointAuthorizationError, NotFoundError } from '../errors'; import { EndpointHostUnEnrolledError, EndpointHostNotFoundError } from '../services/metadata'; /** @@ -51,6 +51,10 @@ export const errorHandler = ( return res.notFound({ body: error }); } + if (error instanceof EndpointAuthorizationError) { + return res.forbidden({ body: error }); + } + // Kibana CORE will take care of `500` errors when the handler `throw`'s, including logging the error throw error; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.test.ts index 573b8dc9cbae59..d5cccedf7bb959 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.test.ts @@ -11,7 +11,7 @@ import { requestContextMock } from '../../lib/detection_engine/routes/__mocks__' import type { EndpointApiNeededAuthz } from './with_endpoint_authz'; import { withEndpointAuthz } from './with_endpoint_authz'; import type { EndpointAuthz } from '../../../common/endpoint/types/authz'; -import { EndpointAuthorizationError } from '../errors'; +import { EndpointAuthorizationError, NotFoundError } from '../errors'; import { getEndpointAuthzInitialStateMock } from '../../../common/endpoint/service/authz/mocks'; describe('When using `withEndpointAuthz()`', () => { @@ -105,4 +105,37 @@ describe('When using `withEndpointAuthz()`', () => { body: expect.any(EndpointAuthorizationError), }); }); + + it('should call additionalChecks callback if defined', async () => { + const additionalChecks = jest.fn(); + const routeContextMock = coreMock.createCustomRequestHandlerContext(mockContext); + await withEndpointAuthz( + { any: ['canGetRunningProcesses'] }, + logger, + mockRequestHandler, + additionalChecks + )(routeContextMock, mockRequest, mockResponse); + + expect(additionalChecks).toHaveBeenCalledWith(routeContextMock, mockRequest); + expect(mockRequestHandler).toHaveBeenCalled(); + }); + + it('should deny access if additionalChecks callback throws an error', async () => { + const error = new NotFoundError('something happen'); + const additionalChecks = jest.fn(async () => { + throw error; + }); + const routeContextMock = coreMock.createCustomRequestHandlerContext(mockContext); + await withEndpointAuthz( + { any: ['canGetRunningProcesses'] }, + logger, + mockRequestHandler, + additionalChecks + )(routeContextMock, mockRequest, mockResponse); + + expect(mockRequestHandler).not.toHaveBeenCalled(); + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: error, + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.ts b/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.ts index a241148c7b714b..e42064488aa596 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/with_endpoint_authz.ts @@ -5,7 +5,8 @@ * 2.0. */ -import type { RequestHandler, Logger } from '@kbn/core/server'; +import type { RequestHandler, KibanaRequest, Logger } from '@kbn/core/server'; +import { errorHandler } from './error_handler'; import { stringify } from '../utils/stringify'; import type { EndpointAuthzKeyList } from '../../../common/endpoint/types/authz'; import type { SecuritySolutionRequestHandlerContext } from '../../types'; @@ -29,11 +30,16 @@ export interface EndpointApiNeededAuthz { * @param neededAuthz * @param routeHandler * @param logger + * @param additionalChecks */ export const withEndpointAuthz = ( neededAuthz: EndpointApiNeededAuthz, logger: Logger, - routeHandler: T + routeHandler: T, + additionalChecks?: ( + context: SecuritySolutionRequestHandlerContext, + request: KibanaRequest + ) => void | Promise ): T => { const needAll: EndpointAuthzKeyList = neededAuthz.all ?? []; const needAny: EndpointAuthzKeyList = neededAuthz.any ?? []; @@ -104,6 +110,16 @@ export const withEndpointAuthz = ( } } + if (additionalChecks) { + try { + await additionalChecks(context, request); + } catch (err) { + logger.debug(() => stringify(err)); + + return errorHandler(logger, response, err); + } + } + // Authz is good call the route handler return (routeHandler as unknown as RequestHandler)(context, request, response); };