Skip to content

Commit

Permalink
[Security Solution][Endpoint] Add authz to file/download apis in supp…
Browse files Browse the repository at this point in the history
…ort of SentinelOne `processes` response action (elastic#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)
  • Loading branch information
paul-tavares authored Jul 30, 2024
1 parent ea64b47 commit 4cf9d18
Show file tree
Hide file tree
Showing 23 changed files with 964 additions and 396 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -211,16 +213,27 @@ export class EndpointActionGenerator extends BaseDataGenerator {
generateActionDetails<
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes
>(
overrides: DeepPartial<ActionDetails<TOutputContent, TParameters>> = {}
): ActionDetails<TOutputContent, TParameters> {
>({
agents: overrideAgents,
command: overrideCommand,
...overrides
}: DeepPartial<ActionDetails<TOutputContent, TParameters>> = {}): ActionDetails<
TOutputContent,
TParameters
> {
const agents = overrideAgents ? [...(overrideAgents as string[])] : ['agent-a'];
const command = overrideCommand ?? 'isolate';

const details: WithAllKeys<ActionDetails> = {
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,
Expand All @@ -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) {
(
Expand Down Expand Up @@ -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<ActionDetails<GetProcessesActionOutputContent>>['outputs']);
}

return merge(details, overrides as ActionDetails) as unknown as ActionDetails<
TOutputContent,
TParameters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}
): EndpointAuthz => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ResponseActionsExecuteParameters,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
GetProcessesActionOutputContent,
} from '../../types';
import { RESPONSE_ACTION_AGENT_TYPE, RESPONSE_ACTION_TYPE } from './constants';

Expand All @@ -40,6 +41,12 @@ export const isGetFileAction = (
return action.command === 'get-file';
};

export const isProcessesAction = (
action: MaybeImmutable<SomeObjectWithCommand>
): action is ActionDetails<GetProcessesActionOutputContent> => {
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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionRequestComponentProps>(
({ command, setStore, store, status, setStatus, ResultComponent }) => {
const { endpointId, agentType } = command.commandDefinition?.meta ?? {};
Expand Down Expand Up @@ -84,141 +52,12 @@ export const GetProcessesActionResult = memo<ActionRequestComponentProps>(
// Show results
return (
<ResultComponent data-test-subj="getProcessesSuccessCallout" showTitle={false}>
{agentType === 'sentinel_one' ? (
<SentinelOneRunningProcessesResults action={completedActionDetails} />
) : (
<EndpointRunningProcessesResults action={completedActionDetails} agentId={endpointId} />
)}
<RunningProcessesActionResults
action={completedActionDetails}
data-test-subj="processesOutput"
/>
</ResultComponent>
);
}
);
GetProcessesActionResult.displayName = 'GetProcessesActionResult';

interface EndpointRunningProcessesResultsProps {
action: MaybeImmutable<ActionDetails<GetProcessesActionOutputContent>>;
/** 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<EndpointRunningProcessesResultsProps>(
({ 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 (
<div key={hostName}>
<KeyValueDisplay
name={hostName}
value={
<StyledEuiBasicTable
data-test-subj={'getProcessListTable'}
items={action.outputs?.[id]?.content.entries ?? []}
columns={columns}
/>
}
/>
<EuiSpacer />
</div>
);
})
) : (
<StyledEuiBasicTable
data-test-subj={'getProcessListTable'}
items={action.outputs?.[agentIds[0]]?.content.entries ?? []}
columns={columns}
/>
)}
</>
);
}
);
EndpointRunningProcessesResults.displayName = 'EndpointRunningProcessesResults';

interface SentinelOneRunningProcessesResultsProps {
action: MaybeImmutable<ActionDetails<GetProcessesActionOutputContent>>;
/**
* 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<SentinelOneRunningProcessesResultsProps>(
({ action, agentId }) => {
const agentIds = agentId ? [agentId] : action.agents;

return (
<>
{agentIds.length === 1 ? (
<ResponseActionFileDownloadLink action={action} canAccessFileDownloadLink={true} />
) : (
agentIds.map((id) => {
return (
<div key={id}>
<KeyValueDisplay
name={action.hosts[id].name}
value={
<ResponseActionFileDownloadLink
action={action}
agentId={id}
canAccessFileDownloadLink={true}
/>
}
/>
</div>
);
})
)}
</>
);
}
);
SentinelOneRunningProcessesResults.displayName = 'SentinelOneRunningProcessesResults';
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +40,7 @@ describe('When using processes action from response actions console', () => {
>;
let consoleSelectors: ReturnType<typeof getConsoleSelectorsAndActionMock>;
let consoleCommands: CommandDefinition[];
let userAuthzMock: ReturnType<AppContextTestRender['getUserPrivilegesMockSetter']>;

const setConsoleCommands = (
capabilities: EndpointCapabilities[] = [...ENDPOINT_CAPABILITIES],
Expand All @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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.'
);
});
Expand Down
Loading

0 comments on commit 4cf9d18

Please sign in to comment.