diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_response_schema.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_response_schema.mock.ts index 1a39d65c1c22f6..c605436576995e 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_response_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_response_schema.mock.ts @@ -6,16 +6,18 @@ */ import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../constants'; +import { getListArrayMock } from '../../../../detection_engine/schemas/types/lists.mock'; import type { EqlRule, EsqlRule, MachineLearningRule, + NewTermsRule, QueryRule, SavedQueryRule, SharedResponseProps, ThreatMatchRule, + ThresholdRule, } from './rule_schemas.gen'; -import { getListArrayMock } from '../../../../detection_engine/schemas/types/lists.mock'; export const ANCHOR_DATE = '2020-02-20T03:57:54.037Z'; @@ -238,3 +240,27 @@ export const getRulesEqlSchemaMock = (anchorDate: string = ANCHOR_DATE): EqlRule tiebreaker_field: undefined, }; }; + +export const getRulesNewTermsSchemaMock = (anchorDate: string = ANCHOR_DATE): NewTermsRule => { + return { + ...getResponseBaseParams(anchorDate), + type: 'new_terms', + query: '*', + language: 'kuery', + new_terms_fields: ['user.name'], + history_window_start: 'now-7d', + }; +}; + +export const getRulesThresholdSchemaMock = (anchorDate: string = ANCHOR_DATE): ThresholdRule => { + return { + ...getResponseBaseParams(anchorDate), + type: 'threshold', + language: 'kuery', + query: 'user.name: root or user.name: admin', + threshold: { + field: 'some.field', + value: 4, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts index 0b30c9bab47828..ec3ca342bf8c9a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts @@ -18,7 +18,7 @@ import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebui import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad'; import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; import { getVersionBuckets } from '../../model/rule_versions/get_version_buckets'; -import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/normalization/rule_converters'; +import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response'; import { PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS } from '../../constants'; export const reviewRuleInstallationRoute = (router: SecuritySolutionPluginRouter) => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts index de7db929790dee..f38fcc7953641d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts @@ -27,7 +27,7 @@ import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client'; import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad'; import { getVersionBuckets } from '../../model/rule_versions/get_version_buckets'; -import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/normalization/rule_converters'; +import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response'; import { PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS } from '../../constants'; export const reviewRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/__mocks__/prebuilt_rule_assets_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/__mocks__/prebuilt_rule_assets_client.ts new file mode 100644 index 00000000000000..0776fefb98656f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/__mocks__/prebuilt_rule_assets_client.ts @@ -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. + */ + +export const createPrebuiltRuleAssetsClient = () => { + return { + fetchLatestAssets: jest.fn(), + fetchLatestVersions: jest.fn(), + fetchAssetsByVersion: jest.fn(), + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client.ts index 7ad4df3cddabd9..1138a48cc39d42 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client.ts @@ -13,7 +13,7 @@ import type { import { withSecuritySpan } from '../../../../../utils/with_security_span'; import { findRules } from '../../../rule_management/logic/search/find_rules'; import { getExistingPrepackagedRules } from '../../../rule_management/logic/search/get_existing_prepackaged_rules'; -import { internalRuleToAPIResponse } from '../../../rule_management/normalization/rule_converters'; +import { internalRuleToAPIResponse } from '../../../rule_management/logic/detection_rules_client/converters/internal_rule_to_api_response'; export interface IPrebuiltRuleObjectsClient { fetchAllInstalledRules(): Promise; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts index c8c257770e26a0..060043c14819f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts @@ -295,7 +295,7 @@ export const createAndAssociateDefaultExceptionList = async ({ : existingRuleExceptionLists; await detectionRulesClient.patchRule({ - nextParams: { + rulePatch: { rule_id: rule.params.ruleId, ...rule.params, exceptions_list: [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_actions_response.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_actions_response.ts index 2bf14ccbf085e7..1b78da705e346b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_actions_response.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_actions_response.ts @@ -25,7 +25,7 @@ import type { BulkActionsDryRunErrCode } from '../../../../../../../common/const import type { PromisePoolError } from '../../../../../../utils/promise_pool'; import type { RuleAlertType } from '../../../../rule_schema'; import type { DryRunError } from '../../../logic/bulk_actions/dry_run'; -import { internalRuleToAPIResponse } from '../../../normalization/rule_converters'; +import { internalRuleToAPIResponse } from '../../../logic/detection_rules_client/converters/internal_rule_to_api_response'; const MAX_ERROR_MESSAGE_LENGTH = 1000; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts index 3b16ba5fa47424..da75e4e33362a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_patch_rules/route.ts @@ -86,7 +86,7 @@ export const bulkPatchRulesRoute = (router: SecuritySolutionPluginRouter, logger }); const patchedRule = await detectionRulesClient.patchRule({ - nextParams: payloadRule, + rulePatch: payloadRule, }); return patchedRule; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts index 0e508f43103d61..3886f63c482b0a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/patch_rule/route.ts @@ -76,7 +76,7 @@ export const patchRuleRoute = (router: SecuritySolutionPluginRouter) => { }); const patchedRule = await detectionRulesClient.patchRule({ - nextParams: params, + rulePatch: params, }); return response.ok({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/index.ts index 7e379651b2faf4..f2e147ef3154fb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/index.ts @@ -7,12 +7,7 @@ export * from './api/register_routes'; -// TODO: https://github.com/elastic/kibana/pull/142950 -// TODO: Revisit and consider moving to the rule_schema subdomain -export { - commonParamsCamelToSnake, - typeSpecificCamelToSnake, - convertCreateAPIToInternalSchema, -} from './normalization/rule_converters'; +export { commonParamsCamelToSnake } from './logic/detection_rules_client/converters/common_params_camel_to_snake'; +export { typeSpecificCamelToSnake } from './logic/detection_rules_client/converters/type_specific_camel_to_snake'; export { transformFromAlertThrottle, transformToNotifyWhen } from './normalization/rule_actions'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts index dd22dac3adc77c..1dfa3a9b0e9ad3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts @@ -12,7 +12,6 @@ import type { SanitizedRule } from '@kbn/alerting-plugin/common'; import { SERVER_APP_ID } from '../../../../../../common/constants'; import type { InternalRuleCreate, RuleParams } from '../../../rule_schema'; import { transformToActionFrequency } from '../../normalization/rule_actions'; -import { convertImmutableToRuleSource } from '../../normalization/rule_converters'; const DUPLICATE_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.cloneRule.duplicateTitle', @@ -47,7 +46,9 @@ export const duplicateRule = async ({ rule }: DuplicateRuleParams): Promise { + test('should convert rule_source params to snake case', () => { + const transformedParams = commonParamsCamelToSnake({ + ...getBaseRuleParams(), + ruleSource: { + type: 'external', + isCustomized: false, + }, + }); + expect(transformedParams).toEqual( + expect.objectContaining({ + rule_source: { + type: 'external', + is_customized: false, + }, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/common_params_camel_to_snake.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/common_params_camel_to_snake.ts new file mode 100644 index 00000000000000..6f98230043e743 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/common_params_camel_to_snake.ts @@ -0,0 +1,47 @@ +/* + * 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 { convertObjectKeysToSnakeCase } from '../../../../../../utils/object_case_converters'; +import type { BaseRuleParams } from '../../../../rule_schema'; +import { migrateLegacyInvestigationFields } from '../../../utils/utils'; + +export const commonParamsCamelToSnake = (params: BaseRuleParams) => { + return { + description: params.description, + risk_score: params.riskScore, + severity: params.severity, + building_block_type: params.buildingBlockType, + namespace: params.namespace, + note: params.note, + license: params.license, + output_index: params.outputIndex, + timeline_id: params.timelineId, + timeline_title: params.timelineTitle, + meta: params.meta, + rule_name_override: params.ruleNameOverride, + timestamp_override: params.timestampOverride, + timestamp_override_fallback_disabled: params.timestampOverrideFallbackDisabled, + investigation_fields: migrateLegacyInvestigationFields(params.investigationFields), + author: params.author, + false_positives: params.falsePositives, + from: params.from, + rule_id: params.ruleId, + max_signals: params.maxSignals, + risk_score_mapping: params.riskScoreMapping, + severity_mapping: params.severityMapping, + threat: params.threat, + to: params.to, + references: params.references, + version: params.version, + exceptions_list: params.exceptionsList, + immutable: params.immutable, + rule_source: convertObjectKeysToSnakeCase(params.ruleSource), + related_integrations: params.relatedIntegrations ?? [], + required_fields: params.requiredFields ?? [], + setup: params.setup ?? '', + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_alerting_rule_to_rule_response.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_alerting_rule_to_rule_response.ts new file mode 100644 index 00000000000000..ab7fb237e64f1d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_alerting_rule_to_rule_response.ts @@ -0,0 +1,26 @@ +/* + * 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 { SanitizedRule } from '@kbn/alerting-plugin/common'; +import { stringifyZodError } from '@kbn/zod-helpers'; +import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import type { RuleParams } from '../../../../rule_schema'; +import { internalRuleToAPIResponse } from './internal_rule_to_api_response'; +import { RuleResponseValidationError } from '../utils'; + +export function convertAlertingRuleToRuleResponse(rule: SanitizedRule): RuleResponse { + const parseResult = RuleResponse.safeParse(internalRuleToAPIResponse(rule)); + + if (!parseResult.success) { + throw new RuleResponseValidationError({ + message: stringifyZodError(parseResult.error), + ruleId: rule.params.ruleId, + }); + } + + return parseResult.data; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response.ts new file mode 100644 index 00000000000000..0cb42100d45122 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response.ts @@ -0,0 +1,39 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { addEcsToRequiredFields } from '../../../utils/utils'; +import type { PrebuiltRuleAsset } from '../../../../prebuilt_rules'; +import { RULE_DEFAULTS } from '../mergers/apply_rule_defaults'; + +export const convertPrebuiltRuleAssetToRuleResponse = ( + prebuiltRuleAsset: PrebuiltRuleAsset +): RuleResponse => { + const immutable = true; + + const ruleResponseSpecificFields = { + id: uuidv4(), + updated_at: new Date().toISOString(), + updated_by: '', + created_at: new Date().toISOString(), + created_by: '', + immutable, + rule_source: { + type: 'external', + is_customized: false, + }, + revision: 1, + }; + + return RuleResponse.parse({ + ...RULE_DEFAULTS, + ...prebuiltRuleAsset, + required_fields: addEcsToRequiredFields(prebuiltRuleAsset.required_fields), + ...ruleResponseSpecificFields, + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule.ts new file mode 100644 index 00000000000000..60a41211a66c56 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule.ts @@ -0,0 +1,210 @@ +/* + * 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 { UpdateRuleData } from '@kbn/alerting-plugin/server/application/rule/methods/update'; +import type { + RuleResponse, + TypeSpecificCreateProps, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { + transformRuleToAlertAction, + transformRuleToAlertResponseAction, +} from '../../../../../../../common/detection_engine/transform_actions'; +import { + normalizeMachineLearningJobIds, + normalizeThresholdObject, +} from '../../../../../../../common/detection_engine/utils'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; +import { convertObjectKeysToCamelCase } from '../../../../../../utils/object_case_converters'; +import type { RuleParams, TypeSpecificRuleParams } from '../../../../rule_schema'; +import { transformToActionFrequency } from '../../../normalization/rule_actions'; +import { addEcsToRequiredFields } from '../../../utils/utils'; + +/** + * These are the fields that are added to the rule response that are not part of the rule params + */ +type RuntimeFields = + | 'id' + | 'created_at' + | 'updated_at' + | 'created_by' + | 'updated_by' + | 'revision' + | 'execution_summary'; + +export const convertRuleResponseToAlertingRule = ( + rule: Omit +): UpdateRuleData => { + const alertActions = rule.actions.map((action) => transformRuleToAlertAction(action)); + const actions = transformToActionFrequency(alertActions, rule.throttle); + + // Because of Omit Typescript doesn't recognize + // that rule is assignable to TypeSpecificCreateProps despite omitted fields + // are not part of type specific props. So we need to cast here. + const typeSpecificParams = typeSpecificSnakeToCamel(rule as TypeSpecificCreateProps); + + return { + name: rule.name, + tags: rule.tags, + params: { + author: rule.author, + buildingBlockType: rule.building_block_type, + description: rule.description, + ruleId: rule.rule_id, + falsePositives: rule.false_positives, + from: rule.from, + investigationFields: rule.investigation_fields, + immutable: rule.immutable, + ruleSource: convertObjectKeysToCamelCase(rule.rule_source), + license: rule.license, + outputIndex: rule.output_index ?? '', + timelineId: rule.timeline_id, + timelineTitle: rule.timeline_title, + meta: rule.meta, + maxSignals: rule.max_signals, + relatedIntegrations: rule.related_integrations, + requiredFields: addEcsToRequiredFields(rule.required_fields), + riskScore: rule.risk_score, + riskScoreMapping: rule.risk_score_mapping, + ruleNameOverride: rule.rule_name_override, + setup: rule.setup, + severity: rule.severity, + severityMapping: rule.severity_mapping, + threat: rule.threat, + timestampOverride: rule.timestamp_override, + timestampOverrideFallbackDisabled: rule.timestamp_override_fallback_disabled, + to: rule.to, + references: rule.references, + namespace: rule.namespace, + note: rule.note, + version: rule.version, + exceptionsList: rule.exceptions_list, + ...typeSpecificParams, + }, + schedule: { interval: rule.interval }, + actions, + }; +}; + +// Converts params from the snake case API format to the internal camel case format AND applies default values where needed. +// Notice that params.language is possibly undefined for most rule types in the API but we default it to kuery to match +// the legacy API behavior +const typeSpecificSnakeToCamel = (params: TypeSpecificCreateProps): TypeSpecificRuleParams => { + switch (params.type) { + case 'eql': { + return { + type: params.type, + language: params.language, + index: params.index, + dataViewId: params.data_view_id, + query: params.query, + filters: params.filters, + timestampField: params.timestamp_field, + eventCategoryOverride: params.event_category_override, + tiebreakerField: params.tiebreaker_field, + alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + }; + } + case 'esql': { + return { + type: params.type, + language: params.language, + query: params.query, + alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + }; + } + case 'threat_match': { + return { + type: params.type, + language: params.language ?? 'kuery', + index: params.index, + dataViewId: params.data_view_id, + query: params.query, + filters: params.filters, + savedId: params.saved_id, + threatFilters: params.threat_filters, + threatQuery: params.threat_query, + threatMapping: params.threat_mapping, + threatLanguage: params.threat_language, + threatIndex: params.threat_index, + threatIndicatorPath: params.threat_indicator_path, + concurrentSearches: params.concurrent_searches, + itemsPerSearch: params.items_per_search, + alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + }; + } + case 'query': { + return { + type: params.type, + language: params.language ?? 'kuery', + index: params.index, + dataViewId: params.data_view_id, + query: params.query ?? '', + filters: params.filters, + savedId: params.saved_id, + responseActions: params.response_actions?.map((rule) => + transformRuleToAlertResponseAction(rule) + ), + alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + }; + } + case 'saved_query': { + return { + type: params.type, + language: params.language ?? 'kuery', + index: params.index, + query: params.query, + filters: params.filters, + savedId: params.saved_id, + dataViewId: params.data_view_id, + responseActions: params.response_actions?.map((rule) => + transformRuleToAlertResponseAction(rule) + ), + alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + }; + } + case 'threshold': { + return { + type: params.type, + language: params.language ?? 'kuery', + index: params.index, + dataViewId: params.data_view_id, + query: params.query, + filters: params.filters, + savedId: params.saved_id, + threshold: normalizeThresholdObject(params.threshold), + alertSuppression: params.alert_suppression?.duration + ? { duration: params.alert_suppression.duration } + : undefined, + }; + } + case 'machine_learning': { + return { + type: params.type, + anomalyThreshold: params.anomaly_threshold, + machineLearningJobId: normalizeMachineLearningJobIds(params.machine_learning_job_id), + alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + }; + } + case 'new_terms': { + return { + type: params.type, + query: params.query, + newTermsFields: params.new_terms_fields, + historyWindowStart: params.history_window_start, + index: params.index, + filters: params.filters, + language: params.language ?? 'kuery', + dataViewId: params.data_view_id, + alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + }; + } + default: { + return assertUnreachable(params); + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/internal_rule_to_api_response.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/internal_rule_to_api_response.ts new file mode 100644 index 00000000000000..452f59df8dcf93 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/internal_rule_to_api_response.ts @@ -0,0 +1,61 @@ +/* + * 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 { ResolvedSanitizedRule, SanitizedRule } from '@kbn/alerting-plugin/common'; +import type { RequiredOptional } from '@kbn/zod-helpers'; +import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { transformAlertToRuleAction } from '../../../../../../../common/detection_engine/transform_actions'; +import { createRuleExecutionSummary } from '../../../../rule_monitoring'; +import type { RuleParams } from '../../../../rule_schema'; +import { + transformFromAlertThrottle, + transformToActionFrequency, +} from '../../../normalization/rule_actions'; +import { typeSpecificCamelToSnake } from './type_specific_camel_to_snake'; +import { commonParamsCamelToSnake } from './common_params_camel_to_snake'; + +export const internalRuleToAPIResponse = ( + rule: SanitizedRule | ResolvedSanitizedRule +): RequiredOptional => { + const executionSummary = createRuleExecutionSummary(rule); + + const isResolvedRule = (obj: unknown): obj is ResolvedSanitizedRule => { + const outcome = (obj as ResolvedSanitizedRule).outcome; + return outcome != null && outcome !== 'exactMatch'; + }; + + const alertActions = rule.actions.map(transformAlertToRuleAction); + const throttle = transformFromAlertThrottle(rule); + const actions = transformToActionFrequency(alertActions, throttle); + + return { + // saved object properties + outcome: isResolvedRule(rule) ? rule.outcome : undefined, + alias_target_id: isResolvedRule(rule) ? rule.alias_target_id : undefined, + alias_purpose: isResolvedRule(rule) ? rule.alias_purpose : undefined, + // Alerting framework params + id: rule.id, + updated_at: rule.updatedAt.toISOString(), + updated_by: rule.updatedBy ?? 'elastic', + created_at: rule.createdAt.toISOString(), + created_by: rule.createdBy ?? 'elastic', + name: rule.name, + tags: rule.tags, + interval: rule.schedule.interval, + enabled: rule.enabled, + revision: rule.revision, + // Security solution shared rule params + ...commonParamsCamelToSnake(rule.params), + // Type specific security solution rule params + ...typeSpecificCamelToSnake(rule.params), + // Actions + throttle: undefined, + actions, + // Execution summary + execution_summary: executionSummary ?? undefined, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.test.ts new file mode 100644 index 00000000000000..08e6d3cc64b0a1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.test.ts @@ -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 type { + AlertSuppressionDuration, + AlertSuppressionMissingFieldsStrategy, +} from '../../../../../../../common/api/detection_engine'; +import { getEqlRuleParams } from '../../../../rule_schema/mocks'; +import { typeSpecificCamelToSnake } from './type_specific_camel_to_snake'; + +describe('typeSpecificCamelToSnake', () => { + describe('EQL', () => { + test('should accept EQL params when existing rule type is EQL', () => { + const params = { + timestampField: 'event.created', + eventCategoryOverride: 'event.not_category', + tiebreakerField: 'event.created', + }; + const eqlRule = { ...getEqlRuleParams(), ...params }; + const transformedParams = typeSpecificCamelToSnake(eqlRule); + expect(transformedParams).toEqual( + expect.objectContaining({ + timestamp_field: 'event.created', + event_category_override: 'event.not_category', + tiebreaker_field: 'event.created', + }) + ); + }); + + test('should accept EQL params with suppression in camel case and convert to snake case when rule type is EQL', () => { + const params = { + timestampField: 'event.created', + eventCategoryOverride: 'event.not_category', + tiebreakerField: 'event.created', + alertSuppression: { + groupBy: ['event.type'], + duration: { + value: 10, + unit: 'm', + } as AlertSuppressionDuration, + missingFieldsStrategy: 'suppress' as AlertSuppressionMissingFieldsStrategy, + }, + }; + const eqlRule = { ...getEqlRuleParams(), ...params }; + const transformedParams = typeSpecificCamelToSnake(eqlRule); + expect(transformedParams).toEqual( + expect.objectContaining({ + timestamp_field: 'event.created', + event_category_override: 'event.not_category', + tiebreaker_field: 'event.created', + alert_suppression: { + group_by: ['event.type'], + duration: { + value: 10, + unit: 'm', + } as AlertSuppressionDuration, + missing_fields_strategy: 'suppress', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.ts new file mode 100644 index 00000000000000..0808d1921e9bf8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.ts @@ -0,0 +1,127 @@ +/* + * 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 { RequiredOptional } from '@kbn/zod-helpers'; +import type { TypeSpecificResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { transformAlertToRuleResponseAction } from '../../../../../../../common/detection_engine/transform_actions'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; +import { convertObjectKeysToSnakeCase } from '../../../../../../utils/object_case_converters'; +import type { TypeSpecificRuleParams } from '../../../../rule_schema'; + +export const typeSpecificCamelToSnake = ( + params: TypeSpecificRuleParams +): RequiredOptional => { + switch (params.type) { + case 'eql': { + return { + type: params.type, + language: params.language, + index: params.index, + data_view_id: params.dataViewId, + query: params.query, + filters: params.filters, + timestamp_field: params.timestampField, + event_category_override: params.eventCategoryOverride, + tiebreaker_field: params.tiebreakerField, + alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + }; + } + case 'esql': { + return { + type: params.type, + language: params.language, + query: params.query, + alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + }; + } + case 'threat_match': { + return { + type: params.type, + language: params.language, + index: params.index, + data_view_id: params.dataViewId, + query: params.query, + filters: params.filters, + saved_id: params.savedId, + threat_filters: params.threatFilters, + threat_query: params.threatQuery, + threat_mapping: params.threatMapping, + threat_language: params.threatLanguage, + threat_index: params.threatIndex, + threat_indicator_path: params.threatIndicatorPath, + concurrent_searches: params.concurrentSearches, + items_per_search: params.itemsPerSearch, + alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + }; + } + case 'query': { + return { + type: params.type, + language: params.language, + index: params.index, + data_view_id: params.dataViewId, + query: params.query, + filters: params.filters, + saved_id: params.savedId, + response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), + alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + }; + } + case 'saved_query': { + return { + type: params.type, + language: params.language, + index: params.index, + query: params.query, + filters: params.filters, + saved_id: params.savedId, + data_view_id: params.dataViewId, + response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), + alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + }; + } + case 'threshold': { + return { + type: params.type, + language: params.language, + index: params.index, + data_view_id: params.dataViewId, + query: params.query, + filters: params.filters, + saved_id: params.savedId, + threshold: params.threshold, + alert_suppression: params.alertSuppression?.duration + ? { duration: params.alertSuppression?.duration } + : undefined, + }; + } + case 'machine_learning': { + return { + type: params.type, + anomaly_threshold: params.anomalyThreshold, + machine_learning_job_id: params.machineLearningJobId, + alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + }; + } + case 'new_terms': { + return { + type: params.type, + query: params.query, + new_terms_fields: params.newTermsFields, + history_window_start: params.historyWindowStart, + index: params.index, + filters: params.filters, + language: params.language, + data_view_id: params.dataViewId, + alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + }; + } + default: { + return assertUnreachable(params); + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts index 7aab6640a1b52c..5578854ed95b2c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_custom_rule.test.ts @@ -6,6 +6,7 @@ */ import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import { getCreateRulesSchemaMock, @@ -35,7 +36,8 @@ describe('DetectionRulesClient.createCustomRule', () => { rulesClient = rulesClientMock.create(); rulesClient.create.mockResolvedValue(getRuleMock(getQueryRuleParams())); - detectionRulesClient = createDetectionRulesClient(rulesClient, mlAuthz); + const savedObjectsClient = savedObjectsClientMock.create(); + detectionRulesClient = createDetectionRulesClient({ rulesClient, mlAuthz, savedObjectsClient }); }); it('should create a rule with the correct parameters and options', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts index fd3ac991a968fe..f91c577f3b2a0e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.create_prebuilt_rule.test.ts @@ -6,6 +6,7 @@ */ import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import { getCreateRulesSchemaMock, @@ -35,7 +36,8 @@ describe('DetectionRulesClient.createPrebuiltRule', () => { rulesClient = rulesClientMock.create(); rulesClient.create.mockResolvedValue(getRuleMock(getQueryRuleParams())); - detectionRulesClient = createDetectionRulesClient(rulesClient, mlAuthz); + const savedObjectsClient = savedObjectsClientMock.create(); + detectionRulesClient = createDetectionRulesClient({ rulesClient, mlAuthz, savedObjectsClient }); }); it('creates a rule with the correct parameters and options', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.delete_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.delete_rule.test.ts index 37cb8e0aa709e3..166656701f3044 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.delete_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.delete_rule.test.ts @@ -6,6 +6,7 @@ */ import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import { buildMlAuthz } from '../../../../machine_learning/authz'; import { createDetectionRulesClient } from './detection_rules_client'; import type { IDetectionRulesClient } from './detection_rules_client_interface'; @@ -20,7 +21,8 @@ describe('DetectionRulesClient.deleteRule', () => { beforeEach(() => { rulesClient = rulesClientMock.create(); - detectionRulesClient = createDetectionRulesClient(rulesClient, mlAuthz); + const savedObjectsClient = savedObjectsClientMock.create(); + detectionRulesClient = createDetectionRulesClient({ rulesClient, mlAuthz, savedObjectsClient }); }); it('should call rulesClient.delete passing the expected ruleId', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts index 474fecc1865196..fb9b4f7995c905 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.import_rule.test.ts @@ -6,19 +6,23 @@ */ import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; -import { readRules } from './read_rules'; -import { getCreateRulesSchemaMock } from '../../../../../../common/api/detection_engine/model/rule_schema/mocks'; -import { getRuleMock } from '../../../routes/__mocks__/request_responses'; -import { getQueryRuleParams } from '../../../rule_schema/mocks'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { + getCreateRulesSchemaMock, + getRulesSchemaMock, +} from '../../../../../../common/api/detection_engine/model/rule_schema/mocks'; import { buildMlAuthz } from '../../../../machine_learning/authz'; import { throwAuthzError } from '../../../../machine_learning/validation'; +import { getRuleMock } from '../../../routes/__mocks__/request_responses'; +import { getQueryRuleParams } from '../../../rule_schema/mocks'; import { createDetectionRulesClient } from './detection_rules_client'; import type { IDetectionRulesClient } from './detection_rules_client_interface'; +import { getRuleByRuleId } from './methods/get_rule_by_rule_id'; jest.mock('../../../../machine_learning/authz'); jest.mock('../../../../machine_learning/validation'); -jest.mock('./read_rules'); +jest.mock('./methods/get_rule_by_rule_id'); describe('DetectionRulesClient.importRule', () => { let rulesClient: ReturnType; @@ -34,21 +38,19 @@ describe('DetectionRulesClient.importRule', () => { version: 1, immutable, }; - const existingRule = getRuleMock({ - ...getQueryRuleParams({ - ruleId: ruleToImport.rule_id, - }), - }); + const existingRule = getRulesSchemaMock(); + existingRule.rule_id = ruleToImport.rule_id; beforeEach(() => { rulesClient = rulesClientMock.create(); rulesClient.create.mockResolvedValue(getRuleMock(getQueryRuleParams())); rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - detectionRulesClient = createDetectionRulesClient(rulesClient, mlAuthz); + const savedObjectsClient = savedObjectsClientMock.create(); + detectionRulesClient = createDetectionRulesClient({ rulesClient, mlAuthz, savedObjectsClient }); }); it('calls rulesClient.create with the correct parameters when rule_id does not match an installed rule', async () => { - (readRules as jest.Mock).mockResolvedValue(null); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(null); await detectionRulesClient.importRule({ ruleToImport, overwriteRules: true, @@ -90,7 +92,8 @@ describe('DetectionRulesClient.importRule', () => { describe('when rule_id matches an installed rule', () => { it('calls rulesClient.update with the correct parameters when overwriteRules is true', async () => { - (readRules as jest.Mock).mockResolvedValue(existingRule); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + await detectionRulesClient.importRule({ ruleToImport, overwriteRules: true, @@ -122,12 +125,9 @@ describe('DetectionRulesClient.importRule', () => { it('ensures overwritten rule DOES NOT preserve fields missed in the imported rule when "overwriteRules" is "true" and matching rule found', async () => { const existingRuleWithTimestampOverride = { ...existingRule, - params: { - ...existingRule.params, - timestamp_override: '2020-01-01T00:00:00Z', - }, + timestamp_override: '2020-01-01T00:00:00Z', }; - (readRules as jest.Mock).mockResolvedValue(existingRuleWithTimestampOverride); + (getRuleByRuleId as jest.Mock).mockResolvedValue(existingRuleWithTimestampOverride); await detectionRulesClient.importRule({ ruleToImport: { @@ -151,7 +151,7 @@ describe('DetectionRulesClient.importRule', () => { }); it('rejects when overwriteRules is false', async () => { - (readRules as jest.Mock).mockResolvedValue(existingRule); + (getRuleByRuleId as jest.Mock).mockResolvedValue(existingRule); await expect( detectionRulesClient.importRule({ ruleToImport, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts index 7f1c2198886366..d17b12415642ae 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts @@ -12,17 +12,20 @@ import { getMlRuleParams, getQueryRuleParams } from '../../../rule_schema/mocks' import { getCreateMachineLearningRulesSchemaMock, getCreateRulesSchemaMock, + getRulesMlSchemaMock, + getRulesSchemaMock, } from '../../../../../../common/api/detection_engine/model/rule_schema/mocks'; -import { readRules } from './read_rules'; +import { getRuleByRuleId } from './methods/get_rule_by_rule_id'; import { buildMlAuthz } from '../../../../machine_learning/authz'; import { throwAuthzError } from '../../../../machine_learning/validation'; import { createDetectionRulesClient } from './detection_rules_client'; import type { IDetectionRulesClient } from './detection_rules_client_interface'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; jest.mock('../../../../machine_learning/authz'); jest.mock('../../../../machine_learning/validation'); -jest.mock('./read_rules'); +jest.mock('./methods/get_rule_by_rule_id'); describe('DetectionRulesClient.patchRule', () => { let rulesClient: ReturnType; @@ -32,97 +35,78 @@ describe('DetectionRulesClient.patchRule', () => { beforeEach(() => { rulesClient = rulesClientMock.create(); - detectionRulesClient = createDetectionRulesClient(rulesClient, mlAuthz); + const savedObjectsClient = savedObjectsClientMock.create(); + detectionRulesClient = createDetectionRulesClient({ rulesClient, mlAuthz, savedObjectsClient }); }); it('calls the rulesClient with expected params', async () => { - const nextParams = getCreateRulesSchemaMock(); - const existingRule = getRuleMock(getQueryRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const rulePatch = getCreateRulesSchemaMock('query-rule-id'); + rulePatch.name = 'new name'; + rulePatch.description = 'new description'; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - await detectionRulesClient.patchRule({ nextParams }); + await detectionRulesClient.patchRule({ rulePatch }); expect(rulesClient.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - name: nextParams.name, + name: rulePatch.name, params: expect.objectContaining({ - ruleId: nextParams.rule_id, - description: nextParams.description, + ruleId: rulePatch.rule_id, + description: rulePatch.description, }), }), }) ); }); - it('returns rule enabled: true if the nexParams have enabled: true', async () => { - const nextParams = { ...getCreateRulesSchemaMock(), enabled: true }; - const existingRule = getRuleMock(getQueryRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); - rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); + it('enables the rule if the nexParams have enabled: true', async () => { + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + existingRule.enabled = false; + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); - const rule = await detectionRulesClient.patchRule({ nextParams }); + // Mock the rule update + const rulePatch = { ...getCreateRulesSchemaMock(), enabled: true }; - expect(rule.enabled).toBe(true); - }); + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw + rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - it('calls the rulesClient with legacy ML params', async () => { - const nextParams = getCreateMachineLearningRulesSchemaMock(); - const existingRule = getRuleMock(getMlRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); - rulesClient.update.mockResolvedValue(getRuleMock(getMlRuleParams())); + const rule = await detectionRulesClient.patchRule({ rulePatch }); - await detectionRulesClient.patchRule({ nextParams }); - expect(rulesClient.update).toHaveBeenCalledWith( + expect(rule.enabled).toBe(true); + expect(rulesClient.enable).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ - params: expect.objectContaining({ - anomalyThreshold: 58, - machineLearningJobId: ['typical-ml-job-id'], - }), - }), + id: existingRule.id, }) ); }); - it('calls the rulesClient with new ML params', async () => { - const nextParams = { - ...getCreateMachineLearningRulesSchemaMock(), - machine_learning_job_id: ['new_job_1', 'new_job_2'], - }; - const existingRule = getRuleMock(getMlRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); - rulesClient.update.mockResolvedValue(getRuleMock(getMlRuleParams())); - - await detectionRulesClient.patchRule({ nextParams }); + it('disables the rule if the nexParams have enabled: false', async () => { + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + existingRule.enabled = true; + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); - expect(rulesClient.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - params: expect.objectContaining({ - anomalyThreshold: 58, - machineLearningJobId: ['new_job_1', 'new_job_2'], - }), - }), - }) - ); - }); + // Mock the rule update + const rulePatch = { ...getCreateRulesSchemaMock(), enabled: false }; - it('should call rulesClient.disable if the rule was enabled and enabled is false', async () => { - const nextParams = { - ...getCreateRulesSchemaMock(), - enabled: false, - }; - const existingRule = { - ...getRuleMock(getQueryRuleParams()), - enabled: true, - }; - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - await detectionRulesClient.patchRule({ nextParams }); + const rule = await detectionRulesClient.patchRule({ rulePatch }); + expect(rule.enabled).toBe(false); expect(rulesClient.disable).toHaveBeenCalledWith( expect.objectContaining({ id: existingRule.id, @@ -130,23 +114,29 @@ describe('DetectionRulesClient.patchRule', () => { ); }); - it('should call rulesClient.enable if the rule was disabled and enabled is true', async () => { - const nextParams = { - ...getCreateRulesSchemaMock(), - enabled: true, - }; - const existingRule = { - ...getRuleMock(getQueryRuleParams()), - enabled: false, - }; - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); - rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); + it('calls the rulesClient with new ML params', async () => { + // Mock the existing rule + const existingRule = getRulesMlSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); - await detectionRulesClient.patchRule({ nextParams }); + // Mock the rule update + const rulePatch = getCreateMachineLearningRulesSchemaMock(); + rulePatch.anomaly_threshold = 42; + rulePatch.machine_learning_job_id = ['new-job-id']; - expect(rulesClient.enable).toHaveBeenCalledWith( + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw + rulesClient.update.mockResolvedValue(getRuleMock(getMlRuleParams())); + + await detectionRulesClient.patchRule({ rulePatch }); + expect(rulesClient.update).toHaveBeenCalledWith( expect.objectContaining({ - id: existingRule.id, + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: rulePatch.anomaly_threshold, + machineLearningJobId: rulePatch.machine_learning_job_id, + }), + }), }) ); }); @@ -156,21 +146,23 @@ describe('DetectionRulesClient.patchRule', () => { throw new Error('mocked MLAuth error'); }); - const nextParams = { - ...getCreateRulesSchemaMock(), - enabled: true, - }; + const rulePatch = getCreateRulesSchemaMock(); - await expect(detectionRulesClient.patchRule({ nextParams })).rejects.toThrow( + await expect(detectionRulesClient.patchRule({ rulePatch })).rejects.toThrow( 'mocked MLAuth error' ); expect(rulesClient.create).not.toHaveBeenCalled(); }); - describe('regression tests', () => { + describe('actions', () => { it("updates the rule's actions if provided", async () => { - const nextParams = { + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const rulePatch = { ...getCreateRulesSchemaMock(), actions: [ { @@ -183,11 +175,12 @@ describe('DetectionRulesClient.patchRule', () => { }, ], }; - const existingRule = getRuleMock(getQueryRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - await detectionRulesClient.patchRule({ nextParams }); + await detectionRulesClient.patchRule({ rulePatch }); expect(rulesClient.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -209,12 +202,12 @@ describe('DetectionRulesClient.patchRule', () => { }); it('does not update actions if none are specified', async () => { - const nextParams = getCreateRulesSchemaMock(); - delete nextParams.actions; - const existingRule = getRuleMock(getQueryRuleParams()); + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); existingRule.actions = [ { - actionTypeId: '.slack', + action_type_id: '.slack', id: '2933e581-d81c-4fe3-88fe-c57c6b8a5bfd', params: { message: 'Rule {{context.rule.name}} generated {{state.signals_count}} signals', @@ -222,10 +215,16 @@ describe('DetectionRulesClient.patchRule', () => { group: 'default', }, ]; - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const rulePatch = getCreateRulesSchemaMock(); + delete rulePatch.actions; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - await detectionRulesClient.patchRule({ nextParams }); + await detectionRulesClient.patchRule({ rulePatch }); expect(rulesClient.update).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts index ce6043a4209074..dfcfc8f7fa3930 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts @@ -6,73 +6,110 @@ */ import type { RulesClient } from '@kbn/alerting-plugin/server'; -import type { MlAuthz } from '../../../../machine_learning/authz'; - +import type { SavedObjectsClientContract } from '@kbn/core/server'; import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema'; +import { withSecuritySpan } from '../../../../../utils/with_security_span'; +import type { MlAuthz } from '../../../../machine_learning/authz'; +import { createPrebuiltRuleAssetsClient } from '../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; import type { - IDetectionRulesClient, CreateCustomRuleArgs, CreatePrebuiltRuleArgs, - UpdateRuleArgs, - PatchRuleArgs, DeleteRuleArgs, - UpgradePrebuiltRuleArgs, + IDetectionRulesClient, ImportRuleArgs, + PatchRuleArgs, + UpdateRuleArgs, + UpgradePrebuiltRuleArgs, } from './detection_rules_client_interface'; - -import { createCustomRule } from './methods/create_custom_rule'; -import { createPrebuiltRule } from './methods/create_prebuilt_rule'; -import { updateRule } from './methods/update_rule'; -import { patchRule } from './methods/patch_rule'; +import { createRule } from './methods/create_rule'; import { deleteRule } from './methods/delete_rule'; -import { upgradePrebuiltRule } from './methods/upgrade_prebuilt_rule'; import { importRule } from './methods/import_rule'; +import { patchRule } from './methods/patch_rule'; +import { updateRule } from './methods/update_rule'; +import { upgradePrebuiltRule } from './methods/upgrade_prebuilt_rule'; -import { withSecuritySpan } from '../../../../../utils/with_security_span'; +interface DetectionRulesClientParams { + rulesClient: RulesClient; + savedObjectsClient: SavedObjectsClientContract; + mlAuthz: MlAuthz; +} + +export const createDetectionRulesClient = ({ + rulesClient, + mlAuthz, + savedObjectsClient, +}: DetectionRulesClientParams): IDetectionRulesClient => { + const prebuiltRuleAssetClient = createPrebuiltRuleAssetsClient(savedObjectsClient); -export const createDetectionRulesClient = ( - rulesClient: RulesClient, - mlAuthz: MlAuthz -): IDetectionRulesClient => ({ - async createCustomRule(args: CreateCustomRuleArgs): Promise { - return withSecuritySpan('DetectionRulesClient.createCustomRule', async () => { - return createCustomRule(rulesClient, args, mlAuthz); - }); - }, + return { + async createCustomRule(args: CreateCustomRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.createCustomRule', async () => { + return createRule({ + rulesClient, + rule: { + ...args.params, + // For backwards compatibility, we default to true if not provided. + // The default enabled value is false for prebuilt rules, and true + // for custom rules. + enabled: args.params.enabled ?? true, + immutable: false, + }, + mlAuthz, + }); + }); + }, - async createPrebuiltRule(args: CreatePrebuiltRuleArgs): Promise { - return withSecuritySpan('DetectionRulesClient.createPrebuiltRule', async () => { - return createPrebuiltRule(rulesClient, args, mlAuthz); - }); - }, + async createPrebuiltRule(args: CreatePrebuiltRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.createPrebuiltRule', async () => { + return createRule({ + rulesClient, + rule: { + ...args.params, + immutable: true, + }, + mlAuthz, + }); + }); + }, - async updateRule(args: UpdateRuleArgs): Promise { - return withSecuritySpan('DetectionRulesClient.updateRule', async () => { - return updateRule(rulesClient, args, mlAuthz); - }); - }, + async updateRule({ ruleUpdate }: UpdateRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.updateRule', async () => { + return updateRule({ rulesClient, prebuiltRuleAssetClient, mlAuthz, ruleUpdate }); + }); + }, - async patchRule(args: PatchRuleArgs): Promise { - return withSecuritySpan('DetectionRulesClient.patchRule', async () => { - return patchRule(rulesClient, args, mlAuthz); - }); - }, + async patchRule({ rulePatch }: PatchRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.patchRule', async () => { + return patchRule({ rulesClient, prebuiltRuleAssetClient, mlAuthz, rulePatch }); + }); + }, - async deleteRule(args: DeleteRuleArgs): Promise { - return withSecuritySpan('DetectionRulesClient.deleteRule', async () => { - return deleteRule(rulesClient, args); - }); - }, + async deleteRule({ ruleId }: DeleteRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.deleteRule', async () => { + return deleteRule({ rulesClient, ruleId }); + }); + }, - async upgradePrebuiltRule(args: UpgradePrebuiltRuleArgs): Promise { - return withSecuritySpan('DetectionRulesClient.upgradePrebuiltRule', async () => { - return upgradePrebuiltRule(rulesClient, args, mlAuthz); - }); - }, + async upgradePrebuiltRule({ ruleAsset }: UpgradePrebuiltRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.upgradePrebuiltRule', async () => { + return upgradePrebuiltRule({ + rulesClient, + ruleAsset, + mlAuthz, + prebuiltRuleAssetClient, + }); + }); + }, - async importRule(args: ImportRuleArgs): Promise { - return withSecuritySpan('DetectionRulesClient.importRule', async () => { - return importRule(rulesClient, args, mlAuthz); - }); - }, -}); + async importRule(args: ImportRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.importRule', async () => { + return importRule({ + rulesClient, + importRulePayload: args, + mlAuthz, + prebuiltRuleAssetClient, + }); + }); + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts index 671460b046fea0..db9d122e7d9127 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts @@ -12,17 +12,20 @@ import { getMlRuleParams, getQueryRuleParams } from '../../../rule_schema/mocks' import { getCreateMachineLearningRulesSchemaMock, getCreateRulesSchemaMock, + getRulesMlSchemaMock, + getRulesSchemaMock, } from '../../../../../../common/api/detection_engine/model/rule_schema/mocks'; -import { readRules } from './read_rules'; +import { getRuleByRuleId } from './methods/get_rule_by_rule_id'; import { buildMlAuthz } from '../../../../machine_learning/authz'; import { throwAuthzError } from '../../../../machine_learning/validation'; import { createDetectionRulesClient } from './detection_rules_client'; import type { IDetectionRulesClient } from './detection_rules_client_interface'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; jest.mock('../../../../machine_learning/authz'); jest.mock('../../../../machine_learning/validation'); -jest.mock('./read_rules'); +jest.mock('./methods/get_rule_by_rule_id'); describe('DetectionRulesClient.updateRule', () => { let rulesClient: ReturnType; @@ -32,13 +35,22 @@ describe('DetectionRulesClient.updateRule', () => { beforeEach(() => { rulesClient = rulesClientMock.create(); - detectionRulesClient = createDetectionRulesClient(rulesClient, mlAuthz); + const savedObjectsClient = savedObjectsClientMock.create(); + detectionRulesClient = createDetectionRulesClient({ rulesClient, mlAuthz, savedObjectsClient }); }); it('calls the rulesClient with expected params', async () => { - const ruleUpdate = getCreateRulesSchemaMock(); - const existingRule = getRuleMock(getQueryRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const ruleUpdate = getCreateRulesSchemaMock('query-rule-id'); + ruleUpdate.name = 'new name'; + ruleUpdate.description = 'new description'; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); await detectionRulesClient.updateRule({ ruleUpdate }); @@ -56,21 +68,18 @@ describe('DetectionRulesClient.updateRule', () => { ); }); - it('returns rule enabled: true if the nexParams have enabled: true', async () => { - const ruleUpdate = { ...getCreateRulesSchemaMock(), enabled: true }; - const existingRule = getRuleMock(getQueryRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); - rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - - const rule = await detectionRulesClient.updateRule({ ruleUpdate }); - - expect(rule.enabled).toBe(true); - }); + it('calls the rulesClient with new ML params', async () => { + // Mock the existing rule + const existingRule = getRulesMlSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); - it('calls the rulesClient with legacy ML params', async () => { + // Mock the rule update const ruleUpdate = getCreateMachineLearningRulesSchemaMock(); - const existingRule = getRuleMock(getMlRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); + ruleUpdate.anomaly_threshold = 42; + ruleUpdate.machine_learning_job_id = ['new-job-id']; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getMlRuleParams())); await detectionRulesClient.updateRule({ ruleUpdate }); @@ -79,48 +88,26 @@ describe('DetectionRulesClient.updateRule', () => { expect.objectContaining({ data: expect.objectContaining({ params: expect.objectContaining({ - anomalyThreshold: 58, - machineLearningJobId: ['typical-ml-job-id'], + anomalyThreshold: ruleUpdate.anomaly_threshold, + machineLearningJobId: ruleUpdate.machine_learning_job_id, }), }), }) ); }); - it('calls the rulesClient with new ML params', async () => { - const ruleUpdate = { - ...getCreateMachineLearningRulesSchemaMock(), - machine_learning_job_id: ['new_job_1', 'new_job_2'], - }; - const existingRule = getRuleMock(getMlRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); - rulesClient.update.mockResolvedValue(getRuleMock(getMlRuleParams())); + it('disables rule if the rule was enabled and enabled is false', async () => { + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + existingRule.enabled = true; + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); - await detectionRulesClient.updateRule({ ruleUpdate }); + // Mock the rule update + const ruleUpdate = { ...getCreateRulesSchemaMock(), enabled: false }; - expect(rulesClient.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - params: expect.objectContaining({ - anomalyThreshold: 58, - machineLearningJobId: ['new_job_1', 'new_job_2'], - }), - }), - }) - ); - }); - - it('should call rulesClient.disable if the rule was enabled and enabled is false', async () => { - const ruleUpdate = { - ...getCreateRulesSchemaMock(), - enabled: false, - }; - const existingRule = { - ...getRuleMock(getQueryRuleParams()), - enabled: true, - }; + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); await detectionRulesClient.updateRule({ ruleUpdate }); @@ -131,17 +118,18 @@ describe('DetectionRulesClient.updateRule', () => { ); }); - it('should call rulesClient.enable if the rule was disabled and enabled is true', async () => { - const ruleUpdate = { - ...getCreateRulesSchemaMock(), - enabled: true, - }; - const existingRule = { - ...getRuleMock(getQueryRuleParams()), - enabled: false, - }; + it('enables rule if the rule was disabled and enabled is true', async () => { + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + existingRule.enabled = false; + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const ruleUpdate = { ...getCreateRulesSchemaMock(), enabled: true }; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); await detectionRulesClient.updateRule({ ruleUpdate }); @@ -169,8 +157,13 @@ describe('DetectionRulesClient.updateRule', () => { expect(rulesClient.create).not.toHaveBeenCalled(); }); - describe('regression tests', () => { + describe('actions', () => { it("updates the rule's actions if provided", async () => { + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update const ruleUpdate = { ...getCreateRulesSchemaMock(), actions: [ @@ -184,8 +177,9 @@ describe('DetectionRulesClient.updateRule', () => { }, ], }; - const existingRule = getRuleMock(getQueryRuleParams()); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); await detectionRulesClient.updateRule({ ruleUpdate }); @@ -210,12 +204,12 @@ describe('DetectionRulesClient.updateRule', () => { }); it('updates actions to empty if none are specified', async () => { - const ruleUpdate = getCreateRulesSchemaMock(); - delete ruleUpdate.actions; - const existingRule = getRuleMock(getQueryRuleParams()); + // Mock the existing rule + const existingRule = getRulesSchemaMock(); + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); existingRule.actions = [ { - actionTypeId: '.slack', + action_type_id: '.slack', id: '2933e581-d81c-4fe3-88fe-c57c6b8a5bfd', params: { message: 'Rule {{context.rule.name}} generated {{state.signals_count}} signals', @@ -223,8 +217,14 @@ describe('DetectionRulesClient.updateRule', () => { group: 'default', }, ]; + + // Mock the rule update + const ruleUpdate = getCreateRulesSchemaMock(); + delete ruleUpdate.actions; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); - (readRules as jest.Mock).mockResolvedValueOnce(existingRule); await detectionRulesClient.updateRule({ ruleUpdate }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts index 38f3507d2f7ae3..7a5ae76371532c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.upgrade_prebuilt_rule.test.ts @@ -10,21 +10,22 @@ import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; import { getCreateEqlRuleSchemaMock, getCreateRulesSchemaMock, + getRulesEqlSchemaMock, + getRulesSchemaMock, } from '../../../../../../common/api/detection_engine/model/rule_schema/mocks'; import type { PrebuiltRuleAsset } from '../../../prebuilt_rules'; - -import { readRules } from './read_rules'; +import { getRuleByRuleId } from './methods/get_rule_by_rule_id'; import { getRuleMock } from '../../../routes/__mocks__/request_responses'; import { getEqlRuleParams, getQueryRuleParams } from '../../../rule_schema/mocks'; - import { buildMlAuthz } from '../../../../machine_learning/authz'; import { throwAuthzError } from '../../../../machine_learning/validation'; import { createDetectionRulesClient } from './detection_rules_client'; import type { IDetectionRulesClient } from './detection_rules_client_interface'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; jest.mock('../../../../machine_learning/authz'); jest.mock('../../../../machine_learning/validation'); -jest.mock('./read_rules'); +jest.mock('./methods/get_rule_by_rule_id'); describe('DetectionRulesClient.upgradePrebuiltRule', () => { let rulesClient: ReturnType; @@ -34,7 +35,8 @@ describe('DetectionRulesClient.upgradePrebuiltRule', () => { beforeEach(() => { rulesClient = rulesClientMock.create(); - detectionRulesClient = createDetectionRulesClient(rulesClient, mlAuthz); + const savedObjectsClient = savedObjectsClientMock.create(); + detectionRulesClient = createDetectionRulesClient({ rulesClient, mlAuthz, savedObjectsClient }); }); it('throws if no matching rule_id is found', async () => { @@ -44,7 +46,7 @@ describe('DetectionRulesClient.upgradePrebuiltRule', () => { rule_id: 'rule-id', }; - (readRules as jest.Mock).mockResolvedValue(null); + (getRuleByRuleId as jest.Mock).mockResolvedValue(null); await expect(detectionRulesClient.upgradePrebuiltRule({ ruleAsset })).rejects.toThrow( `Failed to find rule ${ruleAsset.rule_id}` ); @@ -80,28 +82,24 @@ describe('DetectionRulesClient.upgradePrebuiltRule', () => { rule_id: 'rule-id', }; // Installed version is "query" - const installedRule = getRuleMock({ - ...getQueryRuleParams({ - exceptionsList: [ - { id: 'test_id', list_id: 'hi', type: 'detection', namespace_type: 'agnostic' }, - ], - }), - actions: [ - { - group: 'default', - id: 'test_id', - action_type_id: '.index', - config: { - index: ['index-1', 'index-2'], - }, - }, - ], - ruleId: 'rule-id', - }); + const installedRule = getRulesSchemaMock(); + installedRule.exceptions_list = [ + { id: 'test_id', list_id: 'hi', type: 'detection', namespace_type: 'agnostic' }, + ]; + installedRule.actions = [ + { + group: 'default', + id: 'test_id', + action_type_id: '.index', + params: {}, + }, + ]; + installedRule.rule_id = 'rule-id'; + beforeEach(() => { jest.resetAllMocks(); rulesClient.create.mockResolvedValue(getRuleMock(getQueryRuleParams())); - (readRules as jest.Mock).mockResolvedValue(installedRule); + (getRuleByRuleId as jest.Mock).mockResolvedValue(installedRule); }); it('deletes the old rule', async () => { @@ -117,16 +115,23 @@ describe('DetectionRulesClient.upgradePrebuiltRule', () => { name: ruleAsset.name, tags: ruleAsset.tags, // enabled and actions are kept from original rule - actions: installedRule.actions, + actions: [ + expect.objectContaining({ + actionTypeId: '.index', + group: 'default', + id: 'test_id', + params: {}, + }), + ], enabled: installedRule.enabled, params: expect.objectContaining({ index: ruleAsset.index, description: ruleAsset.description, immutable: true, // exceptions_lists, actions, timeline_id and timeline_title are maintained - timelineTitle: installedRule.params.timelineTitle, - timelineId: installedRule.params.timelineId, - exceptionsList: installedRule.params.exceptionsList, + timelineTitle: installedRule.timeline_title, + timelineId: installedRule.timeline_id, + exceptionsList: installedRule.exceptions_list, }), }), options: { @@ -147,11 +152,9 @@ describe('DetectionRulesClient.upgradePrebuiltRule', () => { rule_id: 'rule-id', }; // Installed version is "eql" - const installedRule = getRuleMock({ - ...getEqlRuleParams(), - }); + const installedRule = getRulesEqlSchemaMock(); beforeEach(() => { - (readRules as jest.Mock).mockResolvedValue(installedRule); + (getRuleByRuleId as jest.Mock).mockResolvedValue(installedRule); }); it('patches the existing rule with the new params from the rule asset', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts index 34c39153206b1e..d7b45f83e8bf81 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts @@ -38,7 +38,7 @@ export interface UpdateRuleArgs { } export interface PatchRuleArgs { - nextParams: RulePatchProps; + rulePatch: RulePatchProps; } export interface DeleteRuleArgs { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_defaults.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_defaults.ts new file mode 100644 index 00000000000000..837df0b3b2f1fd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_defaults.ts @@ -0,0 +1,185 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import type { + RuleCreateProps, + RuleSource, + TypeSpecificCreateProps, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { + DEFAULT_INDICATOR_SOURCE_PATH, + DEFAULT_MAX_SIGNALS, +} from '../../../../../../../common/constants'; +import { + normalizeMachineLearningJobIds, + normalizeThresholdObject, +} from '../../../../../../../common/detection_engine/utils'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; +import { addEcsToRequiredFields } from '../../../utils/utils'; + +export const RULE_DEFAULTS = { + enabled: false, + risk_score_mapping: [], + severity_mapping: [], + interval: '5m' as const, + to: 'now' as const, + from: 'now-6m' as const, + exceptions_list: [], + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + actions: [], + related_integrations: [], + required_fields: [], + setup: '', + references: [], + threat: [], + tags: [], + author: [], + output_index: '', + version: 1, +}; + +export function applyRuleDefaults(rule: RuleCreateProps & { immutable?: boolean }) { + const typeSpecificParams = setTypeSpecificDefaults(rule); + const immutable = rule.immutable ?? false; + + return { + ...RULE_DEFAULTS, + ...rule, + ...typeSpecificParams, + rule_id: rule.rule_id ?? uuidv4(), + immutable, + rule_source: convertImmutableToRuleSource(immutable), + required_fields: addEcsToRequiredFields(rule.required_fields), + }; +} + +const convertImmutableToRuleSource = (immutable: boolean): RuleSource => { + if (immutable) { + return { + type: 'external', + is_customized: false, + }; + } + + return { + type: 'internal', + }; +}; + +export const setTypeSpecificDefaults = (props: TypeSpecificCreateProps) => { + switch (props.type) { + case 'eql': { + return { + type: props.type, + language: props.language, + index: props.index, + data_view_id: props.data_view_id, + query: props.query, + filters: props.filters, + timestamp_field: props.timestamp_field, + event_category_override: props.event_category_override, + tiebreaker_field: props.tiebreaker_field, + alert_suppression: props.alert_suppression, + }; + } + case 'esql': { + return { + type: props.type, + language: props.language, + query: props.query, + alert_suppression: props.alert_suppression, + }; + } + case 'threat_match': { + return { + type: props.type, + language: props.language ?? 'kuery', + index: props.index, + data_view_id: props.data_view_id, + query: props.query, + filters: props.filters, + saved_id: props.saved_id, + threat_filters: props.threat_filters, + threat_query: props.threat_query, + threat_mapping: props.threat_mapping, + threat_language: props.threat_language, + threat_index: props.threat_index, + threat_indicator_path: props.threat_indicator_path ?? DEFAULT_INDICATOR_SOURCE_PATH, + concurrent_searches: props.concurrent_searches, + items_per_search: props.items_per_search, + alert_suppression: props.alert_suppression, + }; + } + case 'query': { + return { + type: props.type, + language: props.language ?? 'kuery', + index: props.index, + data_view_id: props.data_view_id, + query: props.query ?? '', + filters: props.filters, + saved_id: props.saved_id, + response_actions: props.response_actions, + alert_suppression: props.alert_suppression, + }; + } + case 'saved_query': { + return { + type: props.type, + language: props.language ?? 'kuery', + index: props.index, + query: props.query, + filters: props.filters, + saved_id: props.saved_id, + data_view_id: props.data_view_id, + response_actions: props.response_actions, + alert_suppression: props.alert_suppression, + }; + } + case 'threshold': { + return { + type: props.type, + language: props.language ?? 'kuery', + index: props.index, + data_view_id: props.data_view_id, + query: props.query, + filters: props.filters, + saved_id: props.saved_id, + threshold: normalizeThresholdObject(props.threshold), + alert_suppression: props.alert_suppression?.duration + ? { duration: props.alert_suppression.duration } + : undefined, + }; + } + case 'machine_learning': { + return { + type: props.type, + anomaly_threshold: props.anomaly_threshold, + machine_learning_job_id: normalizeMachineLearningJobIds(props.machine_learning_job_id), + alert_suppression: props.alert_suppression, + }; + } + case 'new_terms': { + return { + type: props.type, + query: props.query, + new_terms_fields: props.new_terms_fields, + history_window_start: props.history_window_start, + index: props.index, + filters: props.filters, + language: props.language ?? 'kuery', + data_view_id: props.data_view_id, + alert_suppression: props.alert_suppression, + }; + } + default: { + return assertUnreachable(props); + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.test.ts new file mode 100644 index 00000000000000..49592aff28f951 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.test.ts @@ -0,0 +1,456 @@ +/* + * 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 { + AlertSuppressionDuration, + PatchRuleRequestBody, +} from '../../../../../../../common/api/detection_engine'; +import { + getEsqlRuleSchemaMock, + getRulesEqlSchemaMock, + getRulesMlSchemaMock, + getRulesNewTermsSchemaMock, + getRulesSchemaMock, + getRulesThresholdSchemaMock, + getSavedQuerySchemaMock, + getThreatMatchingSchemaMock, +} from '../../../../../../../common/api/detection_engine/model/rule_schema/mocks'; +import { createPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/__mocks__/prebuilt_rule_assets_client'; +import { applyRulePatch } from './apply_rule_patch'; + +const prebuiltRuleAssetClient = createPrebuiltRuleAssetsClient(); + +describe('applyRulePatch', () => { + describe('EQL', () => { + test('should accept EQL params when existing rule type is EQL', async () => { + const rulePatch = { + timestamp_field: 'event.created', + event_category_override: 'event.not_category', + tiebreaker_field: 'event.created', + }; + const existingRule = getRulesEqlSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + timestamp_field: 'event.created', + event_category_override: 'event.not_category', + tiebreaker_field: 'event.created', + }) + ); + }); + test('should accept EQL params with suppression in snake case and convert to camel case when rule type is EQL', async () => { + const rulePatch = { + timestamp_field: 'event.created', + event_category_override: 'event.not_category', + tiebreaker_field: 'event.created', + alert_suppression: { + group_by: ['event.type'], + duration: { + value: 10, + unit: 'm', + } as AlertSuppressionDuration, + missing_fields_strategy: 'suppress', + }, + }; + const existingRule = getRulesEqlSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + timestamp_field: 'event.created', + event_category_override: 'event.not_category', + tiebreaker_field: 'event.created', + alert_suppression: { + group_by: ['event.type'], + duration: { + value: 10, + unit: 'm', + }, + missing_fields_strategy: 'suppress', + }, + }) + ); + }); + test('should reject invalid EQL params when existing rule type is EQL', async () => { + const rulePatch = { + timestamp_field: 1, + event_category_override: 1, + tiebreaker_field: 1, + } as PatchRuleRequestBody; + const existingRule = getRulesEqlSchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError( + 'event_category_override: Expected string, received number, tiebreaker_field: Expected string, received number, timestamp_field: Expected string, received number' + ); + }); + test('should reject EQL params with invalid suppression group_by field', async () => { + const rulePatch = { + timestamp_field: 'event.created', + event_category_override: 'event.not_category', + tiebreaker_field: 'event.created', + alert_suppression: { + group_by: 'event.type', + duration: { + value: 10, + unit: 'm', + } as AlertSuppressionDuration, + missing_fields_strategy: 'suppress', + }, + }; + const existingRule = getRulesEqlSchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError('alert_suppression.group_by: Expected array, received string'); + }); + }); + + test('should accept threat match params when existing rule type is threat match', async () => { + const rulePatch = { + threat_indicator_path: 'my.indicator', + threat_query: 'test-query', + }; + const existingRule = getThreatMatchingSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + threat_indicator_path: 'my.indicator', + threat_query: 'test-query', + }) + ); + }); + + test('should reject invalid threat match params when existing rule type is threat match', async () => { + const rulePatch = { + threat_indicator_path: 1, + threat_query: 1, + } as PatchRuleRequestBody; + const existingRule = getThreatMatchingSchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError( + 'threat_query: Expected string, received number, threat_indicator_path: Expected string, received number' + ); + }); + + test('should accept query params when existing rule type is query', async () => { + const rulePatch = { + index: ['new-test-index'], + language: 'lucene', + } as PatchRuleRequestBody; + const existingRule = getRulesSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + index: ['new-test-index'], + language: 'lucene', + }) + ); + }); + + test('should reject invalid query params when existing rule type is query', async () => { + const rulePatch = { + index: [1], + language: 'non-language', + } as PatchRuleRequestBody; + const existingRule = getRulesSchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError( + "index.0: Expected string, received number, language: Invalid enum value. Expected 'kuery' | 'lucene', received 'non-language'" + ); + }); + + test('should accept saved query params when existing rule type is saved query', async () => { + const rulePatch = { + index: ['new-test-index'], + language: 'lucene', + } as PatchRuleRequestBody; + const existingRule = getSavedQuerySchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + index: ['new-test-index'], + language: 'lucene', + }) + ); + }); + + test('should reject invalid saved query params when existing rule type is saved query', async () => { + const rulePatch = { + index: [1], + language: 'non-language', + } as PatchRuleRequestBody; + const existingRule = getSavedQuerySchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError( + "index.0: Expected string, received number, language: Invalid enum value. Expected 'kuery' | 'lucene', received 'non-language'" + ); + }); + + test('should accept threshold params when existing rule type is threshold', async () => { + const rulePatch = { + threshold: { + field: ['host.name'], + value: 107, + }, + }; + const existingRule = getRulesThresholdSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + threshold: { + field: ['host.name'], + value: 107, + }, + }) + ); + }); + + test('should reject invalid threshold params when existing rule type is threshold', async () => { + const rulePatch = { + threshold: { + field: ['host.name'], + value: 'invalid', + }, + } as PatchRuleRequestBody; + const existingRule = getRulesThresholdSchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError('threshold.value: Expected number, received string'); + }); + + test('should accept ES|QL alerts suppression params', async () => { + const rulePatch = { + alert_suppression: { + group_by: ['agent.name'], + duration: { value: 4, unit: 'h' as const }, + missing_fields_strategy: 'doNotSuppress' as const, + }, + }; + const existingRule = getEsqlRuleSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + alert_suppression: { + group_by: ['agent.name'], + missing_fields_strategy: 'doNotSuppress', + duration: { value: 4, unit: 'h' }, + }, + }) + ); + }); + + test('should accept threshold alerts suppression params', async () => { + const rulePatch = { + alert_suppression: { + duration: { value: 4, unit: 'h' as const }, + }, + }; + const existingRule = getRulesThresholdSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + alert_suppression: { + duration: { value: 4, unit: 'h' }, + }, + }) + ); + }); + + test('should accept threat_match alerts suppression params', async () => { + const rulePatch = { + alert_suppression: { + group_by: ['agent.name'], + missing_fields_strategy: 'suppress' as const, + }, + }; + const existingRule = getThreatMatchingSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + alert_suppression: { + group_by: ['agent.name'], + missing_fields_strategy: 'suppress', + }, + }) + ); + }); + + test('should accept new_terms alerts suppression params', async () => { + const rulePatch = { + alert_suppression: { + group_by: ['agent.name'], + duration: { value: 4, unit: 'h' as const }, + missing_fields_strategy: 'suppress' as const, + }, + }; + const existingRule = getRulesNewTermsSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + alert_suppression: { + group_by: ['agent.name'], + missing_fields_strategy: 'suppress', + duration: { value: 4, unit: 'h' }, + }, + }) + ); + }); + + describe('machine learning rules', () => { + test('should accept machine learning params when existing rule type is machine learning', async () => { + const rulePatch = { + anomaly_threshold: 5, + }; + const existingRule = getRulesMlSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + anomaly_threshold: 5, + }) + ); + }); + + test('should reject invalid machine learning params when existing rule type is machine learning', async () => { + const rulePatch = { + anomaly_threshold: 'invalid', + } as PatchRuleRequestBody; + const existingRule = getRulesMlSchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError('anomaly_threshold: Expected number, received string'); + }); + + it('accepts suppression params', async () => { + const rulePatch = { + alert_suppression: { + group_by: ['agent.name'], + missing_fields_strategy: 'suppress' as const, + }, + }; + const existingRule = getRulesMlSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + + expect(patchedRule).toEqual( + expect.objectContaining({ + alert_suppression: { + group_by: ['agent.name'], + missing_fields_strategy: 'suppress', + }, + }) + ); + }); + }); + + test('should accept new terms params when existing rule type is new terms', async () => { + const rulePatch = { + new_terms_fields: ['event.new_field'], + }; + const existingRule = getRulesNewTermsSchemaMock(); + const patchedRule = await applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }); + expect(patchedRule).toEqual( + expect.objectContaining({ + new_terms_fields: ['event.new_field'], + }) + ); + }); + + test('should reject invalid new terms params when existing rule type is new terms', async () => { + const rulePatch = { + new_terms_fields: 'invalid', + } as PatchRuleRequestBody; + const existingRule = getRulesNewTermsSchemaMock(); + await expect( + applyRulePatch({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, + }) + ).rejects.toThrowError('new_terms_fields: Expected array, received string'); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts new file mode 100644 index 00000000000000..9d02cd8dbb9df8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts @@ -0,0 +1,334 @@ +/* + * 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 { BadRequestError } from '@kbn/securitysolution-es-utils'; +import { stringifyZodError } from '@kbn/zod-helpers'; +import type { + EqlRule, + EqlRuleResponseFields, + EsqlRule, + EsqlRuleResponseFields, + MachineLearningRule, + MachineLearningRuleResponseFields, + NewTermsRule, + NewTermsRuleResponseFields, + QueryRule, + QueryRuleResponseFields, + RuleResponse, + SavedQueryRule, + SavedQueryRuleResponseFields, + ThreatMatchRule, + ThreatMatchRuleResponseFields, + ThresholdRule, + ThresholdRuleResponseFields, + TypeSpecificResponse, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { + EqlRulePatchFields, + EsqlRulePatchFields, + MachineLearningRulePatchFields, + NewTermsRulePatchFields, + QueryRulePatchFields, + SavedQueryRulePatchFields, + ThreatMatchRulePatchFields, + ThresholdRulePatchFields, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import type { PatchRuleRequestBody } from '../../../../../../../common/api/detection_engine/rule_management'; +import { + normalizeMachineLearningJobIds, + normalizeThresholdObject, +} from '../../../../../../../common/detection_engine/utils'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; +import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; +import { addEcsToRequiredFields } from '../../../utils/utils'; +import { calculateRuleSource } from './rule_source/calculate_rule_source'; + +interface ApplyRulePatchProps { + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; + existingRule: RuleResponse; + rulePatch: PatchRuleRequestBody; +} + +// eslint-disable-next-line complexity +export const applyRulePatch = async ({ + rulePatch, + existingRule, + prebuiltRuleAssetClient, +}: ApplyRulePatchProps): Promise => { + const typeSpecificParams = patchTypeSpecificParams(rulePatch, existingRule); + + const nextRule: RuleResponse = { + // Keep existing values for these fields + id: existingRule.id, + rule_id: existingRule.rule_id, + revision: existingRule.revision, + immutable: existingRule.immutable, + rule_source: existingRule.rule_source, + updated_at: existingRule.updated_at, + updated_by: existingRule.updated_by, + created_at: existingRule.created_at, + created_by: existingRule.created_by, + + // Update values for these fields + enabled: rulePatch.enabled ?? existingRule.enabled, + name: rulePatch.name ?? existingRule.name, + tags: rulePatch.tags ?? existingRule.tags, + author: rulePatch.author ?? existingRule.author, + building_block_type: rulePatch.building_block_type ?? existingRule.building_block_type, + description: rulePatch.description ?? existingRule.description, + false_positives: rulePatch.false_positives ?? existingRule.false_positives, + investigation_fields: rulePatch.investigation_fields ?? existingRule.investigation_fields, + from: rulePatch.from ?? existingRule.from, + license: rulePatch.license ?? existingRule.license, + output_index: rulePatch.output_index ?? existingRule.output_index, + timeline_id: rulePatch.timeline_id ?? existingRule.timeline_id, + timeline_title: rulePatch.timeline_title ?? existingRule.timeline_title, + meta: rulePatch.meta ?? existingRule.meta, + max_signals: rulePatch.max_signals ?? existingRule.max_signals, + related_integrations: rulePatch.related_integrations ?? existingRule.related_integrations, + required_fields: addEcsToRequiredFields(rulePatch.required_fields), + risk_score: rulePatch.risk_score ?? existingRule.risk_score, + risk_score_mapping: rulePatch.risk_score_mapping ?? existingRule.risk_score_mapping, + rule_name_override: rulePatch.rule_name_override ?? existingRule.rule_name_override, + setup: rulePatch.setup ?? existingRule.setup, + severity: rulePatch.severity ?? existingRule.severity, + severity_mapping: rulePatch.severity_mapping ?? existingRule.severity_mapping, + threat: rulePatch.threat ?? existingRule.threat, + timestamp_override: rulePatch.timestamp_override ?? existingRule.timestamp_override, + timestamp_override_fallback_disabled: + rulePatch.timestamp_override_fallback_disabled ?? + existingRule.timestamp_override_fallback_disabled, + to: rulePatch.to ?? existingRule.to, + references: rulePatch.references ?? existingRule.references, + namespace: rulePatch.namespace ?? existingRule.namespace, + note: rulePatch.note ?? existingRule.note, + version: rulePatch.version ?? existingRule.version, + exceptions_list: rulePatch.exceptions_list ?? existingRule.exceptions_list, + interval: rulePatch.interval ?? existingRule.interval, + throttle: rulePatch.throttle ?? existingRule.throttle, + actions: rulePatch.actions ?? existingRule.actions, + ...typeSpecificParams, + }; + + nextRule.rule_source = await calculateRuleSource({ + rule: nextRule, + prebuiltRuleAssetClient, + }); + + return nextRule; +}; + +const patchEqlParams = ( + rulePatch: EqlRulePatchFields, + existingRule: EqlRule +): EqlRuleResponseFields => { + return { + type: existingRule.type, + language: rulePatch.language ?? existingRule.language, + index: rulePatch.index ?? existingRule.index, + data_view_id: rulePatch.data_view_id ?? existingRule.data_view_id, + query: rulePatch.query ?? existingRule.query, + filters: rulePatch.filters ?? existingRule.filters, + timestamp_field: rulePatch.timestamp_field ?? existingRule.timestamp_field, + event_category_override: + rulePatch.event_category_override ?? existingRule.event_category_override, + tiebreaker_field: rulePatch.tiebreaker_field ?? existingRule.tiebreaker_field, + alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +const patchEsqlParams = ( + rulePatch: EsqlRulePatchFields, + existingRule: EsqlRule +): EsqlRuleResponseFields => { + return { + type: existingRule.type, + language: rulePatch.language ?? existingRule.language, + query: rulePatch.query ?? existingRule.query, + alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +const patchThreatMatchParams = ( + rulePatch: ThreatMatchRulePatchFields, + existingRule: ThreatMatchRule +): ThreatMatchRuleResponseFields => { + return { + type: existingRule.type, + language: rulePatch.language ?? existingRule.language, + index: rulePatch.index ?? existingRule.index, + data_view_id: rulePatch.data_view_id ?? existingRule.data_view_id, + query: rulePatch.query ?? existingRule.query, + filters: rulePatch.filters ?? existingRule.filters, + saved_id: rulePatch.saved_id ?? existingRule.saved_id, + threat_filters: rulePatch.threat_filters ?? existingRule.threat_filters, + threat_query: rulePatch.threat_query ?? existingRule.threat_query, + threat_mapping: rulePatch.threat_mapping ?? existingRule.threat_mapping, + threat_language: rulePatch.threat_language ?? existingRule.threat_language, + threat_index: rulePatch.threat_index ?? existingRule.threat_index, + threat_indicator_path: rulePatch.threat_indicator_path ?? existingRule.threat_indicator_path, + concurrent_searches: rulePatch.concurrent_searches ?? existingRule.concurrent_searches, + items_per_search: rulePatch.items_per_search ?? existingRule.items_per_search, + alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +const patchQueryParams = ( + rulePatch: QueryRulePatchFields, + existingRule: QueryRule +): QueryRuleResponseFields => { + return { + type: existingRule.type, + language: rulePatch.language ?? existingRule.language, + index: rulePatch.index ?? existingRule.index, + data_view_id: rulePatch.data_view_id ?? existingRule.data_view_id, + query: rulePatch.query ?? existingRule.query, + filters: rulePatch.filters ?? existingRule.filters, + saved_id: rulePatch.saved_id ?? existingRule.saved_id, + response_actions: rulePatch.response_actions ?? existingRule.response_actions, + alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +const patchSavedQueryParams = ( + rulePatch: SavedQueryRulePatchFields, + existingRule: SavedQueryRule +): SavedQueryRuleResponseFields => { + return { + type: existingRule.type, + language: rulePatch.language ?? existingRule.language, + index: rulePatch.index ?? existingRule.index, + data_view_id: rulePatch.data_view_id ?? existingRule.data_view_id, + query: rulePatch.query ?? existingRule.query, + filters: rulePatch.filters ?? existingRule.filters, + saved_id: rulePatch.saved_id ?? existingRule.saved_id, + response_actions: rulePatch.response_actions ?? existingRule.response_actions, + alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +const patchThresholdParams = ( + rulePatch: ThresholdRulePatchFields, + existingRule: ThresholdRule +): ThresholdRuleResponseFields => { + return { + type: existingRule.type, + language: rulePatch.language ?? existingRule.language, + index: rulePatch.index ?? existingRule.index, + data_view_id: rulePatch.data_view_id ?? existingRule.data_view_id, + query: rulePatch.query ?? existingRule.query, + filters: rulePatch.filters ?? existingRule.filters, + saved_id: rulePatch.saved_id ?? existingRule.saved_id, + threshold: rulePatch.threshold + ? normalizeThresholdObject(rulePatch.threshold) + : existingRule.threshold, + alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +const patchMachineLearningParams = ( + params: MachineLearningRulePatchFields, + existingRule: MachineLearningRule +): MachineLearningRuleResponseFields => { + return { + type: existingRule.type, + anomaly_threshold: params.anomaly_threshold ?? existingRule.anomaly_threshold, + machine_learning_job_id: params.machine_learning_job_id + ? normalizeMachineLearningJobIds(params.machine_learning_job_id) + : existingRule.machine_learning_job_id, + alert_suppression: params.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +const patchNewTermsParams = ( + params: NewTermsRulePatchFields, + existingRule: NewTermsRule +): NewTermsRuleResponseFields => { + return { + type: existingRule.type, + language: params.language ?? existingRule.language, + index: params.index ?? existingRule.index, + data_view_id: params.data_view_id ?? existingRule.data_view_id, + query: params.query ?? existingRule.query, + filters: params.filters ?? existingRule.filters, + new_terms_fields: params.new_terms_fields ?? existingRule.new_terms_fields, + history_window_start: params.history_window_start ?? existingRule.history_window_start, + alert_suppression: params.alert_suppression ?? existingRule.alert_suppression, + }; +}; + +export const patchTypeSpecificParams = ( + params: PatchRuleRequestBody, + existingRule: RuleResponse +): TypeSpecificResponse => { + // Here we do the validation of patch params by rule type to ensure that the fields that are + // passed in to patch are of the correct type, e.g. `query` is a string. Since the combined patch schema + // is a union of types where everything is optional, it's hard to do the validation before we know the rule type - + // a patch request that defines `event_category_override` as a number would not be assignable to the EQL patch schema, + // but would be assignable to the other rule types since they don't specify `event_category_override`. + switch (existingRule.type) { + case 'eql': { + const result = EqlRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchEqlParams(result.data, existingRule); + } + case 'esql': { + const result = EsqlRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchEsqlParams(result.data, existingRule); + } + case 'threat_match': { + const result = ThreatMatchRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchThreatMatchParams(result.data, existingRule); + } + case 'query': { + const result = QueryRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchQueryParams(result.data, existingRule); + } + case 'saved_query': { + const result = SavedQueryRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchSavedQueryParams(result.data, existingRule); + } + case 'threshold': { + const result = ThresholdRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchThresholdParams(result.data, existingRule); + } + case 'machine_learning': { + const result = MachineLearningRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchMachineLearningParams(result.data, existingRule); + } + case 'new_terms': { + const result = NewTermsRulePatchFields.safeParse(params); + if (!result.success) { + throw new BadRequestError(stringifyZodError(result.error)); + } + return patchNewTermsParams(result.data, existingRule); + } + default: { + return assertUnreachable(existingRule); + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_update.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_update.ts new file mode 100644 index 00000000000000..b911e66a1fc457 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_update.ts @@ -0,0 +1,52 @@ +/* + * 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 { + RuleResponse, + RuleUpdateProps, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; +import { applyRuleDefaults } from './apply_rule_defaults'; +import { calculateRuleSource } from './rule_source/calculate_rule_source'; + +interface ApplyRuleUpdateProps { + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; + existingRule: RuleResponse; + ruleUpdate: RuleUpdateProps; +} + +export const applyRuleUpdate = async ({ + prebuiltRuleAssetClient, + existingRule, + ruleUpdate, +}: ApplyRuleUpdateProps): Promise => { + const nextRule: RuleResponse = { + ...applyRuleDefaults(ruleUpdate), + + // Use existing values + enabled: ruleUpdate.enabled ?? existingRule.enabled, + version: ruleUpdate.version ?? existingRule.version, + + // Always keep existing values for these fields + id: existingRule.id, + rule_id: existingRule.rule_id, + revision: existingRule.revision, + immutable: existingRule.immutable, + rule_source: existingRule.rule_source, + updated_at: existingRule.updated_at, + updated_by: existingRule.updated_by, + created_at: existingRule.created_at, + created_by: existingRule.created_by, + }; + + nextRule.rule_source = await calculateRuleSource({ + rule: nextRule, + prebuiltRuleAssetClient, + }); + + return nextRule; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_is_customized.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_is_customized.ts new file mode 100644 index 00000000000000..4f9bb4a060f6f3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_is_customized.ts @@ -0,0 +1,33 @@ +/* + * 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 { RuleResponse } from '../../../../../../../../common/api/detection_engine'; +import { MissingVersion } from '../../../../../../../../common/api/detection_engine'; +import type { PrebuiltRuleAsset } from '../../../../../prebuilt_rules'; +import { calculateRuleFieldsDiff } from '../../../../../prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff'; +import { convertRuleToDiffable } from '../../../../../prebuilt_rules/logic/diff/normalization/convert_rule_to_diffable'; +import { convertPrebuiltRuleAssetToRuleResponse } from '../../converters/convert_prebuilt_rule_asset_to_rule_response'; + +export function calculateIsCustomized( + baseRule: PrebuiltRuleAsset | undefined, + nextRule: RuleResponse +) { + if (baseRule == null) { + // If the base version is missing, we consider the rule to be customized + return true; + } + + const baseRuleWithDefaults = convertPrebuiltRuleAssetToRuleResponse(baseRule); + + const fieldsDiff = calculateRuleFieldsDiff({ + base_version: MissingVersion, + current_version: convertRuleToDiffable(baseRuleWithDefaults), + target_version: convertRuleToDiffable(nextRule), + }); + + return Object.values(fieldsDiff).some((diff) => diff.has_update); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.test.ts new file mode 100644 index 00000000000000..e44c69d2705d54 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { createPrebuiltRuleAssetsClient } from '../../../../../prebuilt_rules/logic/rule_assets/__mocks__/prebuilt_rule_assets_client'; +import { applyRuleDefaults } from '../apply_rule_defaults'; +import { calculateRuleSource } from './calculate_rule_source'; + +const prebuiltRuleAssetClient = createPrebuiltRuleAssetsClient(); + +const getSampleRuleAsset = () => { + return applyRuleDefaults({ + rule_id: 'test-rule-id', + name: 'Test rule', + description: 'Test description', + type: 'query', + query: 'user.name: root or user.name: admin', + severity: 'high', + risk_score: 55, + }); +}; + +const getSampleRule = () => { + return { + ...getSampleRuleAsset(), + id: 'test-rule-id', + updated_at: '2021-01-01T00:00:00Z', + updated_by: 'test-user', + created_at: '2021-01-01T00:00:00Z', + created_by: 'test-user', + revision: 1, + }; +}; + +describe('calculateRuleSource', () => { + it('returns an internal rule source when the rule is not prebuilt', async () => { + const rule = getSampleRule(); + rule.immutable = false; + + const result = await calculateRuleSource({ + prebuiltRuleAssetClient, + rule, + }); + expect(result).toEqual({ + type: 'internal', + }); + }); + + it('returns an external rule source with customized false when the rule is prebuilt', async () => { + const rule = getSampleRule(); + rule.immutable = true; + + const baseRule = getSampleRuleAsset(); + prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([baseRule]); + + const result = await calculateRuleSource({ + prebuiltRuleAssetClient, + rule, + }); + expect(result).toEqual( + expect.objectContaining({ + type: 'external', + is_customized: false, + }) + ); + }); + + it('returns is_customized true when the rule is prebuilt and has been customized', async () => { + const rule = getSampleRule(); + rule.immutable = true; + rule.name = 'Updated name'; + + const baseRule = getSampleRuleAsset(); + prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([baseRule]); + + const result = await calculateRuleSource({ + prebuiltRuleAssetClient, + rule, + }); + expect(result).toEqual( + expect.objectContaining({ + type: 'external', + is_customized: true, + }) + ); + }); + + it('returns is_customized false when the rule has only changes to revision, updated_at, updated_by', async () => { + const rule = getSampleRule(); + rule.immutable = true; + rule.revision = 5; + rule.updated_at = '2024-01-01T00:00:00Z'; + rule.updated_by = 'new-user'; + + const baseRule = getSampleRuleAsset(); + prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([baseRule]); + + const result = await calculateRuleSource({ + prebuiltRuleAssetClient, + rule, + }); + expect(result).toEqual( + expect.objectContaining({ + type: 'external', + is_customized: false, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.ts new file mode 100644 index 00000000000000..742cd20544a60b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.ts @@ -0,0 +1,47 @@ +/* + * 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 { + RuleResponse, + RuleSource, +} from '../../../../../../../../common/api/detection_engine/model/rule_schema'; +import type { PrebuiltRuleAsset } from '../../../../../prebuilt_rules'; +import type { IPrebuiltRuleAssetsClient } from '../../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; +import { calculateIsCustomized } from './calculate_is_customized'; + +interface CalculateRuleSourceProps { + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; + rule: RuleResponse; +} + +export async function calculateRuleSource({ + prebuiltRuleAssetClient, + rule, +}: CalculateRuleSourceProps): Promise { + if (rule.immutable) { + // This is a prebuilt rule and, despite the name, they are not immutable. So + // we need to recalculate `ruleSource.isCustomized` based on the rule's contents. + const prebuiltRulesResponse = await prebuiltRuleAssetClient.fetchAssetsByVersion([ + { + rule_id: rule.rule_id, + version: rule.version, + }, + ]); + const baseRule: PrebuiltRuleAsset | undefined = prebuiltRulesResponse.at(0); + + const isCustomized = calculateIsCustomized(baseRule, rule); + + return { + type: 'external', + is_customized: isCustomized, + }; + } + + return { + type: 'internal', + }; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/__mocks__/get_rule_by_rule_id.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/__mocks__/get_rule_by_rule_id.ts new file mode 100644 index 00000000000000..251cd7f6991951 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/__mocks__/get_rule_by_rule_id.ts @@ -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 type { RuleResponse } from '../../../../../../../../common/api/detection_engine'; +import { getRulesSchemaMock } from '../../../../../../../../common/api/detection_engine/model/rule_schema/rule_response_schema.mock'; + +export const getRuleByRuleId = jest + .fn() + .mockImplementation(async (): Promise => getRulesSchemaMock()); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_custom_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_custom_rule.ts deleted file mode 100644 index 963cac7e10dd13..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_custom_rule.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { RulesClient } from '@kbn/alerting-plugin/server'; -import { stringifyZodError } from '@kbn/zod-helpers'; -import type { CreateCustomRuleArgs } from '../detection_rules_client_interface'; -import type { MlAuthz } from '../../../../../machine_learning/authz'; -import type { RuleParams } from '../../../../rule_schema'; -import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; -import { - convertCreateAPIToInternalSchema, - internalRuleToAPIResponse, -} from '../../../normalization/rule_converters'; -import { validateMlAuth, RuleResponseValidationError } from '../utils'; - -export const createCustomRule = async ( - rulesClient: RulesClient, - args: CreateCustomRuleArgs, - mlAuthz: MlAuthz -): Promise => { - const { params } = args; - await validateMlAuth(mlAuthz, params.type); - - const internalRule = convertCreateAPIToInternalSchema(params, { immutable: false }); - const rule = await rulesClient.create({ - data: internalRule, - }); - - /* Trying to convert the rule to a RuleResponse object */ - const parseResult = RuleResponse.safeParse(internalRuleToAPIResponse(rule)); - - if (!parseResult.success) { - throw new RuleResponseValidationError({ - message: stringifyZodError(parseResult.error), - ruleId: rule.params.ruleId, - }); - } - - return parseResult.data; -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_prebuilt_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_prebuilt_rule.ts deleted file mode 100644 index 0f0a4aea12d7b2..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_prebuilt_rule.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 { RulesClient } from '@kbn/alerting-plugin/server'; -import { stringifyZodError } from '@kbn/zod-helpers'; -import type { CreatePrebuiltRuleArgs } from '../detection_rules_client_interface'; -import type { MlAuthz } from '../../../../../machine_learning/authz'; -import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; -import type { RuleParams } from '../../../../rule_schema'; -import { - convertCreateAPIToInternalSchema, - internalRuleToAPIResponse, -} from '../../../normalization/rule_converters'; -import { validateMlAuth, RuleResponseValidationError } from '../utils'; - -export const createPrebuiltRule = async ( - rulesClient: RulesClient, - args: CreatePrebuiltRuleArgs, - mlAuthz: MlAuthz -): Promise => { - const { params } = args; - - await validateMlAuth(mlAuthz, params.type); - - const internalRule = convertCreateAPIToInternalSchema(params, { - immutable: true, - defaultEnabled: false, - }); - - const rule = await rulesClient.create({ - data: internalRule, - }); - - /* Trying to convert the rule to a RuleResponse object */ - const parseResult = RuleResponse.safeParse(internalRuleToAPIResponse(rule)); - - if (!parseResult.success) { - throw new RuleResponseValidationError({ - message: stringifyZodError(parseResult.error), - ruleId: rule.params.ruleId, - }); - } - - return parseResult.data; -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_rule.ts new file mode 100644 index 00000000000000..772e0c775d8b4c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/create_rule.ts @@ -0,0 +1,55 @@ +/* + * 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 { RulesClient } from '@kbn/alerting-plugin/server'; +import { ruleTypeMappings } from '@kbn/securitysolution-rules'; +import { SERVER_APP_ID } from '../../../../../../../common'; +import type { + RuleCreateProps, + RuleResponse, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import type { MlAuthz } from '../../../../../machine_learning/authz'; +import type { RuleParams } from '../../../../rule_schema'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; +import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; +import { applyRuleDefaults } from '../mergers/apply_rule_defaults'; +import { validateMlAuth } from '../utils'; + +interface CreateRuleOptions { + rulesClient: RulesClient; + mlAuthz: MlAuthz; + rule: RuleCreateProps & { immutable: boolean }; + id?: string; + allowMissingConnectorSecrets?: boolean; +} + +export const createRule = async ({ + rulesClient, + mlAuthz, + rule, + id, + allowMissingConnectorSecrets, +}: CreateRuleOptions): Promise => { + await validateMlAuth(mlAuthz, rule.type); + + const ruleWithDefaults = applyRuleDefaults(rule); + + const payload = { + ...convertRuleResponseToAlertingRule(ruleWithDefaults), + alertTypeId: ruleTypeMappings[rule.type], + consumer: SERVER_APP_ID, + enabled: rule.enabled ?? false, + }; + + const createdRule = await rulesClient.create({ + data: payload, + options: { id }, + allowMissingConnectorSecrets, + }); + + return convertAlertingRuleToRuleResponse(createdRule); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/delete_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/delete_rule.ts index ec1491e8159d77..4a9ca8abcdeb18 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/delete_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/delete_rule.ts @@ -6,9 +6,13 @@ */ import type { RulesClient } from '@kbn/alerting-plugin/server'; -import type { DeleteRuleArgs } from '../detection_rules_client_interface'; +import type { RuleObjectId } from '../../../../../../../common/api/detection_engine'; -export const deleteRule = async (rulesClient: RulesClient, args: DeleteRuleArgs): Promise => { - const { ruleId } = args; +interface DeleteRuleParams { + rulesClient: RulesClient; + ruleId: RuleObjectId; +} + +export const deleteRule = async ({ rulesClient, ruleId }: DeleteRuleParams): Promise => { await rulesClient.delete({ id: ruleId }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_id.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_id.ts new file mode 100644 index 00000000000000..39ca15fda42f30 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_id.ts @@ -0,0 +1,34 @@ +/* + * 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 { RulesClient } from '@kbn/alerting-plugin/server'; +import type { + RuleObjectId, + RuleResponse, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import type { RuleParams } from '../../../../rule_schema'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; + +interface GethRuleByIdOptions { + rulesClient: RulesClient; + id: RuleObjectId; +} + +export const getRuleById = async ({ + rulesClient, + id, +}: GethRuleByIdOptions): Promise => { + try { + const rule = await rulesClient.resolve({ id }); + return convertAlertingRuleToRuleResponse(rule); + } catch (err) { + if (err?.output?.statusCode === 404) { + return null; + } + throw err; + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_id_or_rule_id.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_id_or_rule_id.ts new file mode 100644 index 00000000000000..fce28d1a1c0300 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_id_or_rule_id.ts @@ -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 type { RulesClient } from '@kbn/alerting-plugin/server'; +import type { + RuleObjectId, + RuleResponse, + RuleSignatureId, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { invariant } from '../../../../../../../common/utils/invariant'; +import { getRuleById } from './get_rule_by_id'; +import { getRuleByRuleId } from './get_rule_by_rule_id'; + +interface GetRuleByIdOptions { + rulesClient: RulesClient; + id: RuleObjectId | undefined; + ruleId: RuleSignatureId | undefined; +} + +export const getRuleByIdOrRuleId = async ({ + rulesClient, + id, + ruleId, +}: GetRuleByIdOptions): Promise => { + if (id != null) { + return getRuleById({ rulesClient, id }); + } + if (ruleId != null) { + return getRuleByRuleId({ rulesClient, ruleId }); + } + invariant(false, 'Either id or ruleId must be provided'); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_rule_id.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_rule_id.ts new file mode 100644 index 00000000000000..fda00cd292b885 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/get_rule_by_rule_id.ts @@ -0,0 +1,38 @@ +/* + * 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 { RulesClient } from '@kbn/alerting-plugin/server'; +import type { + RuleResponse, + RuleSignatureId, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { findRules } from '../../search/find_rules'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; + +interface GetRuleByRuleIdOptions { + rulesClient: RulesClient; + ruleId: RuleSignatureId; +} + +export const getRuleByRuleId = async ({ + rulesClient, + ruleId, +}: GetRuleByRuleIdOptions): Promise => { + const findRuleResponse = await findRules({ + rulesClient, + filter: `alert.attributes.params.ruleId: "${ruleId}"`, + page: 1, + fields: undefined, + perPage: undefined, + sortField: undefined, + sortOrder: undefined, + }); + if (findRuleResponse.data.length === 0) { + return null; + } + return convertAlertingRuleToRuleResponse(findRuleResponse.data[0]); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts index 55a0399f1a5282..adb28133b62f3c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/import_rule.ts @@ -6,78 +6,67 @@ */ import type { RulesClient } from '@kbn/alerting-plugin/server'; -import { stringifyZodError } from '@kbn/zod-helpers'; +import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; import type { MlAuthz } from '../../../../../machine_learning/authz'; -import type { ImportRuleArgs } from '../detection_rules_client_interface'; -import type { RuleAlertType, RuleParams } from '../../../../rule_schema'; +import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; import { createBulkErrorObject } from '../../../../routes/utils'; -import { - convertCreateAPIToInternalSchema, - convertUpdateAPIToInternalSchema, - internalRuleToAPIResponse, -} from '../../../normalization/rule_converters'; -import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; - -import { validateMlAuth, RuleResponseValidationError } from '../utils'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; +import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; +import type { ImportRuleArgs } from '../detection_rules_client_interface'; +import { applyRuleUpdate } from '../mergers/apply_rule_update'; +import { validateMlAuth } from '../utils'; +import { createRule } from './create_rule'; +import { getRuleByRuleId } from './get_rule_by_rule_id'; -import { readRules } from '../read_rules'; +interface ImportRuleOptions { + rulesClient: RulesClient; + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; + importRulePayload: ImportRuleArgs; + mlAuthz: MlAuthz; +} -export const importRule = async ( - rulesClient: RulesClient, - importRulePayload: ImportRuleArgs, - mlAuthz: MlAuthz -): Promise => { +export const importRule = async ({ + rulesClient, + importRulePayload, + prebuiltRuleAssetClient, + mlAuthz, +}: ImportRuleOptions): Promise => { const { ruleToImport, overwriteRules, allowMissingConnectorSecrets } = importRulePayload; await validateMlAuth(mlAuthz, ruleToImport.type); - const existingRule = await readRules({ + const existingRule = await getRuleByRuleId({ rulesClient, ruleId: ruleToImport.rule_id, - id: undefined, }); if (existingRule && !overwriteRules) { throw createBulkErrorObject({ - ruleId: existingRule.params.ruleId, + ruleId: existingRule.rule_id, statusCode: 409, - message: `rule_id: "${existingRule.params.ruleId}" already exists`, + message: `rule_id: "${existingRule.rule_id}" already exists`, }); } - let importedInternalRule: RuleAlertType; - if (existingRule && overwriteRules) { - const ruleUpdateParams = convertUpdateAPIToInternalSchema({ + const ruleWithUpdates = await applyRuleUpdate({ + prebuiltRuleAssetClient, existingRule, ruleUpdate: ruleToImport, }); - importedInternalRule = await rulesClient.update({ + const updatedRule = await rulesClient.update({ id: existingRule.id, - data: ruleUpdateParams, - }); - } else { - /* Rule does not exist, so we'll create it */ - const ruleCreateParams = convertCreateAPIToInternalSchema(ruleToImport, { - immutable: false, - }); - - importedInternalRule = await rulesClient.create({ - data: ruleCreateParams, - allowMissingConnectorSecrets, + data: convertRuleResponseToAlertingRule(ruleWithUpdates), }); + return convertAlertingRuleToRuleResponse(updatedRule); } - /* Trying to convert an internal rule to a RuleResponse object */ - const parseResult = RuleResponse.safeParse(internalRuleToAPIResponse(importedInternalRule)); - - if (!parseResult.success) { - throw new RuleResponseValidationError({ - message: stringifyZodError(parseResult.error), - ruleId: importedInternalRule.params.ruleId, - }); - } - - return parseResult.data; + /* Rule does not exist, so we'll create it */ + return createRule({ + rulesClient, + mlAuthz, + rule: ruleToImport, + allowMissingConnectorSecrets, + }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts index ce9956c5eec841..d615d5fc5a8171 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts @@ -6,34 +6,35 @@ */ import type { RulesClient } from '@kbn/alerting-plugin/server'; -import { stringifyZodError } from '@kbn/zod-helpers'; +import type { + RulePatchProps, + RuleResponse, +} from '../../../../../../../common/api/detection_engine/model/rule_schema'; import type { MlAuthz } from '../../../../../machine_learning/authz'; -import type { PatchRuleArgs } from '../detection_rules_client_interface'; +import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; +import { applyRulePatch } from '../mergers/apply_rule_patch'; import { getIdError } from '../../../utils/utils'; -import { - convertPatchAPIToInternalSchema, - internalRuleToAPIResponse, -} from '../../../normalization/rule_converters'; -import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; +import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; +import { ClientError, toggleRuleEnabledOnUpdate, validateMlAuth } from '../utils'; +import { getRuleByIdOrRuleId } from './get_rule_by_id_or_rule_id'; -import { - validateMlAuth, - ClientError, - toggleRuleEnabledOnUpdate, - RuleResponseValidationError, -} from '../utils'; +interface PatchRuleOptions { + rulesClient: RulesClient; + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; + rulePatch: RulePatchProps; + mlAuthz: MlAuthz; +} -import { readRules } from '../read_rules'; +export const patchRule = async ({ + rulesClient, + prebuiltRuleAssetClient, + rulePatch, + mlAuthz, +}: PatchRuleOptions): Promise => { + const { rule_id: ruleId, id } = rulePatch; -export const patchRule = async ( - rulesClient: RulesClient, - args: PatchRuleArgs, - mlAuthz: MlAuthz -): Promise => { - const { nextParams } = args; - const { rule_id: ruleId, id } = nextParams; - - const existingRule = await readRules({ + const existingRule = await getRuleByIdOrRuleId({ rulesClient, ruleId, id, @@ -44,32 +45,20 @@ export const patchRule = async ( throw new ClientError(error.message, error.statusCode); } - await validateMlAuth(mlAuthz, nextParams.type ?? existingRule.params.type); + await validateMlAuth(mlAuthz, rulePatch.type ?? existingRule.type); - const patchedRule = convertPatchAPIToInternalSchema(nextParams, existingRule); + const patchedRule = await applyRulePatch({ + prebuiltRuleAssetClient, + existingRule, + rulePatch, + }); const patchedInternalRule = await rulesClient.update({ id: existingRule.id, - data: patchedRule, + data: convertRuleResponseToAlertingRule(patchedRule), }); - const { enabled } = await toggleRuleEnabledOnUpdate( - rulesClient, - existingRule, - nextParams.enabled - ); - - /* Trying to convert the internal rule to a RuleResponse object */ - const parseResult = RuleResponse.safeParse( - internalRuleToAPIResponse({ ...patchedInternalRule, enabled }) - ); - - if (!parseResult.success) { - throw new RuleResponseValidationError({ - message: stringifyZodError(parseResult.error), - ruleId: patchedInternalRule.params.ruleId, - }); - } + const { enabled } = await toggleRuleEnabledOnUpdate(rulesClient, existingRule, patchedRule); - return parseResult.data; + return convertAlertingRuleToRuleResponse({ ...patchedInternalRule, enabled }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts index 8684a7ccd2c61c..cf42074c2a0425 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts @@ -6,36 +6,37 @@ */ import type { RulesClient } from '@kbn/alerting-plugin/server'; -import { stringifyZodError } from '@kbn/zod-helpers'; +import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; import type { MlAuthz } from '../../../../../machine_learning/authz'; -import type { UpdateRuleArgs } from '../detection_rules_client_interface'; +import { applyRuleUpdate } from '../mergers/apply_rule_update'; import { getIdError } from '../../../utils/utils'; -import { - convertUpdateAPIToInternalSchema, - internalRuleToAPIResponse, -} from '../../../normalization/rule_converters'; -import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; -import { - validateMlAuth, - ClientError, - toggleRuleEnabledOnUpdate, - RuleResponseValidationError, -} from '../utils'; +import { ClientError, toggleRuleEnabledOnUpdate, validateMlAuth } from '../utils'; -import { readRules } from '../read_rules'; +import type { RuleUpdateProps } from '../../../../../../../common/api/detection_engine'; +import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; +import { getRuleByIdOrRuleId } from './get_rule_by_id_or_rule_id'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; -export const updateRule = async ( - rulesClient: RulesClient, - args: UpdateRuleArgs, - mlAuthz: MlAuthz -): Promise => { - const { ruleUpdate } = args; +interface UpdateRuleArguments { + rulesClient: RulesClient; + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; + ruleUpdate: RuleUpdateProps; + mlAuthz: MlAuthz; +} + +export const updateRule = async ({ + rulesClient, + prebuiltRuleAssetClient, + ruleUpdate, + mlAuthz, +}: UpdateRuleArguments): Promise => { const { rule_id: ruleId, id } = ruleUpdate; await validateMlAuth(mlAuthz, ruleUpdate.type); - const existingRule = await readRules({ + const existingRule = await getRuleByIdOrRuleId({ rulesClient, ruleId, id, @@ -46,33 +47,21 @@ export const updateRule = async ( throw new ClientError(error.message, error.statusCode); } - const newInternalRule = convertUpdateAPIToInternalSchema({ + const ruleWithUpdates = await applyRuleUpdate({ + prebuiltRuleAssetClient, existingRule, ruleUpdate, }); - const updatedInternalRule = await rulesClient.update({ + const updatedRule = await rulesClient.update({ id: existingRule.id, - data: newInternalRule, + data: convertRuleResponseToAlertingRule(ruleWithUpdates), }); - const { enabled } = await toggleRuleEnabledOnUpdate( - rulesClient, - existingRule, - ruleUpdate.enabled - ); - - /* Trying to convert the internal rule to a RuleResponse object */ - const parseResult = RuleResponse.safeParse( - internalRuleToAPIResponse({ ...updatedInternalRule, enabled }) - ); + const { enabled } = await toggleRuleEnabledOnUpdate(rulesClient, existingRule, ruleWithUpdates); - if (!parseResult.success) { - throw new RuleResponseValidationError({ - message: stringifyZodError(parseResult.error), - ruleId: updatedInternalRule.params.ruleId, - }); - } - - return parseResult.data; + return convertAlertingRuleToRuleResponse({ + ...updatedRule, + enabled, + }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts index 8c1079f5716dbe..4eef323be2fd95 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts @@ -6,94 +6,74 @@ */ import type { RulesClient } from '@kbn/alerting-plugin/server'; -import { stringifyZodError } from '@kbn/zod-helpers'; +import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; import type { MlAuthz } from '../../../../../machine_learning/authz'; -import type { RuleParams } from '../../../../rule_schema'; -import type { UpgradePrebuiltRuleArgs } from '../detection_rules_client_interface'; -import { - convertPatchAPIToInternalSchema, - convertCreateAPIToInternalSchema, - internalRuleToAPIResponse, -} from '../../../normalization/rule_converters'; -import { transformAlertToRuleAction } from '../../../../../../../common/detection_engine/transform_actions'; -import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; - -import { validateMlAuth, ClientError, RuleResponseValidationError } from '../utils'; - -import { readRules } from '../read_rules'; - -export const upgradePrebuiltRule = async ( - rulesClient: RulesClient, - upgradePrebuiltRulePayload: UpgradePrebuiltRuleArgs, - mlAuthz: MlAuthz -): Promise => { - const { ruleAsset } = upgradePrebuiltRulePayload; - +import type { PrebuiltRuleAsset } from '../../../../prebuilt_rules'; +import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; +import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; +import { applyRulePatch } from '../mergers/apply_rule_patch'; +import { ClientError, validateMlAuth } from '../utils'; +import { createRule } from './create_rule'; +import { getRuleByRuleId } from './get_rule_by_rule_id'; + +export const upgradePrebuiltRule = async ({ + rulesClient, + ruleAsset, + mlAuthz, + prebuiltRuleAssetClient, +}: { + rulesClient: RulesClient; + ruleAsset: PrebuiltRuleAsset; + mlAuthz: MlAuthz; + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; +}): Promise => { await validateMlAuth(mlAuthz, ruleAsset.type); - const existingRule = await readRules({ + const existingRule = await getRuleByRuleId({ rulesClient, ruleId: ruleAsset.rule_id, - id: undefined, }); if (!existingRule) { throw new ClientError(`Failed to find rule ${ruleAsset.rule_id}`, 500); } - if (ruleAsset.type !== existingRule.params.type) { + if (ruleAsset.type !== existingRule.type) { // If we're trying to change the type of a prepackaged rule, we need to delete the old one // and replace it with the new rule, keeping the enabled setting, actions, throttle, id, // and exception lists from the old rule await rulesClient.delete({ id: existingRule.id }); - const internalRule = convertCreateAPIToInternalSchema( - { + const createdRule = await createRule({ + rulesClient, + mlAuthz, + rule: { ...ruleAsset, + immutable: true, enabled: existingRule.enabled, - exceptions_list: existingRule.params.exceptionsList, - actions: existingRule.actions.map(transformAlertToRuleAction), - timeline_id: existingRule.params.timelineId, - timeline_title: existingRule.params.timelineTitle, + exceptions_list: existingRule.exceptions_list, + actions: existingRule.actions, + timeline_id: existingRule.timeline_id, + timeline_title: existingRule.timeline_title, }, - { immutable: true, defaultEnabled: existingRule.enabled } - ); - - const createdRule = await rulesClient.create({ - data: internalRule, - options: { id: existingRule.id }, + id: existingRule.id, }); - /* Trying to convert the rule to a RuleResponse object */ - const parseResult = RuleResponse.safeParse(internalRuleToAPIResponse(createdRule)); - - if (!parseResult.success) { - throw new RuleResponseValidationError({ - message: stringifyZodError(parseResult.error), - ruleId: createdRule.params.ruleId, - }); - } - - return parseResult.data; + return createdRule; } // Else, simply patch it. - const patchedRule = convertPatchAPIToInternalSchema(ruleAsset, existingRule); + const patchedRule = await applyRulePatch({ + prebuiltRuleAssetClient, + existingRule, + rulePatch: ruleAsset, + }); const patchedInternalRule = await rulesClient.update({ id: existingRule.id, - data: patchedRule, + data: convertRuleResponseToAlertingRule(patchedRule), }); - /* Trying to convert the internal rule to a RuleResponse object */ - const parseResult = RuleResponse.safeParse(internalRuleToAPIResponse(patchedInternalRule)); - - if (!parseResult.success) { - throw new RuleResponseValidationError({ - message: stringifyZodError(parseResult.error), - ruleId: patchedInternalRule.params.ruleId, - }); - } - - return parseResult.data; + return convertAlertingRuleToRuleResponse(patchedInternalRule); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/read_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/read_rules.ts index d699d5ee7dd559..67c7746bd0eb36 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/read_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/read_rules.ts @@ -30,6 +30,8 @@ export interface ReadRuleOptions { * be returned as a not-found or a thrown error that is not 404. * @param ruleId - This is a close second to being fast as long as it can find the rule_id from * a filter query against the ruleId property in params using `alert.attributes.params.ruleId: "${ruleId}"` + * + * @deprecated Should be replaced with DetectionRulesClient.getRuleById once it's implemented */ export const readRules = async ({ rulesClient, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/utils.ts index 4f25497b305640..db2af377eb5b1d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/utils.ts @@ -12,21 +12,21 @@ import type { RulesClient } from '@kbn/alerting-plugin/server'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import type { MlAuthz } from '../../../../machine_learning/authz'; -import type { RuleAlertType } from '../../../rule_schema'; import type { RuleSignatureId } from '../../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; import { throwAuthzError } from '../../../../machine_learning/validation'; +import type { RuleResponse } from '../../../../../../common/api/detection_engine'; export const toggleRuleEnabledOnUpdate = async ( rulesClient: RulesClient, - existingRule: RuleAlertType, - updatedRuleEnabled?: boolean + existingRule: RuleResponse, + updatedRule: RuleResponse ): Promise<{ enabled: boolean }> => { - if (existingRule.enabled && updatedRuleEnabled === false) { + if (existingRule.enabled && !updatedRule.enabled) { await rulesClient.disable({ id: existingRule.id }); return { enabled: false }; } - if (!existingRule.enabled && updatedRuleEnabled === true) { + if (!existingRule.enabled && updatedRule.enabled) { await rulesClient.enable({ id: existingRule.id }); return { enabled: true }; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts index 08794143ce161b..5093393d6d6573 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts @@ -15,7 +15,7 @@ import { getRuleMock, } from '../../../routes/__mocks__/request_responses'; import { getThreatMock } from '../../../../../../common/detection_engine/schemas/types/threat.mock'; -import { internalRuleToAPIResponse } from '../../normalization/rule_converters'; +import { internalRuleToAPIResponse } from '../detection_rules_client/converters/internal_rule_to_api_response'; import { getEqlRuleParams, getQueryRuleParams } from '../../../rule_schema/mocks'; import { getExportByObjectIds } from './get_export_by_object_ids'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts index ce57b33227ca42..7c3142aed85f6d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts @@ -13,7 +13,7 @@ import type { ExceptionListClient } from '@kbn/lists-plugin/server'; import type { RulesClient, PartialRule } from '@kbn/alerting-plugin/server'; import type { ActionsClient } from '@kbn/actions-plugin/server'; import { withSecuritySpan } from '../../../../../utils/with_security_span'; -import { internalRuleToAPIResponse } from '../../normalization/rule_converters'; +import { internalRuleToAPIResponse } from '../detection_rules_client/converters/internal_rule_to_api_response'; import type { RuleParams } from '../../../rule_schema'; import { hasValidRuleType } from '../../../rule_schema'; import { findRules } from '../search/find_rules'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.test.ts deleted file mode 100644 index 5df02371befa2e..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -/* - * 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 { - commonParamsCamelToSnake, - patchTypeSpecificSnakeToCamel, - typeSpecificCamelToSnake, -} from './rule_converters'; -import { - getBaseRuleParams, - getEqlRuleParams, - getEsqlRuleParams, - getMlRuleParams, - getNewTermsRuleParams, - getQueryRuleParams, - getSavedQueryRuleParams, - getThreatRuleParams, - getThresholdRuleParams, -} from '../../rule_schema/mocks'; -import type { - AlertSuppressionDuration, - PatchRuleRequestBody, - AlertSuppressionMissingFieldsStrategy, -} from '../../../../../common/api/detection_engine'; - -describe('rule_converters', () => { - describe('patchTypeSpecificSnakeToCamel', () => { - describe('EQL', () => { - test('should accept EQL params when existing rule type is EQL', () => { - const patchParams = { - timestamp_field: 'event.created', - event_category_override: 'event.not_category', - tiebreaker_field: 'event.created', - }; - const rule = getEqlRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - timestampField: 'event.created', - eventCategoryOverride: 'event.not_category', - tiebreakerField: 'event.created', - }) - ); - }); - test('should accept EQL params with suppression in snake case and convert to camel case when rule type is EQL', () => { - const patchParams = { - timestamp_field: 'event.created', - event_category_override: 'event.not_category', - tiebreaker_field: 'event.created', - alert_suppression: { - group_by: ['event.type'], - duration: { - value: 10, - unit: 'm', - } as AlertSuppressionDuration, - missing_fields_strategy: 'suppress', - }, - }; - const rule = getEqlRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - timestampField: 'event.created', - eventCategoryOverride: 'event.not_category', - tiebreakerField: 'event.created', - alertSuppression: { - groupBy: ['event.type'], - duration: { - value: 10, - unit: 'm', - }, - missingFieldsStrategy: 'suppress', - }, - }) - ); - }); - test('should reject invalid EQL params when existing rule type is EQL', () => { - const patchParams = { - timestamp_field: 1, - event_category_override: 1, - tiebreaker_field: 1, - } as PatchRuleRequestBody; - const rule = getEqlRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - 'event_category_override: Expected string, received number, tiebreaker_field: Expected string, received number, timestamp_field: Expected string, received number' - ); - }); - test('should reject EQL params with invalid suppression group_by field', () => { - const patchParams = { - timestamp_field: 'event.created', - event_category_override: 'event.not_category', - tiebreaker_field: 'event.created', - alert_suppression: { - group_by: 'event.type', - duration: { - value: 10, - unit: 'm', - } as AlertSuppressionDuration, - missing_fields_strategy: 'suppress', - }, - }; - const rule = getEqlRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - 'alert_suppression.group_by: Expected array, received string' - ); - }); - }); - - describe('machine learning rules', () => { - test('should accept machine learning params when existing rule type is machine learning', () => { - const patchParams = { - anomaly_threshold: 5, - }; - const rule = getMlRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - anomalyThreshold: 5, - }) - ); - }); - - test('should reject invalid machine learning params when existing rule type is machine learning', () => { - const patchParams = { - anomaly_threshold: 'invalid', - } as PatchRuleRequestBody; - const rule = getMlRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - 'anomaly_threshold: Expected number, received string' - ); - }); - - it('accepts suppression params', () => { - const patchParams = { - alert_suppression: { - group_by: ['agent.name'], - missing_fields_strategy: 'suppress' as const, - }, - }; - const rule = getMlRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - - expect(patchedParams).toEqual( - expect.objectContaining({ - alertSuppression: { - groupBy: ['agent.name'], - missingFieldsStrategy: 'suppress', - }, - }) - ); - }); - }); - - test('should accept threat match params when existing rule type is threat match', () => { - const patchParams = { - threat_indicator_path: 'my.indicator', - threat_query: 'test-query', - }; - const rule = getThreatRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - threatIndicatorPath: 'my.indicator', - threatQuery: 'test-query', - }) - ); - }); - - test('should reject invalid threat match params when existing rule type is threat match', () => { - const patchParams = { - threat_indicator_path: 1, - threat_query: 1, - } as PatchRuleRequestBody; - const rule = getThreatRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - 'threat_query: Expected string, received number, threat_indicator_path: Expected string, received number' - ); - }); - - test('should accept query params when existing rule type is query', () => { - const patchParams = { - index: ['new-test-index'], - language: 'lucene', - } as PatchRuleRequestBody; - const rule = getQueryRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - index: ['new-test-index'], - language: 'lucene', - }) - ); - }); - - test('should reject invalid query params when existing rule type is query', () => { - const patchParams = { - index: [1], - language: 'non-language', - } as PatchRuleRequestBody; - const rule = getQueryRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - "index.0: Expected string, received number, language: Invalid enum value. Expected 'kuery' | 'lucene', received 'non-language'" - ); - }); - - test('should accept saved query params when existing rule type is saved query', () => { - const patchParams = { - index: ['new-test-index'], - language: 'lucene', - } as PatchRuleRequestBody; - const rule = getSavedQueryRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - index: ['new-test-index'], - language: 'lucene', - }) - ); - }); - - test('should reject invalid saved query params when existing rule type is saved query', () => { - const patchParams = { - index: [1], - language: 'non-language', - } as PatchRuleRequestBody; - const rule = getSavedQueryRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - "index.0: Expected string, received number, language: Invalid enum value. Expected 'kuery' | 'lucene', received 'non-language'" - ); - }); - - test('should accept threshold params when existing rule type is threshold', () => { - const patchParams = { - threshold: { - field: ['host.name'], - value: 107, - }, - }; - const rule = getThresholdRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - threshold: { - field: ['host.name'], - value: 107, - }, - }) - ); - }); - - test('should reject invalid threshold params when existing rule type is threshold', () => { - const patchParams = { - threshold: { - field: ['host.name'], - value: 'invalid', - }, - } as PatchRuleRequestBody; - const rule = getThresholdRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - 'threshold.value: Expected number, received string' - ); - }); - - test('should accept ES|QL alerts suppression params', () => { - const patchParams = { - alert_suppression: { - group_by: ['agent.name'], - duration: { value: 4, unit: 'h' as const }, - missing_fields_strategy: 'doNotSuppress' as const, - }, - }; - const rule = getEsqlRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - alertSuppression: { - groupBy: ['agent.name'], - missingFieldsStrategy: 'doNotSuppress', - duration: { value: 4, unit: 'h' }, - }, - }) - ); - }); - - test('should accept threshold alerts suppression params', () => { - const patchParams = { - alert_suppression: { - duration: { value: 4, unit: 'h' as const }, - }, - }; - const rule = getThresholdRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - alertSuppression: { - duration: { value: 4, unit: 'h' }, - }, - }) - ); - }); - - test('should accept threat_match alerts suppression params', () => { - const patchParams = { - alert_suppression: { - group_by: ['agent.name'], - missing_fields_strategy: 'suppress' as const, - }, - }; - const rule = getThreatRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - alertSuppression: { - groupBy: ['agent.name'], - missingFieldsStrategy: 'suppress', - }, - }) - ); - }); - - test('should accept new_terms alerts suppression params', () => { - const patchParams = { - alert_suppression: { - group_by: ['agent.name'], - duration: { value: 4, unit: 'h' as const }, - missing_fields_strategy: 'suppress' as const, - }, - }; - const rule = getNewTermsRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - alertSuppression: { - groupBy: ['agent.name'], - missingFieldsStrategy: 'suppress', - duration: { value: 4, unit: 'h' }, - }, - }) - ); - }); - - test('should accept new terms params when existing rule type is new terms', () => { - const patchParams = { - new_terms_fields: ['event.new_field'], - }; - const rule = getNewTermsRuleParams(); - const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule); - expect(patchedParams).toEqual( - expect.objectContaining({ - newTermsFields: ['event.new_field'], - }) - ); - }); - - test('should reject invalid new terms params when existing rule type is new terms', () => { - const patchParams = { - new_terms_fields: 'invalid', - } as PatchRuleRequestBody; - const rule = getNewTermsRuleParams(); - expect(() => patchTypeSpecificSnakeToCamel(patchParams, rule)).toThrowError( - 'new_terms_fields: Expected array, received string' - ); - }); - }); - - describe('typeSpecificCamelToSnake', () => { - describe('EQL', () => { - test('should accept EQL params when existing rule type is EQL', () => { - const params = { - timestampField: 'event.created', - eventCategoryOverride: 'event.not_category', - tiebreakerField: 'event.created', - }; - const eqlRule = { ...getEqlRuleParams(), ...params }; - const transformedParams = typeSpecificCamelToSnake(eqlRule); - expect(transformedParams).toEqual( - expect.objectContaining({ - timestamp_field: 'event.created', - event_category_override: 'event.not_category', - tiebreaker_field: 'event.created', - }) - ); - }); - - test('should accept EQL params with suppression in camel case and convert to snake case when rule type is EQL', () => { - const params = { - timestampField: 'event.created', - eventCategoryOverride: 'event.not_category', - tiebreakerField: 'event.created', - alertSuppression: { - groupBy: ['event.type'], - duration: { - value: 10, - unit: 'm', - } as AlertSuppressionDuration, - missingFieldsStrategy: 'suppress' as AlertSuppressionMissingFieldsStrategy, - }, - }; - const eqlRule = { ...getEqlRuleParams(), ...params }; - const transformedParams = typeSpecificCamelToSnake(eqlRule); - expect(transformedParams).toEqual( - expect.objectContaining({ - timestamp_field: 'event.created', - event_category_override: 'event.not_category', - tiebreaker_field: 'event.created', - alert_suppression: { - group_by: ['event.type'], - duration: { - value: 10, - unit: 'm', - } as AlertSuppressionDuration, - missing_fields_strategy: 'suppress', - }, - }) - ); - }); - }); - - describe('machine learning rules', () => { - it('accepts normal params', () => { - const params = { - anomalyThreshold: 74, - machineLearningJobId: ['job-1'], - }; - const ruleParams = { ...getMlRuleParams(), ...params }; - const transformedParams = typeSpecificCamelToSnake(ruleParams); - expect(transformedParams).toEqual( - expect.objectContaining({ - anomaly_threshold: 74, - machine_learning_job_id: ['job-1'], - }) - ); - }); - - it('accepts suppression params', () => { - const params = { - anomalyThreshold: 74, - machineLearningJobId: ['job-1'], - alertSuppression: { - groupBy: ['event.type'], - duration: { - value: 10, - unit: 'm', - } as AlertSuppressionDuration, - missingFieldsStrategy: 'suppress' as AlertSuppressionMissingFieldsStrategy, - }, - }; - const ruleParams = { ...getMlRuleParams(), ...params }; - const transformedParams = typeSpecificCamelToSnake(ruleParams); - expect(transformedParams).toEqual( - expect.objectContaining({ - anomaly_threshold: 74, - machine_learning_job_id: ['job-1'], - alert_suppression: { - group_by: ['event.type'], - duration: { - value: 10, - unit: 'm', - }, - missing_fields_strategy: 'suppress', - }, - }) - ); - }); - }); - }); - - describe('commonParamsCamelToSnake', () => { - test('should convert rule_source params to snake case', () => { - const transformedParams = commonParamsCamelToSnake({ - ...getBaseRuleParams(), - ruleSource: { - type: 'external', - isCustomized: false, - }, - }); - expect(transformedParams).toEqual( - expect.objectContaining({ - rule_source: { - type: 'external', - is_customized: false, - }, - }) - ); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts deleted file mode 100644 index db815f32fb5ed3..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts +++ /dev/null @@ -1,869 +0,0 @@ -/* - * 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 { v4 as uuidv4 } from 'uuid'; - -import { stringifyZodError } from '@kbn/zod-helpers'; -import { BadRequestError } from '@kbn/securitysolution-es-utils'; -import { ruleTypeMappings } from '@kbn/securitysolution-rules'; -import type { ResolvedSanitizedRule, SanitizedRule } from '@kbn/alerting-plugin/common'; - -import type { RequiredOptional } from '@kbn/zod-helpers'; -import { - DEFAULT_INDICATOR_SOURCE_PATH, - DEFAULT_MAX_SIGNALS, - SERVER_APP_ID, -} from '../../../../../common/constants'; - -import type { PatchRuleRequestBody } from '../../../../../common/api/detection_engine/rule_management'; -import type { - RuleCreateProps, - RuleUpdateProps, - TypeSpecificCreateProps, - TypeSpecificResponse, -} from '../../../../../common/api/detection_engine/model/rule_schema'; -import { - EqlRulePatchFields, - EsqlRulePatchFields, - MachineLearningRulePatchFields, - NewTermsRulePatchFields, - QueryRulePatchFields, - SavedQueryRulePatchFields, - ThreatMatchRulePatchFields, - ThresholdRulePatchFields, - RuleResponse, -} from '../../../../../common/api/detection_engine/model/rule_schema'; - -import { - transformAlertToRuleAction, - transformAlertToRuleResponseAction, - transformRuleToAlertAction, - transformRuleToAlertResponseAction, -} from '../../../../../common/detection_engine/transform_actions'; - -import { - normalizeMachineLearningJobIds, - normalizeThresholdObject, -} from '../../../../../common/detection_engine/utils'; - -import { assertUnreachable } from '../../../../../common/utility_types'; - -import type { - InternalRuleCreate, - RuleParams, - TypeSpecificRuleParams, - BaseRuleParams, - EqlRuleParams, - EqlSpecificRuleParams, - EsqlRuleParams, - EsqlSpecificRuleParams, - ThreatRuleParams, - ThreatSpecificRuleParams, - QueryRuleParams, - QuerySpecificRuleParams, - SavedQuerySpecificRuleParams, - SavedQueryRuleParams, - ThresholdRuleParams, - ThresholdSpecificRuleParams, - MachineLearningRuleParams, - MachineLearningSpecificRuleParams, - InternalRuleUpdate, - NewTermsRuleParams, - NewTermsSpecificRuleParams, - RuleSourceCamelCased, -} from '../../rule_schema'; -import { transformFromAlertThrottle, transformToActionFrequency } from './rule_actions'; -import { - addEcsToRequiredFields, - convertAlertSuppressionToCamel, - convertAlertSuppressionToSnake, - migrateLegacyInvestigationFields, -} from '../utils/utils'; -import { createRuleExecutionSummary } from '../../rule_monitoring'; -import type { PrebuiltRuleAsset } from '../../prebuilt_rules'; -import { convertObjectKeysToSnakeCase } from '../../../../utils/object_case_converters'; - -const DEFAULT_FROM = 'now-6m' as const; -const DEFAULT_TO = 'now' as const; -const DEFAULT_INTERVAL = '5m' as const; - -// These functions provide conversions from the request API schema to the internal rule schema and from the internal rule schema -// to the response API schema. This provides static type-check assurances that the internal schema is in sync with the API schema for -// required and default-able fields. However, it is still possible to add an optional field to the API schema -// without causing a type-check error here. - -// Converts params from the snake case API format to the internal camel case format AND applies default values where needed. -// Notice that params.language is possibly undefined for most rule types in the API but we default it to kuery to match -// the legacy API behavior -export const typeSpecificSnakeToCamel = ( - params: TypeSpecificCreateProps -): TypeSpecificRuleParams => { - switch (params.type) { - case 'eql': { - return { - type: params.type, - language: params.language, - index: params.index, - dataViewId: params.data_view_id, - query: params.query, - filters: params.filters, - timestampField: params.timestamp_field, - eventCategoryOverride: params.event_category_override, - tiebreakerField: params.tiebreaker_field, - alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), - }; - } - case 'esql': { - return { - type: params.type, - language: params.language, - query: params.query, - alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), - }; - } - case 'threat_match': { - return { - type: params.type, - language: params.language ?? 'kuery', - index: params.index, - dataViewId: params.data_view_id, - query: params.query, - filters: params.filters, - savedId: params.saved_id, - threatFilters: params.threat_filters, - threatQuery: params.threat_query, - threatMapping: params.threat_mapping, - threatLanguage: params.threat_language, - threatIndex: params.threat_index, - threatIndicatorPath: params.threat_indicator_path ?? DEFAULT_INDICATOR_SOURCE_PATH, - concurrentSearches: params.concurrent_searches, - itemsPerSearch: params.items_per_search, - alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), - }; - } - case 'query': { - return { - type: params.type, - language: params.language ?? 'kuery', - index: params.index, - dataViewId: params.data_view_id, - query: params.query ?? '', - filters: params.filters, - savedId: params.saved_id, - responseActions: params.response_actions?.map(transformRuleToAlertResponseAction), - alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), - }; - } - case 'saved_query': { - return { - type: params.type, - language: params.language ?? 'kuery', - index: params.index, - query: params.query, - filters: params.filters, - savedId: params.saved_id, - dataViewId: params.data_view_id, - responseActions: params.response_actions?.map(transformRuleToAlertResponseAction), - alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), - }; - } - case 'threshold': { - return { - type: params.type, - language: params.language ?? 'kuery', - index: params.index, - dataViewId: params.data_view_id, - query: params.query, - filters: params.filters, - savedId: params.saved_id, - threshold: normalizeThresholdObject(params.threshold), - alertSuppression: params.alert_suppression?.duration - ? { duration: params.alert_suppression.duration } - : undefined, - }; - } - case 'machine_learning': { - return { - type: params.type, - anomalyThreshold: params.anomaly_threshold, - machineLearningJobId: normalizeMachineLearningJobIds(params.machine_learning_job_id), - alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), - }; - } - case 'new_terms': { - return { - type: params.type, - query: params.query, - newTermsFields: params.new_terms_fields, - historyWindowStart: params.history_window_start, - index: params.index, - filters: params.filters, - language: params.language ?? 'kuery', - dataViewId: params.data_view_id, - alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), - }; - } - default: { - return assertUnreachable(params); - } - } -}; - -const patchEqlParams = ( - params: EqlRulePatchFields, - existingRule: EqlRuleParams -): EqlSpecificRuleParams => { - return { - type: existingRule.type, - language: params.language ?? existingRule.language, - index: params.index ?? existingRule.index, - dataViewId: params.data_view_id ?? existingRule.dataViewId, - query: params.query ?? existingRule.query, - filters: params.filters ?? existingRule.filters, - timestampField: params.timestamp_field ?? existingRule.timestampField, - eventCategoryOverride: params.event_category_override ?? existingRule.eventCategoryOverride, - tiebreakerField: params.tiebreaker_field ?? existingRule.tiebreakerField, - alertSuppression: - convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, - }; -}; - -const patchEsqlParams = ( - params: EsqlRulePatchFields, - existingRule: EsqlRuleParams -): EsqlSpecificRuleParams => { - return { - type: existingRule.type, - language: params.language ?? existingRule.language, - query: params.query ?? existingRule.query, - alertSuppression: - convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, - }; -}; - -const patchThreatMatchParams = ( - params: ThreatMatchRulePatchFields, - existingRule: ThreatRuleParams -): ThreatSpecificRuleParams => { - return { - type: existingRule.type, - language: params.language ?? existingRule.language, - index: params.index ?? existingRule.index, - dataViewId: params.data_view_id ?? existingRule.dataViewId, - query: params.query ?? existingRule.query, - filters: params.filters ?? existingRule.filters, - savedId: params.saved_id ?? existingRule.savedId, - threatFilters: params.threat_filters ?? existingRule.threatFilters, - threatQuery: params.threat_query ?? existingRule.threatQuery, - threatMapping: params.threat_mapping ?? existingRule.threatMapping, - threatLanguage: params.threat_language ?? existingRule.threatLanguage, - threatIndex: params.threat_index ?? existingRule.threatIndex, - threatIndicatorPath: params.threat_indicator_path ?? existingRule.threatIndicatorPath, - concurrentSearches: params.concurrent_searches ?? existingRule.concurrentSearches, - itemsPerSearch: params.items_per_search ?? existingRule.itemsPerSearch, - alertSuppression: - convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, - }; -}; - -const patchQueryParams = ( - params: QueryRulePatchFields, - existingRule: QueryRuleParams -): QuerySpecificRuleParams => { - return { - type: existingRule.type, - language: params.language ?? existingRule.language, - index: params.index ?? existingRule.index, - dataViewId: params.data_view_id ?? existingRule.dataViewId, - query: params.query ?? existingRule.query, - filters: params.filters ?? existingRule.filters, - savedId: params.saved_id ?? existingRule.savedId, - responseActions: - params.response_actions?.map(transformRuleToAlertResponseAction) ?? - existingRule.responseActions, - alertSuppression: - convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, - }; -}; - -const patchSavedQueryParams = ( - params: SavedQueryRulePatchFields, - existingRule: SavedQueryRuleParams -): SavedQuerySpecificRuleParams => { - return { - type: existingRule.type, - language: params.language ?? existingRule.language, - index: params.index ?? existingRule.index, - dataViewId: params.data_view_id ?? existingRule.dataViewId, - query: params.query ?? existingRule.query, - filters: params.filters ?? existingRule.filters, - savedId: params.saved_id ?? existingRule.savedId, - responseActions: - params.response_actions?.map(transformRuleToAlertResponseAction) ?? - existingRule.responseActions, - alertSuppression: - convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, - }; -}; - -const patchThresholdParams = ( - params: ThresholdRulePatchFields, - existingRule: ThresholdRuleParams -): ThresholdSpecificRuleParams => { - return { - type: existingRule.type, - language: params.language ?? existingRule.language, - index: params.index ?? existingRule.index, - dataViewId: params.data_view_id ?? existingRule.dataViewId, - query: params.query ?? existingRule.query, - filters: params.filters ?? existingRule.filters, - savedId: params.saved_id ?? existingRule.savedId, - threshold: params.threshold - ? normalizeThresholdObject(params.threshold) - : existingRule.threshold, - alertSuppression: params.alert_suppression ?? existingRule.alertSuppression, - }; -}; - -const patchMachineLearningParams = ( - params: MachineLearningRulePatchFields, - existingRule: MachineLearningRuleParams -): MachineLearningSpecificRuleParams => { - return { - type: existingRule.type, - anomalyThreshold: params.anomaly_threshold ?? existingRule.anomalyThreshold, - machineLearningJobId: params.machine_learning_job_id - ? normalizeMachineLearningJobIds(params.machine_learning_job_id) - : existingRule.machineLearningJobId, - alertSuppression: - convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, - }; -}; - -const patchNewTermsParams = ( - params: NewTermsRulePatchFields, - existingRule: NewTermsRuleParams -): NewTermsSpecificRuleParams => { - return { - type: existingRule.type, - language: params.language ?? existingRule.language, - index: params.index ?? existingRule.index, - dataViewId: params.data_view_id ?? existingRule.dataViewId, - query: params.query ?? existingRule.query, - filters: params.filters ?? existingRule.filters, - newTermsFields: params.new_terms_fields ?? existingRule.newTermsFields, - historyWindowStart: params.history_window_start ?? existingRule.historyWindowStart, - alertSuppression: - convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression, - }; -}; - -export const patchTypeSpecificSnakeToCamel = ( - params: PatchRuleRequestBody, - existingRule: RuleParams -): TypeSpecificRuleParams => { - // Here we do the validation of patch params by rule type to ensure that the fields that are - // passed in to patch are of the correct type, e.g. `query` is a string. Since the combined patch schema - // is a union of types where everything is optional, it's hard to do the validation before we know the rule type - - // a patch request that defines `event_category_override` as a number would not be assignable to the EQL patch schema, - // but would be assignable to the other rule types since they don't specify `event_category_override`. - switch (existingRule.type) { - case 'eql': { - const result = EqlRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchEqlParams(result.data, existingRule); - } - case 'esql': { - const result = EsqlRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchEsqlParams(result.data, existingRule); - } - case 'threat_match': { - const result = ThreatMatchRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchThreatMatchParams(result.data, existingRule); - } - case 'query': { - const result = QueryRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchQueryParams(result.data, existingRule); - } - case 'saved_query': { - const result = SavedQueryRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchSavedQueryParams(result.data, existingRule); - } - case 'threshold': { - const result = ThresholdRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchThresholdParams(result.data, existingRule); - } - case 'machine_learning': { - const result = MachineLearningRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchMachineLearningParams(result.data, existingRule); - } - case 'new_terms': { - const result = NewTermsRulePatchFields.safeParse(params); - if (!result.success) { - throw new BadRequestError(stringifyZodError(result.error)); - } - return patchNewTermsParams(result.data, existingRule); - } - default: { - return assertUnreachable(existingRule); - } - } -}; - -interface ConvertUpdateAPIToInternalSchemaProps { - existingRule: SanitizedRule; - ruleUpdate: RuleUpdateProps; -} - -export const convertUpdateAPIToInternalSchema = ({ - existingRule, - ruleUpdate, -}: ConvertUpdateAPIToInternalSchemaProps) => { - const alertActions = - ruleUpdate.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; - const actions = transformToActionFrequency(alertActions, ruleUpdate.throttle); - - const typeSpecificParams = typeSpecificSnakeToCamel(ruleUpdate); - - const newInternalRule: InternalRuleUpdate = { - name: ruleUpdate.name, - tags: ruleUpdate.tags ?? [], - params: { - author: ruleUpdate.author ?? [], - buildingBlockType: ruleUpdate.building_block_type, - description: ruleUpdate.description, - ruleId: existingRule.params.ruleId, - falsePositives: ruleUpdate.false_positives ?? [], - from: ruleUpdate.from ?? 'now-6m', - investigationFields: ruleUpdate.investigation_fields, - immutable: existingRule.params.immutable, - ruleSource: convertImmutableToRuleSource(existingRule.params.immutable), - license: ruleUpdate.license, - outputIndex: ruleUpdate.output_index ?? '', - timelineId: ruleUpdate.timeline_id, - timelineTitle: ruleUpdate.timeline_title, - meta: ruleUpdate.meta, - maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, - relatedIntegrations: ruleUpdate.related_integrations ?? [], - requiredFields: addEcsToRequiredFields(ruleUpdate.required_fields), - riskScore: ruleUpdate.risk_score, - riskScoreMapping: ruleUpdate.risk_score_mapping ?? [], - ruleNameOverride: ruleUpdate.rule_name_override, - setup: ruleUpdate.setup, - severity: ruleUpdate.severity, - severityMapping: ruleUpdate.severity_mapping ?? [], - threat: ruleUpdate.threat ?? [], - timestampOverride: ruleUpdate.timestamp_override, - timestampOverrideFallbackDisabled: ruleUpdate.timestamp_override_fallback_disabled, - to: ruleUpdate.to ?? 'now', - references: ruleUpdate.references ?? [], - namespace: ruleUpdate.namespace, - note: ruleUpdate.note, - version: ruleUpdate.version ?? existingRule.params.version, - exceptionsList: ruleUpdate.exceptions_list ?? [], - ...typeSpecificParams, - }, - schedule: { interval: ruleUpdate.interval ?? '5m' }, - actions, - }; - - return newInternalRule; -}; - -// eslint-disable-next-line complexity -export const convertPatchAPIToInternalSchema = ( - nextParams: PatchRuleRequestBody, - existingRule: SanitizedRule -): InternalRuleUpdate => { - const typeSpecificParams = patchTypeSpecificSnakeToCamel(nextParams, existingRule.params); - const existingParams = existingRule.params; - - const alertActions = - nextParams.actions?.map((action) => transformRuleToAlertAction(action)) ?? existingRule.actions; - const throttle = nextParams.throttle ?? transformFromAlertThrottle(existingRule); - const actions = transformToActionFrequency(alertActions, throttle); - - return { - name: nextParams.name ?? existingRule.name, - tags: nextParams.tags ?? existingRule.tags, - params: { - author: nextParams.author ?? existingParams.author, - buildingBlockType: nextParams.building_block_type ?? existingParams.buildingBlockType, - description: nextParams.description ?? existingParams.description, - ruleId: existingParams.ruleId, - falsePositives: nextParams.false_positives ?? existingParams.falsePositives, - investigationFields: nextParams.investigation_fields ?? existingParams.investigationFields, - from: nextParams.from ?? existingParams.from, - immutable: existingParams.immutable, - ruleSource: convertImmutableToRuleSource(existingParams.immutable), - license: nextParams.license ?? existingParams.license, - outputIndex: nextParams.output_index ?? existingParams.outputIndex, - timelineId: nextParams.timeline_id ?? existingParams.timelineId, - timelineTitle: nextParams.timeline_title ?? existingParams.timelineTitle, - meta: nextParams.meta ?? existingParams.meta, - maxSignals: nextParams.max_signals ?? existingParams.maxSignals, - relatedIntegrations: nextParams.related_integrations ?? existingParams.relatedIntegrations, - requiredFields: addEcsToRequiredFields(nextParams.required_fields), - riskScore: nextParams.risk_score ?? existingParams.riskScore, - riskScoreMapping: nextParams.risk_score_mapping ?? existingParams.riskScoreMapping, - ruleNameOverride: nextParams.rule_name_override ?? existingParams.ruleNameOverride, - setup: nextParams.setup ?? existingParams.setup, - severity: nextParams.severity ?? existingParams.severity, - severityMapping: nextParams.severity_mapping ?? existingParams.severityMapping, - threat: nextParams.threat ?? existingParams.threat, - timestampOverride: nextParams.timestamp_override ?? existingParams.timestampOverride, - timestampOverrideFallbackDisabled: - nextParams.timestamp_override_fallback_disabled ?? - existingParams.timestampOverrideFallbackDisabled, - to: nextParams.to ?? existingParams.to, - references: nextParams.references ?? existingParams.references, - namespace: nextParams.namespace ?? existingParams.namespace, - note: nextParams.note ?? existingParams.note, - version: nextParams.version ?? existingParams.version, - exceptionsList: nextParams.exceptions_list ?? existingParams.exceptionsList, - ...typeSpecificParams, - }, - schedule: { interval: nextParams.interval ?? existingRule.schedule.interval }, - actions, - }; -}; - -interface RuleCreateOptions { - immutable?: boolean; - defaultEnabled?: boolean; -} - -// eslint-disable-next-line complexity -export const convertCreateAPIToInternalSchema = ( - input: RuleCreateProps, - options?: RuleCreateOptions -): InternalRuleCreate => { - const { immutable = false, defaultEnabled = true } = options ?? {}; - - const typeSpecificParams = typeSpecificSnakeToCamel(input); - const newRuleId = input.rule_id ?? uuidv4(); - - const alertActions = input.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; - const actions = transformToActionFrequency(alertActions, input.throttle); - - return { - name: input.name, - tags: input.tags ?? [], - alertTypeId: ruleTypeMappings[input.type], - consumer: SERVER_APP_ID, - params: { - author: input.author ?? [], - buildingBlockType: input.building_block_type, - description: input.description, - ruleId: newRuleId, - falsePositives: input.false_positives ?? [], - investigationFields: input.investigation_fields, - from: input.from ?? DEFAULT_FROM, - immutable, - ruleSource: convertImmutableToRuleSource(immutable), - license: input.license, - outputIndex: input.output_index ?? '', - timelineId: input.timeline_id, - timelineTitle: input.timeline_title, - meta: input.meta, - maxSignals: input.max_signals ?? DEFAULT_MAX_SIGNALS, - riskScore: input.risk_score, - riskScoreMapping: input.risk_score_mapping ?? [], - ruleNameOverride: input.rule_name_override, - severity: input.severity, - severityMapping: input.severity_mapping ?? [], - threat: input.threat ?? [], - timestampOverride: input.timestamp_override, - timestampOverrideFallbackDisabled: input.timestamp_override_fallback_disabled, - to: input.to ?? DEFAULT_TO, - references: input.references ?? [], - namespace: input.namespace, - note: input.note, - version: input.version ?? 1, - exceptionsList: input.exceptions_list ?? [], - relatedIntegrations: input.related_integrations ?? [], - requiredFields: addEcsToRequiredFields(input.required_fields), - setup: input.setup ?? '', - ...typeSpecificParams, - }, - schedule: { interval: input.interval ?? '5m' }, - enabled: input.enabled ?? defaultEnabled, - actions, - }; -}; - -// Converts the internal rule data structure to the response API schema -export const typeSpecificCamelToSnake = ( - params: TypeSpecificRuleParams -): RequiredOptional => { - switch (params.type) { - case 'eql': { - return { - type: params.type, - language: params.language, - index: params.index, - data_view_id: params.dataViewId, - query: params.query, - filters: params.filters, - timestamp_field: params.timestampField, - event_category_override: params.eventCategoryOverride, - tiebreaker_field: params.tiebreakerField, - alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), - }; - } - case 'esql': { - return { - type: params.type, - language: params.language, - query: params.query, - alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), - }; - } - case 'threat_match': { - return { - type: params.type, - language: params.language, - index: params.index, - data_view_id: params.dataViewId, - query: params.query, - filters: params.filters, - saved_id: params.savedId, - threat_filters: params.threatFilters, - threat_query: params.threatQuery, - threat_mapping: params.threatMapping, - threat_language: params.threatLanguage, - threat_index: params.threatIndex, - threat_indicator_path: params.threatIndicatorPath, - concurrent_searches: params.concurrentSearches, - items_per_search: params.itemsPerSearch, - alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), - }; - } - case 'query': { - return { - type: params.type, - language: params.language, - index: params.index, - data_view_id: params.dataViewId, - query: params.query, - filters: params.filters, - saved_id: params.savedId, - response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), - alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), - }; - } - case 'saved_query': { - return { - type: params.type, - language: params.language, - index: params.index, - query: params.query, - filters: params.filters, - saved_id: params.savedId, - data_view_id: params.dataViewId, - response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), - alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), - }; - } - case 'threshold': { - return { - type: params.type, - language: params.language, - index: params.index, - data_view_id: params.dataViewId, - query: params.query, - filters: params.filters, - saved_id: params.savedId, - threshold: params.threshold, - alert_suppression: params.alertSuppression?.duration - ? { duration: params.alertSuppression?.duration } - : undefined, - }; - } - case 'machine_learning': { - return { - type: params.type, - anomaly_threshold: params.anomalyThreshold, - machine_learning_job_id: params.machineLearningJobId, - alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), - }; - } - case 'new_terms': { - return { - type: params.type, - query: params.query, - new_terms_fields: params.newTermsFields, - history_window_start: params.historyWindowStart, - index: params.index, - filters: params.filters, - language: params.language, - data_view_id: params.dataViewId, - alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), - }; - } - default: { - return assertUnreachable(params); - } - } -}; - -// TODO: separate out security solution defined common params from Alerting framework common params -// so we can explicitly specify the return type of this function -export const commonParamsCamelToSnake = (params: BaseRuleParams) => { - return { - description: params.description, - risk_score: params.riskScore, - severity: params.severity, - building_block_type: params.buildingBlockType, - namespace: params.namespace, - note: params.note, - license: params.license, - output_index: params.outputIndex, - timeline_id: params.timelineId, - timeline_title: params.timelineTitle, - meta: params.meta, - rule_name_override: params.ruleNameOverride, - timestamp_override: params.timestampOverride, - timestamp_override_fallback_disabled: params.timestampOverrideFallbackDisabled, - investigation_fields: migrateLegacyInvestigationFields(params.investigationFields), - author: params.author, - false_positives: params.falsePositives, - from: params.from, - rule_id: params.ruleId, - max_signals: params.maxSignals, - risk_score_mapping: params.riskScoreMapping, - severity_mapping: params.severityMapping, - threat: params.threat, - to: params.to, - references: params.references, - version: params.version, - exceptions_list: params.exceptionsList, - immutable: params.immutable, - rule_source: convertObjectKeysToSnakeCase(params.ruleSource), - related_integrations: params.relatedIntegrations ?? [], - required_fields: params.requiredFields ?? [], - setup: params.setup ?? '', - }; -}; - -export const internalRuleToAPIResponse = ( - rule: SanitizedRule | ResolvedSanitizedRule -): RequiredOptional => { - const executionSummary = createRuleExecutionSummary(rule); - - const isResolvedRule = (obj: unknown): obj is ResolvedSanitizedRule => - (obj as ResolvedSanitizedRule).outcome != null; - - const alertActions = rule.actions.map(transformAlertToRuleAction); - const throttle = transformFromAlertThrottle(rule); - const actions = transformToActionFrequency(alertActions, throttle); - - return { - // saved object properties - outcome: isResolvedRule(rule) ? rule.outcome : undefined, - alias_target_id: isResolvedRule(rule) ? rule.alias_target_id : undefined, - alias_purpose: isResolvedRule(rule) ? rule.alias_purpose : undefined, - // Alerting framework params - id: rule.id, - updated_at: rule.updatedAt.toISOString(), - updated_by: rule.updatedBy ?? 'elastic', - created_at: rule.createdAt.toISOString(), - created_by: rule.createdBy ?? 'elastic', - name: rule.name, - tags: rule.tags, - interval: rule.schedule.interval, - enabled: rule.enabled, - revision: rule.revision, - // Security solution shared rule params - ...commonParamsCamelToSnake(rule.params), - // Type specific security solution rule params - ...typeSpecificCamelToSnake(rule.params), - // Actions - throttle: undefined, - actions, - // Execution summary - execution_summary: executionSummary ?? undefined, - }; -}; - -export const convertPrebuiltRuleAssetToRuleResponse = ( - prebuiltRuleAsset: PrebuiltRuleAsset -): RuleResponse => { - const prebuiltRuleAssetDefaults = { - enabled: false, - risk_score_mapping: [], - severity_mapping: [], - interval: DEFAULT_INTERVAL, - to: DEFAULT_TO, - from: DEFAULT_FROM, - exceptions_list: [], - false_positives: [], - max_signals: DEFAULT_MAX_SIGNALS, - actions: [], - related_integrations: [], - required_fields: [], - setup: '', - note: '', - references: [], - threat: [], - tags: [], - author: [], - }; - - const immutable = true; - - const ruleResponseSpecificFields = { - id: uuidv4(), - updated_at: new Date(0).toISOString(), - updated_by: '', - created_at: new Date(0).toISOString(), - created_by: '', - immutable, - rule_source: convertObjectKeysToSnakeCase(convertImmutableToRuleSource(immutable)), - revision: 1, - }; - - return RuleResponse.parse({ - ...prebuiltRuleAssetDefaults, - ...prebuiltRuleAsset, - required_fields: addEcsToRequiredFields(prebuiltRuleAsset.required_fields), - ...ruleResponseSpecificFields, - }); -}; - -export const convertImmutableToRuleSource = (immutable: boolean): RuleSourceCamelCased => { - if (immutable) { - return { - type: 'external', - isCustomized: false, - }; - } - - return { - type: 'internal', - }; -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.test.ts index 61436a04c26753..536a314fa6c09f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.test.ts @@ -38,7 +38,7 @@ import { getMlRuleParams, getQueryRuleParams, getThreatRuleParams } from '../../ import { createRulesAndExceptionsStreamFromNdJson } from '../logic/import/create_rules_stream_from_ndjson'; import type { RuleExceptionsPromiseFromStreams } from '../logic/import/import_rules_utils'; -import { internalRuleToAPIResponse } from '../normalization/rule_converters'; +import { internalRuleToAPIResponse } from '../logic/detection_rules_client/converters/internal_rule_to_api_response'; type PromiseFromStreams = RuleToImport | Error; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts index 66fa635e768ad4..bf6227ddcbfe8a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts @@ -17,8 +17,6 @@ import type { SavedObjectsClientContract } from '@kbn/core/server'; import type { RuleAction } from '@kbn/securitysolution-io-ts-alerting-types'; import type { - AlertSuppression, - AlertSuppressionCamel, InvestigationFields, RequiredField, RequiredFieldInput, @@ -33,7 +31,7 @@ import type { BulkError, OutputError } from '../../routes/utils'; import { createBulkErrorObject } from '../../routes/utils'; import type { InvestigationFieldsCombined, RuleAlertType, RuleParams } from '../../rule_schema'; import { hasValidRuleType } from '../../rule_schema'; -import { internalRuleToAPIResponse } from '../normalization/rule_converters'; +import { internalRuleToAPIResponse } from '../logic/detection_rules_client/converters/internal_rule_to_api_response'; type PromiseFromStreams = RuleToImport | Error; const MAX_CONCURRENT_SEARCHES = 10; @@ -347,28 +345,6 @@ export const getInvalidConnectors = async ( return [Array.from(errors.values()), Array.from(rulesAcc.values())]; }; -export const convertAlertSuppressionToCamel = ( - input: AlertSuppression | undefined -): AlertSuppressionCamel | undefined => - input - ? { - groupBy: input.group_by, - duration: input.duration, - missingFieldsStrategy: input.missing_fields_strategy, - } - : undefined; - -export const convertAlertSuppressionToSnake = ( - input: AlertSuppressionCamel | undefined -): AlertSuppression | undefined => - input - ? { - group_by: input.groupBy, - duration: input.duration, - missing_fields_strategy: input.missingFieldsStrategy, - } - : undefined; - /** * In ESS 8.10.x "investigation_fields" are mapped as string[]. * For 8.11+ logic is added on read in our endpoints to migrate diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts index 298b7f62d2973a..dd77122ac45600 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts @@ -31,7 +31,7 @@ import { type UnifiedQueryRuleParams, } from '../../rule_schema'; import { type BulkError, createBulkErrorObject } from '../../routes/utils'; -import { internalRuleToAPIResponse } from '../normalization/rule_converters'; +import { internalRuleToAPIResponse } from '../logic/detection_rules_client/converters/internal_rule_to_api_response'; export const transformValidateBulkError = ( ruleId: string, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts index c2faa464b75da5..7b48e32bf99625 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts @@ -23,6 +23,7 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { DEFAULT_PREVIEW_INDEX, DETECTION_ENGINE_RULES_PREVIEW, + SERVER_APP_ID, } from '../../../../../../common/constants'; import { validateCreateRuleProps } from '../../../../../../common/api/detection_engine/rule_management'; import { RuleExecutionStatusEnum } from '../../../../../../common/api/detection_engine/rule_monitoring'; @@ -34,7 +35,6 @@ import { PreviewRulesSchema } from '../../../../../../common/api/detection_engin import type { StartPlugins, SetupPlugins } from '../../../../../plugin'; import { buildSiemResponse } from '../../../routes/utils'; -import { convertCreateAPIToInternalSchema } from '../../../rule_management'; import type { RuleParams } from '../../../rule_schema'; import { createPreviewRuleExecutionLogger } from './preview_rule_execution_logger'; import { parseInterval } from '../../../rule_types/utils/utils'; @@ -64,6 +64,8 @@ import { createSecurityRuleTypeWrapper } from '../../../rule_types/create_securi import { assertUnreachable } from '../../../../../../common/utility_types'; import { wrapScopedClusterClient } from './wrap_scoped_cluster_client'; import { wrapSearchSourceClient } from './wrap_search_source_client'; +import { applyRuleDefaults } from '../../../rule_management/logic/detection_rules_client/mergers/apply_rule_defaults'; +import { convertRuleResponseToAlertingRule } from '../../../rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule'; const PREVIEW_TIMEOUT_SECONDS = 60; const MAX_ROUTE_CONCURRENCY = 10; @@ -118,7 +120,7 @@ export const previewRulesRoute = ( }); } - const internalRule = convertCreateAPIToInternalSchema(request.body); + const internalRule = convertRuleResponseToAlertingRule(applyRuleDefaults(request.body)); const previewRuleParams = internalRule.params; const mlAuthz = buildMlAuthz({ @@ -237,6 +239,8 @@ export const previewRulesRoute = ( createdAt: new Date(), createdBy: username ?? 'preview-created-by', producer: 'preview-producer', + consumer: SERVER_APP_ID, + enabled: true, revision: 0, ruleTypeId, ruleTypeName, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts index b3000edf895dc7..5d5065170deff6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts @@ -5,11 +5,6 @@ * 2.0. */ import type { SanitizedRuleConfig } from '@kbn/alerting-plugin/common'; -import type { - RuleActionArrayCamel, - RuleActionNotifyWhen, - RuleActionThrottle, -} from '@kbn/securitysolution-io-ts-alerting-types'; import type { EQL_RULE_TYPE_ID, ESQL_RULE_TYPE_ID, @@ -22,12 +17,9 @@ import type { THRESHOLD_RULE_TYPE_ID, } from '@kbn/securitysolution-rules'; import * as z from 'zod'; +import type { CreateRuleData } from '@kbn/alerting-plugin/server/application/rule/methods/create'; +import type { UpdateRuleData } from '@kbn/alerting-plugin/server/application/rule/methods/update'; import { RuleResponseAction } from '../../../../../common/api/detection_engine'; -import type { - IsRuleEnabled, - RuleName, - RuleTagArray, -} from '../../../../../common/api/detection_engine/model/rule_schema'; import { AlertsIndex, AlertsIndexNamespace, @@ -334,29 +326,8 @@ export type AllRuleTypes = | typeof THRESHOLD_RULE_TYPE_ID | typeof NEW_TERMS_RULE_TYPE_ID; -export interface InternalRuleCreate { - name: RuleName; - tags: RuleTagArray; - alertTypeId: AllRuleTypes; +export type InternalRuleCreate = CreateRuleData & { consumer: typeof SERVER_APP_ID; - schedule: { - interval: string; - }; - enabled: IsRuleEnabled; - actions: RuleActionArrayCamel; - params: RuleParams; - throttle?: RuleActionThrottle | null; - notifyWhen?: RuleActionNotifyWhen | null; -} +}; -export interface InternalRuleUpdate { - name: RuleName; - tags: RuleTagArray; - schedule: { - interval: string; - }; - actions: RuleActionArrayCamel; - params: RuleParams; - throttle?: RuleActionThrottle | null; - notifyWhen?: RuleActionNotifyWhen | null; -} +export type InternalRuleUpdate = UpdateRuleData; diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index 1f36f7ecff234e..ea1673fe9a5de1 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -122,10 +122,11 @@ export class RequestContextFactory implements IRequestContextFactory { savedObjectsClient: coreContext.savedObjects.client, }); - return createDetectionRulesClient( - startPlugins.alerting.getRulesClientWithRequest(request), - mlAuthz - ); + return createDetectionRulesClient({ + rulesClient: startPlugins.alerting.getRulesClientWithRequest(request), + savedObjectsClient: coreContext.savedObjects.client, + mlAuthz, + }); }), getDetectionEngineHealthClient: memoize(() => diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_rule_exceptions_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_rule_exceptions_ess.ts index 94069641061708..a1708cf4d6ae03 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_rule_exceptions_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_rule_exceptions_ess.ts @@ -14,7 +14,6 @@ import { getRuleSOById, createRuleThroughAlertingEndpoint, getRuleSavedObjectWithLegacyInvestigationFields, - checkInvestigationFieldSoValue, } from '../../../../utils'; import { createAlertsIndex, @@ -79,25 +78,15 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - /** - * Confirm type on SO so that it's clear in the tests whether it's expected that - * the SO itself is migrated to the inteded object type, or if the transformation is - * happening just on the response. In this case, change will - * NOT include a migration on SO. - */ const { hits: { hits: [{ _source: ruleSO }], }, } = await getRuleSOById(es, ruleWithLegacyInvestigationField.id); - const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue(ruleSO, { - field_names: ['client.address', 'agent.name'], - }); expect( ruleSO?.alert.params.exceptionsList.some((list) => list.type === 'rule_default') ).to.eql(true); - expect(isInvestigationFieldMigratedInSo).to.eql(false); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_bulk.ts index 7e496ea73194d5..947b191469e3d6 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_bulk.ts @@ -532,7 +532,7 @@ export default ({ getService }: FtrProviderContext) => { ); }); - it('should patch a rule with a legacy investigation field and transform field in response', async () => { + it('should patch a rule with a legacy investigation field and migrate field', async () => { // patch a simple rule's name const { body } = await securitySolutionApi .bulkPatchRules({ @@ -548,19 +548,13 @@ export default ({ getService }: FtrProviderContext) => { }); expect(bodyToCompareLegacyField.name).to.eql('some other name'); - /** - * Confirm type on SO so that it's clear in the tests whether it's expected that - * the SO itself is migrated to the inteded object type, or if the transformation is - * happening just on the response. In this case, change should - * NOT include a migration on SO. - */ const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( undefined, { field_names: ['client.address', 'agent.name'] }, es, body[0].id ); - expect(isInvestigationFieldMigratedInSo).to.eql(false); + expect(isInvestigationFieldMigratedInSo).to.eql(true); }); it('should patch a rule with a legacy investigation field - empty array - and transform field in response', async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_ess.ts index 30398cd2cd1e9d..d28358519e307a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/trial_license_complete_tier/patch_rules_ess.ts @@ -137,7 +137,7 @@ export default ({ getService }: FtrProviderContext) => { ); }); - it('should patch a rule with a legacy investigation field and transform response', async () => { + it('should patch a rule with a legacy investigation field and migrate field', async () => { const { body } = await supertest .patch(DETECTION_ENGINE_RULES_URL) .set('kbn-xsrf', 'true') @@ -152,12 +152,7 @@ export default ({ getService }: FtrProviderContext) => { expect(bodyToCompare.investigation_fields).to.eql({ field_names: ['client.address', 'agent.name'], }); - /** - * Confirm type on SO so that it's clear in the tests whether it's expected that - * the SO itself is migrated to the inteded object type, or if the transformation is - * happening just on the response. In this case, change should - * NOT include a migration on SO. - */ + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( undefined, { @@ -166,7 +161,7 @@ export default ({ getService }: FtrProviderContext) => { es, body.id ); - expect(isInvestigationFieldMigratedInSo).to.eql(false); + expect(isInvestigationFieldMigratedInSo).to.eql(true); }); it('should patch a rule with a legacy investigation field - empty array - and transform response', async () => {