diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/aggregated_prebuilt_rules_error.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/aggregated_prebuilt_rules_error.ts index 9a90a69c43fe5..19e13ee5515cb 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/aggregated_prebuilt_rules_error.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/aggregated_prebuilt_rules_error.ts @@ -5,11 +5,17 @@ * 2.0. */ -export interface AggregatedPrebuiltRuleError { - message: string; - status_code?: number; - rules: Array<{ - rule_id: string; - name?: string; - }>; -} +import { z } from 'zod'; +import { RuleName, RuleSignatureId } from '../../model/rule_schema/common_attributes.gen'; + +export type AggregatedPrebuiltRuleError = z.infer; +export const AggregatedPrebuiltRuleError = z.object({ + message: z.string(), + status_code: z.number().optional(), + rules: z.array( + z.object({ + rule_id: RuleSignatureId, + name: RuleName.optional(), + }) + ), +}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.test.ts new file mode 100644 index 0000000000000..b58a254f9dc49 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.test.ts @@ -0,0 +1,204 @@ +/* + * 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 { expectParseError, expectParseSuccess, stringifyZodError } from '@kbn/zod-helpers'; +import { + PickVersionValues, + RuleUpgradeSpecifier, + UpgradeSpecificRulesRequest, + UpgradeAllRulesRequest, + PerformRuleUpgradeResponseBody, + PerformRuleUpgradeRequestBody, +} from './perform_rule_upgrade_route'; + +describe('Perform Rule Upgrade Route Schemas', () => { + describe('PickVersionValues', () => { + test('validates correct enum values', () => { + const validValues = ['BASE', 'CURRENT', 'TARGET', 'MERGED']; + validValues.forEach((value) => { + const result = PickVersionValues.safeParse(value); + expectParseSuccess(result); + expect(result.data).toBe(value); + }); + }); + + test('rejects invalid enum values', () => { + const invalidValues = ['RESOLVED', 'MALFORMED_STRING']; + invalidValues.forEach((value) => { + const result = PickVersionValues.safeParse(value); + expectParseError(result); + expect(stringifyZodError(result.error)).toMatchInlineSnapshot( + `"Invalid enum value. Expected 'BASE' | 'CURRENT' | 'TARGET' | 'MERGED', received '${value}'"` + ); + }); + }); + }); + + describe('RuleUpgradeSpecifier', () => { + const validSpecifier = { + rule_id: 'rule-1', + revision: 1, + version: 1, + pick_version: 'TARGET', + }; + + test('validates a valid upgrade specifier without fields property', () => { + const result = RuleUpgradeSpecifier.safeParse(validSpecifier); + expectParseSuccess(result); + expect(result.data).toEqual(validSpecifier); + }); + + test('validates a valid upgrade specifier with a fields property', () => { + const specifierWithFields = { + ...validSpecifier, + fields: { + name: { + pick_version: 'CURRENT', + }, + }, + }; + const result = RuleUpgradeSpecifier.safeParse(specifierWithFields); + expectParseSuccess(result); + expect(result.data).toEqual(specifierWithFields); + }); + + test('rejects upgrade specifier with invalid pick_version rule_id', () => { + const invalid = { ...validSpecifier, rule_id: 123 }; + const result = RuleUpgradeSpecifier.safeParse(invalid); + expectParseError(result); + expect(stringifyZodError(result.error)).toMatchInlineSnapshot( + `"rule_id: Expected string, received number"` + ); + }); + }); + + describe('UpgradeSpecificRulesRequest', () => { + const validRequest = { + mode: 'SPECIFIC_RULES', + rules: [ + { + rule_id: 'rule-1', + revision: 1, + version: 1, + }, + ], + }; + + test('validates a correct upgrade specific rules request', () => { + const result = UpgradeSpecificRulesRequest.safeParse(validRequest); + expectParseSuccess(result); + expect(result.data).toEqual(validRequest); + }); + + test('rejects invalid mode', () => { + const invalid = { ...validRequest, mode: 'INVALID_MODE' }; + const result = UpgradeSpecificRulesRequest.safeParse(invalid); + expectParseError(result); + expect(stringifyZodError(result.error)).toMatchInlineSnapshot( + `"mode: Invalid literal value, expected \\"SPECIFIC_RULES\\""` + ); + }); + + test('rejects paylaod with missing rules array', () => { + const invalid = { ...validRequest, rules: undefined }; + const result = UpgradeSpecificRulesRequest.safeParse(invalid); + expectParseError(result); + expect(stringifyZodError(result.error)).toMatchInlineSnapshot(`"rules: Required"`); + }); + }); + + describe('UpgradeAllRulesRequest', () => { + const validRequest = { + mode: 'ALL_RULES', + }; + + test('validates a correct upgrade all rules request', () => { + const result = UpgradeAllRulesRequest.safeParse(validRequest); + expectParseSuccess(result); + expect(result.data).toEqual(validRequest); + }); + + test('allows optional pick_version', () => { + const withPickVersion = { ...validRequest, pick_version: 'BASE' }; + const result = UpgradeAllRulesRequest.safeParse(withPickVersion); + expectParseSuccess(result); + expect(result.data).toEqual(withPickVersion); + }); + }); + + describe('PerformRuleUpgradeRequestBody', () => { + test('validates a correct upgrade specific rules request', () => { + const validRequest = { + mode: 'SPECIFIC_RULES', + pick_version: 'BASE', + rules: [ + { + rule_id: 'rule-1', + revision: 1, + version: 1, + }, + ], + }; + const result = PerformRuleUpgradeRequestBody.safeParse(validRequest); + expectParseSuccess(result); + expect(result.data).toEqual(validRequest); + }); + + test('validates a correct upgrade all rules request', () => { + const validRequest = { + mode: 'ALL_RULES', + pick_version: 'BASE', + }; + const result = PerformRuleUpgradeRequestBody.safeParse(validRequest); + expectParseSuccess(result); + expect(result.data).toEqual(validRequest); + }); + + test('rejects invalid mode', () => { + const invalid = { mode: 'INVALID_MODE' }; + const result = PerformRuleUpgradeRequestBody.safeParse(invalid); + expectParseError(result); + expect(stringifyZodError(result.error)).toMatchInlineSnapshot( + `"mode: Invalid discriminator value. Expected 'ALL_RULES' | 'SPECIFIC_RULES'"` + ); + }); + }); +}); + +describe('PerformRuleUpgradeResponseBody', () => { + const validResponse = { + summary: { + total: 1, + succeeded: 1, + skipped: 0, + failed: 0, + }, + results: { + updated: [], + skipped: [], + }, + errors: [], + }; + + test('validates a correct perform rule upgrade response', () => { + const result = PerformRuleUpgradeResponseBody.safeParse(validResponse); + expectParseSuccess(result); + expect(result.data).toEqual(validResponse); + }); + + test('rejects missing required fields', () => { + const propsToDelete = Object.keys(validResponse); + propsToDelete.forEach((deletedProp) => { + const invalidResponse = Object.fromEntries( + Object.entries(validResponse).filter(([key]) => key !== deletedProp) + ); + const result = PerformRuleUpgradeResponseBody.safeParse(invalidResponse); + expectParseError(result); + expect(stringifyZodError(result.error)).toMatchInlineSnapshot(`"${deletedProp}: Required"`); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts index b1d3b166a513e..e22574267d6fe 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts @@ -5,94 +5,176 @@ * 2.0. */ -import { enumeration } from '@kbn/securitysolution-io-ts-types'; -import * as t from 'io-ts'; +import { z } from 'zod'; -import type { RuleResponse } from '../../model'; -import type { AggregatedPrebuiltRuleError } from '../model'; +import { + RuleSignatureId, + RuleVersion, + RuleName, + RuleTagArray, + RuleDescription, + Severity, + SeverityMapping, + RiskScore, + RiskScoreMapping, + RuleReferenceArray, + RuleFalsePositiveArray, + ThreatArray, + InvestigationGuide, + SetupGuide, + RelatedIntegrationArray, + RequiredFieldArray, + MaxSignals, + BuildingBlockType, + RuleIntervalFrom, + RuleInterval, + RuleExceptionList, + RuleNameOverride, + TimestampOverride, + TimestampOverrideFallbackDisabled, + TimelineTemplateId, + TimelineTemplateTitle, + IndexPatternArray, + DataViewId, + RuleQuery, + QueryLanguage, + RuleFilterArray, + SavedQueryId, + KqlQueryLanguage, +} from '../../model/rule_schema/common_attributes.gen'; +import { + MachineLearningJobId, + AnomalyThreshold, +} from '../../model/rule_schema/specific_attributes/ml_attributes.gen'; +import { + ThreatQuery, + ThreatMapping, + ThreatIndex, + ThreatFilters, + ThreatIndicatorPath, +} from '../../model/rule_schema/specific_attributes/threat_match_attributes.gen'; +import { + NewTermsFields, + HistoryWindowStart, +} from '../../model/rule_schema/specific_attributes/new_terms_attributes.gen'; +import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen'; +import { AggregatedPrebuiltRuleError } from '../model'; -export enum PickVersionValues { - BASE = 'BASE', - CURRENT = 'CURRENT', - TARGET = 'TARGET', -} +export type PickVersionValues = z.infer; +export const PickVersionValues = z.enum(['BASE', 'CURRENT', 'TARGET', 'MERGED']); +export type PickVersionValuesEnum = typeof PickVersionValues.enum; +export const PickVersionValuesEnum = PickVersionValues.enum; -export const TPickVersionValues = enumeration('PickVersionValues', PickVersionValues); +const createUpgradeFieldSchema = (fieldSchema: T) => + z + .discriminatedUnion('pick_version', [ + z.object({ + pick_version: PickVersionValues, + }), + z.object({ + pick_version: z.literal('RESOLVED'), + resolved_value: fieldSchema, + }), + ]) + .optional(); -export const RuleUpgradeSpecifier = t.exact( - t.intersection([ - t.type({ - rule_id: t.string, - /** - * This parameter is needed for handling race conditions with Optimistic Concurrency Control. - * Two or more users can call upgrade/_review and upgrade/_perform endpoints concurrently. - * Also, in general the time between these two calls can be anything. - * The idea is to only allow the user to install a rule if the user has reviewed the exact version - * of it that had been returned from the _review endpoint. If the version changed on the BE, - * upgrade/_perform endpoint will return a version mismatch error for this rule. - */ - revision: t.number, - /** - * The target version to upgrade to. - */ - version: t.number, - }), - t.partial({ - pick_version: TPickVersionValues, - }), - ]) -); -export type RuleUpgradeSpecifier = t.TypeOf; +export type RuleUpgradeSpecifier = z.infer; +export const RuleUpgradeSpecifier = z.object({ + rule_id: RuleSignatureId, + revision: z.number(), + version: RuleVersion, + pick_version: PickVersionValues.optional(), + // Fields that can be customized during the upgrade workflow + // as decided in: https://github.com/elastic/kibana/issues/186544 + fields: z + .object({ + name: createUpgradeFieldSchema(RuleName), + tags: createUpgradeFieldSchema(RuleTagArray), + description: createUpgradeFieldSchema(RuleDescription), + severity: createUpgradeFieldSchema(Severity), + severity_mapping: createUpgradeFieldSchema(SeverityMapping), + risk_score: createUpgradeFieldSchema(RiskScore), + risk_score_mapping: createUpgradeFieldSchema(RiskScoreMapping), + references: createUpgradeFieldSchema(RuleReferenceArray), + false_positives: createUpgradeFieldSchema(RuleFalsePositiveArray), + threat: createUpgradeFieldSchema(ThreatArray), + note: createUpgradeFieldSchema(InvestigationGuide), + setup: createUpgradeFieldSchema(SetupGuide), + related_integrations: createUpgradeFieldSchema(RelatedIntegrationArray), + required_fields: createUpgradeFieldSchema(RequiredFieldArray), + max_signals: createUpgradeFieldSchema(MaxSignals), + building_block_type: createUpgradeFieldSchema(BuildingBlockType), + from: createUpgradeFieldSchema(RuleIntervalFrom), + interval: createUpgradeFieldSchema(RuleInterval), + exceptions_list: createUpgradeFieldSchema(RuleExceptionList), + rule_name_override: createUpgradeFieldSchema(RuleNameOverride), + timestamp_override: createUpgradeFieldSchema(TimestampOverride), + timestamp_override_fallback_disabled: createUpgradeFieldSchema( + TimestampOverrideFallbackDisabled + ), + timeline_id: createUpgradeFieldSchema(TimelineTemplateId), + timeline_title: createUpgradeFieldSchema(TimelineTemplateTitle), + index: createUpgradeFieldSchema(IndexPatternArray), + data_view_id: createUpgradeFieldSchema(DataViewId), + query: createUpgradeFieldSchema(RuleQuery), + language: createUpgradeFieldSchema(QueryLanguage), + filters: createUpgradeFieldSchema(RuleFilterArray), + saved_id: createUpgradeFieldSchema(SavedQueryId), + machine_learning_job_id: createUpgradeFieldSchema(MachineLearningJobId), + anomaly_threshold: createUpgradeFieldSchema(AnomalyThreshold), + threat_query: createUpgradeFieldSchema(ThreatQuery), + threat_mapping: createUpgradeFieldSchema(ThreatMapping), + threat_index: createUpgradeFieldSchema(ThreatIndex), + threat_filters: createUpgradeFieldSchema(ThreatFilters), + threat_indicator_path: createUpgradeFieldSchema(ThreatIndicatorPath), + threat_language: createUpgradeFieldSchema(KqlQueryLanguage), + new_terms_fields: createUpgradeFieldSchema(NewTermsFields), + history_window_start: createUpgradeFieldSchema(HistoryWindowStart), + }) + .optional(), +}); -export type UpgradeSpecificRulesRequest = t.TypeOf; -export const UpgradeSpecificRulesRequest = t.exact( - t.intersection([ - t.type({ - mode: t.literal(`SPECIFIC_RULES`), - rules: t.array(RuleUpgradeSpecifier), - }), - t.partial({ - pick_version: TPickVersionValues, - }), - ]) -); +export type UpgradeSpecificRulesRequest = z.infer; +export const UpgradeSpecificRulesRequest = z.object({ + mode: z.literal('SPECIFIC_RULES'), + rules: z.array(RuleUpgradeSpecifier), + pick_version: PickVersionValues.optional(), +}); -export const UpgradeAllRulesRequest = t.exact( - t.intersection([ - t.type({ - mode: t.literal(`ALL_RULES`), - }), - t.partial({ - pick_version: TPickVersionValues, - }), - ]) -); +export type UpgradeAllRulesRequest = z.infer; +export const UpgradeAllRulesRequest = z.object({ + mode: z.literal('ALL_RULES'), + pick_version: PickVersionValues.optional(), +}); -export const PerformRuleUpgradeRequestBody = t.union([ - UpgradeAllRulesRequest, - UpgradeSpecificRulesRequest, -]); -export type PerformRuleUpgradeRequestBody = t.TypeOf; +export type SkipRuleUpgradeReason = z.infer; +export const SkipRuleUpgradeReason = z.enum(['RULE_UP_TO_DATE']); +export type SkipRuleUpgradeReasonEnum = typeof SkipRuleUpgradeReason.enum; +export const SkipRuleUpgradeReasonEnum = SkipRuleUpgradeReason.enum; -export enum SkipRuleUpgradeReason { - RULE_UP_TO_DATE = 'RULE_UP_TO_DATE', -} +export type SkippedRuleUpgrade = z.infer; +export const SkippedRuleUpgrade = z.object({ + rule_id: z.string(), + reason: SkipRuleUpgradeReason, +}); -export interface SkippedRuleUpgrade { - rule_id: string; - reason: SkipRuleUpgradeReason; -} +export type PerformRuleUpgradeResponseBody = z.infer; +export const PerformRuleUpgradeResponseBody = z.object({ + summary: z.object({ + total: z.number(), + succeeded: z.number(), + skipped: z.number(), + failed: z.number(), + }), + results: z.object({ + updated: z.array(RuleResponse), + skipped: z.array(SkippedRuleUpgrade), + }), + errors: z.array(AggregatedPrebuiltRuleError), +}); -export interface PerformRuleUpgradeResponseBody { - summary: { - total: number; - succeeded: number; - skipped: number; - failed: number; - }; - results: { - updated: RuleResponse[]; - skipped: SkippedRuleUpgrade[]; - }; - errors: AggregatedPrebuiltRuleError[]; -} +export type PerformRuleUpgradeRequestBody = z.infer; +export const PerformRuleUpgradeRequestBody = z.discriminatedUnion('mode', [ + UpgradeAllRulesRequest, + UpgradeSpecificRulesRequest, +]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts index 0d1693a69806e..f95189d6af34d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts @@ -6,11 +6,12 @@ */ import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { PERFORM_RULE_UPGRADE_URL, - SkipRuleUpgradeReason, PerformRuleUpgradeRequestBody, - PickVersionValues, + PickVersionValuesEnum, + SkipRuleUpgradeReasonEnum, } from '../../../../../../common/api/detection_engine/prebuilt_rules'; import type { PerformRuleUpgradeResponseBody, @@ -18,7 +19,6 @@ import type { } from '../../../../../../common/api/detection_engine/prebuilt_rules'; import { assertUnreachable } from '../../../../../../common/utility_types'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import { buildRouteValidation } from '../../../../../utils/build_validation/route_validation'; import type { PromisePoolError } from '../../../../../utils/promise_pool'; import { buildSiemResponse } from '../../../routes/utils'; import { aggregatePrebuiltRuleErrors } from '../../logic/aggregate_prebuilt_rule_errors'; @@ -48,7 +48,7 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => version: '1', validate: { request: { - body: buildRouteValidation(PerformRuleUpgradeRequestBody), + body: buildRouteValidationWithZod(PerformRuleUpgradeRequestBody), }, }, }, @@ -63,7 +63,8 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient); const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient); - const { mode, pick_version: globalPickVersion = PickVersionValues.TARGET } = request.body; + const { mode, pick_version: globalPickVersion = PickVersionValuesEnum.TARGET } = + request.body; const fetchErrors: Array> = []; const targetRules: PrebuiltRuleAsset[] = []; @@ -105,7 +106,7 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => if (!upgradeableRuleIds.has(rule.rule_id)) { skippedRules.push({ rule_id: rule.rule_id, - reason: SkipRuleUpgradeReason.RULE_UP_TO_DATE, + reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE, }); return; } @@ -132,7 +133,7 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => const rulePickVersion = versionSpecifiersMap?.get(current.rule_id)?.pick_version ?? globalPickVersion; switch (rulePickVersion) { - case PickVersionValues.BASE: + case PickVersionValuesEnum.BASE: const baseVersion = ruleVersionsMap.get(current.rule_id)?.base; if (baseVersion) { targetRules.push({ ...baseVersion, version: target.version }); @@ -143,10 +144,14 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => }); } break; - case PickVersionValues.CURRENT: + case PickVersionValuesEnum.CURRENT: targetRules.push({ ...current, version: target.version }); break; - case PickVersionValues.TARGET: + case PickVersionValuesEnum.TARGET: + targetRules.push(target); + break; + case PickVersionValuesEnum.MERGED: + // TODO: Implement functionality to handle MERGED targetRules.push(target); break; default: