[EDR Workflows] Add Crowdstrike connector and Actions (#180175)

This commit is contained in:
Tomasz Ciecierski 2024-04-17 20:51:19 +02:00 committed by GitHub
parent e47ac7f2d9
commit b66be1f69b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1345 additions and 48 deletions

View file

@ -19,6 +19,7 @@ import {
ActionTypeSecrets,
ActionTypeParams,
} from './types';
import { isBidirectionalConnectorType } from './lib/bidirectional_connectors';
export interface ActionTypeRegistryOpts {
licensing: LicensingPluginSetup;
@ -230,8 +231,10 @@ export class ActionTypeRegistry {
.filter(([_, actionType]) =>
featureId ? actionType.supportedFeatureIds.includes(featureId) : true
)
// Temporarily don't return SentinelOne connector for Security Solution Rule Actions
.filter(([actionTypeId]) => (featureId ? actionTypeId !== '.sentinelone' : true))
// Temporarily don't return SentinelOne and Crowdstrike connector for Security Solution Rule Actions
.filter(([actionTypeId]) =>
featureId ? !isBidirectionalConnectorType(actionTypeId) : true
)
.map(([actionTypeId, actionType]) => ({
id: actionTypeId,
name: actionType.name,

View file

@ -18,6 +18,7 @@ import {
CONNECTORS_ADVANCED_EXECUTE_PRIVILEGE_API_TAG,
CONNECTORS_BASIC_EXECUTE_PRIVILEGE_API_TAG,
} from '../feature';
import { forEach } from 'lodash';
const request = {} as KibanaRequest;
@ -237,50 +238,54 @@ describe('ensureAuthorized', () => {
});
});
test('checks SentinelOne connector privileges correctly', async () => {
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
> = jest.fn();
describe('Bi-directional connectors', () => {
forEach(['.sentinelone', '.crowdstrike'], (actionTypeId) => {
test(`checks ${actionTypeId} connector privileges correctly`, async () => {
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
> = jest.fn();
authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges);
const actionsAuthorization = new ActionsAuthorization({
request,
authorization,
});
authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges);
const actionsAuthorization = new ActionsAuthorization({
request,
authorization,
});
checkPrivileges.mockResolvedValueOnce({
username: 'some-user',
hasAllRequested: true,
privileges: [
{
privilege: mockAuthorizationAction('myType', 'execute'),
authorized: true,
},
],
});
checkPrivileges.mockResolvedValueOnce({
username: 'some-user',
hasAllRequested: true,
privileges: [
{
privilege: mockAuthorizationAction('myType', 'execute'),
authorized: true,
},
],
});
await actionsAuthorization.ensureAuthorized({
operation: 'execute',
actionTypeId: '.sentinelone',
});
await actionsAuthorization.ensureAuthorized({
operation: 'execute',
actionTypeId,
});
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith(
ACTION_SAVED_OBJECT_TYPE,
'get'
);
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith(
ACTION_SAVED_OBJECT_TYPE,
'get'
);
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith(
ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE,
'create'
);
expect(authorization.actions.savedObject.get).toHaveBeenCalledWith(
ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE,
'create'
);
expect(checkPrivileges).toHaveBeenCalledWith({
kibana: [
mockAuthorizationAction(ACTION_SAVED_OBJECT_TYPE, 'get'),
mockAuthorizationAction(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'),
ADVANCED_EXECUTE_AUTHZ,
],
expect(checkPrivileges).toHaveBeenCalledWith({
kibana: [
mockAuthorizationAction(ACTION_SAVED_OBJECT_TYPE, 'get'),
mockAuthorizationAction(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'),
ADVANCED_EXECUTE_AUTHZ,
],
});
});
});
});
});

View file

@ -12,6 +12,7 @@ import {
ACTION_SAVED_OBJECT_TYPE,
ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE,
} from '../constants/saved_objects';
import { isBidirectionalConnectorType } from '../lib/bidirectional_connectors';
import { AuthorizationMode } from './get_authorization_mode_by_source';
export interface ConstructorOptions {
@ -44,6 +45,7 @@ export class ActionsAuthorization {
private readonly request: KibanaRequest;
private readonly authorization?: SecurityPluginSetup['authz'];
private readonly authorizationMode: AuthorizationMode;
constructor({
request,
authorization,
@ -77,9 +79,9 @@ export class ActionsAuthorization {
kibana: [
...privileges,
...additionalPrivileges,
// SentinelOne sub-actions require that a user have `all` privilege to Actions and Connectors.
// SentinelOne and Crowdstrike sub-actions require that a user have `all` privilege to Actions and Connectors.
// This is a temporary solution until a more robust RBAC approach can be implemented for sub-actions
actionTypeId === '.sentinelone'
isBidirectionalConnectorType(actionTypeId)
? 'api:actions:execute-advanced-connectors'
: 'api:actions:execute-basic-connectors',
],

View file

@ -44,6 +44,7 @@ import { RelatedSavedObjects } from './related_saved_objects';
import { createActionEventLogRecordObject } from './create_action_event_log_record_object';
import { ActionExecutionError, ActionExecutionErrorReason } from './errors/action_execution_error';
import type { ActionsAuthorization } from '../authorization/actions_authorization';
import { isBidirectionalConnectorType } from './bidirectional_connectors';
// 1,000,000 nanoseconds in 1 millisecond
const Millis2Nanos = 1000 * 1000;
@ -698,8 +699,8 @@ const ensureAuthorizedToExecute = async ({
additionalPrivileges,
actionTypeId,
});
} else if (actionTypeId === '.sentinelone') {
// SentinelOne sub-actions require that a user have `all` privilege to Actions and Connectors.
} else if (isBidirectionalConnectorType(actionTypeId)) {
// SentinelOne and Crowdstrike sub-actions require that a user have `all` privilege to Actions and Connectors.
// This is a temporary solution until a more robust RBAC approach can be implemented for sub-actions
await authorization.ensureAuthorized({
operation: 'execute',

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
const BIDIRECTIONAL_CONNECTOR_TYPES = ['.sentinelone', '.crowdstrike'];
export const isBidirectionalConnectorType = (type: string | undefined) => {
if (!type) {
return false;
}
return BIDIRECTIONAL_CONNECTOR_TYPES.includes(type);
};

View file

@ -88,7 +88,7 @@ export abstract class SubActionConnector<Config, Secrets> {
}
private getHeaders(headers?: AxiosRequestHeaders): Record<string, AxiosHeaderValue> {
return { ...headers, 'Content-Type': 'application/json' };
return { 'Content-Type': 'application/json', ...headers };
}
private validateResponse(responseSchema: Type<unknown>, data: unknown) {

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export const CROWDSTRIKE_TITLE = 'CrowdStrike';
export const CROWDSTRIKE_CONNECTOR_ID = '.crowdstrike';
export const API_MAX_RESULTS = 1000;
export enum SUB_ACTION {
GET_AGENT_DETAILS = 'getAgentDetails',
HOST_ACTIONS = 'hostActions',
}

View file

@ -0,0 +1,238 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { SUB_ACTION } from './constants';
// Connector schema
export const CrowdstrikeConfigSchema = schema.object({
url: schema.string(),
});
export const CrowdstrikeSecretsSchema = schema.object({
clientId: schema.string(),
clientSecret: schema.string(),
});
export const CrowdstrikeBaseApiResponseSchema = schema.object(
{
resources: schema.arrayOf(schema.any()),
errors: schema.nullable(schema.arrayOf(schema.any())),
meta: schema.object(
{
query_time: schema.maybe(schema.number()),
powered_by: schema.maybe(schema.string()),
trace_id: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
),
},
{ unknowns: 'allow' }
);
export const CrowdstrikeGetAgentsResponseSchema = schema.object(
{
resources: schema.arrayOf(
schema.arrayOf(
schema.object(
{
device_id: schema.maybe(schema.string()),
cid: schema.maybe(schema.string()),
agent_load_flags: schema.maybe(schema.string()),
agent_local_time: schema.maybe(schema.string()),
agent_version: schema.maybe(schema.string()),
bios_manufacturer: schema.maybe(schema.string()),
bios_version: schema.maybe(schema.string()),
config_id_base: schema.maybe(schema.string()),
config_id_build: schema.maybe(schema.string()),
config_id_platform: schema.maybe(schema.string()),
cpu_signature: schema.maybe(schema.string()),
cpu_vendor: schema.maybe(schema.string()),
external_ip: schema.maybe(schema.string()),
mac_address: schema.maybe(schema.string()),
instance_id: schema.maybe(schema.string()),
service_provider: schema.maybe(schema.string()),
service_provider_account_id: schema.maybe(schema.string()),
hostname: schema.maybe(schema.string()),
first_seen: schema.maybe(schema.string()),
last_login_timestamp: schema.maybe(schema.string()),
last_login_user: schema.maybe(schema.string()),
last_login_uid: schema.maybe(schema.string()),
last_seen: schema.maybe(schema.string()),
local_ip: schema.maybe(schema.string()),
major_version: schema.maybe(schema.string()),
minor_version: schema.maybe(schema.string()),
os_version: schema.maybe(schema.string()),
platform_id: schema.maybe(schema.string()),
platform_name: schema.maybe(schema.string()),
policies: schema.maybe(
schema.arrayOf(
schema.object(
{
policy_type: schema.maybe(schema.string()),
policy_id: schema.maybe(schema.string()),
applied: schema.maybe(schema.boolean()),
settings_hash: schema.maybe(schema.string()),
assigned_date: schema.maybe(schema.string()),
applied_date: schema.maybe(schema.string()),
rule_groups: schema.maybe(schema.any()),
},
{ unknowns: 'allow' }
)
)
),
reduced_functionality_mode: schema.maybe(schema.string()),
device_policies: schema.maybe(
schema.object(
{
prevention: schema.object(
{
policy_type: schema.maybe(schema.string()),
policy_id: schema.maybe(schema.string()),
applied: schema.maybe(schema.boolean()),
settings_hash: schema.maybe(schema.string()),
assigned_date: schema.maybe(schema.string()),
applied_date: schema.maybe(schema.string()),
rule_groups: schema.any(),
},
{ unknowns: 'allow' }
),
sensor_update: schema.object(
{
policy_type: schema.maybe(schema.string()),
policy_id: schema.maybe(schema.string()),
applied: schema.maybe(schema.boolean()),
settings_hash: schema.maybe(schema.string()),
assigned_date: schema.maybe(schema.string()),
applied_date: schema.maybe(schema.string()),
uninstall_protection: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
),
global_config: schema.object(
{
policy_type: schema.maybe(schema.string()),
policy_id: schema.maybe(schema.string()),
applied: schema.maybe(schema.boolean()),
settings_hash: schema.maybe(schema.string()),
assigned_date: schema.maybe(schema.string()),
applied_date: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
),
remote_response: schema.object(
{
policy_type: schema.maybe(schema.string()),
policy_id: schema.maybe(schema.string()),
applied: schema.maybe(schema.boolean()),
settings_hash: schema.maybe(schema.string()),
assigned_date: schema.maybe(schema.string()),
applied_date: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
),
},
{ unknowns: 'allow' }
)
),
groups: schema.maybe(schema.arrayOf(schema.any())),
group_hash: schema.maybe(schema.string()),
product_type_desc: schema.maybe(schema.string()),
provision_status: schema.maybe(schema.string()),
serial_number: schema.maybe(schema.string()),
status: schema.maybe(schema.string()),
system_manufacturer: schema.maybe(schema.string()),
system_product_name: schema.maybe(schema.string()),
tags: schema.maybe(schema.arrayOf(schema.any())),
modified_timestamp: schema.any(),
meta: schema.maybe(
schema.object(
{
version: schema.maybe(schema.string()),
version_string: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
)
),
zone_group: schema.maybe(schema.string()),
kernel_version: schema.maybe(schema.string()),
chassis_type: schema.maybe(schema.string()),
chassis_type_desc: schema.maybe(schema.string()),
connection_ip: schema.maybe(schema.string()),
default_gateway_ip: schema.maybe(schema.string()),
connection_mac_address: schema.maybe(schema.string()),
linux_sensor_mode: schema.maybe(schema.string()),
deployment_type: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
)
)
),
errors: schema.nullable(schema.arrayOf(schema.any())),
meta: schema.object(
{
query_time: schema.maybe(schema.number()),
powered_by: schema.maybe(schema.string()),
trace_id: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
),
},
{ unknowns: 'allow' }
);
export const CrowdstrikeHostActionsResponseSchema = schema.object(
{
resources: schema.arrayOf(
schema.object(
{
id: schema.maybe(schema.string()),
path: schema.maybe(schema.string()),
},
{ 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 const CrowdstrikeHostActionsParamsSchema = schema.object({
command: schema.oneOf([schema.literal('contain'), schema.literal('lift_containment')]),
actionParameters: schema.maybe(schema.object({}, { unknowns: 'allow' })),
ids: schema.arrayOf(schema.string()),
alertIds: schema.maybe(schema.arrayOf(schema.string())),
});
export const CrowdstrikeGetAgentsParamsSchema = schema.object({
ids: schema.arrayOf(schema.string()),
});
export const CrowdstrikeGetTokenResponseSchema = schema.object(
{
access_token: schema.string(),
expires_in: schema.number(),
token_type: schema.string(),
id_token: schema.maybe(schema.string()),
issued_token_type: schema.maybe(schema.string()),
refresh_token: schema.maybe(schema.string()),
scope: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
);
export const CrowdstrikeHostActionsSchema = schema.object({
subAction: schema.literal(SUB_ACTION.HOST_ACTIONS),
subActionParams: CrowdstrikeHostActionsParamsSchema,
});
export const CrowdstrikeActionParamsSchema = schema.oneOf([CrowdstrikeHostActionsSchema]);

View file

@ -0,0 +1,31 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import {
CrowdstrikeBaseApiResponseSchema,
CrowdstrikeConfigSchema,
CrowdstrikeGetAgentsParamsSchema,
CrowdstrikeGetAgentsResponseSchema,
CrowdstrikeHostActionsParamsSchema,
CrowdstrikeSecretsSchema,
CrowdstrikeActionParamsSchema,
CrowdstrikeGetTokenResponseSchema,
} from './schema';
export type CrowdstrikeConfig = TypeOf<typeof CrowdstrikeConfigSchema>;
export type CrowdstrikeSecrets = TypeOf<typeof CrowdstrikeSecretsSchema>;
export type CrowdstrikeBaseApiResponse = TypeOf<typeof CrowdstrikeBaseApiResponseSchema>;
export type CrowdstrikeGetAgentsParams = Partial<TypeOf<typeof CrowdstrikeGetAgentsParamsSchema>>;
export type CrowdstrikeGetAgentsResponse = TypeOf<typeof CrowdstrikeGetAgentsResponseSchema>;
export type CrowdstrikeGetTokenResponse = TypeOf<typeof CrowdstrikeGetTokenResponseSchema>;
export type CrowdstrikeHostActionsParams = TypeOf<typeof CrowdstrikeHostActionsParamsSchema>;
export type CrowdstrikeActionParams = TypeOf<typeof CrowdstrikeActionParamsSchema>;

View file

@ -14,6 +14,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues;
export const allowedExperimentalValues = Object.freeze({
isMustacheAutocompleteOn: false,
sentinelOneConnectorOn: true,
crowdstrikeConnectorOn: false,
});
export type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -16,5 +16,13 @@ import SentinelOneLogo from '../connector_types/sentinelone/logo';
export { SENTINELONE_CONNECTOR_ID, SUB_ACTION } from '../../common/sentinelone/constants';
export { SentinelOneLogo };
import CrowdstrikeLogo from '../connector_types/crowdstrike/logo';
export {
CROWDSTRIKE_CONNECTOR_ID,
SUB_ACTION as CROWDSTRIKE_SUB_ACTION,
} from '../../common/crowdstrike/constants';
export { CrowdstrikeLogo };
export { BEDROCK_CONNECTOR_ID } from '../../common/bedrock/constants';
export { BedrockLogo };

View file

@ -0,0 +1,67 @@
/*
* 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 { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import type {
ActionTypeModel as ConnectorTypeModel,
GenericValidationResult,
} from '@kbn/triggers-actions-ui-plugin/public';
import {
CROWDSTRIKE_CONNECTOR_ID,
CROWDSTRIKE_TITLE,
SUB_ACTION,
} from '../../../common/crowdstrike/constants';
import type {
CrowdstrikeConfig,
CrowdstrikeSecrets,
CrowdstrikeActionParams,
} from '../../../common/crowdstrike/types';
interface ValidationErrors {
subAction: string[];
}
export function getConnectorType(): ConnectorTypeModel<
CrowdstrikeConfig,
CrowdstrikeSecrets,
CrowdstrikeActionParams
> {
return {
id: CROWDSTRIKE_CONNECTOR_ID,
actionTypeTitle: CROWDSTRIKE_TITLE,
iconClass: lazy(() => import('./logo')),
isExperimental: true,
selectMessage: i18n.translate(
'xpack.stackConnectors.security.crowdstrike.config.selectMessageText',
{
defaultMessage: 'Execute CrowdStrike response actions',
}
),
validateParams: async (
actionParams: CrowdstrikeActionParams
): Promise<GenericValidationResult<ValidationErrors>> => {
const translations = await import('./translations');
const errors: ValidationErrors = {
subAction: [],
};
const { subAction } = actionParams;
// The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid
if (!subAction) {
errors.subAction.push(translations.ACTION_REQUIRED);
} else if (!Object.values(SUB_ACTION).includes(subAction)) {
errors.subAction.push(translations.INVALID_ACTION);
}
return { errors };
},
actionConnectorFields: lazy(() => import('./crowdstrike_connector')),
actionParamsFields: lazy(() => import('./crowdstrike_params_empty')),
// TODO: Enable once we add support for automated response actions
// actionParamsFields: lazy(() => import('./crowdstrike_params')),
};
}

View file

@ -0,0 +1,56 @@
/*
* 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 React from 'react';
import {
ActionConnectorFieldsProps,
ConfigFieldSchema,
SecretsFieldSchema,
SimpleConnectorForm,
} from '@kbn/triggers-actions-ui-plugin/public';
import * as i18n from './translations';
const CROWDSTRIKE_DEFAULT_API_URL = 'https://api.crowdstrike.com';
const configFormSchema: ConfigFieldSchema[] = [
{
id: 'url',
label: i18n.URL_LABEL,
isUrlField: true,
defaultValue: CROWDSTRIKE_DEFAULT_API_URL,
},
];
const secretsFormSchema: SecretsFieldSchema[] = [
{
id: 'clientId',
label: i18n.CLIENT_ID_LABEL,
isPasswordField: false,
isRequired: true,
},
{
id: 'clientSecret',
label: i18n.CLIENT_SECRET_LABEL,
isPasswordField: true,
isRequired: true,
},
];
const CrowdstrikeActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
readOnly,
isEdit,
}) => (
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={configFormSchema}
secretsFormSchema={secretsFormSchema}
/>
);
// eslint-disable-next-line import/no-default-export
export { CrowdstrikeActionConnectorFields as default };

View file

@ -0,0 +1,13 @@
/*
* 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 React from 'react';
const CrowdstrikeParamsFields = () => <></>;
// eslint-disable-next-line import/no-default-export
export { CrowdstrikeParamsFields as default };

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { getConnectorType as getCrowdStrikeConnectorType } from './crowdstrike';

View file

@ -0,0 +1,41 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 116.4 88.8" style="enable-background:new 0 0 116.4 88.8;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FC0000;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="88.7" width="116.4" x="0" y="0.1">
</sliceSourceBounds>
</sfw>
</metadata>
<desc>
Created with Sketch.
</desc>
<g id="Page-1">
<g id="Home-v4.1" transform="translate(-26.000000, -22.000000)">
<g id="CS-Logo" transform="translate(26.000000, 22.000000)">
<path id="Fill-110" class="st0" d="M113.3,71.7c-2.6-0.2-7.1-0.8-12.9,1.8c-5.7,2.7-8,2.8-10.8,2.5c0.8,1.4,2.5,3.3,7.7,3.7
c5.2,0.3,7.8,0.5,5,6.6c0.1-1.8-0.4-5.4-5.6-4.8s-6.4,5-0.8,7.1c-1.8,0.3-5.7,0.5-8.4-6.1c-1.9,0.8-4.8,2.3-10.2-1.5
c1.9,0.6,4.2,0.7,4.2,0.7c-4.7-2.1-9.3-6-12.1-9.7c2.3,1.6,4.8,3.2,7.4,3.5c-3-3.4-10-10.3-18.6-17.3c5.5,3.3,12.2,8.6,23,7.4
C92.1,64.4,99.4,62.1,113.3,71.7">
</path>
<path id="Fill-112" class="st0" d="M67.4,70.4c-7.3-2.7-8.8-3.3-18.2-5.3c-9.3-2.1-18.5-6.3-24.7-13c4.3,2.8,13.2,8.3,22.3,7.7
c-1.4-1.8-3.9-3.1-7-4.5C43.2,56,53.6,58.3,67.4,70.4">
</path>
<path id="Fill-118" class="st0" d="M104.1,64.3c6.4,0.6,6.1,1.5,6.1,3.1C107.5,65.4,104.1,64.3,104.1,64.3 M65.5,31.2
C37.9,23.3,26.9,13.4,18.4,3.1c3.9,11.9,13.1,16.2,23,24.2s10.5,12.3,13.4,17.1c6.5,10.6,7.5,12.3,14,16.9
c7.6,5,16.8,1.6,26.9,3.2s18.4,9.2,20.2,12.1c2.1-3.7-2.9-9.1-4.3-10.5c0.7-4.9-10.9-7.1-15.4-8.7c-0.9-0.3-3-0.8-1.2-5.2
C97.5,46.1,100.2,40.8,65.5,31.2">
</path>
<path id="Fill-114" class="st0" d="M52.1,45.9c-1.8-4.9-4.9-11.1-19.9-20.4C24.8,20.8,14.1,15,0,0c1,4,5.5,14.5,27.9,28.2
C35.3,33.1,44.9,36.1,52.1,45.9">
</path>
<path id="Fill-116" class="st0" d="M52.1,55.1c-1.7-4.1-5.3-9.4-19-16.9c-6.4-3.6-17.2-9.2-27-19.8C7,22.2,11.5,30.6,31,41.1
C36.4,44.2,45.5,47,52.1,55.1">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,14 @@
/*
* 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 React from 'react';
import CrowdstrikeLogo from './logo.svg';
const Logo = () => {
return <img width="32" height="32" src={CrowdstrikeLogo} alt="CrowdStrike" />;
};
// eslint-disable-next-line import/no-default-export
export { Logo as default };

View file

@ -0,0 +1,45 @@
/*
* 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 { i18n } from '@kbn/i18n';
// config form
export const URL_LABEL = i18n.translate(
'xpack.stackConnectors.security.crowdstrike.config.urlTextFieldLabel',
{
defaultMessage: 'Crowdstrike API URL',
}
);
export const CLIENT_ID_LABEL = i18n.translate(
'xpack.stackConnectors.security.crowdstrike.config.clientIdTextFieldLabel',
{
defaultMessage: 'Crowdstrike Client ID',
}
);
export const CLIENT_SECRET_LABEL = i18n.translate(
'xpack.stackConnectors.security.crowdstrike.config.clientSecretTextFieldLabel',
{
defaultMessage: 'Client Secret',
}
);
export const ACTION_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.crowdstrike.params.error.requiredActionText',
{
defaultMessage: 'Action is required.',
}
);
export const INVALID_ACTION = i18n.translate(
'xpack.stackConnectors.security.crowdstrike.params.error.invalidActionText',
{
defaultMessage: 'Invalid action name.',
}
);

View file

@ -0,0 +1,16 @@
/*
* 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 type { CrowdstrikeHostActionsParams } from '../../../common/crowdstrike/types';
import type { SUB_ACTION } from '../../../common/crowdstrike/constants';
export type CrowdstrikeExecuteSubActionParams = CrowdstrikeHostActionsParams;
export interface CrowdstrikeExecuteActionParams {
subAction: SUB_ACTION;
subActionParams: CrowdstrikeExecuteSubActionParams;
}

View file

@ -31,6 +31,7 @@ import { getXmattersConnectorType } from './xmatters';
import { getD3SecurityConnectorType } from './d3security';
import { ExperimentalFeaturesService } from '../common/experimental_features_service';
import { getSentinelOneConnectorType } from './sentinelone';
import { getCrowdStrikeConnectorType } from './crowdstrike';
export interface RegistrationServices {
validateEmailAddresses: (
@ -72,4 +73,7 @@ export function registerConnectorTypes({
if (ExperimentalFeaturesService.get().sentinelOneConnectorOn) {
connectorTypeRegistry.register(getSentinelOneConnectorType());
}
if (ExperimentalFeaturesService.get().crowdstrikeConnectorOn) {
connectorTypeRegistry.register(getCrowdStrikeConnectorType());
}
}

View file

@ -0,0 +1,211 @@
/*
* 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 { CrowdstrikeConnector } from './crowdstrike';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { CROWDSTRIKE_CONNECTOR_ID } from '../../../public/common';
const tokenPath = 'https://api.crowdstrike.com/oauth2/token';
const hostPath = 'https://api.crowdstrike.com/devices/entities/devices/v2';
const actionsPath = 'https://api.crowdstrike.com/devices/entities/devices-actions/v2';
describe('CrowdstrikeConnector', () => {
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: loggingSystemMock.createLogger(),
services: actionsMock.createServices(),
});
let mockedRequest: jest.Mock;
beforeEach(() => {
// @ts-expect-error private static - but I still want to reset it
CrowdstrikeConnector.token = null;
// @ts-expect-error
mockedRequest = connector.request = jest.fn() as jest.Mock;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('executeHostActions', () => {
it('should make a POST request to the correct URL with correct data', async () => {
const mockResponse = { data: { id: 'testid', path: 'testpath' } };
//
mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } });
mockedRequest.mockResolvedValueOnce(mockResponse);
const result = await connector.executeHostActions({
command: 'contain',
ids: ['id1', 'id2'],
});
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,
})
);
expect(mockedRequest).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
url: actionsPath,
method: 'post',
params: { action_name: 'contain' },
data: { ids: ['id1', 'id2'] },
paramsSerializer: expect.any(Function),
responseSchema: expect.any(Object),
})
);
expect(result).toEqual({ id: 'testid', path: 'testpath' });
});
});
describe('getAgentDetails', () => {
it('should make a GET request to the correct URL with correct params', async () => {
const mockResponse = { data: { resources: [{}] } };
mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } });
mockedRequest.mockResolvedValueOnce(mockResponse);
const result = await connector.getAgentDetails({ ids: ['id1', 'id2'] });
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,
})
);
expect(mockedRequest).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testToken',
}),
method: 'GET',
params: { ids: ['id1', 'id2'] },
paramsSerializer: expect.any(Function),
responseSchema: expect.any(Object),
url: hostPath,
})
);
expect(result).toEqual({ resources: [{}] });
});
});
describe('getTokenRequest', () => {
it('should make a POST request to the correct URL with correct headers', async () => {
const mockResponse = { data: { access_token: 'testToken' } };
mockedRequest.mockResolvedValueOnce(mockResponse);
// @ts-expect-error private method - but I still want to
const result = await connector.getTokenRequest();
expect(mockedRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: tokenPath,
method: 'post',
headers: {
accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
authorization: expect.stringContaining('Basic'),
},
})
);
expect(result).toEqual('testToken');
});
it('should not call getTokenRequest if the token already exists', async () => {
const mockResponse = { data: { resources: [{}] } };
mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } });
mockedRequest.mockResolvedValue(mockResponse);
await connector.getAgentDetails({ ids: ['id1', 'id2'] });
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,
})
);
expect(mockedRequest).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testToken',
}),
method: 'GET',
params: { ids: ['id1', 'id2'] },
paramsSerializer: expect.any(Function),
responseSchema: expect.any(Object),
url: hostPath,
})
);
expect(mockedRequest).toHaveBeenCalledTimes(2);
await connector.getAgentDetails({ ids: ['id1', 'id2'] });
expect(mockedRequest).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testToken',
}),
method: 'GET',
params: { ids: ['id1', 'id2'] },
paramsSerializer: expect.any(Function),
responseSchema: expect.any(Object),
url: hostPath,
})
);
expect(mockedRequest).toHaveBeenCalledTimes(3);
});
it('should throw error when something goes wrong', async () => {
const mockResponse = { code: 400, message: 'something goes wrong' };
mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } });
mockedRequest.mockRejectedValueOnce(mockResponse);
// expect(mockedRequest).toThrowError('access denied, invalid bearer token');
await expect(() => connector.getAgentDetails({ ids: ['id1', 'id2'] })).rejects.toThrowError(
'something goes wrong'
);
expect(mockedRequest).toHaveBeenCalledTimes(2);
});
it('should repeat the call one time if theres 401 error ', async () => {
const mockResponse = { code: 401, message: 'access denied, invalid bearer token' };
mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } });
mockedRequest.mockRejectedValueOnce(mockResponse);
// expect(mockedRequest).toThrowError('access denied, invalid bearer token');
await expect(() => connector.getAgentDetails({ ids: ['id1', 'id2'] })).rejects.toThrowError();
expect(mockedRequest).toHaveBeenCalledTimes(3);
});
});
});

View file

@ -0,0 +1,191 @@
/*
* 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 { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
import type { AxiosError } from 'axios';
import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
import type {
CrowdstrikeConfig,
CrowdstrikeSecrets,
CrowdstrikeGetAgentsResponse,
CrowdstrikeGetAgentsParams,
CrowdstrikeBaseApiResponse,
CrowdstrikeHostActionsParams,
CrowdstrikeGetTokenResponse,
} from '../../../common/crowdstrike/types';
import {
CrowdstrikeGetAgentsResponseSchema,
CrowdstrikeHostActionsParamsSchema,
CrowdstrikeGetAgentsParamsSchema,
CrowdstrikeGetTokenResponseSchema,
CrowdstrikeHostActionsResponseSchema,
} from '../../../common/crowdstrike/schema';
import { SUB_ACTION } from '../../../common/crowdstrike/constants';
import { CrowdstrikeError } from './error';
const paramsSerializer = (params: Record<string, string>) => {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
};
/**
* Crowdstrike Connector
* @constructor
* @param {string} token - Authorization token received from OAuth2 API, that needs to be sent along with each request.
* @param {number} tokenExpiryTimeout - Tokens are valid for 30 minutes, so we will refresh them every 29 minutes
* @param {base64} base64encodedToken - The base64 encoded token used for authentication.
*/
export class CrowdstrikeConnector extends SubActionConnector<
CrowdstrikeConfig,
CrowdstrikeSecrets
> {
private static token: string | null;
private static tokenExpiryTimeout: NodeJS.Timeout;
private static base64encodedToken: string;
private urls: {
getToken: string;
agents: string;
hostAction: string;
};
constructor(params: ServiceParams<CrowdstrikeConfig, CrowdstrikeSecrets>) {
super(params);
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`,
};
if (!CrowdstrikeConnector.base64encodedToken) {
CrowdstrikeConnector.base64encodedToken = Buffer.from(
this.secrets.clientId + ':' + this.secrets.clientSecret
).toString('base64');
}
this.registerSubActions();
}
private registerSubActions() {
this.registerSubAction({
name: SUB_ACTION.GET_AGENT_DETAILS,
method: 'getAgentDetails',
schema: CrowdstrikeGetAgentsParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.HOST_ACTIONS,
method: 'executeHostActions',
schema: CrowdstrikeHostActionsParamsSchema,
});
}
public async executeHostActions({ alertIds, ...payload }: CrowdstrikeHostActionsParams) {
return this.crowdstrikeApiRequest({
url: this.urls.hostAction,
method: 'post',
params: {
action_name: payload.command,
},
data: {
ids: payload.ids,
...(payload.actionParameters
? {
action_parameters: Object.entries(payload.actionParameters).map(([name, value]) => ({
name,
value,
})),
}
: {}),
},
paramsSerializer,
responseSchema: CrowdstrikeHostActionsResponseSchema,
});
}
public async getAgentDetails(
payload: CrowdstrikeGetAgentsParams
): Promise<CrowdstrikeGetAgentsResponse> {
return this.crowdstrikeApiRequest({
url: this.urls.agents,
method: 'GET',
params: {
ids: payload.ids,
},
paramsSerializer,
responseSchema: CrowdstrikeGetAgentsResponseSchema,
});
}
private async getTokenRequest() {
const response = await this.request<CrowdstrikeGetTokenResponse>({
url: this.urls.getToken,
method: 'post',
headers: {
accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
authorization: 'Basic ' + this.base64encodedToken,
},
responseSchema: CrowdstrikeGetTokenResponseSchema,
});
const token = response.data?.access_token;
if (token) {
// Clear any existing timeout
clearTimeout(CrowdstrikeConnector.tokenExpiryTimeout);
// Set a timeout to reset the token after 29 minutes (it expires after 30 minutes)
CrowdstrikeConnector.tokenExpiryTimeout = setTimeout(() => {
CrowdstrikeConnector.token = null;
}, 29 * 60 * 1000);
}
return token;
}
private async crowdstrikeApiRequest<R extends CrowdstrikeBaseApiResponse>(
req: SubActionRequestParams<R>,
retried?: boolean
): Promise<R> {
try {
if (!CrowdstrikeConnector.token) {
CrowdstrikeConnector.token = (await this.getTokenRequest()) as string;
}
const response = await this.request<R>({
...req,
headers: {
...req.headers,
Authorization: `Bearer ${CrowdstrikeConnector.token}`,
},
});
return response.data;
} catch (error) {
if (error.code === 401 && !retried) {
CrowdstrikeConnector.token = null;
return this.crowdstrikeApiRequest(req, true);
}
throw new CrowdstrikeError(error.message);
}
}
protected getResponseErrorMessage(
error: AxiosError<{ errors: [{ message: string; code: number }] }>
): string {
const errorData = error.response?.data?.errors?.[0];
if (errorData) {
return errorData.message;
}
if (!error.response?.status) {
return `Unknown API Error: ${JSON.stringify(error.response?.data ?? {})}`;
}
return `API Error: ${JSON.stringify(error.response.data ?? {})}`;
}
}

View file

@ -0,0 +1,32 @@
/*
* 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.
*/
const parseStatusMessage = (message: string): { code: number; message: string } => {
const regex = /Status code: (\d+). Message: (.+)/;
const match = message.match(regex);
if (match) {
return {
code: parseInt(match[1], 10),
message: match[2],
};
}
return {
code: 500,
message,
};
};
export class CrowdstrikeError extends Error {
public code: number;
public message: string;
constructor(message: string) {
super(message);
const parsedMessage = parseStatusMessage(message);
this.code = parsedMessage?.code;
this.message = parsedMessage?.message || 'Unknown Crowdstrike error';
}
}

View file

@ -0,0 +1,36 @@
/*
* 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 {
SubActionConnectorType,
ValidatorType,
} from '@kbn/actions-plugin/server/sub_action_framework/types';
import { SecurityConnectorFeatureId } from '@kbn/actions-plugin/common';
import { urlAllowListValidator } from '@kbn/actions-plugin/server';
import { CROWDSTRIKE_CONNECTOR_ID, CROWDSTRIKE_TITLE } from '../../../common/crowdstrike/constants';
import {
CrowdstrikeConfigSchema,
CrowdstrikeSecretsSchema,
} from '../../../common/crowdstrike/schema';
import { CrowdstrikeConfig, CrowdstrikeSecrets } from '../../../common/crowdstrike/types';
import { CrowdstrikeConnector } from './crowdstrike';
export const getCrowdstrikeConnectorType = (): SubActionConnectorType<
CrowdstrikeConfig,
CrowdstrikeSecrets
> => ({
id: CROWDSTRIKE_CONNECTOR_ID,
name: CROWDSTRIKE_TITLE,
getService: (params) => new CrowdstrikeConnector(params),
schema: {
config: CrowdstrikeConfigSchema,
secrets: CrowdstrikeSecretsSchema,
},
validators: [{ type: ValidatorType.CONFIG, validator: urlAllowListValidator('url') }],
supportedFeatureIds: [SecurityConnectorFeatureId],
minimumLicenseRequired: 'enterprise' as const,
});

View file

@ -32,6 +32,7 @@ import { getOpsgenieConnectorType } from './opsgenie';
import type { ActionParamsType as ServiceNowITSMActionParams } from './servicenow_itsm';
import type { ActionParamsType as ServiceNowSIRActionParams } from './servicenow_sir';
import { getSentinelOneConnectorType } from './sentinelone';
import { getCrowdstrikeConnectorType } from './crowdstrike';
import { ExperimentalFeatures } from '../../common/experimental_features';
export { ConnectorTypeId as CasesWebhookConnectorTypeId } from './cases_webhook';
@ -112,4 +113,7 @@ export function registerConnectorTypes({
if (experimentalFeatures.sentinelOneConnectorOn) {
actions.registerSubActionConnectorType(getSentinelOneConnectorType());
}
if (experimentalFeatures.crowdstrikeConnectorOn) {
actions.registerSubActionConnectorType(getCrowdstrikeConnectorType());
}
}

View file

@ -12,6 +12,7 @@ import { FtrConfigProviderContext, findTestPluginPaths } from '@kbn/test';
import { getAllExternalServiceSimulatorPaths } from '@kbn/actions-simulators-plugin/server/plugin';
import { ExperimentalConfigKeys } from '@kbn/stack-connectors-plugin/common/experimental_features';
import { SENTINELONE_CONNECTOR_ID } from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
import { CROWDSTRIKE_CONNECTOR_ID } from '@kbn/stack-connectors-plugin/common/crowdstrike/constants';
import { services } from './services';
import { getTlsWebhookServerUrls } from './lib/get_tls_webhook_servers';
@ -52,6 +53,7 @@ const enabledActionTypes = [
'.gen-ai',
'.d3security',
SENTINELONE_CONNECTOR_ID,
CROWDSTRIKE_CONNECTOR_ID,
'.slack',
'.slack_api',
'.tines',

View file

@ -16,5 +16,5 @@ export default createTestConfig('security_and_spaces', {
publicBaseUrl: true,
testFiles: [require.resolve('./tests')],
useDedicatedTaskRunner: true,
experimentalFeatures: ['sentinelOneConnectorOn'],
experimentalFeatures: ['sentinelOneConnectorOn', 'crowdstrikeConnectorOn'],
});

View file

@ -16,5 +16,5 @@ export default createTestConfig('security_and_spaces', {
publicBaseUrl: true,
testFiles: [require.resolve('./tests')],
useDedicatedTaskRunner: false,
experimentalFeatures: ['sentinelOneConnectorOn'],
experimentalFeatures: ['sentinelOneConnectorOn', 'crowdstrikeConnectorOn'],
});

View file

@ -16,5 +16,5 @@ export default createTestConfig('security_and_spaces', {
publicBaseUrl: true,
testFiles: [require.resolve('.')],
useDedicatedTaskRunner: true,
experimentalFeatures: ['sentinelOneConnectorOn'],
experimentalFeatures: ['sentinelOneConnectorOn', 'crowdstrikeConnectorOn'],
});

View file

@ -0,0 +1,233 @@
/*
* 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 {
CROWDSTRIKE_CONNECTOR_ID,
SUB_ACTION,
} from '@kbn/stack-connectors-plugin/common/crowdstrike/constants';
import { FeaturesPrivileges, Role } from '@kbn/security-plugin/common';
import SuperTest from 'supertest';
import expect from '@kbn/expect';
import { getUrlPrefix } from '../../../../../common/lib';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import { createSupertestErrorLogger } from '../../../../../common/lib/log_supertest_errors';
// eslint-disable-next-line import/no-default-export
export default function createCrowdstrikeTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const securityService = getService('security');
const log = getService('log');
const logErrorDetails = createSupertestErrorLogger(log);
describe('Crowdstrike', () => {
describe('sub-actions authz', () => {
interface CreatedUser {
username: string;
password: string;
deleteUser: () => Promise<void>;
}
// Crowdstrike supported sub-actions
const crowdstrikeSubActions = [SUB_ACTION.HOST_ACTIONS, SUB_ACTION.GET_AGENT_DETAILS];
let connectorId: string;
const createUser = async ({
username,
password = 'changeme',
kibanaFeatures = { actions: ['all'] },
}: {
username: string;
password?: string;
kibanaFeatures?: FeaturesPrivileges;
}): Promise<CreatedUser> => {
const role: Role = {
name: username,
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
feature: {
// Important: Saved Objects Managemnt should be set to `all` to ensure that authz
// is not defaulted to the check done against SO's for Crowdstrike
savedObjectsManagement: ['all'],
...kibanaFeatures,
},
spaces: ['*'],
},
],
};
await securityService.role.create(role.name, {
kibana: role.kibana,
elasticsearch: role.elasticsearch,
});
await securityService.user.create(username, {
password: 'changeme',
full_name: role.name,
roles: [role.name],
});
return {
username,
password,
deleteUser: async () => {
await securityService.user.delete(role.name);
await securityService.role.delete(role.name);
},
};
};
before(async () => {
const response = await supertest
.post(`${getUrlPrefix('default')}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.on('error', logErrorDetails)
.send({
name: 'My sub connector',
connector_type_id: CROWDSTRIKE_CONNECTOR_ID,
config: { url: 'https://some.non.existent.com' },
secrets: { clientId: 'abc-123', clientSecret: 'test-secret' },
})
.expect(200);
connectorId = response.body.id;
});
after(async () => {
if (connectorId) {
await supertest
.delete(`${getUrlPrefix('default')}/api/actions/connector/${connectorId}`)
.set('kbn-xsrf', 'true')
.send()
.expect(({ ok, status }) => {
// Should cover all success codes (ex. 204 (no content), 200, etc...)
if (!ok) {
throw new Error(
`Expected delete to return a status code in the 200, but got ${status}`
);
}
});
connectorId = '';
}
});
const executeSubAction = async ({
subAction,
subActionParams,
expectedHttpCode = 200,
username = 'elastic',
password = 'changeme',
errorLogger = logErrorDetails,
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
subAction: string;
subActionParams: Record<string, unknown>;
expectedHttpCode?: number;
username?: string;
password?: string;
errorLogger?: (err: any) => void;
}) => {
return supertestWithoutAuth
.post(`${getUrlPrefix('default')}/api/actions/connector/${connectorId}/_execute`)
.set('kbn-xsrf', 'foo')
.on('error', errorLogger)
.auth(username, password)
.send({
params: {
subAction,
subActionParams,
},
})
.expect(expectedHttpCode);
};
describe('and user has NO privileges', () => {
let user: CreatedUser;
before(async () => {
user = await createUser({
username: 'read_access_user',
kibanaFeatures: { actions: ['read'] },
});
});
after(async () => {
if (user) {
await user.deleteUser();
}
});
for (const crowdstrikeSubAction of crowdstrikeSubActions) {
it(`should deny execute of ${crowdstrikeSubAction}`, async () => {
const execRes = await executeSubAction({
supertest: supertestWithoutAuth,
subAction: crowdstrikeSubAction,
subActionParams: {},
username: user.username,
password: user.password,
expectedHttpCode: 403,
errorLogger: logErrorDetails.ignoreCodes([403]),
});
expect(execRes.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: 'Unauthorized to execute a ".crowdstrike" action',
});
});
}
});
describe('and user has proper privileges', () => {
let user: CreatedUser;
before(async () => {
user = await createUser({
username: 'all_access_user',
});
});
after(async () => {
if (user) {
await user.deleteUser();
// @ts-expect-error
user = undefined;
}
});
for (const crowdstrikeSubAction of crowdstrikeSubActions) {
it(`should allow execute of ${crowdstrikeSubAction}`, async () => {
const {
// eslint-disable-next-line @typescript-eslint/naming-convention
body: { status, message, connector_id },
} = await executeSubAction({
supertest: supertestWithoutAuth,
subAction: crowdstrikeSubAction,
subActionParams: {},
username: user.username,
password: user.password,
});
expect({ status, message, connector_id }).to.eql({
status: 'error',
message: 'an error occurred while running the action',
connector_id: connectorId,
});
});
}
});
});
});
}

View file

@ -21,6 +21,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide
loadTestFile(require.resolve('./connector_types/oauth_access_token'));
loadTestFile(require.resolve('./connector_types/cases_webhook'));
loadTestFile(require.resolve('./connector_types/crowdstrike'));
loadTestFile(require.resolve('./connector_types/jira'));
loadTestFile(require.resolve('./connector_types/resilient'));
loadTestFile(require.resolve('./connector_types/servicenow_itsm'));

View file

@ -53,6 +53,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
'.bedrock',
'.sentinelone',
'.cases',
'.crowdstrike',
].sort()
);
});

View file

@ -22,4 +22,5 @@ export default createTestConfig('spaces_only', {
testFiles: [require.resolve('.')],
reportName: 'X-Pack Alerting API Integration Tests - Actions',
enableFooterInEmail: false,
experimentalFeatures: ['crowdstrikeConnectorOn'],
});

View file

@ -36,6 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'--xpack.task_manager.monitored_aggregated_stats_refresh_rate=5000',
'--xpack.task_manager.ephemeral_tasks.enabled=false',
'--xpack.task_manager.ephemeral_tasks.request_capacity=100',
`--xpack.stack_connectors.enableExperimental=${JSON.stringify(['crowdstrikeConnectorOn'])}`,
...findTestPluginPaths(path.resolve(__dirname, 'plugins')),
],
},

View file

@ -54,6 +54,7 @@ export default function ({ getService }: FtrProviderContext) {
'actions:.bedrock',
'actions:.cases',
'actions:.cases-webhook',
'actions:.crowdstrike',
'actions:.d3security',
'actions:.email',
'actions:.gen-ai',