[Security Solution][Endpoint] Add processes API and Response Console command support for SentinelOne (#188151)

## Summary

- Adds UI support to the Response Console for `processes` against
SentinelOne hosts
- Adds API support for triggering a `running_processes` response action
against SentinelOne Hosts
- Functionality is behind a new feature flag (disabled by default):
`responseActionsSentinelOneProcessesEnabled`


> [!NOTE]
> The `processes` response action for SentinelOne will remain in
`pending` and will not complete. A subsequent PR will introduce the
necessary logic to check and complete this response action.
This commit is contained in:
Paul Tavares 2024-07-17 08:04:53 -04:00 committed by GitHub
parent 92634f40a5
commit 7e67c48adb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 417 additions and 46 deletions

View file

@ -110,7 +110,7 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = {
},
manual: {
endpoint: true,
sentinel_one: false,
sentinel_one: true,
crowdstrike: false,
},
},

View file

@ -9,10 +9,10 @@ import type { TypeOf } from '@kbn/config-schema';
import type { EcsError } from '@elastic/ecs';
import type { BaseFileMetadata, FileCompression, FileJSON } from '@kbn/files-plugin/common';
import type {
ResponseActionBodySchema,
UploadActionApiRequestBody,
KillProcessRouteRequestSchema,
ResponseActionBodySchema,
SuspendProcessRouteRequestSchema,
UploadActionApiRequestBody,
} from '../../api/endpoint';
import type { ActionStatusRequestSchema } from '../../api/endpoint/actions/action_status_route';
import type { NoParametersRequestSchema } from '../../api/endpoint/actions/common/base';

View file

@ -116,6 +116,14 @@ export interface SentinelOneGetFileResponseMeta {
filename: string;
}
export interface SentinelOneProcessesRequestMeta extends SentinelOneGetFileRequestMeta {
/**
* The Parent Task Is that is executing the kill process action in SentinelOne.
* Used to check on the status of that action
*/
parentTaskId: string;
}
export interface SentinelOneKillProcessRequestMeta extends SentinelOneIsolationRequestMeta {
/**
* The Parent Task Is that is executing the kill process action in SentinelOne.

View file

@ -84,6 +84,9 @@ export const allowedExperimentalValues = Object.freeze({
/** Enables the `kill-process` response action for SentinelOne */
responseActionsSentinelOneKillProcessEnabled: false,
/** Enable the `processes` response actions for SentinelOne */
responseActionsSentinelOneProcessesEnabled: false,
/**
* Enables the ability to send Response actions to Crowdstrike and persist the results
* in ES.

View file

@ -45,17 +45,19 @@ const StyledEuiBasicTable = styled(EuiBasicTable)`
export const GetProcessesActionResult = memo<ActionRequestComponentProps>(
({ command, setStore, store, status, setStatus, ResultComponent }) => {
const endpointId = command.commandDefinition?.meta?.endpointId;
const { endpointId, agentType } = command.commandDefinition?.meta ?? {};
const comment = command.args.args?.comment?.[0];
const actionCreator = useSendGetEndpointProcessesRequest();
const actionRequestBody = useMemo(() => {
return endpointId
? {
endpoint_ids: [endpointId],
comment: command.args.args?.comment?.[0],
comment,
agent_type: agentType,
}
: undefined;
}, [endpointId, command.args.args?.comment]);
}, [endpointId, comment, agentType]);
const { result, actionDetails: completedActionDetails } = useConsoleActionSubmitter<
ProcessesRequestBody,

View file

@ -14,49 +14,59 @@ import {
import React from 'react';
import { getEndpointConsoleCommands } from '../../lib/console_commands_definition';
import { responseActionsHttpMocks } from '../../../../mocks/response_actions_http_mocks';
import { enterConsoleCommand } from '../../../console/mocks';
import { enterConsoleCommand, getConsoleSelectorsAndActionMock } from '../../../console/mocks';
import { waitFor } from '@testing-library/react';
import { getEndpointAuthzInitialState } from '../../../../../../common/endpoint/service/authz';
import type { EndpointCapabilities } from '../../../../../../common/endpoint/service/response_actions/constants';
import type {
EndpointCapabilities,
ResponseActionAgentType,
} from '../../../../../../common/endpoint/service/response_actions/constants';
import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants';
import { UPGRADE_AGENT_FOR_RESPONDER } from '../../../../../common/translations';
jest.mock('../../../../../common/experimental_features_service');
import type { CommandDefinition } from '../../../console';
describe('When using processes action from response actions console', () => {
let render: (
capabilities?: EndpointCapabilities[]
) => Promise<ReturnType<AppContextTestRender['render']>>;
let mockedContext: AppContextTestRender;
let render: () => Promise<ReturnType<AppContextTestRender['render']>>;
let renderResult: ReturnType<AppContextTestRender['render']>;
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
let consoleManagerMockAccess: ReturnType<
typeof getConsoleManagerMockRenderResultQueriesAndActions
>;
let consoleSelectors: ReturnType<typeof getConsoleSelectorsAndActionMock>;
let consoleCommands: CommandDefinition[];
const setConsoleCommands = (
capabilities: EndpointCapabilities[] = [...ENDPOINT_CAPABILITIES],
agentType: ResponseActionAgentType = 'endpoint'
): void => {
consoleCommands = getEndpointConsoleCommands({
agentType,
endpointAgentId: 'a.b.c',
endpointCapabilities: capabilities,
endpointPrivileges: {
...getEndpointAuthzInitialState(),
loading: false,
canKillProcess: true,
canSuspendProcess: true,
canGetRunningProcesses: true,
},
});
};
beforeEach(() => {
const mockedContext = createAppRootMockRenderer();
mockedContext = createAppRootMockRenderer();
apiMocks = responseActionsHttpMocks(mockedContext.coreStart.http);
setConsoleCommands();
render = async (capabilities: EndpointCapabilities[] = [...ENDPOINT_CAPABILITIES]) => {
render = async () => {
renderResult = mockedContext.render(
<ConsoleManagerTestComponent
registerConsoleProps={() => {
return {
consoleProps: {
'data-test-subj': 'test',
commands: getEndpointConsoleCommands({
agentType: 'endpoint',
endpointAgentId: 'a.b.c',
endpointCapabilities: [...capabilities],
endpointPrivileges: {
...getEndpointAuthzInitialState(),
loading: false,
canKillProcess: true,
canSuspendProcess: true,
canGetRunningProcesses: true,
},
}),
commands: consoleCommands,
},
};
}}
@ -67,13 +77,15 @@ describe('When using processes action from response actions console', () => {
await consoleManagerMockAccess.clickOnRegisterNewConsole();
await consoleManagerMockAccess.openRunningConsole();
consoleSelectors = getConsoleSelectorsAndActionMock(renderResult);
return renderResult;
};
});
it('should show an error if the `running_processes` capability is not present in the endpoint', async () => {
await render([]);
setConsoleCommands([]);
await render();
enterConsoleCommand(renderResult, 'processes');
expect(renderResult.getByTestId('test-validationError-message').textContent).toEqual(
@ -228,4 +240,80 @@ describe('When using processes action from response actions console', () => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);
});
});
describe('and when agent type is SentinelOne', () => {
beforeEach(() => {
mockedContext.setExperimentalFlag({ responseActionsSentinelOneProcessesEnabled: true });
setConsoleCommands([], 'sentinel_one');
});
it('should display processes command --help', async () => {
await render();
enterConsoleCommand(renderResult, 'processes --help');
await waitFor(() => {
expect(renderResult.getByTestId('test-helpOutput').textContent).toEqual(
'About' +
'Show all running processes' +
'Usage' +
'processes [--comment]' +
'Example' +
'processes --comment "get the processes"' +
'Optional parameters' +
'--comment - A comment to go along with the action'
);
});
});
it('should display correct entry in help panel', async () => {
await render();
consoleSelectors.openHelpPanel();
expect(
renderResult.getByTestId('test-commandList-Responseactions-processes')
).toHaveTextContent('processesShow all running processes');
});
it('should call the api with agentType of SentinelOne', async () => {
await render();
enterConsoleCommand(renderResult, 'processes');
await waitFor(() => {
expect(apiMocks.responseProvider.processes).toHaveBeenCalledWith({
body: '{"endpoint_ids":["a.b.c"],"agent_type":"sentinel_one"}',
path: '/api/endpoint/action/running_procs',
version: '2023-10-31',
});
});
});
describe('and `responseActionsSentinelOneProcessesEnabled` feature flag is disabled', () => {
beforeEach(() => {
mockedContext.setExperimentalFlag({ responseActionsSentinelOneProcessesEnabled: false });
setConsoleCommands([], 'sentinel_one');
});
it('should not display `processes` command in console help', async () => {
await render();
consoleSelectors.openHelpPanel();
expect(renderResult.queryByTestId('test-commandList-Responseactions-processes')).toBeNull();
});
it('should error if user enters `process` command', async () => {
await render();
enterConsoleCommand(renderResult, 'processes');
await waitFor(() => {
expect(renderResult.getByTestId('test-validationError')).toHaveTextContent(
'Unsupported actionSupport for processes is not currently available for SentinelOne.'
);
});
await waitFor(() => {
expect(apiMocks.responseProvider.processes).not.toHaveBeenCalled();
});
});
});
});
});

View file

@ -551,6 +551,7 @@ const adjustCommandsForSentinelOne = ({
}): CommandDefinition[] => {
const featureFlags = ExperimentalFeaturesService.get();
const isKillProcessEnabled = featureFlags.responseActionsSentinelOneKillProcessEnabled;
const isProcessesEnabled = featureFlags.responseActionsSentinelOneProcessesEnabled;
return commandList.map((command) => {
// Kill-Process: adjust command to accept only `processName`
@ -574,6 +575,7 @@ const adjustCommandsForSentinelOne = ({
if (
command.name === 'status' ||
(command.name === 'kill-process' && !isKillProcessEnabled) ||
(command.name === 'processes' && !isProcessesEnabled) ||
!isAgentTypeAndActionSupported(
'sentinel_one',
RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP[command.name as ConsoleResponseActionCommands],

View file

@ -37,7 +37,10 @@ import type {
KillOrSuspendProcessRequestBody,
} from '../../../../../../common/endpoint/types';
import type { SearchHit, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import type { ResponseActionGetFileRequestBody } from '../../../../../../common/api/endpoint';
import type {
ResponseActionGetFileRequestBody,
GetProcessesRequestBody,
} from '../../../../../../common/api/endpoint';
import { SUB_ACTION } from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
import { ACTIONS_SEARCH_PAGE_SIZE } from '../../constants';
import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
@ -75,14 +78,15 @@ describe('SentinelOneActionsClient class', () => {
s1ActionsClient = new SentinelOneActionsClient(classConstructorOptions);
});
it.each(['suspendProcess', 'runningProcesses', 'execute', 'upload', 'scan'] as Array<
keyof ResponseActionsClient
>)('should throw an un-supported error for %s', async (methodName) => {
// @ts-expect-error Purposely passing in empty object for options
await expect(s1ActionsClient[methodName]({})).rejects.toBeInstanceOf(
ResponseActionsNotSupportedError
);
});
it.each(['suspendProcess', 'execute', 'upload', 'scan'] as Array<keyof ResponseActionsClient>)(
'should throw an un-supported error for %s',
async (methodName) => {
// @ts-expect-error Purposely passing in empty object for options
await expect(s1ActionsClient[methodName]({})).rejects.toBeInstanceOf(
ResponseActionsNotSupportedError
);
}
);
it('should error if multiple agent ids are received', async () => {
const payload = createS1IsolationOptions();
@ -1294,4 +1298,163 @@ describe('SentinelOneActionsClient class', () => {
expect(classConstructorOptions.casesClient?.attachments.bulkCreate).toHaveBeenCalled();
});
});
describe('#runningProcesses()', () => {
let processesActionRequest: GetProcessesRequestBody;
beforeEach(() => {
// @ts-expect-error readonly prop assignment
classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneProcessesEnabled =
true;
processesActionRequest = responseActionsClientMock.createRunningProcessesOptions();
});
it('should error if feature flag is disabled', async () => {
// @ts-expect-error readonly prop assignment
classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneProcessesEnabled =
false;
await expect(s1ActionsClient.runningProcesses(processesActionRequest)).rejects.toThrow(
`processes not supported for sentinel_one agent type. Feature disabled`
);
});
it('should error if host is running Windows', async () => {
connectorActionsMock.execute.mockResolvedValue(
responseActionsClientMock.createConnectorActionExecuteResponse({
data: sentinelOneMock.createGetAgentsResponse([
sentinelOneMock.createSentinelOneAgentDetails({ osType: 'windows' }),
]),
})
);
await expect(s1ActionsClient.runningProcesses(processesActionRequest)).rejects.toThrow(
'Retrieval of running processes for Windows host is not supported by SentinelOne'
);
});
it('should retrieve script execution information from S1 using host OS', async () => {
await s1ActionsClient.runningProcesses(processesActionRequest);
expect(connectorActionsMock.execute).toHaveBeenCalledWith({
params: {
subAction: 'getRemoteScripts',
subActionParams: {
osTypes: 'linux',
query: 'process list',
scriptType: 'dataCollection',
},
},
});
});
it('should error if unable to get S1 script information', async () => {
const executeMockImplementation = connectorActionsMock.execute.getMockImplementation()!;
connectorActionsMock.execute.mockImplementation(async (options) => {
if (options.params.subAction === SUB_ACTION.GET_REMOTE_SCRIPTS) {
return responseActionsClientMock.createConnectorActionExecuteResponse({
data: { data: [] },
});
}
return executeMockImplementation.call(connectorActionsMock, options);
});
await expect(s1ActionsClient.runningProcesses(processesActionRequest)).rejects.toThrow(
'Unable to find a script from SentinelOne to handle [running-processes] response action for host running [linux])'
);
});
it('should send execute script request to S1 for process list', async () => {
await s1ActionsClient.runningProcesses(processesActionRequest);
expect(connectorActionsMock.execute).toHaveBeenCalledWith({
params: {
subAction: 'executeScript',
subActionParams: {
filter: { uuids: '1-2-3' },
script: {
inputParams: '',
outputDestination: 'SentinelCloud',
requiresApproval: false,
scriptId: '1466645476786791838',
taskDescription: expect.stringContaining(
'Action triggered from Elastic Security by user [foo] for action [running-processes'
),
},
},
},
});
});
it('should return action details on success', async () => {
await s1ActionsClient.runningProcesses(processesActionRequest);
expect(getActionDetailsByIdMock).toHaveBeenCalled();
});
it('should create action request doc with expected meta info', async () => {
await s1ActionsClient.runningProcesses(processesActionRequest);
expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith(
{
document: {
'@timestamp': expect.any(String),
EndpointActions: {
action_id: expect.any(String),
data: {
command: 'running-processes',
comment: 'test comment',
hosts: { '1-2-3': { name: 'sentinelone-1460' } },
parameters: undefined,
},
expiration: expect.any(String),
input_type: 'sentinel_one',
type: 'INPUT_ACTION',
},
agent: { id: ['1-2-3'] },
meta: {
agentId: '1845174760470303882',
agentUUID: '1-2-3',
hostName: 'sentinelone-1460',
parentTaskId: 'task-789',
},
user: { id: 'foo' },
},
index: '.logs-endpoint.actions-default',
refresh: 'wait_for',
},
{ meta: true }
);
});
it('should update cases', async () => {
processesActionRequest = {
...processesActionRequest,
case_ids: ['case-1'],
};
await s1ActionsClient.runningProcesses(processesActionRequest);
expect(classConstructorOptions.casesClient?.attachments.bulkCreate).toHaveBeenCalled();
});
it('should still create action request when running in automated mode', async () => {
classConstructorOptions.isAutomated = true;
classConstructorOptions.connectorActions =
responseActionsClientMock.createNormalizedExternalConnectorClient(
sentinelOneMock.createConnectorActionsClient()
);
s1ActionsClient = new SentinelOneActionsClient(classConstructorOptions);
await s1ActionsClient.runningProcesses(processesActionRequest);
expect(classConstructorOptions.esClient.index).toHaveBeenCalledWith(
expect.objectContaining({
document: expect.objectContaining({
error: { message: 'Action [running-processes] not supported' },
}),
}),
{ meta: true }
);
});
});
});

View file

@ -27,7 +27,11 @@ import type {
} from '@elastic/elasticsearch/lib/api/types';
import type { Readable } from 'stream';
import type { Mutable } from 'utility-types';
import type { SentinelOneKillProcessScriptArgs, SentinelOneScriptArgs } from './types';
import type {
SentinelOneKillProcessScriptArgs,
SentinelOneScriptArgs,
SentinelOneProcessListScriptArgs,
} from './types';
import { ACTIONS_SEARCH_PAGE_SIZE } from '../../constants';
import type { NormalizedExternalConnectorClient } from '../lib/normalized_external_connector_client';
import { SENTINEL_ONE_ACTIVITY_INDEX_PATTERN } from '../../../../../../common';
@ -65,8 +69,11 @@ import type {
SentinelOneKillProcessRequestMeta,
UploadedFileInfo,
ResponseActionParametersWithProcessName,
GetProcessesActionOutputContent,
SentinelOneProcessesRequestMeta,
} from '../../../../../../common/endpoint/types';
import type {
GetProcessesRequestBody,
IsolationRouteRequestBody,
ResponseActionGetFileRequestBody,
} from '../../../../../../common/api/endpoint';
@ -663,6 +670,80 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
return actionDetails;
}
public async runningProcesses(
actionRequest: GetProcessesRequestBody,
options?: CommonResponseActionMethodOptions
): Promise<ActionDetails<GetProcessesActionOutputContent>> {
if (
!this.options.endpointService.experimentalFeatures.responseActionsSentinelOneProcessesEnabled
) {
throw new ResponseActionsClientError(
`processes not supported for ${this.agentType} agent type. Feature disabled`,
400
);
}
const reqIndexOptions: ResponseActionsClientWriteActionRequestToEndpointIndexOptions<
undefined,
GetProcessesActionOutputContent,
Partial<SentinelOneProcessesRequestMeta>
> = {
...actionRequest,
...this.getMethodOptions(options),
command: 'running-processes',
meta: { parentTaskId: '' },
};
if (!reqIndexOptions.error) {
let error = (await this.validateRequest(reqIndexOptions)).error;
if (!error) {
const s1AgentDetails = await this.getAgentDetails(reqIndexOptions.endpoint_ids[0]);
const processesScriptInfo = await this.fetchScriptInfo<SentinelOneProcessListScriptArgs>(
'running-processes',
s1AgentDetails.osType
);
try {
const s1Response = await this.sendAction<SentinelOneExecuteScriptResponse>(
SUB_ACTION.EXECUTE_SCRIPT,
{
filter: {
uuids: actionRequest.endpoint_ids[0],
},
script: {
scriptId: processesScriptInfo.scriptId,
taskDescription: this.buildExternalComment(reqIndexOptions),
requiresApproval: false,
outputDestination: 'SentinelCloud',
inputParams: processesScriptInfo.buildScriptArgs({}),
},
}
);
reqIndexOptions.meta = {
parentTaskId: s1Response.data?.data?.parentTaskId ?? '',
};
} catch (err) {
error = err;
}
}
reqIndexOptions.error = error?.message;
if (!this.options.isAutomated && error) {
throw error;
}
}
const { actionDetails } = await this.handleResponseActionCreation<
undefined,
GetProcessesActionOutputContent
>(reqIndexOptions);
return actionDetails;
}
async processPendingActions({
abortSignal,
addToQueue,
@ -670,7 +751,6 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
if (abortSignal.aborted) {
return;
}
for await (const pendingActions of this.fetchAllPendingActions()) {
if (abortSignal.aborted) {
return;
@ -729,14 +809,15 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
private async fetchScriptInfo<
TScriptOptions extends SentinelOneScriptArgs = SentinelOneScriptArgs
>(
scriptType: Extract<ResponseActionsApiCommandNames, 'kill-process'>,
scriptType: Extract<ResponseActionsApiCommandNames, 'kill-process' | 'running-processes'>,
osType: string | 'linux' | 'macos' | 'windows'
): Promise<FetchScriptInfoResponse<TScriptOptions>> {
const searchQueryParams: Mutable<Partial<SentinelOneGetRemoteScriptsParams>> = {
query: '',
osTypes: osType,
};
let buildScriptArgs = NOOP_THROW as FetchScriptInfoResponse<TScriptOptions>['buildScriptArgs'];
let buildScriptArgs: FetchScriptInfoResponse<TScriptOptions>['buildScriptArgs'] =
NOOP_THROW as FetchScriptInfoResponse<TScriptOptions>['buildScriptArgs'];
let isDesiredScript: (
scriptInfo: SentinelOneGetRemoteScriptsResponse['data'][number]
) => boolean = () => false;
@ -746,7 +827,6 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
case 'kill-process':
searchQueryParams.query = 'terminate';
searchQueryParams.scriptType = 'action';
isDesiredScript = (scriptInfo) => {
return (
scriptInfo.creator === 'SentinelOne' &&
@ -758,6 +838,22 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
};
break;
case 'running-processes':
if (osType === 'windows') {
throw new ResponseActionsClientError(
`Retrieval of running processes for Windows host is not supported by SentinelOne`,
405
);
}
searchQueryParams.query = 'process list';
searchQueryParams.scriptType = 'dataCollection';
isDesiredScript = (scriptInfo) => {
return scriptInfo.creator === 'SentinelOne' && scriptInfo.creatorId === '-1';
};
break;
default:
throw new ResponseActionsClientError(
`Unable to fetch SentinelOne script for OS [${osType}]. Unknown script type [${scriptType}]`
@ -780,9 +876,10 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
);
}
// Define the `buildScriptArgs` callback for the Script type
switch (scriptType) {
case 'kill-process':
buildScriptArgs = (args: SentinelOneKillProcessScriptArgs) => {
buildScriptArgs = ((args: SentinelOneKillProcessScriptArgs) => {
if (!args.processName) {
throw new ResponseActionsClientError(
`'processName' missing while building script args for [${s1Script.scriptName} (id: ${s1Script.id})] script`
@ -795,9 +892,13 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
// Linux + Macos
return `--terminate --processes "${args.processName}" --force`;
};
}) as FetchScriptInfoResponse<TScriptOptions>['buildScriptArgs'];
break;
case 'running-processes':
buildScriptArgs = () => '';
break;
}
return {

View file

@ -9,7 +9,11 @@ export interface SentinelOneKillProcessScriptArgs {
processName: string;
}
export type SentinelOneProcessListScriptArgs = Record<string, never>;
/**
* All the possible set of arguments running SentinelOne scripts that we support for response actions
*/
export type SentinelOneScriptArgs = SentinelOneKillProcessScriptArgs;
export type SentinelOneScriptArgs =
| SentinelOneKillProcessScriptArgs
| SentinelOneProcessListScriptArgs;