Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EDR Workflows] Initialize CrowdStrike session API #201420

Merged
merged 7 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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