[Security Solution][Endpoint] Add logic to SentinelOne response actions to check and complete kill-process actions (#188033)

## Summary

#### Security Solution

- adds logic to the SentinelOne response actions client to check on the
status of `kill-process` actions in SentinelOne and writes response
document to ES if complete


#### Stack Connector changes to SentinelOne Connector

- Added new sub-action: `downloadRemoteScriptResults()`: returns a
`Stream` allowing the download of a SentinelOne task execution results
This commit is contained in:
Paul Tavares 2024-07-17 10:56:42 -04:00 committed by GitHub
parent 4903542a79
commit 1cae23769a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 12099 additions and 11396 deletions

View file

@ -14,6 +14,9 @@ import type {
SentinelOneGetActivitiesResponse,
SentinelOneGetAgentsResponse,
SentinelOneActivityRecord,
SentinelOneOsType,
SentinelOneGetRemoteScriptStatusApiResponse,
SentinelOneRemoteScriptExecutionStatus,
} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import { EndpointActionGenerator } from './endpoint_action_generator';
import { SENTINEL_ONE_ACTIVITY_INDEX_PATTERN } from '../..';
@ -26,6 +29,21 @@ import type {
} from '../types';
export class SentinelOneDataGenerator extends EndpointActionGenerator {
static readonly scriptExecutionStatusValues: Readonly<
Array<SentinelOneRemoteScriptExecutionStatus['status']>
> = Object.freeze([
'canceled',
'completed',
'created',
'expired',
'failed',
'in_progress',
'partially_completed',
'pending',
'pending_user_action',
'scheduled',
]);
generate<
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes,
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
@ -368,6 +386,44 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator {
errors: null,
};
}
generateSentinelOneApiRemoteScriptStatusResponse(
overrides: Partial<SentinelOneRemoteScriptExecutionStatus> = {}
): SentinelOneGetRemoteScriptStatusApiResponse {
const scriptExecutionStatus: SentinelOneRemoteScriptExecutionStatus = {
accountId: this.seededUUIDv4(),
accountName: 'Elastic',
agentComputerName: this.randomHostname(),
agentId: this.seededUUIDv4(),
agentIsActive: true,
agentIsDecommissioned: false,
agentMachineType: 'server',
agentOsType: this.randomOSFamily() as SentinelOneOsType,
agentUuid: this.seededUUIDv4(),
createdAt: '2024-06-04T15:48:07.183909Z',
description: 'Terminate Processes',
detailedStatus: 'Execution completed successfully',
groupId: '1392053568591146999',
groupName: 'Default Group',
id: this.seededUUIDv4(),
initiatedBy: this.randomUser(),
initiatedById: '1809444483386312727',
parentTaskId: this.seededUUIDv4(),
scriptResultsSignature: '632e6e027',
siteId: '1392053568582758390',
siteName: 'Default site',
status: this.randomChoice(SentinelOneDataGenerator.scriptExecutionStatusValues),
statusCode: 'ok',
statusDescription: 'Completed',
type: 'script_execution',
updatedAt: '2024-06-04T15:49:20.508099Z',
};
return {
data: [merge(scriptExecutionStatus, overrides)],
pagination: { totalItems: 1, nextCursor: undefined },
};
}
}
// Activity types from SentinelOne. Values can be retrieved from the SentineOne API at:

View file

@ -56,6 +56,7 @@ export interface KillProcessActionOutputContent {
command?: string;
pid?: number;
entity_id?: string;
process_name?: string;
}
export interface ResponseActionGetFileOutputContent {
@ -135,7 +136,7 @@ export interface LogsEndpointAction<
agent: {
id: string | string[];
};
EndpointActions: EndpointActionFields & ActionRequestFields;
EndpointActions: EndpointActionFields<TParameters, TOutputContent> & ActionRequestFields;
error?: EcsError;
user: {
id: string;

View file

@ -131,3 +131,8 @@ export interface SentinelOneKillProcessRequestMeta extends SentinelOneIsolationR
*/
parentTaskId: string;
}
export interface SentinelOneKillProcessResponseMeta {
/** The SentinelOne task ID associated with the completion of the kill-process action */
taskId: string;
}

View file

@ -6,6 +6,7 @@
*/
import type { RequestHandler } from '@kbn/core/server';
import { stringify } from '../../utils/stringify';
import type { EndpointActionFileInfoParams } from '../../../../common/api/endpoint';
import { EndpointActionFileInfoSchema } from '../../../../common/api/endpoint';
import type { ResponseActionsClient } from '../../services';
@ -35,6 +36,8 @@ export const getActionFileInfoRouteHandler = (
const logger = endpointContext.logFactory.get('actionFileInfo');
return async (context, req, res) => {
logger.debug(() => `Get response action file info:\n${stringify(req.params)}`);
const { action_id: requestActionId, file_id: fileId } = req.params;
const coreContext = await context.core;

View file

@ -450,7 +450,7 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
comment: actionRequest.comment ?? undefined,
...(actionRequest.alert_ids ? { alert_id: actionRequest.alert_ids } : {}),
...(actionRequest.hosts ? { hosts: actionRequest.hosts } : {}),
parameters: actionRequest.parameters as EndpointActionDataParameterTypes,
parameters: actionRequest.parameters as TParameters,
},
},
user: {

View file

@ -17,6 +17,7 @@ import {
import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
import { merge } from 'lodash';
import { SentinelOneDataGenerator } from '../../../../../../common/endpoint/data_generators/sentinelone_data_generator';
import type { NormalizedExternalConnectorClient } from '../../..';
import type { ResponseActionsClientOptionsMock } from '../mocks';
import { responseActionsClientMock } from '../mocks';
@ -288,6 +289,13 @@ const createConnectorActionsClientMock = (): ActionsClientMock => {
},
});
case SUB_ACTION.GET_REMOTE_SCRIPT_STATUS:
return responseActionsClientMock.createConnectorActionExecuteResponse({
data: new SentinelOneDataGenerator(
'seed'
).generateSentinelOneApiRemoteScriptStatusResponse({ status: 'completed' }),
});
default:
return responseActionsClientMock.createConnectorActionExecuteResponse();
}

View file

@ -35,6 +35,9 @@ import type {
ResponseActionGetFileParameters,
SentinelOneGetFileRequestMeta,
KillOrSuspendProcessRequestBody,
KillProcessActionOutputContent,
ResponseActionParametersWithProcessName,
SentinelOneKillProcessRequestMeta,
} from '../../../../../../common/endpoint/types';
import type { SearchHit, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import type {
@ -47,6 +50,10 @@ import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-ser
import { Readable } from 'stream';
import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/constants';
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import type {
SentinelOneGetRemoteScriptStatusApiResponse,
SentinelOneRemoteScriptExecutionStatus,
} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
jest.mock('../../action_details_by_id', () => {
const originalMod = jest.requireActual('../../action_details_by_id');
@ -738,6 +745,174 @@ 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);
});
};
beforeEach(() => {
const s1DataGenerator = new SentinelOneDataGenerator('seed');
actionRequestsSearchResponse = s1DataGenerator.toEsSearchResponse([
s1DataGenerator.generateActionEsHit<
ResponseActionParametersWithProcessName,
KillProcessActionOutputContent,
SentinelOneKillProcessRequestMeta
>({
agent: { id: 'agent-uuid-1' },
EndpointActions: {
data: { command: 'kill-process', parameters: { process_name: 'foo' } },
},
meta: {
agentId: 's1-agent-a',
agentUUID: 'agent-uuid-1',
hostName: 's1-host-name',
parentTaskId: 's1-parent-task-123',
},
}),
]);
const actionResponsesSearchResponse = s1DataGenerator.toEsSearchResponse<
LogsEndpointActionResponse | EndpointActionResponse
>([]);
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient,
index: ENDPOINT_ACTIONS_INDEX,
response: actionRequestsSearchResponse,
pitUsage: true,
});
applyEsClientSearchMock({
esClientMock: classConstructorOptions.esClient,
index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
response: actionResponsesSearchResponse,
});
});
it('should create response at error if request has no parentTaskId', async () => {
actionRequestsSearchResponse.hits.hits[0]!._source!.meta!.parentTaskId = '';
await s1ActionsClient.processPendingActions(processPendingActionsOptions);
expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({
'@timestamp': expect.any(String),
EndpointActions: {
action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499',
completed_at: expect.any(String),
data: {
command: 'kill-process',
comment: '',
},
input_type: 'sentinel_one',
started_at: expect.any(String),
},
agent: {
id: 'agent-uuid-1',
},
error: {
message:
"Action request missing SentinelOne 'parentTaskId' value - unable check on its status",
},
meta: undefined,
});
});
it('should do nothing if action is still pending', async () => {
setGetRemoteScriptStatusConnectorResponse(
new SentinelOneDataGenerator('seed').generateSentinelOneApiRemoteScriptStatusResponse({
status: 'pending',
})
);
await s1ActionsClient.processPendingActions(processPendingActionsOptions);
expect(processPendingActionsOptions.addToQueue).not.toHaveBeenCalled();
});
it.each`
s1ScriptStatus | expectedResponseActionResponse
${'canceled'} | ${'failure'}
${'expired'} | ${'failure'}
${'failed'} | ${'failure'}
${'completed'} | ${'success'}
`(
'should create $expectedResponseActionResponse response when S1 script status is $s1ScriptStatus',
async ({ s1ScriptStatus, expectedResponseActionResponse }) => {
const s1ScriptStatusResponse = new SentinelOneDataGenerator(
'seed'
).generateSentinelOneApiRemoteScriptStatusResponse({
status: s1ScriptStatus,
});
setGetRemoteScriptStatusConnectorResponse(s1ScriptStatusResponse);
await s1ActionsClient.processPendingActions(processPendingActionsOptions);
if (expectedResponseActionResponse === 'failure') {
expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith(
expect.objectContaining({
error: {
message: expect.any(String),
},
})
);
} else {
expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith(
expect.objectContaining({
meta: { taskId: s1ScriptStatusResponse.data[0].id },
error: undefined,
EndpointActions: expect.objectContaining({
data: expect.objectContaining({
output: {
type: 'json',
content: {
code: 'ok',
command: 'kill-process',
process_name: 'foo',
},
},
}),
}),
})
);
}
}
);
it.each([
'created',
'pending',
'pending_user_action',
'scheduled',
'in_progress',
'partially_completed',
])('should leave action pending when S1 script status is %s', async (s1ScriptStatus) => {
setGetRemoteScriptStatusConnectorResponse(
new SentinelOneDataGenerator('seed').generateSentinelOneApiRemoteScriptStatusResponse({
status: s1ScriptStatus as SentinelOneRemoteScriptExecutionStatus['status'],
})
);
await s1ActionsClient.processPendingActions(processPendingActionsOptions);
expect(processPendingActionsOptions.addToQueue).not.toHaveBeenCalled();
});
});
});
describe('#getFile()', () => {

View file

@ -19,6 +19,8 @@ import type {
SentinelOneGetRemoteScriptsParams,
SentinelOneGetRemoteScriptsResponse,
SentinelOneExecuteScriptResponse,
SentinelOneRemoteScriptExecutionStatus,
SentinelOneGetRemoteScriptStatusApiResponse,
} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import type {
QueryDslQueryContainer,
@ -71,6 +73,7 @@ import type {
ResponseActionParametersWithProcessName,
GetProcessesActionOutputContent,
SentinelOneProcessesRequestMeta,
SentinelOneKillProcessResponseMeta,
} from '../../../../../../common/endpoint/types';
import type {
GetProcessesRequestBody,
@ -795,6 +798,23 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
}
}
break;
case 'kill-process':
{
const responseDocsForKillProcess = await this.checkPendingKillProcessActions(
typePendingActions as Array<
ResponseActionsClientPendingAction<
ResponseActionParametersWithProcessName,
KillProcessActionOutputContent,
SentinelOneKillProcessRequestMeta
>
>
);
if (responseDocsForKillProcess.length) {
addToQueue(...responseDocsForKillProcess);
}
}
break;
}
}
}
@ -1321,4 +1341,175 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
return completedResponses;
}
/**
* Calculates the state of a SentinelOne Task using the response from their task status API. It
* returns a normalized object with basic info derived from the task status value
* @param taskStatusRecord
* @private
*/
private calculateTaskState(taskStatusRecord: SentinelOneRemoteScriptExecutionStatus): {
isPending: boolean;
isError: boolean;
message: string;
} {
const taskStatusValue = taskStatusRecord.status;
let message =
taskStatusRecord.detailedStatus ?? taskStatusRecord.statusDescription ?? taskStatusValue;
let isPending: boolean;
let isError: boolean;
switch (taskStatusValue) {
// PENDING STATUSES ------------------------------------------
case 'created':
case 'pending':
case 'pending_user_action':
case 'scheduled':
case 'in_progress':
case 'partially_completed':
isPending = true;
isError = true;
break;
// COMPLETE STATUSES ------------------------------------------
case 'canceled':
isPending = false;
isError = true;
message = `SentinelOne Parent Task Id [${taskStatusRecord.parentTaskId}] was canceled${
taskStatusRecord.detailedStatus ? ` - ${taskStatusRecord.detailedStatus}` : ''
}`;
break;
case 'completed':
isPending = false;
isError = false;
break;
case 'expired':
isPending = false;
isError = true;
break;
case 'failed':
isPending = false;
isError = true;
break;
default:
isPending = false;
isError = true;
message = `Unable to determine task state - unknown SentinelOne task status value [${taskStatusRecord}] for task parent id [${taskStatusRecord.parentTaskId}]`;
}
return {
isPending,
isError,
message,
};
}
private async checkPendingKillProcessActions(
actionRequests: Array<
ResponseActionsClientPendingAction<
ResponseActionParametersWithProcessName,
KillProcessActionOutputContent,
SentinelOneKillProcessRequestMeta
>
>
): 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<
KillProcessActionOutputContent,
SentinelOneKillProcessResponseMeta
>({
actionId: actionRequest.EndpointActions.action_id,
agentId: Array.isArray(actionRequest.agent.id)
? actionRequest.agent.id[0]
: actionRequest.agent.id,
data: {
command: 'kill-process',
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 killProcessStatus = s1TaskStatusApiResponse.data.data[0];
const taskState = this.calculateTaskState(killProcessStatus);
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<
KillProcessActionOutputContent,
SentinelOneKillProcessResponseMeta
>({
actionId: actionRequest.EndpointActions.action_id,
agentId: Array.isArray(actionRequest.agent.id)
? actionRequest.agent.id[0]
: actionRequest.agent.id,
data: {
command: 'kill-process',
comment: taskState.message,
output: {
type: 'json',
content: {
code: killProcessStatus.statusCode ?? killProcessStatus.status,
command: actionRequest.EndpointActions.data.command,
process_name: actionRequest.EndpointActions.data.parameters?.process_name,
},
},
},
error,
meta: {
taskId: killProcessStatus.id,
},
})
);
}
}
}
}
this.log.debug(
() =>
`${completedResponses.length} kill-process action responses generated:\n${stringify(
completedResponses
)}`
);
if (warnings.length > 0) {
this.log.warn(warnings.join('\n'));
}
return completedResponses;
}
}

View file

@ -17,6 +17,7 @@ export enum SUB_ACTION {
GET_REMOTE_SCRIPTS = 'getRemoteScripts',
GET_REMOTE_SCRIPT_STATUS = 'getRemoteScriptStatus',
GET_REMOTE_SCRIPT_RESULTS = 'getRemoteScriptResults',
DOWNLOAD_REMOTE_SCRIPT_RESULTS = 'downloadRemoteScriptResults',
FETCH_AGENT_FILES = 'fetchAgentFiles',
DOWNLOAD_AGENT_FILE = 'downloadAgentFile',
GET_ACTIVITIES = 'getActivities',

View file

@ -375,6 +375,24 @@ export const SentinelOneExecuteScriptResponseSchema = schema.object({
),
});
export const SentinelOneGetRemoteScriptResultsParamsSchema = schema.object({
taskIds: schema.arrayOf(schema.string()),
});
export const SentinelOneGetRemoteScriptResultsResponseSchema = schema.object(
{
errors: schema.nullable(schema.arrayOf(schema.object({ type: schema.string() }))),
data: schema.any(),
},
{ unknowns: 'allow' }
);
export const SentinelOneDownloadRemoteScriptResultsParamsSchema = schema.object({
taskId: schema.string({ minLength: 1 }),
});
export const SentinelOneDownloadRemoteScriptResultsResponseSchema = schema.stream();
export const SentinelOneGetRemoteScriptStatusParamsSchema = schema.object(
{
parentTaskId: schema.string(),
@ -388,39 +406,7 @@ export const SentinelOneGetRemoteScriptStatusResponseSchema = schema.object({
nextCursor: schema.nullable(schema.string()),
}),
errors: schema.nullable(schema.arrayOf(schema.object({ type: schema.string() }))),
data: schema.arrayOf(
schema.object(
{
agentIsDecommissioned: schema.nullable(schema.boolean()),
agentComputerName: schema.nullable(schema.string()),
status: schema.nullable(schema.string()),
groupName: schema.nullable(schema.string()),
initiatedById: schema.nullable(schema.string()),
parentTaskId: schema.nullable(schema.string()),
updatedAt: schema.nullable(schema.string()),
createdAt: schema.nullable(schema.string()),
agentIsActive: schema.nullable(schema.boolean()),
agentOsType: schema.nullable(schema.string()),
agentMachineType: schema.nullable(schema.string()),
id: schema.nullable(schema.string()),
siteName: schema.nullable(schema.string()),
detailedStatus: schema.nullable(schema.string()),
siteId: schema.nullable(schema.string()),
scriptResultsSignature: schema.nullable(schema.nullable(schema.string())),
initiatedBy: schema.nullable(schema.string()),
accountName: schema.nullable(schema.string()),
groupId: schema.nullable(schema.string()),
agentUuid: schema.nullable(schema.string()),
accountId: schema.nullable(schema.string()),
type: schema.nullable(schema.string()),
scriptResultsPath: schema.nullable(schema.string()),
scriptResultsBucket: schema.nullable(schema.string()),
description: schema.nullable(schema.string()),
agentId: schema.nullable(schema.string()),
},
{ unknowns: 'allow' }
)
),
data: schema.arrayOf(schema.mapOf(schema.string(), schema.any())),
});
export const SentinelOneBaseFilterSchema = schema.object({
@ -576,18 +562,10 @@ export const SentinelOneBaseFilterSchema = schema.object({
alertIds: AlertIds,
});
export const SentinelOneKillProcessParamsSchema = SentinelOneBaseFilterSchema.extends({
processName: schema.string(),
});
export const SentinelOneIsolateHostParamsSchema = SentinelOneBaseFilterSchema;
export const SentinelOneGetAgentsParamsSchema = SentinelOneBaseFilterSchema;
export const SentinelOneGetRemoteScriptsStatusParams = schema.object({
parentTaskId: schema.string(),
});
export const SentinelOneIsolateHostSchema = schema.object({
subAction: schema.literal(SUB_ACTION.ISOLATE_HOST),
subActionParams: SentinelOneIsolateHostParamsSchema,

View file

@ -14,7 +14,7 @@ import {
SentinelOneGetAgentsResponseSchema,
SentinelOneGetRemoteScriptsParamsSchema,
SentinelOneGetRemoteScriptsResponseSchema,
SentinelOneGetRemoteScriptsStatusParams,
SentinelOneGetRemoteScriptStatusParamsSchema,
SentinelOneIsolateHostParamsSchema,
SentinelOneSecretsSchema,
SentinelOneActionParamsSchema,
@ -24,8 +24,23 @@ import {
SentinelOneGetActivitiesParamsSchema,
SentinelOneGetActivitiesResponseSchema,
SentinelOneExecuteScriptResponseSchema,
SentinelOneGetRemoteScriptResultsParamsSchema,
SentinelOneDownloadRemoteScriptResultsParamsSchema,
} from './schema';
interface SentinelOnePagination {
pagination: {
totalItems: number;
nextCursor?: string;
};
}
interface SentinelOneErrors {
errors?: string[];
}
export type SentinelOneOsType = 'linux' | 'macos' | 'windows';
export type SentinelOneConfig = TypeOf<typeof SentinelOneConfigSchema>;
export type SentinelOneSecrets = TypeOf<typeof SentinelOneSecretsSchema>;
@ -39,8 +54,79 @@ export type SentinelOneExecuteScriptResponse = TypeOf<
typeof SentinelOneExecuteScriptResponseSchema
>;
export interface SentinelOneRemoteScriptExecutionStatus {
accountId: string;
accountName: string;
agentComputerName: string;
agentId: string;
agentIsActive: boolean;
agentIsDecommissioned: boolean;
agentMachineType: string;
agentOsType: SentinelOneOsType;
agentUuid: string;
createdAt: string;
description?: string;
detailedStatus?: string;
groupId: string;
groupName: string;
/** The `id` can be used to retrieve the script results file from sentinleone */
id: string;
initiatedBy: string;
initiatedById: string;
parentTaskId: string;
/** `scriptResultsSignature` will be present only when there is a file with results */
scriptResultsSignature?: string;
siteId: string;
siteName: string;
status:
| 'canceled'
| 'completed'
| 'created'
| 'expired'
| 'failed'
| 'in_progress'
| 'partially_completed'
| 'pending'
| 'pending_user_action'
| 'scheduled';
statusCode?: string;
statusDescription: string;
type: string;
updatedAt: string;
}
export type SentinelOneGetRemoteScriptStatusParams = TypeOf<
typeof SentinelOneGetRemoteScriptsStatusParams
typeof SentinelOneGetRemoteScriptStatusParamsSchema
>;
export interface SentinelOneGetRemoteScriptStatusApiResponse
extends SentinelOnePagination,
SentinelOneErrors {
data: SentinelOneRemoteScriptExecutionStatus[];
}
export type SentinelOneGetRemoteScriptResultsParams = TypeOf<
typeof SentinelOneGetRemoteScriptResultsParamsSchema
>;
export interface SentinelOneGetRemoteScriptResults {
download_links: Array<{
downloadUrl: string;
fileName: string;
taskId: string;
}>;
errors?: Array<{
taskId: string;
errorString: string;
}>;
}
export interface SentinelOneGetRemoteScriptResultsApiResponse extends SentinelOneErrors {
data: SentinelOneGetRemoteScriptResults;
}
export type SentinelOneDownloadRemoteScriptResultsParams = TypeOf<
typeof SentinelOneDownloadRemoteScriptResultsParamsSchema
>;
export type SentinelOneGetRemoteScriptsParams = TypeOf<

View file

@ -24,6 +24,7 @@ import {
SentinelOneConfig,
SentinelOneFetchAgentFilesResponse,
SentinelOneGetAgentsResponse,
SentinelOneGetRemoteScriptResults,
SentinelOneSecrets,
} from '../../../common/sentinelone/types';
@ -132,6 +133,18 @@ const createAgentDetailsMock = (
return merge(details, overrides);
};
const createRemoteScriptResultsMock = (): SentinelOneGetRemoteScriptResults => {
return {
download_links: [
{
downloadUrl: 'https://remote/script/results/download',
fileName: 'some_file_name',
taskId: 'task-123',
},
],
};
};
const createGetAgentsApiResponseMock = (): SentinelOneGetAgentsResponse => {
return {
pagination: {
@ -163,6 +176,10 @@ class SentinelOneConnectorTestClass extends SentinelOneConnector {
data: { success: true },
} as SentinelOneFetchAgentFilesResponse,
downloadAgentFileApiResponse: Readable.from(['test']),
getRemoteScriptResults: {
data: createRemoteScriptResultsMock(),
},
downloadRemoteScriptResults: Readable.from(['test']),
};
public requestSpy = jest.fn(async ({ url }: SubActionRequestParams<any>) => {
@ -179,6 +196,14 @@ class SentinelOneConnectorTestClass extends SentinelOneConnector {
return sentinelOneConnectorMocks.createAxiosResponse(
this.mockResponses.downloadAgentFileApiResponse
);
} else if (/remote-scripts\/fetch-files/.test(url)) {
return sentinelOneConnectorMocks.createAxiosResponse(
this.mockResponses.getRemoteScriptResults
);
} else if (/remote\/script\/results\/download/.test(url)) {
return sentinelOneConnectorMocks.createAxiosResponse(
this.mockResponses.downloadRemoteScriptResults
);
}
return response;
@ -213,4 +238,5 @@ export const sentinelOneConnectorMocks = Object.freeze({
createAxiosResponse: createAxiosResponseMock,
createGetAgentsApiResponse: createGetAgentsApiResponseMock,
createAgentDetails: createAgentDetailsMock,
createRemoteScriptResults: createRemoteScriptResultsMock,
});

View file

@ -120,4 +120,31 @@ describe('SentinelOne Connector', () => {
);
});
});
describe('#downloadRemoteScriptResults()', () => {
it('should call SentinelOne api to retrieve task results', async () => {
await connectorInstance.downloadRemoteScriptResults({ taskId: 'task-123' });
expect(connectorInstance.requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
url: `${connectorInstance.constructorParams.config.url}${API_PATH}/remote-scripts/fetch-files`,
data: { data: { taskIds: ['task-123'] } },
})
);
});
it('should error if task does not have a download url', async () => {
connectorInstance.mockResponses.getRemoteScriptResults.data.download_links = [];
await expect(
connectorInstance.downloadRemoteScriptResults({ taskId: 'task-123' })
).rejects.toThrow('Download URL for script results of task id [task-123] not found');
});
it('should return a Stream for downloading the file', async () => {
await expect(
connectorInstance.downloadRemoteScriptResults({ taskId: 'task-123' })
).resolves.toEqual(connectorInstance.mockResponses.downloadRemoteScriptResults);
});
});
});

View file

@ -8,6 +8,7 @@
import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
import type { AxiosError } from 'axios';
import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
import { Stream } from 'stream';
import type {
SentinelOneConfig,
SentinelOneSecrets,
@ -37,12 +38,20 @@ import {
SentinelOneDownloadAgentFileResponseSchema,
SentinelOneGetActivitiesParamsSchema,
SentinelOneGetActivitiesResponseSchema,
SentinelOneGetRemoteScriptResultsResponseSchema,
SentinelOneGetRemoteScriptResultsParamsSchema,
SentinelOneDownloadRemoteScriptResultsParamsSchema,
SentinelOneDownloadRemoteScriptResultsResponseSchema,
} from '../../../common/sentinelone/schema';
import { SUB_ACTION } from '../../../common/sentinelone/constants';
import {
SentinelOneFetchAgentFilesParams,
SentinelOneDownloadAgentFileParams,
SentinelOneGetActivitiesParams,
SentinelOneGetRemoteScriptResultsParams,
SentinelOneDownloadRemoteScriptResultsParams,
SentinelOneGetRemoteScriptResultsApiResponse,
SentinelOneGetRemoteScriptStatusApiResponse,
} from '../../../common/sentinelone/types';
export const API_MAX_RESULTS = 1000;
@ -59,6 +68,7 @@ export class SentinelOneConnector extends SubActionConnector<
remoteScripts: string;
remoteScriptStatus: string;
remoteScriptsExecute: string;
remoteScriptsResults: string;
activities: string;
};
@ -71,6 +81,7 @@ export class SentinelOneConnector extends SubActionConnector<
remoteScripts: `${this.config.url}${API_PATH}/remote-scripts`,
remoteScriptStatus: `${this.config.url}${API_PATH}/remote-scripts/status`,
remoteScriptsExecute: `${this.config.url}${API_PATH}/remote-scripts/execute`,
remoteScriptsResults: `${this.config.url}${API_PATH}/remote-scripts/fetch-files`,
agents: `${this.config.url}${API_PATH}/agents`,
activities: `${this.config.url}${API_PATH}/activities`,
};
@ -109,6 +120,18 @@ export class SentinelOneConnector extends SubActionConnector<
schema: SentinelOneGetRemoteScriptStatusParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.GET_REMOTE_SCRIPT_RESULTS,
method: 'getRemoteScriptResults',
schema: SentinelOneGetRemoteScriptResultsParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.DOWNLOAD_REMOTE_SCRIPT_RESULTS,
method: 'downloadRemoteScriptResults',
schema: SentinelOneDownloadRemoteScriptResultsParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.GET_AGENTS,
method: 'getAgents',
@ -269,14 +292,59 @@ export class SentinelOneConnector extends SubActionConnector<
});
}
public async getRemoteScriptStatus(payload: SentinelOneGetRemoteScriptStatusParams) {
public async getRemoteScriptStatus(
payload: SentinelOneGetRemoteScriptStatusParams
): Promise<SentinelOneGetRemoteScriptStatusApiResponse> {
return this.sentinelOneApiRequest({
url: this.urls.remoteScriptStatus,
params: {
parent_task_id: payload.parentTaskId,
},
responseSchema: SentinelOneGetRemoteScriptStatusResponseSchema,
}) as unknown as SentinelOneGetRemoteScriptStatusApiResponse;
}
public async getRemoteScriptResults({
taskIds,
}: SentinelOneGetRemoteScriptResultsParams): Promise<SentinelOneGetRemoteScriptResultsApiResponse> {
return this.sentinelOneApiRequest({
url: this.urls.remoteScriptsResults,
method: 'post',
data: { data: { taskIds } },
responseSchema: SentinelOneGetRemoteScriptResultsResponseSchema,
}) as unknown as SentinelOneGetRemoteScriptResultsApiResponse;
}
public async downloadRemoteScriptResults({
taskId,
}: SentinelOneDownloadRemoteScriptResultsParams): Promise<Stream> {
const scriptResultsInfo = await this.getRemoteScriptResults({ taskIds: [taskId] });
this.logger.debug(
() => `script results for taskId [${taskId}]:\n${JSON.stringify(scriptResultsInfo)}`
);
let fileUrl: string = '';
for (const downloadLinkInfo of scriptResultsInfo.data.download_links) {
if (downloadLinkInfo.taskId === taskId) {
fileUrl = downloadLinkInfo.downloadUrl;
break;
}
}
if (!fileUrl) {
throw new Error(`Download URL for script results of task id [${taskId}] not found`);
}
const downloadConnection = await this.request({
url: fileUrl,
method: 'get',
responseType: 'stream',
responseSchema: SentinelOneDownloadRemoteScriptResultsResponseSchema,
});
return downloadConnection.data;
}
private async sentinelOneApiRequest<R extends SentinelOneBaseApiResponse>(