mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
4d5de12e9e
commit
618e27c418
19 changed files with 704 additions and 266 deletions
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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')} />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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' } }]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -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' } }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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[]> {
|
||||
|
|
|
@ -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]'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,5 +7,4 @@
|
|||
|
||||
export * from './utils';
|
||||
export * from './fetch_action_responses';
|
||||
export * from './validate_action_id';
|
||||
export * from './get_action_agent_type';
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue