From 99a413506f502f86fef438d334741ec2bad64728 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Thu, 28 Nov 2024 17:31:22 +0100 Subject: [PATCH] [EDR Workflows] Initialize CrowdStrike session API (#201420) --- .../crowdstrike_agent_status_client.ts | 2 +- .../common/crowdstrike/constants.ts | 1 + .../common/crowdstrike/schema.ts | 44 +++++ .../common/crowdstrike/types.ts | 4 + .../common/experimental_features.ts | 1 + .../crowdstrike/crowdstrike.test.ts | 86 +++++++- .../crowdstrike/crowdstrike.ts | 65 +++++- .../connector_types/crowdstrike/index.ts | 10 +- .../crowdstrike/rtr_session_manager.test.ts | 187 ++++++++++++++++++ .../crowdstrike/rtr_session_manager.ts | 106 ++++++++++ .../server/connector_types/index.ts | 2 +- 11 files changed, 492 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.ts diff --git a/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/crowdstrike/crowdstrike_agent_status_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/crowdstrike/crowdstrike_agent_status_client.ts index dff47cf86ab6f..1bd7c2eece1da 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/crowdstrike/crowdstrike_agent_status_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/crowdstrike/crowdstrike_agent_status_client.ts @@ -132,7 +132,7 @@ export class CrowdstrikeAgentStatusClient extends AgentStatusClient { const agentStatuses = await this.getAgentStatusFromConnectorAction(agentIds); return agentIds.reduce((acc, agentId) => { - const { device, crowdstrike } = mostRecentAgentInfosByAgentId[agentId]; + const { device, crowdstrike } = mostRecentAgentInfosByAgentId[agentId] || {}; const agentStatus = agentStatuses[agentId]; const pendingActions = allPendingActions.find( diff --git a/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts b/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts index c221d74c3b8a0..c5186edf4a378 100644 --- a/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts +++ b/x-pack/plugins/stack_connectors/common/crowdstrike/constants.ts @@ -13,4 +13,5 @@ export enum SUB_ACTION { GET_AGENT_DETAILS = 'getAgentDetails', HOST_ACTIONS = 'hostActions', GET_AGENT_ONLINE_STATUS = 'getAgentOnlineStatus', + EXECUTE_RTR_COMMAND = 'executeRTRCommand', } diff --git a/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts b/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts index 4e3d6464821b6..379adfb0fcc95 100644 --- a/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts +++ b/x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts @@ -231,6 +231,8 @@ export const CrowdstrikeHostActionsResponseSchema = schema.object( { unknowns: 'allow' } ); +// TODO temporary any value +export const CrowdstrikeRTRCommandParamsSchema = schema.any(); export const CrowdstrikeHostActionsParamsSchema = schema.object({ command: schema.oneOf([schema.literal('contain'), schema.literal('lift_containment')]), actionParameters: schema.maybe(schema.object({}, { unknowns: 'allow' })), @@ -261,3 +263,45 @@ export const CrowdstrikeHostActionsSchema = schema.object({ }); export const CrowdstrikeActionParamsSchema = schema.oneOf([CrowdstrikeHostActionsSchema]); + +export const CrowdstrikeInitRTRResponseSchema = schema.object( + { + meta: schema.maybe( + schema.object( + { + query_time: schema.maybe(schema.number()), + powered_by: schema.maybe(schema.string()), + trace_id: schema.maybe(schema.string()), + }, + { unknowns: 'allow' } + ) + ), + batch_id: schema.maybe(schema.string()), + resources: schema.maybe( + 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' } + ) + ) + ), + errors: schema.maybe(schema.arrayOf(schema.any())), + }, + { unknowns: 'allow' } +); + +export const CrowdstrikeInitRTRParamsSchema = schema.object({ + endpoint_ids: schema.arrayOf(schema.string()), +}); diff --git a/x-pack/plugins/stack_connectors/common/crowdstrike/types.ts b/x-pack/plugins/stack_connectors/common/crowdstrike/types.ts index c0f98ee1b90a1..3c9cc15ea167e 100644 --- a/x-pack/plugins/stack_connectors/common/crowdstrike/types.ts +++ b/x-pack/plugins/stack_connectors/common/crowdstrike/types.ts @@ -17,6 +17,8 @@ import { CrowdstrikeGetTokenResponseSchema, CrowdstrikeGetAgentsResponseSchema, RelaxedCrowdstrikeBaseApiResponseSchema, + CrowdstrikeInitRTRResponseSchema, + CrowdstrikeInitRTRParamsSchema, } from './schema'; export type CrowdstrikeConfig = TypeOf; @@ -33,7 +35,9 @@ export type CrowdstrikeGetAgentOnlineStatusResponse = TypeOf< typeof CrowdstrikeGetAgentOnlineStatusResponseSchema >; export type CrowdstrikeGetTokenResponse = TypeOf; +export type CrowdstrikeInitRTRResponse = TypeOf; export type CrowdstrikeHostActionsParams = TypeOf; export type CrowdstrikeActionParams = TypeOf; +export type CrowdstrikeInitRTRParams = TypeOf; diff --git a/x-pack/plugins/stack_connectors/common/experimental_features.ts b/x-pack/plugins/stack_connectors/common/experimental_features.ts index 9c81371b4b458..56f3ba247554c 100644 --- a/x-pack/plugins/stack_connectors/common/experimental_features.ts +++ b/x-pack/plugins/stack_connectors/common/experimental_features.ts @@ -16,6 +16,7 @@ export const allowedExperimentalValues = Object.freeze({ sentinelOneConnectorOn: true, crowdstrikeConnectorOn: true, inferenceConnectorOn: false, + crowdstrikeConnectorRTROn: false, }); export type ExperimentalConfigKeys = Array; 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 0c3d851981fde..eec431d8a4dcf 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 @@ -18,14 +18,18 @@ const onlineStatusPath = 'https://api.crowdstrike.com/devices/entities/online-st const actionsPath = 'https://api.crowdstrike.com/devices/entities/devices-actions/v2'; describe('CrowdstrikeConnector', () => { const logger = loggingSystemMock.createLogger(); - const connector = new CrowdstrikeConnector({ - configurationUtilities: actionsConfigMock.create(), - connector: { id: '1', type: CROWDSTRIKE_CONNECTOR_ID }, - config: { url: 'https://api.crowdstrike.com' }, - secrets: { clientId: '123', clientSecret: 'secret' }, - logger, - services: actionsMock.createServices(), - }); + const connector = new CrowdstrikeConnector( + { + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: CROWDSTRIKE_CONNECTOR_ID }, + config: { url: 'https://api.crowdstrike.com' }, + secrets: { clientId: '123', clientSecret: 'secret' }, + logger, + services: actionsMock.createServices(), + }, + // @ts-expect-error passing a true value just for testing purposes + { crowdstrikeConnectorRTROn: true } + ); let mockedRequest: jest.Mock; let connectorUsageCollector: ConnectorUsageCollector; @@ -341,4 +345,70 @@ describe('CrowdstrikeConnector', () => { expect(mockedRequest).toHaveBeenCalledTimes(3); }); }); + describe('batchInitRTRSession', () => { + it('should make a POST request to the correct URL with correct data', async () => { + const mockResponse = { data: { batch_id: 'testBatchId' } }; + mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); + mockedRequest.mockResolvedValueOnce(mockResponse); + + await connector.batchInitRTRSession( + { 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), + }, + method: 'post', + responseSchema: expect.any(Object), + url: tokenPath, + }), + connectorUsageCollector + ); + expect(mockedRequest).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: 'https://api.crowdstrike.com/real-time-response/combined/batch-init-session/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'); + }); + + 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')); + + await expect( + connector.batchInitRTRSession({ endpoint_ids: ['id1', 'id2'] }, connectorUsageCollector) + ).rejects.toThrow('Failed to fetch batch init session'); + }); + + 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'] }, + connectorUsageCollector + ); + + expect(mockedRequest).toHaveBeenCalledTimes(4); + // @ts-expect-error private static - but I still want to test it + expect(CrowdstrikeConnector.currentBatchId).toBe('testBatchId'); + }); + }); }); 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 a4fc84ae6a49a..686612956d175 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 @@ -10,6 +10,8 @@ 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 { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { CrowdStrikeSessionManager } from './rtr_session_manager'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; import { isAggregateError, NodeSystemError } from './types'; import type { CrowdstrikeConfig, @@ -20,6 +22,7 @@ import type { CrowdstrikeGetTokenResponse, CrowdstrikeGetAgentOnlineStatusResponse, RelaxedCrowdstrikeBaseApiResponse, + CrowdstrikeInitRTRParams, } from '../../../common/crowdstrike/types'; import { CrowdstrikeHostActionsParamsSchema, @@ -27,6 +30,8 @@ import { CrowdstrikeGetTokenResponseSchema, CrowdstrikeHostActionsResponseSchema, RelaxedCrowdstrikeBaseApiResponseSchema, + CrowdstrikeInitRTRResponseSchema, + CrowdstrikeRTRCommandParamsSchema, } from '../../../common/crowdstrike/schema'; import { SUB_ACTION } from '../../../common/crowdstrike/constants'; import { CrowdstrikeError } from './error'; @@ -51,21 +56,34 @@ 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; + + private crowdStrikeSessionManager: CrowdStrikeSessionManager; private urls: { getToken: string; agents: string; hostAction: string; agentStatus: string; + batchInitRTRSession: string; + batchRefreshRTRSession: string; }; - constructor(params: ServiceParams) { + constructor( + params: ServiceParams, + experimentalFeatures: ExperimentalFeatures + ) { super(params); + this.experimentalFeatures = experimentalFeatures; this.urls = { getToken: `${this.config.url}/oauth2/token`, hostAction: `${this.config.url}/devices/entities/devices-actions/v2`, agents: `${this.config.url}/devices/entities/devices/v2`, 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`, }; if (!CrowdstrikeConnector.base64encodedToken) { @@ -74,6 +92,10 @@ export class CrowdstrikeConnector extends SubActionConnector< ).toString('base64'); } + this.crowdStrikeSessionManager = new CrowdStrikeSessionManager( + this.urls, + this.crowdstrikeApiRequest + ); this.registerSubActions(); } @@ -95,6 +117,14 @@ export class CrowdstrikeConnector extends SubActionConnector< method: 'getAgentOnlineStatus', schema: CrowdstrikeGetAgentsParamsSchema, }); + + if (this.experimentalFeatures.crowdstrikeConnectorRTROn) { + this.registerSubAction({ + name: SUB_ACTION.EXECUTE_RTR_COMMAND, + method: 'executeRTRCommand', + schema: CrowdstrikeRTRCommandParamsSchema, // Define a proper schema for the command + }); + } } public async executeHostActions( @@ -224,6 +254,39 @@ export class CrowdstrikeConnector extends SubActionConnector< } } + public async batchInitRTRSession( + payload: CrowdstrikeInitRTRParams, + connectorUsageCollector: ConnectorUsageCollector + ) { + const response = await this.crowdstrikeApiRequest( + { + url: this.urls.batchInitRTRSession, + method: 'post', + data: { + host_ids: payload.endpoint_ids, + }, + paramsSerializer, + responseSchema: CrowdstrikeInitRTRResponseSchema, + }, + connectorUsageCollector + ); + + CrowdstrikeConnector.currentBatchId = response.batch_id; + } + + // TODO: WIP - just to have session init logic in place + public async executeRTRCommand( + payload: { command: string; endpoint_ids: string[] }, + connectorUsageCollector: ConnectorUsageCollector + ) { + const batchId = await this.crowdStrikeSessionManager.initializeSession( + { endpoint_ids: payload.endpoint_ids }, + connectorUsageCollector + ); + + return Promise.resolve({ batchId }); + } + protected getResponseErrorMessage( error: AxiosError<{ errors: Array<{ message: string; code: number }> }> ): string { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/index.ts index f7c50478979c7..0617822837c0a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/index.ts @@ -11,6 +11,7 @@ import { } from '@kbn/actions-plugin/server/sub_action_framework/types'; import { SecurityConnectorFeatureId } from '@kbn/actions-plugin/common'; import { urlAllowListValidator } from '@kbn/actions-plugin/server'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; import { CROWDSTRIKE_CONNECTOR_ID, CROWDSTRIKE_TITLE } from '../../../common/crowdstrike/constants'; import { CrowdstrikeConfigSchema, @@ -19,13 +20,12 @@ import { import { CrowdstrikeConfig, CrowdstrikeSecrets } from '../../../common/crowdstrike/types'; import { CrowdstrikeConnector } from './crowdstrike'; -export const getCrowdstrikeConnectorType = (): SubActionConnectorType< - CrowdstrikeConfig, - CrowdstrikeSecrets -> => ({ +export const getCrowdstrikeConnectorType = ( + experimentalFeatures: ExperimentalFeatures +): SubActionConnectorType => ({ id: CROWDSTRIKE_CONNECTOR_ID, name: CROWDSTRIKE_TITLE, - getService: (params) => new CrowdstrikeConnector(params), + getService: (params) => new CrowdstrikeConnector(params, experimentalFeatures), schema: { config: CrowdstrikeConfigSchema, secrets: CrowdstrikeSecretsSchema, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.test.ts new file mode 100644 index 0000000000000..c5ba7b112c946 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.test.ts @@ -0,0 +1,187 @@ +/* + * 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 { CrowdStrikeSessionManager } from './rtr_session_manager'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { CrowdstrikeInitRTRResponseSchema } from '../../../common/crowdstrike/schema'; + +// There is a lot of logic in private fields/methods of CrowdStrikeSessionManager that we need to test. +class TestableCrowdStrikeSessionManager extends CrowdStrikeSessionManager { + // Expose private fields + public getCurrentBatchId(): string | null { + return this.currentBatchId; + } + + public getRefreshInterval(): NodeJS.Timeout | null { + return this.refreshInterval; + } + + public getCloseSessionTimeout(): NodeJS.Timeout | null { + return this.closeSessionTimeout; + } + + // Expose private methods + public callStartRefreshInterval(connectorUsageCollector: ConnectorUsageCollector): void { + this.startRefreshInterval(connectorUsageCollector); + } + + public callResetCloseSessionTimeout(): void { + this.resetCloseSessionTimeout(); + } + + public async callRefreshSession(connectorUsageCollector: ConnectorUsageCollector): Promise { + await this.refreshSession(connectorUsageCollector); + } + + public async callTerminateSession(): Promise { + await this.terminateSession(); + } +} + +jest.useFakeTimers(); + +describe('CrowdstrikeSessionManager', () => { + const mockUrls = { + batchInitRTRSession: 'https://api.example.com/init', + batchRefreshRTRSession: 'https://api.example.com/refresh', + }; + + let mockApiRequest: jest.Mock; + let mockConnectorUsageCollector: ConnectorUsageCollector; + let sessionManager: TestableCrowdStrikeSessionManager; + + beforeEach(() => { + jest.clearAllMocks(); // Clear mocks to reset timer calls + + mockApiRequest = jest.fn(); + mockConnectorUsageCollector = {} as ConnectorUsageCollector; + + sessionManager = new TestableCrowdStrikeSessionManager(mockUrls, mockApiRequest); + }); + + describe('initializeSession', () => { + it('should initialize a session if no current batch ID exists', async () => { + const mockResponse = { batch_id: 'mock-batch-id' }; + mockApiRequest.mockResolvedValueOnce(mockResponse); + + const payload = { endpoint_ids: ['endpoint1', 'endpoint2'] }; + const result = await sessionManager.initializeSession(payload, mockConnectorUsageCollector); + + expect(mockApiRequest).toHaveBeenCalledWith( + { + url: mockUrls.batchInitRTRSession, + method: 'post', + data: { host_ids: payload.endpoint_ids }, + responseSchema: CrowdstrikeInitRTRResponseSchema, + }, + mockConnectorUsageCollector + ); + + expect(result).toEqual('mock-batch-id'); + expect(sessionManager.getCurrentBatchId()).toEqual('mock-batch-id'); + }); + + it('should reset the close session timeout after initialization', async () => { + const mockResponse = { batch_id: 'mock-batch-id' }; + mockApiRequest.mockResolvedValueOnce(mockResponse); + + const payload = { endpoint_ids: ['endpoint1', 'endpoint2'] }; + await sessionManager.initializeSession(payload, mockConnectorUsageCollector); + + // Advance timers to simulate the timeout trigger + jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes + + expect(sessionManager.getCloseSessionTimeout()).toBeNull(); + }); + }); + + describe('terminateSession', () => { + it('should clear refresh interval and close session timeout', async () => { + sessionManager.callResetCloseSessionTimeout(); + sessionManager.callStartRefreshInterval(mockConnectorUsageCollector); + + await sessionManager.callTerminateSession(); + + // Verify both timers were cleared + expect(sessionManager.getRefreshInterval()).toBeNull(); + expect(sessionManager.getCloseSessionTimeout()).toBeNull(); + }); + }); + + describe('startRefreshInterval', () => { + it('should start a new refresh interval', () => { + sessionManager.callStartRefreshInterval(mockConnectorUsageCollector); + + // Simulate timer triggering + jest.advanceTimersByTime(5 * 60 * 1000); // 5 minutes + + // Validate that `refreshSession` was called + expect(mockApiRequest).toHaveBeenCalledWith( + { + url: mockUrls.batchRefreshRTRSession, + method: 'post', + data: { batch_id: null }, + responseSchema: CrowdstrikeInitRTRResponseSchema, + }, + mockConnectorUsageCollector + ); + }); + + it('should clear any existing refresh interval before starting a new one', () => { + // Spy on clearInterval to verify it was called + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + expect(sessionManager.getRefreshInterval()).toBeNull(); + + // Start the first interval + sessionManager.callStartRefreshInterval(mockConnectorUsageCollector); + const firstInterval = sessionManager.getRefreshInterval(); + expect(firstInterval).not.toBeNull(); + + // Start another interval + sessionManager.callStartRefreshInterval(mockConnectorUsageCollector); + const secondInterval = sessionManager.getRefreshInterval(); + + // Ensure a new interval was set + expect(secondInterval).not.toBeNull(); + expect(secondInterval).not.toBe(firstInterval); // Verify the interval changed + + // Verify clearInterval was called with the first interval + expect(clearIntervalSpy).toHaveBeenCalledWith(firstInterval); + }); + }); + + describe('resetCloseSessionTimeout', () => { + it('should set a timeout to terminate the session after 10 minutes of inactivity', () => { + sessionManager.callResetCloseSessionTimeout(); + + // Fast forward time to simulate timeout execution + jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes + + // Ensure timeout was triggered + expect(sessionManager.getCloseSessionTimeout()).toBeNull(); + }); + + it('should clear any existing timeout before setting a new one', () => { + // Set the first timeout + sessionManager.callResetCloseSessionTimeout(); + + const firstTimeout = sessionManager.getCloseSessionTimeout(); + expect(firstTimeout).not.toBeNull(); + + // Set another timeout, which should replace the first one + sessionManager.callResetCloseSessionTimeout(); + + const secondTimeout = sessionManager.getCloseSessionTimeout(); + expect(secondTimeout).not.toBe(firstTimeout); + + // Simulate timeout expiration + jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes + expect(sessionManager.getCloseSessionTimeout()).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.ts b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.ts new file mode 100644 index 0000000000000..6830049f2e518 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/rtr_session_manager.ts @@ -0,0 +1,106 @@ +/* + * 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 { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { CrowdstrikeInitRTRResponseSchema } from '../../../common/crowdstrike/schema'; +import { + CrowdstrikeInitRTRParams, + RelaxedCrowdstrikeBaseApiResponse, +} from '../../../common/crowdstrike/types'; + +export class CrowdStrikeSessionManager { + protected currentBatchId: string | null = null; + protected refreshInterval: NodeJS.Timeout | null = null; + protected closeSessionTimeout: NodeJS.Timeout | null = null; + + constructor( + private urls: { batchInitRTRSession: string; batchRefreshRTRSession: string }, + private apiRequest: ( + req: SubActionRequestParams, + connectorUsageCollector: ConnectorUsageCollector, + retried?: boolean + ) => Promise + ) {} + + async initializeSession( + payload: CrowdstrikeInitRTRParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + if (!this.currentBatchId) { + // Make a request to initialize the session + const response = await this.apiRequest( + { + url: this.urls.batchInitRTRSession, + method: 'post', + data: { + host_ids: payload.endpoint_ids, + }, + responseSchema: CrowdstrikeInitRTRResponseSchema, + }, + connectorUsageCollector + ); + + this.currentBatchId = response.batch_id!; + + // Start the refresh interval + this.startRefreshInterval(connectorUsageCollector); + } + + // Reset the close session timeout + this.resetCloseSessionTimeout(); + + return this.currentBatchId; + } + + protected startRefreshInterval(connectorUsageCollector: ConnectorUsageCollector) { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + + this.refreshInterval = setInterval(() => { + this.refreshSession(connectorUsageCollector).catch(() => {}); + }, 5 * 60 * 1000); // Refresh every 5 minutes + } + + protected async refreshSession(connectorUsageCollector: ConnectorUsageCollector): Promise { + await this.apiRequest( + { + url: this.urls.batchRefreshRTRSession, + method: 'post', + data: { + batch_id: this.currentBatchId, + }, + responseSchema: CrowdstrikeInitRTRResponseSchema, + }, + connectorUsageCollector + ); + } + + protected resetCloseSessionTimeout() { + if (this.closeSessionTimeout) { + clearTimeout(this.closeSessionTimeout); + } + + this.closeSessionTimeout = setTimeout(() => { + this.terminateSession().catch(() => {}); + }, 10 * 60 * 1000); // Close session after 10 minutes of inactivity + } + + protected async terminateSession(): Promise { + // Clear intervals and timeouts + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + + if (this.closeSessionTimeout) { + clearTimeout(this.closeSessionTimeout); + this.closeSessionTimeout = null; + } + } +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index 09d8a44c2a287..a156547cc2fa6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -117,7 +117,7 @@ export function registerConnectorTypes({ actions.registerSubActionConnectorType(getSentinelOneConnectorType()); } if (experimentalFeatures.crowdstrikeConnectorOn) { - actions.registerSubActionConnectorType(getCrowdstrikeConnectorType()); + actions.registerSubActionConnectorType(getCrowdstrikeConnectorType(experimentalFeatures)); } if (experimentalFeatures.inferenceConnectorOn) { actions.registerSubActionConnectorType(getInferenceConnectorType());