[Security Solution][Endpoint] Add logic to check and complete processes response action for SentinelOne (#188849)

## Summary

PR adds logic to background task to check and complete `processes`
response actions for SentinelOne. Changes include:

- Added logic, invoked by background task, to check and complete pending
`processes` response actions for SentinelOne
- UI: the Response console was adjusted for `processes` command to show
the download link to the file that contains the output of the
`processes` for sentinelone
- ℹ️ SentinelOne does not return the actual results for display, but
rather provides a `.zip` file that contains the output in `.csv` format
- Fixed a UI issue with the file download component where every time the
browser window was focus on, the component would show a loading
animation
- Updated the SentinelOne dev script so that the integration policy
created uses `30s` for the data pulling interval
This commit is contained in:
Paul Tavares 2024-07-24 12:12:00 -04:00 committed by GitHub
parent 4d5de12e9e
commit 618e27c418
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 704 additions and 266 deletions

View file

@ -51,12 +51,16 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator {
>(
overrides: DeepPartial<LogsEndpointAction<TParameters, TOutputContent, TMeta>> = {}
): LogsEndpointAction<TParameters, TOutputContent, TMeta> {
return super.generate({
EndpointActions: {
input_type: 'sentinel_one',
},
...overrides,
}) as LogsEndpointAction<TParameters, TOutputContent, TMeta>;
return super.generate(
merge(
{
EndpointActions: {
input_type: 'sentinel_one',
},
},
overrides
)
) as LogsEndpointAction<TParameters, TOutputContent, TMeta>;
}
/** Generate a SentinelOne activity index ES doc */

View file

@ -20,3 +20,11 @@ export class EndpointError<MetaType = unknown> extends Error {
}
}
}
/**
* Type guard to check if a given Error is an instance of EndpointError
* @param err
*/
export const isEndpointError = (err: Error): err is EndpointError => {
return err instanceof EndpointError;
};

View file

@ -116,7 +116,7 @@ export interface SentinelOneGetFileResponseMeta {
filename: string;
}
export interface SentinelOneProcessesRequestMeta extends SentinelOneGetFileRequestMeta {
export interface SentinelOneProcessesRequestMeta extends SentinelOneActionRequestCommonMeta {
/**
* The Parent Task Is that is executing the kill process action in SentinelOne.
* Used to check on the status of that action
@ -124,6 +124,11 @@ export interface SentinelOneProcessesRequestMeta extends SentinelOneGetFileReque
parentTaskId: string;
}
export interface SentinelOneProcessesResponseMeta {
/** The SentinelOne task ID associated with the completion of the running-processes action */
taskId: string;
}
export interface SentinelOneKillProcessRequestMeta extends SentinelOneIsolationRequestMeta {
/**
* The Parent Task Is that is executing the kill process action in SentinelOne.

View file

@ -7,11 +7,15 @@
import React, { memo, useMemo } from 'react';
import styled from 'styled-components';
import { EuiBasicTable } from '@elastic/eui';
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 { 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';
@ -73,6 +77,33 @@ export const GetProcessesActionResult = memo<ActionRequestComponentProps>(
dataTestSubj: 'getProcesses',
});
if (!completedActionDetails || !completedActionDetails.wasSuccessful) {
return result;
}
// Show results
return (
<ResultComponent data-test-subj="getProcessesSuccessCallout" showTitle={false}>
{agentType === 'sentinel_one' ? (
<SentinelOneRunningProcessesResults action={completedActionDetails} />
) : (
<EndpointRunningProcessesResults action={completedActionDetails} agentId={endpointId} />
)}
</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(
() => [
{
@ -116,27 +147,78 @@ export const GetProcessesActionResult = memo<ActionRequestComponentProps>(
[]
);
const tableEntries = useMemo(() => {
if (endpointId) {
return completedActionDetails?.outputs?.[endpointId]?.content.entries ?? [];
}
return [];
}, [completedActionDetails?.outputs, endpointId]);
if (!completedActionDetails || !completedActionDetails.wasSuccessful) {
return result;
}
// Show results
return (
<ResultComponent data-test-subj="getProcessesSuccessCallout" showTitle={false}>
<StyledEuiBasicTable
data-test-subj={'getProcessListTable'}
items={[...tableEntries]}
columns={columns}
/>
</ResultComponent>
<>
{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}
/>
)}
</>
);
}
);
GetProcessesActionResult.displayName = 'GetProcessesActionResult';
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';

View file

@ -287,6 +287,18 @@ describe('When using processes action from response actions console', () => {
});
});
it('should display download link to access results', async () => {
await render();
enterConsoleCommand(renderResult, 'processes');
await waitFor(() => {
expect(renderResult.getByTestId('getProcessesSuccessCallout').textContent).toEqual(
'Click here to download(ZIP file passcode: elastic).' +
'Files are periodically deleted to clear storage space. Download and save file locally if needed.'
);
});
});
describe('and `responseActionsSentinelOneProcessesEnabled` feature flag is disabled', () => {
beforeEach(() => {
mockedContext.setExperimentalFlag({ responseActionsSentinelOneProcessesEnabled: false });

View file

@ -9,8 +9,8 @@ import React, { memo } from 'react';
import { css } from '@emotion/react';
export interface KeyValueDisplayProps {
name: string;
value: string;
name: React.ReactNode;
value: React.ReactNode;
}
export const KeyValueDisplay = memo<KeyValueDisplayProps>(({ name, value }) => {
return (

View file

@ -138,7 +138,7 @@ export const ResponseActionFileDownloadLink = memo<ResponseActionFileDownloadLin
}, [action, agentId]);
const {
isFetching,
isLoading,
data: fileInfo,
error,
} = useGetFileInfo(action, undefined, {
@ -149,7 +149,7 @@ export const ResponseActionFileDownloadLink = memo<ResponseActionFileDownloadLin
return null;
}
if (isFetching) {
if (isLoading) {
return <EuiSkeletonText lines={1} data-test-subj={getTestId('loading')} />;
}

View file

@ -1029,7 +1029,7 @@ export const addSentinelOneIntegrationToAgentPolicy = async ({
type: 'text',
},
interval: {
value: '1m',
value: '30s',
type: 'text',
},
tags: {
@ -1057,7 +1057,7 @@ export const addSentinelOneIntegrationToAgentPolicy = async ({
type: 'text',
},
interval: {
value: '5m',
value: '30s',
type: 'text',
},
tags: {
@ -1085,7 +1085,7 @@ export const addSentinelOneIntegrationToAgentPolicy = async ({
type: 'text',
},
interval: {
value: '5m',
value: '30s',
type: 'text',
},
tags: {
@ -1113,7 +1113,7 @@ export const addSentinelOneIntegrationToAgentPolicy = async ({
type: 'text',
},
interval: {
value: '5m',
value: '30s',
type: 'text',
},
tags: {
@ -1141,7 +1141,7 @@ export const addSentinelOneIntegrationToAgentPolicy = async ({
type: 'text',
},
interval: {
value: '5m',
value: '30s',
type: 'text',
},
tags: {

View file

@ -59,11 +59,9 @@ describe('when calling the Action Details route handler', () => {
expect(mockScopedEsClient.asInternalUser.search).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
body: {
query: {
bool: {
filter: expect.arrayContaining([{ term: { action_id: 'a-b-c' } }]),
},
query: {
bool: {
filter: expect.arrayContaining([{ term: { action_id: 'a-b-c' } }]),
},
},
}),

View file

@ -126,11 +126,9 @@ describe('When using `getActionDetailsById()', () => {
expect(esClient.search).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
body: {
query: {
bool: {
filter: [{ term: { action_id: '123' } }],
},
query: {
bool: {
filter: [{ term: { action_id: '123' } }],
},
},
}),

View file

@ -7,22 +7,16 @@
import type { ElasticsearchClient } from '@kbn/core/server';
import { fetchActionRequestById } from './utils/fetch_action_request_by_id';
import type { FetchActionResponsesResult } from './utils/fetch_action_responses';
import { fetchActionResponses } from './utils/fetch_action_responses';
import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants';
import {
formatEndpointActionResults,
mapToNormalizedActionRequest,
getAgentHostNamesWithIds,
createActionDetailsRecord,
} from './utils';
import type {
ActionDetails,
EndpointActivityLogAction,
LogsEndpointAction,
} from '../../../../common/endpoint/types';
import { catchAndWrapError } from '../../utils';
import { EndpointError } from '../../../../common/endpoint/errors';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { EndpointError, isEndpointError } from '../../../../common/endpoint/errors';
import { NotFoundError } from '../../errors';
import type { EndpointMetadataService } from '../metadata';
@ -37,49 +31,26 @@ export const getActionDetailsById = async <T extends ActionDetails = ActionDetai
metadataService: EndpointMetadataService,
actionId: string
): Promise<T> => {
let actionRequestsLogEntries: EndpointActivityLogAction[];
let normalizedActionRequest: ReturnType<typeof mapToNormalizedActionRequest> | undefined;
let actionResponses: FetchActionResponsesResult;
try {
// Get both the Action Request(s) and action Response(s)
const [actionRequestEsSearchResults, actionResponseResult] = await Promise.all([
const [actionRequestEsDoc, actionResponseResult] = await Promise.all([
// Get the action request(s)
esClient
.search<LogsEndpointAction>(
{
index: ENDPOINT_ACTIONS_INDEX,
body: {
query: {
bool: {
filter: [{ term: { action_id: actionId } }],
},
},
},
},
{
ignore: [404],
}
)
.catch(catchAndWrapError),
fetchActionRequestById(esClient, actionId),
// Get all responses
fetchActionResponses({ esClient, actionIds: [actionId] }),
]);
actionResponses = actionResponseResult;
actionRequestsLogEntries = formatEndpointActionResults(
actionRequestEsSearchResults?.hits?.hits ?? []
);
// Multiple Action records could have been returned, but we only really
// need one since they both hold similar data
const actionDoc = actionRequestsLogEntries[0]?.item.data;
if (actionDoc) {
normalizedActionRequest = mapToNormalizedActionRequest(actionDoc);
}
normalizedActionRequest = mapToNormalizedActionRequest(actionRequestEsDoc);
} catch (error) {
if (isEndpointError(error)) {
throw error;
}
throw new EndpointError(error.message, error);
}

View file

@ -17,6 +17,7 @@ import { applyEsClientSearchMock } from '../../../../mocks/utils.mock';
import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { BaseDataGenerator } from '../../../../../../common/endpoint/data_generators/base_data_generator';
import { Readable } from 'stream';
import { EndpointActionGenerator } from '../../../../../../common/endpoint/data_generators/endpoint_action_generator';
describe('EndpointActionsClient', () => {
let classConstructorOptions: ResponseActionsClientOptions;
@ -416,15 +417,36 @@ describe('EndpointActionsClient', () => {
);
describe('#getFileDownload()', () => {
beforeEach(() => {
const endpointActionGenerator = new EndpointActionGenerator('seed');
const actionRequestsSearchResponse = endpointActionGenerator.toEsSearchResponse([
endpointActionGenerator.generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' } },
}),
]);
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
index: ENDPOINT_ACTIONS_INDEX,
response: actionRequestsSearchResponse,
});
});
it('should throw error if agent type for the action id is not endpoint', async () => {
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
index: ENDPOINT_ACTIONS_INDEX,
response: BaseDataGenerator.toEsSearchResponse([]),
response: BaseDataGenerator.toEsSearchResponse([
new EndpointActionGenerator('seed').generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' }, input_type: 'sentinel_one' },
}),
]),
});
await expect(endpointActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
'Action id [abc] not found with an agent type of [endpoint]'
'Action id [abc] with agent type of [endpoint] not found'
);
});
@ -446,15 +468,36 @@ describe('EndpointActionsClient', () => {
});
describe('#getFileInfo()', () => {
beforeEach(() => {
const endpointActionGenerator = new EndpointActionGenerator('seed');
const actionRequestsSearchResponse = endpointActionGenerator.toEsSearchResponse([
endpointActionGenerator.generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' } },
}),
]);
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
index: ENDPOINT_ACTIONS_INDEX,
response: actionRequestsSearchResponse,
});
});
it('should throw error if agent type for the action id is not endpoint', async () => {
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
index: ENDPOINT_ACTIONS_INDEX,
response: BaseDataGenerator.toEsSearchResponse([]),
response: BaseDataGenerator.toEsSearchResponse([
new EndpointActionGenerator('seed').generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' }, input_type: 'sentinel_one' },
}),
]),
});
await expect(endpointActionsClient.getFileInfo('abc', '123')).rejects.toThrow(
'Action id [abc] not found with an agent type of [endpoint]'
'Action id [abc] with agent type of [endpoint] not found'
);
});

View file

@ -13,8 +13,9 @@ import { AttachmentType, ExternalReferenceStorageType } from '@kbn/cases-plugin/
import type { CaseAttachments } from '@kbn/cases-plugin/public/types';
import { i18n } from '@kbn/i18n';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { NotFoundError } from '../../../../errors';
import { fetchActionRequestById } from '../../utils/fetch_action_request_by_id';
import { SimpleMemCache } from './simple_mem_cache';
import { validateActionId } from '../../utils/validate_action_id';
import {
fetchActionResponses,
fetchEndpointActionResponses,
@ -328,6 +329,36 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
);
}
/**
* Fetches the Action request ES document for a given action id
* @param actionId
* @protected
*/
protected async fetchActionRequestEsDoc<
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes,
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
TMeta extends {} = {}
>(actionId: string): Promise<LogsEndpointAction<TParameters, TOutputContent, TMeta>> {
const cacheKey = `fetchActionRequestEsDoc-${actionId}`;
const cachedResponse =
this.cache.get<LogsEndpointAction<TParameters, TOutputContent, TMeta>>(cacheKey);
if (cachedResponse) {
this.log.debug(
`fetchActionRequestEsDoc(): returning cached response for action id ${actionId}`
);
return cachedResponse;
}
return fetchActionRequestById<TParameters, TOutputContent, TMeta>(
this.options.esClient,
actionId
).then((actionRequestDoc) => {
this.cache.set(cacheKey, actionRequestDoc);
return actionRequestDoc;
});
}
/**
* Fetches the Response Action ES response documents for a given action id
* @param actionId
@ -595,8 +626,15 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
(comment ? `: ${comment}` : '')
);
}
protected async ensureValidActionId(actionId: string): Promise<void> {
return validateActionId(this.options.esClient, actionId, this.agentType);
const actionRequest = await this.fetchActionRequestEsDoc(actionId);
if (actionRequest.EndpointActions.input_type !== this.agentType) {
throw new NotFoundError(
`Action id [${actionId}] with agent type of [${this.agentType}] not found`
);
}
}
protected fetchAllPendingActions(): AsyncIterable<ResponseActionsClientPendingAction[]> {

View file

@ -35,9 +35,6 @@ import type {
ResponseActionGetFileParameters,
SentinelOneGetFileRequestMeta,
KillOrSuspendProcessRequestBody,
KillProcessActionOutputContent,
ResponseActionParametersWithProcessName,
SentinelOneKillProcessRequestMeta,
} from '../../../../../../common/endpoint/types';
import type { SearchHit, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import type {
@ -376,6 +373,22 @@ describe('SentinelOneActionsClient class', () => {
let abortController: AbortController;
let processPendingActionsOptions: ProcessPendingActionsMethodOptions;
const setGetRemoteScriptStatusConnectorResponse = (
response: SentinelOneGetRemoteScriptStatusApiResponse
): void => {
const executeMockFn = (connectorActionsMock.execute as jest.Mock).getMockImplementation();
(connectorActionsMock.execute as jest.Mock).mockImplementation(async (options) => {
if (options.params.subAction === SUB_ACTION.GET_REMOTE_SCRIPT_STATUS) {
return responseActionsClientMock.createConnectorActionExecuteResponse({
data: response,
});
}
return executeMockFn!(options);
});
};
beforeEach(() => {
abortController = new AbortController();
processPendingActionsOptions = {
@ -746,42 +759,23 @@ describe('SentinelOneActionsClient class', () => {
});
});
describe('for kill-process response action', () => {
let actionRequestsSearchResponse: SearchResponse<
LogsEndpointAction<
ResponseActionParametersWithProcessName,
KillProcessActionOutputContent,
SentinelOneKillProcessRequestMeta
>
>;
const setGetRemoteScriptStatusConnectorResponse = (
response: SentinelOneGetRemoteScriptStatusApiResponse
): void => {
const executeMockFn = (connectorActionsMock.execute as jest.Mock).getMockImplementation();
(connectorActionsMock.execute as jest.Mock).mockImplementation(async (options) => {
if (options.params.subAction === SUB_ACTION.GET_REMOTE_SCRIPT_STATUS) {
return responseActionsClientMock.createConnectorActionExecuteResponse({
data: response,
});
}
return executeMockFn!(options);
});
};
// The following response actions use SentinelOne's remote execution scripts, thus they can be
// tested the same
describe.each`
actionName | requestData | responseOutputContent
${'kill-process'} | ${{ command: 'kill-process', parameters: { process_name: 'foo' } }} | ${{ code: 'ok', command: 'kill-process', process_name: 'foo' }}
${'running-processes'} | ${{ command: 'running-processes', parameters: undefined }} | ${{ code: '', entries: [] }}
`('for $actionName response action', ({ actionName, requestData, responseOutputContent }) => {
let actionRequestsSearchResponse: SearchResponse<LogsEndpointAction>;
beforeEach(() => {
const s1DataGenerator = new SentinelOneDataGenerator('seed');
actionRequestsSearchResponse = s1DataGenerator.toEsSearchResponse([
s1DataGenerator.generateActionEsHit<
ResponseActionParametersWithProcessName,
KillProcessActionOutputContent,
SentinelOneKillProcessRequestMeta
>({
s1DataGenerator.generateActionEsHit({
agent: { id: 'agent-uuid-1' },
EndpointActions: {
data: { command: 'kill-process', parameters: { process_name: 'foo' } },
data: requestData,
},
meta: {
agentId: 's1-agent-a',
@ -810,6 +804,7 @@ describe('SentinelOneActionsClient class', () => {
});
it('should create response at error if request has no parentTaskId', async () => {
// @ts-expect-error
actionRequestsSearchResponse.hits.hits[0]!._source!.meta!.parentTaskId = '';
await s1ActionsClient.processPendingActions(processPendingActionsOptions);
@ -819,7 +814,7 @@ describe('SentinelOneActionsClient class', () => {
action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499',
completed_at: expect.any(String),
data: {
command: 'kill-process',
command: requestData.command,
comment: '',
},
input_type: 'sentinel_one',
@ -881,11 +876,7 @@ describe('SentinelOneActionsClient class', () => {
data: expect.objectContaining({
output: {
type: 'json',
content: {
code: 'ok',
command: 'kill-process',
process_name: 'foo',
},
content: responseOutputContent,
},
}),
}),
@ -1132,6 +1123,25 @@ describe('SentinelOneActionsClient class', () => {
describe('#getFileInfo()', () => {
beforeEach(() => {
const s1DataGenerator = new SentinelOneDataGenerator('seed');
const actionRequestsSearchResponse = s1DataGenerator.toEsSearchResponse([
s1DataGenerator.generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' } },
meta: {
agentId: 's1-agent-a',
agentUUID: 'agent-uuid-1',
hostName: 's1-host-name',
},
}),
]);
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient,
index: ENDPOINT_ACTIONS_INDEX,
response: actionRequestsSearchResponse,
});
// @ts-expect-error updating readonly attribute
classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled =
true;
@ -1151,11 +1161,16 @@ describe('SentinelOneActionsClient class', () => {
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
index: ENDPOINT_ACTIONS_INDEX,
response: SentinelOneDataGenerator.toEsSearchResponse([]),
response: SentinelOneDataGenerator.toEsSearchResponse([
new SentinelOneDataGenerator('seed').generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' }, input_type: 'endpoint' },
}),
]),
});
await expect(s1ActionsClient.getFileInfo('abc', '123')).rejects.toThrow(
'Action id [abc] not found with an agent type of [sentinel_one]'
'Action id [abc] with agent type of [sentinel_one] not found'
);
});
@ -1198,6 +1213,24 @@ describe('SentinelOneActionsClient class', () => {
classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled =
true;
const actionRequestsSearchResponse = s1DataGenerator.toEsSearchResponse([
s1DataGenerator.generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' } },
meta: {
agentId: 's1-agent-a',
agentUUID: 'agent-uuid-1',
hostName: 's1-host-name',
},
}),
]);
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient,
index: ENDPOINT_ACTIONS_INDEX,
response: actionRequestsSearchResponse,
});
const esHit = s1DataGenerator.generateResponseEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' } },
@ -1231,6 +1264,9 @@ describe('SentinelOneActionsClient class', () => {
// @ts-expect-error updating readonly attribute
classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled =
false;
// @ts-expect-error updating readonly attribute
classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneProcessesEnabled =
false;
await expect(s1ActionsClient.getFileDownload('acb', '123')).rejects.toThrow(
'File downloads are not supported for sentinel_one agent type. Feature disabled'
@ -1241,11 +1277,16 @@ describe('SentinelOneActionsClient class', () => {
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
index: ENDPOINT_ACTIONS_INDEX,
response: SentinelOneDataGenerator.toEsSearchResponse([]),
response: SentinelOneDataGenerator.toEsSearchResponse([
s1DataGenerator.generateActionEsHit({
agent: { id: '123' },
EndpointActions: { data: { command: 'get-file' }, input_type: 'endpoint' },
}),
]),
});
await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
'Action id [abc] not found with an agent type of [sentinel_one]'
'Action id [abc] with agent type of [sentinel_one] not found'
);
});
@ -1296,7 +1337,7 @@ describe('SentinelOneActionsClient class', () => {
(connectorActionsMock.execute as jest.Mock).mockReturnValue({ data: undefined });
await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
'Unable to establish a readable stream for file with SentinelOne'
'Unable to establish a file download Readable stream with SentinelOne for response action [get-file] [abc]'
);
});

View file

@ -13,14 +13,15 @@ import { groupBy } from 'lodash';
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
import type {
SentinelOneDownloadAgentFileParams,
SentinelOneExecuteScriptResponse,
SentinelOneGetActivitiesParams,
SentinelOneGetActivitiesResponse,
SentinelOneGetAgentsResponse,
SentinelOneGetRemoteScriptResultsApiResponse,
SentinelOneGetRemoteScriptsParams,
SentinelOneGetRemoteScriptsResponse,
SentinelOneExecuteScriptResponse,
SentinelOneRemoteScriptExecutionStatus,
SentinelOneGetRemoteScriptStatusApiResponse,
SentinelOneRemoteScriptExecutionStatus,
} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import type {
QueryDslQueryContainer,
@ -31,8 +32,8 @@ import type { Readable } from 'stream';
import type { Mutable } from 'utility-types';
import type {
SentinelOneKillProcessScriptArgs,
SentinelOneScriptArgs,
SentinelOneProcessListScriptArgs,
SentinelOneScriptArgs,
} from './types';
import { ACTIONS_SEARCH_PAGE_SIZE } from '../../constants';
import type { NormalizedExternalConnectorClient } from '../lib/normalized_external_connector_client';
@ -54,6 +55,7 @@ import type {
ActionDetails,
EndpointActionDataParameterTypes,
EndpointActionResponseDataOutput,
GetProcessesActionOutputContent,
KillProcessActionOutputContent,
KillProcessRequestBody,
LogsEndpointAction,
@ -61,6 +63,7 @@ import type {
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
ResponseActionParametersWithProcessData,
ResponseActionParametersWithProcessName,
SentinelOneActionRequestCommonMeta,
SentinelOneActivityDataForType80,
SentinelOneActivityEsDoc,
@ -69,11 +72,10 @@ import type {
SentinelOneIsolationRequestMeta,
SentinelOneIsolationResponseMeta,
SentinelOneKillProcessRequestMeta,
UploadedFileInfo,
ResponseActionParametersWithProcessName,
GetProcessesActionOutputContent,
SentinelOneProcessesRequestMeta,
SentinelOneKillProcessResponseMeta,
SentinelOneProcessesRequestMeta,
SentinelOneProcessesResponseMeta,
UploadedFileInfo,
} from '../../../../../../common/endpoint/types';
import type {
GetProcessesRequestBody,
@ -505,15 +507,27 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
}
async getFileInfo(actionId: string, agentId: string): Promise<UploadedFileInfo> {
await this.ensureValidActionId(actionId);
const {
EndpointActions: {
data: { command },
},
} = await this.fetchActionRequestEsDoc(actionId);
const {
responseActionsSentinelOneGetFileEnabled: isGetFileEnabled,
responseActionsSentinelOneProcessesEnabled: isRunningProcessesEnabled,
} = this.options.endpointService.experimentalFeatures;
if (
!this.options.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled
(command === 'get-file' && !isGetFileEnabled) ||
(command === 'running-processes' && !isRunningProcessesEnabled)
) {
throw new ResponseActionsClientError(
`File downloads are not supported for ${this.agentType} agent type. Feature disabled`,
400
);
}
await this.ensureValidActionId(actionId);
const fileInfo: UploadedFileInfo = {
actionId,
@ -528,19 +542,69 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
};
try {
const agentResponse = await this.fetchGetFileResponseEsDocForAgentId(actionId, agentId);
switch (command) {
case 'get-file':
{
const agentResponse = await this.fetchEsResponseDocForAgentId<
ResponseActionGetFileOutputContent,
SentinelOneGetFileResponseMeta
>(actionId, agentId);
// Unfortunately, there is no way to determine if a file is still available in SentinelOne without actually
// calling the download API, which would return the following error:
// { "errors":[ {
// "code":4100010,
// "detail":"The requested files do not exist. Fetched files are deleted after 3 days, or earlier if more than 30 files are fetched.",
// "title":"Resource not found"
// } ] }
fileInfo.status = 'READY';
fileInfo.created = agentResponse.meta?.createdAt ?? '';
fileInfo.name = agentResponse.meta?.filename ?? '';
fileInfo.mimeType = 'application/octet-stream';
// Unfortunately, there is no way to determine if a file is still available in SentinelOne without actually
// calling the download API, which would return the following error:
// { "errors":[ {
// "code":4100010,
// "detail":"The requested files do not exist. Fetched files are deleted after 3 days, or earlier if more than 30 files are fetched.",
// "title":"Resource not found"
// } ] }
fileInfo.status = 'READY';
fileInfo.created = agentResponse.meta?.createdAt ?? '';
fileInfo.name = agentResponse.meta?.filename ?? '';
fileInfo.mimeType = 'application/octet-stream';
}
break;
case 'running-processes':
{
const agentResponse = await this.fetchEsResponseDocForAgentId<
GetProcessesActionOutputContent,
SentinelOneProcessesResponseMeta
>(actionId, agentId);
const s1TaskId = agentResponse.meta?.taskId ?? '';
fileInfo.created = agentResponse['@timestamp'];
const { data: s1ScriptResultsApiResponse } =
await this.sendAction<SentinelOneGetRemoteScriptResultsApiResponse>(
SUB_ACTION.GET_REMOTE_SCRIPT_RESULTS,
{
taskIds: [s1TaskId],
}
);
const fileDownloadLink = (s1ScriptResultsApiResponse?.data.download_links ?? []).find(
(linkInfo) => {
return linkInfo.taskId === s1TaskId;
}
);
if (!fileDownloadLink) {
this.log.debug(
`No download link found in SentinelOne for Task Id [${s1TaskId}]. Setting file status to DELETED`
);
fileInfo.status = 'DELETED';
} else {
fileInfo.status = 'READY';
fileInfo.name = fileDownloadLink.fileName ?? `${actionId}-${agentId}.zip`;
fileInfo.mimeType = 'application/octet-stream';
}
}
break;
default:
throw new ResponseActionsClientError(`${command} does not support file downloads`, 400);
}
} catch (e) {
// Ignore "no response doc" error for the agent and just return the file info with the status of 'AWAITING_UPLOAD'
if (!(e instanceof ResponseActionAgentResponseEsDocNotFound)) {
@ -552,8 +616,21 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
}
async getFileDownload(actionId: string, agentId: string): Promise<GetFileDownloadMethodResponse> {
await this.ensureValidActionId(actionId);
const {
EndpointActions: {
data: { command },
},
} = await this.fetchActionRequestEsDoc(actionId);
const {
responseActionsSentinelOneGetFileEnabled: isGetFileEnabled,
responseActionsSentinelOneProcessesEnabled: isRunningProcessesEnabled,
} = this.options.endpointService.experimentalFeatures;
if (
!this.options.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled
(command === 'get-file' && !isGetFileEnabled) ||
(command === 'running-processes' && !isRunningProcessesEnabled)
) {
throw new ResponseActionsClientError(
`File downloads are not supported for ${this.agentType} agent type. Feature disabled`,
@ -561,35 +638,91 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
);
}
await this.ensureValidActionId(actionId);
let downloadStream: Readable | undefined;
let fileName: string = 'download.zip';
const agentResponse = await this.fetchGetFileResponseEsDocForAgentId(actionId, agentId);
try {
switch (command) {
case 'get-file':
{
const getFileAgentResponse = await this.fetchEsResponseDocForAgentId<
ResponseActionGetFileOutputContent,
SentinelOneGetFileResponseMeta
>(actionId, agentId);
if (!agentResponse.meta?.activityLogEntryId) {
throw new ResponseActionsClientError(
`Unable to retrieve file from SentinelOne. Response ES document is missing [meta.activityLogEntryId]`
if (!getFileAgentResponse.meta?.activityLogEntryId) {
throw new ResponseActionsClientError(
`Unable to retrieve file from SentinelOne. Response ES document is missing [meta.activityLogEntryId]`
);
}
const downloadAgentFileMethodOptions: SentinelOneDownloadAgentFileParams = {
agentUUID: agentId,
activityId: getFileAgentResponse.meta?.activityLogEntryId,
};
const { data } = await this.sendAction<Readable>(
SUB_ACTION.DOWNLOAD_AGENT_FILE,
downloadAgentFileMethodOptions
);
if (data) {
downloadStream = data;
fileName = getFileAgentResponse.meta.filename;
}
}
break;
case 'running-processes':
{
const processesAgentResponse = await this.fetchEsResponseDocForAgentId<
{},
SentinelOneProcessesResponseMeta
>(actionId, agentId);
if (!processesAgentResponse.meta?.taskId) {
throw new ResponseActionsClientError(
`Unable to retrieve file from SentinelOne for Response Action [${actionId}]. Response ES document is missing [meta.taskId]`
);
}
const { data } = await this.sendAction<Readable>(
SUB_ACTION.DOWNLOAD_REMOTE_SCRIPT_RESULTS,
{
taskId: processesAgentResponse.meta?.taskId,
}
);
if (data) {
downloadStream = data;
fileName = `${actionId}_${agentId}.zip`;
}
}
break;
default:
throw new ResponseActionsClientError(`${command} does not support file downloads`, 400);
}
if (!downloadStream) {
throw new ResponseActionsClientError(
`Unable to establish a file download Readable stream with SentinelOne for response action [${command}] [${actionId}]`
);
}
} catch (e) {
this.log.debug(
() =>
`Attempt to get file download stream from SentinelOne for response action failed with:\n${stringify(
e
)}`
);
}
const downloadAgentFileMethodOptions: SentinelOneDownloadAgentFileParams = {
agentUUID: agentId,
activityId: agentResponse.meta?.activityLogEntryId,
};
const { data } = await this.sendAction<Readable>(
SUB_ACTION.DOWNLOAD_AGENT_FILE,
downloadAgentFileMethodOptions
);
if (!data) {
throw new ResponseActionsClientError(
`Unable to establish a readable stream for file with SentinelOne`
);
throw e;
}
return {
stream: data,
fileName: agentResponse.meta.filename,
stream: downloadStream,
mimeType: undefined,
fileName,
};
}
@ -754,6 +887,12 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
if (abortSignal.aborted) {
return;
}
const addResponsesToQueueIfAny = (responseList: LogsEndpointActionResponse[]): void => {
if (responseList.length > 0) {
addToQueue(...responseList);
}
};
for await (const pendingActions of this.fetchAllPendingActions()) {
if (abortSignal.aborted) {
return;
@ -769,22 +908,35 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
switch (actionType as ResponseActionsApiCommandNames) {
case 'isolate':
case 'unisolate':
{
const isolationResponseDocs = await this.checkPendingIsolateOrReleaseActions(
addResponsesToQueueIfAny(
await this.checkPendingIsolateOrReleaseActions(
typePendingActions as Array<
ResponseActionsClientPendingAction<undefined, {}, SentinelOneIsolationRequestMeta>
>,
actionType as 'isolate' | 'unisolate'
);
if (isolationResponseDocs.length) {
addToQueue(...isolationResponseDocs);
}
}
)
);
break;
case 'running-processes':
addResponsesToQueueIfAny(
await this.checkPendingRunningProcessesAction(
typePendingActions as Array<
ResponseActionsClientPendingAction<
undefined,
GetProcessesActionOutputContent,
SentinelOneProcessesRequestMeta
>
>
)
);
break;
// FIXME:PT refactor kill-process entry here when that PR is merged
case 'get-file':
{
const responseDocsForGetFile = await this.checkPendingGetFileActions(
addResponsesToQueueIfAny(
await this.checkPendingGetFileActions(
typePendingActions as Array<
ResponseActionsClientPendingAction<
ResponseActionGetFileParameters,
@ -792,11 +944,8 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
SentinelOneGetFileRequestMeta
>
>
);
if (responseDocsForGetFile.length) {
addToQueue(...responseDocsForGetFile);
}
}
)
);
break;
case 'kill-process':
@ -928,17 +1077,12 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
} as FetchScriptInfoResponse<TScriptOptions>;
}
private async fetchGetFileResponseEsDocForAgentId(
actionId: string,
agentId: string
): Promise<
LogsEndpointActionResponse<ResponseActionGetFileOutputContent, SentinelOneGetFileResponseMeta>
> {
private async fetchEsResponseDocForAgentId<
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
TMeta extends {} = {}
>(actionId: string, agentId: string): Promise<LogsEndpointActionResponse<TOutputContent, TMeta>> {
const agentResponse = (
await this.fetchActionResponseEsDocs<
ResponseActionGetFileOutputContent,
SentinelOneGetFileResponseMeta
>(actionId, [agentId])
await this.fetchActionResponseEsDocs<TOutputContent, TMeta>(actionId, [agentId])
)[agentId];
if (!agentResponse) {
@ -948,13 +1092,6 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
);
}
if (agentResponse.EndpointActions.data.command !== 'get-file') {
throw new ResponseActionsClientError(
`Invalid action ID [${actionId}] - Not a get-file action: [${agentResponse.EndpointActions.data.command}]`,
400
);
}
return agentResponse;
}
@ -1162,6 +1299,110 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
return completedResponses;
}
private async checkPendingRunningProcessesAction(
actionRequests: Array<
ResponseActionsClientPendingAction<
undefined,
GetProcessesActionOutputContent,
SentinelOneProcessesRequestMeta
>
>
): Promise<LogsEndpointActionResponse[]> {
const warnings: string[] = [];
const completedResponses: LogsEndpointActionResponse[] = [];
for (const pendingAction of actionRequests) {
const actionRequest = pendingAction.action;
const s1ParentTaskId = actionRequest.meta?.parentTaskId;
if (!s1ParentTaskId) {
completedResponses.push(
this.buildActionResponseEsDoc<
GetProcessesActionOutputContent,
SentinelOneProcessesResponseMeta
>({
actionId: actionRequest.EndpointActions.action_id,
agentId: Array.isArray(actionRequest.agent.id)
? actionRequest.agent.id[0]
: actionRequest.agent.id,
data: {
command: 'running-processes',
comment: '',
},
error: {
message: `Action request missing SentinelOne 'parentTaskId' value - unable check on its status`,
},
})
);
warnings.push(
`Response Action [${actionRequest.EndpointActions.action_id}] is missing [meta.parentTaskId]! (should not have happened)`
);
} else {
const s1TaskStatusApiResponse =
await this.sendAction<SentinelOneGetRemoteScriptStatusApiResponse>(
SUB_ACTION.GET_REMOTE_SCRIPT_STATUS,
{ parentTaskId: s1ParentTaskId }
);
if (s1TaskStatusApiResponse.data?.data.length) {
const processListScriptExecStatus = s1TaskStatusApiResponse.data.data[0];
const taskState = this.calculateTaskState(processListScriptExecStatus);
if (!taskState.isPending) {
this.log.debug(`Action is completed - generating response doc for it`);
const error: LogsEndpointActionResponse['error'] = taskState.isError
? {
message: `Action failed to execute in SentinelOne. message: ${taskState.message}`,
}
: undefined;
completedResponses.push(
this.buildActionResponseEsDoc<
GetProcessesActionOutputContent,
SentinelOneProcessesResponseMeta
>({
actionId: actionRequest.EndpointActions.action_id,
agentId: Array.isArray(actionRequest.agent.id)
? actionRequest.agent.id[0]
: actionRequest.agent.id,
data: {
command: 'running-processes',
comment: taskState.message,
output: {
type: 'json',
content: {
code: '',
entries: [],
},
},
},
error,
meta: {
taskId: processListScriptExecStatus.id,
},
})
);
}
}
}
}
this.log.debug(
() =>
`${completedResponses.length} running-processes action responses generated:\n${stringify(
completedResponses
)}`
);
if (warnings.length > 0) {
this.log.warn(warnings.join('\n'));
}
return completedResponses;
}
private async checkPendingGetFileActions(
actionRequests: Array<
ResponseActionsClientPendingAction<

View file

@ -0,0 +1,51 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { NotFoundError } from '../../../errors';
import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants';
import type {
EndpointActionDataParameterTypes,
EndpointActionResponseDataOutput,
LogsEndpointAction,
} from '../../../../../common/endpoint/types';
import { catchAndWrapError } from '../../../utils';
/**
* Fetches a single Action request document.
* @param esClient
* @param actionId
*
* @throws
*/
export const fetchActionRequestById = async <
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes,
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
TMeta extends {} = {}
>(
esClient: ElasticsearchClient,
actionId: string
): Promise<LogsEndpointAction<TParameters, TOutputContent, TMeta>> => {
const searchResponse = await esClient
.search<LogsEndpointAction<TParameters, TOutputContent, TMeta>>(
{
index: ENDPOINT_ACTIONS_INDEX,
query: { bool: { filter: [{ term: { action_id: actionId } }] } },
size: 1,
},
{ ignore: [404] }
)
.catch(catchAndWrapError);
const actionRequest = searchResponse.hits.hits?.[0]?._source;
if (!actionRequest) {
throw new NotFoundError(`Action with id '${actionId}' not found.`);
}
return actionRequest;
};

View file

@ -34,7 +34,7 @@ export const getActionAgentType = async (
})
.catch(catchAndWrapError);
if (!response?.hits?.hits[0]._source?.EndpointActions.input_type) {
if (!response?.hits?.hits[0]?._source?.EndpointActions.input_type) {
throw new NotFoundError(`Action id [${actionId}] not found`, response);
}

View file

@ -7,5 +7,4 @@
export * from './utils';
export * from './fetch_action_responses';
export * from './validate_action_id';
export * from './get_action_agent_type';

View file

@ -1,53 +0,0 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { NotFoundError } from '../../../errors';
import { catchAndWrapError } from '../../../utils';
import type { LogsEndpointAction } from '../../../../../common/endpoint/types';
import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants';
/**
* Validates that a given action ID is a valid Endpoint action
*
* @throws
*/
export const validateActionId = async (
esClient: ElasticsearchClient,
actionId: string,
agentType?: ResponseActionAgentType
): Promise<void> => {
const response = await esClient
.search<LogsEndpointAction>({
index: ENDPOINT_ACTIONS_INDEX,
body: {
query: {
bool: {
filter: [
{ term: { action_id: actionId } },
{ term: { type: 'INPUT_ACTION' } },
...(agentType ? [{ term: { 'EndpointActions.input_type': agentType } }] : []),
],
},
},
},
_source: false,
size: 1,
})
.catch(catchAndWrapError);
if (!(response.hits?.total as SearchTotalHits)?.value) {
throw new NotFoundError(
`Action id [${actionId}] not found${
agentType ? ` with an agent type of [${agentType}]` : ''
}`,
response
);
}
};