Skip to content

Commit

Permalink
[EDR Workflows] Initialize CrowdStrike session API (elastic#201420)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomsonpl authored Nov 28, 2024
1 parent 2890570 commit 99a4135
Show file tree
Hide file tree
Showing 11 changed files with 492 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export class CrowdstrikeAgentStatusClient extends AgentStatusClient {
const agentStatuses = await this.getAgentStatusFromConnectorAction(agentIds);

return agentIds.reduce<AgentStatusRecords>((acc, agentId) => {
const { device, crowdstrike } = mostRecentAgentInfosByAgentId[agentId];
const { device, crowdstrike } = mostRecentAgentInfosByAgentId[agentId] || {};

const agentStatus = agentStatuses[agentId];
const pendingActions = allPendingActions.find(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export enum SUB_ACTION {
GET_AGENT_DETAILS = 'getAgentDetails',
HOST_ACTIONS = 'hostActions',
GET_AGENT_ONLINE_STATUS = 'getAgentOnlineStatus',
EXECUTE_RTR_COMMAND = 'executeRTRCommand',
}
44 changes: 44 additions & 0 deletions x-pack/plugins/stack_connectors/common/crowdstrike/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })),
Expand Down Expand Up @@ -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()),
});
4 changes: 4 additions & 0 deletions x-pack/plugins/stack_connectors/common/crowdstrike/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
CrowdstrikeGetTokenResponseSchema,
CrowdstrikeGetAgentsResponseSchema,
RelaxedCrowdstrikeBaseApiResponseSchema,
CrowdstrikeInitRTRResponseSchema,
CrowdstrikeInitRTRParamsSchema,
} from './schema';

export type CrowdstrikeConfig = TypeOf<typeof CrowdstrikeConfigSchema>;
Expand All @@ -33,7 +35,9 @@ export type CrowdstrikeGetAgentOnlineStatusResponse = TypeOf<
typeof CrowdstrikeGetAgentOnlineStatusResponseSchema
>;
export type CrowdstrikeGetTokenResponse = TypeOf<typeof CrowdstrikeGetTokenResponseSchema>;
export type CrowdstrikeInitRTRResponse = TypeOf<typeof CrowdstrikeInitRTRResponseSchema>;

export type CrowdstrikeHostActionsParams = TypeOf<typeof CrowdstrikeHostActionsParamsSchema>;

export type CrowdstrikeActionParams = TypeOf<typeof CrowdstrikeActionParamsSchema>;
export type CrowdstrikeInitRTRParams = TypeOf<typeof CrowdstrikeInitRTRParamsSchema>;
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const allowedExperimentalValues = Object.freeze({
sentinelOneConnectorOn: true,
crowdstrikeConnectorOn: true,
inferenceConnectorOn: false,
crowdstrikeConnectorRTROn: false,
});

export type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,13 +22,16 @@ import type {
CrowdstrikeGetTokenResponse,
CrowdstrikeGetAgentOnlineStatusResponse,
RelaxedCrowdstrikeBaseApiResponse,
CrowdstrikeInitRTRParams,
} from '../../../common/crowdstrike/types';
import {
CrowdstrikeHostActionsParamsSchema,
CrowdstrikeGetAgentsParamsSchema,
CrowdstrikeGetTokenResponseSchema,
CrowdstrikeHostActionsResponseSchema,
RelaxedCrowdstrikeBaseApiResponseSchema,
CrowdstrikeInitRTRResponseSchema,
CrowdstrikeRTRCommandParamsSchema,
} from '../../../common/crowdstrike/schema';
import { SUB_ACTION } from '../../../common/crowdstrike/constants';
import { CrowdstrikeError } from './error';
Expand All @@ -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<CrowdstrikeConfig, CrowdstrikeSecrets>) {
constructor(
params: ServiceParams<CrowdstrikeConfig, CrowdstrikeSecrets>,
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) {
Expand All @@ -74,6 +92,10 @@ export class CrowdstrikeConnector extends SubActionConnector<
).toString('base64');
}

this.crowdStrikeSessionManager = new CrowdStrikeSessionManager(
this.urls,
this.crowdstrikeApiRequest
);
this.registerSubActions();
}

Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<CrowdstrikeConfig, CrowdstrikeSecrets> => ({
id: CROWDSTRIKE_CONNECTOR_ID,
name: CROWDSTRIKE_TITLE,
getService: (params) => new CrowdstrikeConnector(params),
getService: (params) => new CrowdstrikeConnector(params, experimentalFeatures),
schema: {
config: CrowdstrikeConfigSchema,
secrets: CrowdstrikeSecretsSchema,
Expand Down
Loading

0 comments on commit 99a4135

Please sign in to comment.