diff --git a/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts b/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts index c5186edf4a378..a75f379ce471f 100644 --- a/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts +++ b/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts @@ -14,4 +14,7 @@ export enum SUB_ACTION { HOST_ACTIONS = 'hostActions', GET_AGENT_ONLINE_STATUS = 'getAgentOnlineStatus', EXECUTE_RTR_COMMAND = 'executeRTRCommand', + EXECUTE_ACTIVE_RESPONDER_RTR = 'batchActiveResponderExecuteRTR', + EXECUTE_ADMIN_RTR = 'batchAdminExecuteRTR', + GET_RTR_CLOUD_SCRIPTS = 'getRTRCloudScripts', } diff --git a/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts b/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts index d5e8cd693af98..2d69120d3d2fc 100644 --- a/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts +++ b/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts @@ -307,3 +307,46 @@ export const CrowdstrikeInitRTRResponseSchema = schema.object( export const CrowdstrikeInitRTRParamsSchema = schema.object({ endpoint_ids: schema.arrayOf(schema.string()), }); + +export const CrowdstrikeExecuteRTRResponseSchema = schema.object( + { + combined: schema.object( + { + resources: schema.recordOf( + schema.string(), + schema.object( + { + session_id: schema.maybe(schema.string()), + task_id: schema.maybe(schema.string()), + complete: schema.maybe(schema.boolean()), + stdout: schema.maybe(schema.string()), + stderr: schema.maybe(schema.string()), + base_command: schema.maybe(schema.string()), + aid: schema.maybe(schema.string()), + errors: schema.maybe(schema.arrayOf(schema.any())), + query_time: schema.maybe(schema.number()), + offline_queued: schema.maybe(schema.boolean()), + }, + { unknowns: 'allow' } + ) + ), + }, + { unknowns: 'allow' } + ), + meta: schema.object( + { + query_time: schema.maybe(schema.number()), + powered_by: schema.maybe(schema.string()), + trace_id: schema.maybe(schema.string()), + }, + { unknowns: 'allow' } + ), + errors: schema.nullable(schema.arrayOf(schema.any())), + }, + { unknowns: 'allow' } +); + +export type CrowdStrikeExecuteRTRResponse = typeof CrowdstrikeExecuteRTRResponseSchema; + +// TODO: will be part of a next PR +export const CrowdstrikeGetScriptsParamsSchema = schema.any({}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts index eec431d8a4dcf..815e22de5259c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts @@ -345,70 +345,185 @@ describe('CrowdstrikeConnector', () => { expect(mockedRequest).toHaveBeenCalledTimes(3); }); }); - describe('batchInitRTRSession', () => { + describe('executeRTRCommand', () => { it('should make a POST request to the correct URL with correct data', async () => { - const mockResponse = { data: { batch_id: 'testBatchId' } }; + const mockResponse = { data: obfuscatedRTRResponse }; + mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); - mockedRequest.mockResolvedValueOnce(mockResponse); + mockedRequest.mockResolvedValue(mockResponse); - await connector.batchInitRTRSession( - { endpoint_ids: ['id1', 'id2'] }, + const result = await connector.executeRTRCommand( + { + command: 'runscript -Raw', + endpoint_ids: ['id1', 'id2'], + }, + connectorUsageCollector + ); + + expect(mockedRequest).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + url: 'https://api.crowdstrike.com/real-time-response/combined/batch-command/v1', + method: 'post', + data: expect.objectContaining({ + command_string: 'runscript -Raw', + hosts: ['id1', 'id2'], + }), + }), + connectorUsageCollector + ); + + expect(result).toEqual(obfuscatedRTRResponse); + }); + }); + + describe('batchActiveResponderExecuteRTR', () => { + it('should make a POST request to the correct URL with correct data', async () => { + const mockResponse = { data: obfuscatedRTRResponse }; + + mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); + mockedRequest.mockResolvedValue(mockResponse); + + const result = await connector.batchActiveResponderExecuteRTR( + { + command: 'runscript', + endpoint_ids: ['id1', 'id2'], + }, connectorUsageCollector ); expect(mockedRequest).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - headers: { - accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - authorization: expect.any(String), - }, + url: 'https://api.crowdstrike.com/oauth2/token', method: 'post', - responseSchema: expect.any(Object), - url: tokenPath, }), connectorUsageCollector ); + expect(mockedRequest).toHaveBeenNthCalledWith( - 2, + 3, expect.objectContaining({ - url: 'https://api.crowdstrike.com/real-time-response/combined/batch-init-session/v1', + url: 'https://api.crowdstrike.com/real-time-response/combined/batch-active-responder-command/v1', method: 'post', - data: { host_ids: ['id1', 'id2'] }, - paramsSerializer: expect.any(Function), - responseSchema: expect.any(Object), }), connectorUsageCollector ); - // @ts-expect-error private static - but I still want to test it - expect(CrowdstrikeConnector.currentBatchId).toBe('testBatchId'); + + expect(result).toEqual(obfuscatedRTRResponse); }); + }); + + describe('batchAdminExecuteRTR', () => { + it('should make a POST request to the correct URL with correct data', async () => { + const mockResponse = { data: obfuscatedRTRResponse }; - it('should handle error when fetching batch init session', async () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); - mockedRequest.mockRejectedValueOnce(new Error('Failed to fetch batch init session')); + mockedRequest.mockResolvedValue(mockResponse); + + const result = await connector.batchAdminExecuteRTR( + { + command: 'runscript', + endpoint_ids: ['id1', 'id2'], + }, + connectorUsageCollector + ); + + expect(mockedRequest).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + url: 'https://api.crowdstrike.com/oauth2/token', + method: 'post', + }), + connectorUsageCollector + ); + + expect(mockedRequest).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + url: 'https://api.crowdstrike.com/real-time-response/combined/batch-admin-command/v1', + method: 'post', + }), + connectorUsageCollector + ); - await expect( - connector.batchInitRTRSession({ endpoint_ids: ['id1', 'id2'] }, connectorUsageCollector) - ).rejects.toThrow('Failed to fetch batch init session'); + expect(result).toEqual(obfuscatedRTRResponse); }); + }); + + describe('getRTRCloudScripts', () => { + it('should make a GET request to the correct URL with correct params', async () => { + const mockResponse = { data: { scripts: [{}] } }; - it('should retry once if token is invalid', async () => { - const mockResponse = { data: { batch_id: 'testBatchId' } }; mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); - mockedRequest.mockRejectedValueOnce({ code: 401 }); - mockedRequest.mockResolvedValueOnce({ data: { access_token: 'newTestToken' } }); mockedRequest.mockResolvedValueOnce(mockResponse); - await connector.batchInitRTRSession( - { endpoint_ids: ['id1', 'id2'] }, + const result = await connector.getRTRCloudScripts( + { ids: ['script1', 'script2'] }, + connectorUsageCollector + ); + + expect(mockedRequest).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + url: 'https://api.crowdstrike.com/oauth2/token', + method: 'post', + }), connectorUsageCollector ); - expect(mockedRequest).toHaveBeenCalledTimes(4); - // @ts-expect-error private static - but I still want to test it - expect(CrowdstrikeConnector.currentBatchId).toBe('testBatchId'); + expect(mockedRequest).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: 'https://api.crowdstrike.com/real-time-response/entities/scripts/v1', + method: 'GET', + }), + connectorUsageCollector + ); + + expect(result).toEqual({ scripts: [{}] }); }); }); }); + +const obfuscatedRTRResponse = { + combined: { + resources: { + host1: { + session_id: 'abcdef123456', + task_id: 'task123', + complete: true, + stdout: + 'bin \n boot \n dev \n etc \n home \n lib \n lib64 \n media \n mnt \n opt \n proc \n root \n run \n sbin \n srv \n sys \n tmp \n usr \n var \n', + stderr: '', + base_command: 'runscript', + aid: 'aid123', + errors: [{ message: 'Error example', code: 123 }], + query_time: 1234567890, + offline_queued: false, + }, + host2: { + session_id: 'ghijkl789101', + task_id: 'task456', + complete: false, + stdout: + 'bin \n boot \n dev \n etc \n home \n lib \n lib64 \n media \n mnt \n opt \n proc \n root \n run \n sbin \n srv \n sys \n tmp \n usr \n var \n', + stderr: '', + base_command: 'getscripts', + aid: 'aid456', + errors: null, + query_time: 9876543210, + offline_queued: true, + }, + }, + }, + meta: { + query_time: 1234567890, + powered_by: 'CrowdStrike', + trace_id: 'trace-abcdef123456', + }, + errors: [ + { message: 'An example error', code: 500 }, + { message: 'Another error example', code: 404 }, + ], +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts index bd1f6613d5f04..0a41bbc7dde65 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts @@ -22,7 +22,6 @@ import type { CrowdstrikeGetTokenResponse, CrowdstrikeGetAgentOnlineStatusResponse, RelaxedCrowdstrikeBaseApiResponse, - CrowdstrikeInitRTRParams, } from '../../../common/crowdstrike/types'; import { CrowdstrikeHostActionsParamsSchema, @@ -30,12 +29,16 @@ import { CrowdstrikeGetTokenResponseSchema, CrowdstrikeHostActionsResponseSchema, RelaxedCrowdstrikeBaseApiResponseSchema, - CrowdstrikeInitRTRResponseSchema, CrowdstrikeRTRCommandParamsSchema, + CrowdstrikeExecuteRTRResponseSchema, + CrowdstrikeGetScriptsParamsSchema, + CrowdStrikeExecuteRTRResponse, } from '../../../common/crowdstrike/schema'; import { SUB_ACTION } from '../../../common/crowdstrike/constants'; import { CrowdstrikeError } from './error'; +const SUPPORTED_RTR_COMMANDS = ['runscript']; + const paramsSerializer = (params: Record) => { return Object.entries(params) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) @@ -56,8 +59,6 @@ export class CrowdstrikeConnector extends SubActionConnector< > { private static token: string | null; private static tokenExpiryTimeout: NodeJS.Timeout; - // @ts-expect-error not used at the moment, will be used in a follow up PR - private static currentBatchId: string | undefined; private static base64encodedToken: string; private experimentalFeatures: ExperimentalFeatures; @@ -69,6 +70,10 @@ export class CrowdstrikeConnector extends SubActionConnector< agentStatus: string; batchInitRTRSession: string; batchRefreshRTRSession: string; + batchExecuteRTR: string; + batchActiveResponderExecuteRTR: string; + batchAdminExecuteRTR: string; + getRTRCloudScriptsDetails: string; }; constructor( @@ -84,6 +89,10 @@ export class CrowdstrikeConnector extends SubActionConnector< agentStatus: `${this.config.url}/devices/entities/online-state/v1`, batchInitRTRSession: `${this.config.url}/real-time-response/combined/batch-init-session/v1`, batchRefreshRTRSession: `${this.config.url}/real-time-response/combined/batch-refresh-session/v1`, + batchExecuteRTR: `${this.config.url}/real-time-response/combined/batch-command/v1`, + batchActiveResponderExecuteRTR: `${this.config.url}/real-time-response/combined/batch-active-responder-command/v1`, + batchAdminExecuteRTR: `${this.config.url}/real-time-response/combined/batch-admin-command/v1`, + getRTRCloudScriptsDetails: `${this.config.url}/real-time-response/entities/scripts/v1`, }; if (!CrowdstrikeConnector.base64encodedToken) { @@ -124,6 +133,22 @@ export class CrowdstrikeConnector extends SubActionConnector< method: 'executeRTRCommand', schema: CrowdstrikeRTRCommandParamsSchema, // Define a proper schema for the command }); + this.registerSubAction({ + name: SUB_ACTION.EXECUTE_ACTIVE_RESPONDER_RTR, + method: 'batchActiveResponderExecuteRTR', + schema: CrowdstrikeRTRCommandParamsSchema, // Define a proper schema for the command + }); + this.registerSubAction({ + name: SUB_ACTION.EXECUTE_ADMIN_RTR, + method: 'batchAdminExecuteRTR', + schema: CrowdstrikeRTRCommandParamsSchema, // Define a proper schema for the command + }); + // temporary to fetch scripts and help testing + this.registerSubAction({ + name: SUB_ACTION.GET_RTR_CLOUD_SCRIPTS, + method: 'getRTRCloudScripts', + schema: CrowdstrikeGetScriptsParamsSchema, + }); } } @@ -194,7 +219,7 @@ export class CrowdstrikeConnector extends SubActionConnector< ) as Promise; } - private async getTokenRequest(connectorUsageCollector: ConnectorUsageCollector) { + private getTokenRequest = async (connectorUsageCollector: ConnectorUsageCollector) => { const response = await this.request( { url: this.urls.getToken, @@ -219,13 +244,13 @@ export class CrowdstrikeConnector extends SubActionConnector< }, 29 * 60 * 1000); } return token; - } + }; - private async crowdstrikeApiRequest( + private crowdstrikeApiRequest = async ( req: SubActionRequestParams, connectorUsageCollector: ConnectorUsageCollector, retried?: boolean - ): Promise { + ): Promise => { try { if (!CrowdstrikeConnector.token) { CrowdstrikeConnector.token = (await this.getTokenRequest( @@ -257,39 +282,114 @@ export class CrowdstrikeConnector extends SubActionConnector< } throw new CrowdstrikeError(error.message); } - } + }; - public async batchInitRTRSession( - payload: CrowdstrikeInitRTRParams, + // Helper method to execute RTR commands with different API endpoints + private executeRTRCommandWithUrl = async ( + url: string, + payload: { + command: string; + endpoint_ids: string[]; + overwriteUrl?: 'batchExecuteRTR' | 'batchActiveResponderExecuteRTR' | 'batchAdminExecuteRTR'; + }, connectorUsageCollector: ConnectorUsageCollector - ) { - const response = await this.crowdstrikeApiRequest( + ): Promise => { + // Some commands are only available in specific API endpoints, however there's an additional requirement check for the command's argument + // Eg. runscript command is available with the batchExecuteRTR endpoint, but if it goes with --Raw parameter, it should go to batchAdminExecuteRTR endpoint + // This overwrite value will be coming from kibana response actions api + const csUrl = payload.overwriteUrl ? this.urls[payload.overwriteUrl] : url; + + const batchId = await this.crowdStrikeSessionManager.initializeSession( + { endpoint_ids: payload.endpoint_ids }, + connectorUsageCollector + ); + + const baseCommand = payload.command.split(' ')[0]; + + if (!SUPPORTED_RTR_COMMANDS.includes(baseCommand)) { + throw new CrowdstrikeError('Command not supported'); + } + return await this.crowdstrikeApiRequest( { - url: this.urls.batchInitRTRSession, + url: csUrl, method: 'post', data: { - host_ids: payload.endpoint_ids, + base_command: baseCommand, + command_string: payload.command, + batch_id: batchId, + hosts: payload.endpoint_ids, + persist_all: false, }, paramsSerializer, - responseSchema: CrowdstrikeInitRTRResponseSchema, + responseSchema: + CrowdstrikeExecuteRTRResponseSchema as unknown as SubActionRequestParams['responseSchema'], }, connectorUsageCollector ); + }; + + // Public method for generic RTR command execution + public async executeRTRCommand( + payload: { + command: string; + endpoint_ids: string[]; + overwriteUrl?: 'batchActiveResponderExecuteRTR' | 'batchAdminExecuteRTR'; + }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + return await this.executeRTRCommandWithUrl( + this.urls.batchExecuteRTR, + payload, + connectorUsageCollector + ); + } - CrowdstrikeConnector.currentBatchId = response.batch_id; + // Public method for Active Responder RTR command execution + public async batchActiveResponderExecuteRTR( + payload: { + command: string; + endpoint_ids: string[]; + overwriteUrl?: 'batchAdminExecuteRTR'; + }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + return await this.executeRTRCommandWithUrl( + this.urls.batchActiveResponderExecuteRTR, + payload, + connectorUsageCollector + ); } - // TODO: WIP - just to have session init logic in place - public async executeRTRCommand( - payload: { command: string; endpoint_ids: string[] }, + // Public method for Admin RTR command execution + public async batchAdminExecuteRTR( + payload: { + command: string; + endpoint_ids: string[]; + }, connectorUsageCollector: ConnectorUsageCollector - ) { - const batchId = await this.crowdStrikeSessionManager.initializeSession( - { endpoint_ids: payload.endpoint_ids }, + ): Promise { + return await this.executeRTRCommandWithUrl( + this.urls.batchAdminExecuteRTR, + payload, connectorUsageCollector ); + } - return Promise.resolve({ batchId }); + // TODO: for now just for testing purposes, will be a part of a following PR + public async getRTRCloudScripts( + payload: CrowdstrikeGetAgentsParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + // @ts-expect-error will be a part of the next PR + return this.crowdstrikeApiRequest( + { + url: this.urls.getRTRCloudScriptsDetails, + method: 'GET', + paramsSerializer, + responseSchema: RelaxedCrowdstrikeBaseApiResponseSchema, + }, + connectorUsageCollector + ); } protected getResponseErrorMessage(