mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[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:
parent
4903542a79
commit
1cae23769a
15 changed files with 12099 additions and 11396 deletions
File diff suppressed because it is too large
Load diff
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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()', () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue