From b5c119496ace2c56da6752c7e9ab26ef963fec95 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 5 Dec 2024 17:29:51 +0100 Subject: [PATCH] [SecuritySolution][Onboarding] Siem migration start card and flyout - Part 1 (#202886) ## Summary Part of: https://github.com/elastic/security-team/issues/10667 Implementation of the Onboarding card to create migrations using the flyout > [!NOTE] > This feature needs `siemMigrationsEnabled` experimental flag enabled to work. Otherwise only the default topic will be available and the topic selector won't be displayed. image #### To do in part 2: - Complete implementation of migration finished panel: chart and stats - Improve implementation of migration ready panel: should only allow open the flyout - Implement missing steps in the flyout: Macros and Lookups ### Test Enable experimental flag Use file: [1733135547_1420.json](https://github.com/user-attachments/files/18010116/1733135547_1420.json) --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../common/api/quickstart_client.gen.ts | 4 +- .../common/siem_migrations/constants.ts | 2 + .../model/api/rules/rule_migration.gen.ts | 13 +- .../api/rules/rule_migration.schema.yaml | 211 +++++++++-------- .../siem_migrations/model/common.gen.ts | 9 - .../siem_migrations/model/common.schema.yaml | 5 - .../model/rule_migration.gen.ts | 23 +- .../model/rule_migration.schema.yaml | 42 ++-- .../components/onboarding_body/body_config.ts | 7 + .../cards/common/card_content_panel.tsx | 2 +- .../ai_connector/ai_connector_card.tsx | 23 +- .../ai_connector/connectors_check_complete.ts | 2 +- .../siem_migrations/ai_connector/index.ts | 6 +- .../start_migration/context.tsx | 35 +++ .../images/card_header_icon.png | Bin 0 -> 1251 bytes .../siem_migrations/start_migration/index.ts | 28 +++ .../missing_ai_connector_callout.tsx | 38 +++ .../panels/migration_progress_panel.tsx | 45 ++++ .../panels/migration_ready_panel.tsx | 68 ++++++ .../panels/migration_result_panel.tsx | 87 +++++++ .../panels/upload_rules_panel.styles.ts | 19 ++ .../panels/upload_rules_panel.tsx | 80 +++++++ .../start_migration_card.styles.ts | 20 ++ .../start_migration/start_migration_card.tsx | 79 +++++++ .../start_migration_check_complete.ts | 16 ++ .../start_migration/translations.ts | 118 ++++++++++ .../start_migration/upload_rules_panels.tsx | 46 ++++ .../public/onboarding/constants.ts | 1 + .../siem_migrations/common/icon/index.tsx | 8 + .../common/icon/siem_migrations.svg | 28 +++ .../public/siem_migrations/rules/api/index.ts | 219 ++++++++++-------- .../components/data_input_flyout/constants.ts | 26 +++ .../data_input_flyout/data_input_flyout.tsx | 112 +++++++++ .../components/data_input_flyout/index.ts | 8 + .../steps/common/sub_step_wrapper.tsx | 26 +++ .../steps/rules/rules_data_input.tsx | 95 ++++++++ .../rules/sub_steps/check_resources/index.tsx | 30 +++ .../sub_steps/check_resources/translations.ts | 20 ++ .../copy_export_query/copy_export_query.tsx | 53 +++++ .../sub_steps/copy_export_query/index.tsx | 26 +++ .../copy_export_query/translations.ts | 18 ++ .../sub_steps/rules_file_upload/index.tsx | 58 +++++ .../rules_file_upload/parse_rules_file.ts | 79 +++++++ .../rules_file_upload/rules_file_upload.tsx | 122 ++++++++++ .../rules_file_upload/translations.ts | 70 ++++++ .../steps/rules/translations.ts | 13 ++ .../components/data_input_flyout/types.ts | 10 + .../rules/components/header_buttons/index.tsx | 4 +- .../siem_migrations/rules/pages/index.tsx | 2 +- .../hooks/common/api_request_reducer.ts | 26 +++ .../rules/service/hooks/translations.ts | 17 ++ .../service/hooks/use_create_migration.ts | 55 +++++ .../{ => service}/hooks/use_latest_stats.ts | 2 +- .../service/hooks/use_start_migration.ts | 52 +++++ .../rules/service/rule_migrations_service.ts | 145 +++++++++--- .../siem_migrations/rules/service/storage.ts | 30 ++- .../rules/service/success_notification.tsx | 6 +- .../rules/service/translations.ts | 23 ++ .../public/siem_migrations/rules/types.ts | 4 +- .../lib/siem_migrations/rules/api/create.ts | 13 +- .../lib/siem_migrations/rules/api/get.ts | 20 +- .../rules/api/util/installation.ts | 6 +- .../data/rule_migrations_data_rules_client.ts | 108 ++++----- .../rules/data/rule_migrations_field_maps.ts | 3 +- .../match_prebuilt_rule.ts | 2 +- .../services/security_solution_api.gen.ts | 13 +- 66 files changed, 2194 insertions(+), 387 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/context.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/images/card_header_icon.png create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/index.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/missing_ai_connector_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_progress_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_ready_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_result_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.styles.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.styles.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_check_complete.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/translations.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/upload_rules_panels.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/common/icon/index.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/common/icon/siem_migrations.svg create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/constants.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/data_input_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/index.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/common/sub_step_wrapper.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/rules_data_input.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/index.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/translations.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/copy_export_query.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/index.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/translations.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/index.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/parse_rules_file.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/rules_file_upload.tsx create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/translations.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/translations.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/types.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/common/api_request_reducer.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/translations.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts rename x-pack/plugins/security_solution/public/siem_migrations/rules/{ => service}/hooks/use_latest_stats.ts (91%) create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_start_migration.ts create mode 100644 x-pack/plugins/security_solution/public/siem_migrations/rules/service/translations.ts diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index 994fb70deaa25..0ef67f164409b 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -356,6 +356,7 @@ import type { ResolveTimelineResponse, } from './timeline/resolve_timeline/resolve_timeline_route.gen'; import type { + CreateRuleMigrationRequestParamsInput, CreateRuleMigrationRequestBodyInput, CreateRuleMigrationResponse, GetAllStatsRuleMigrationResponse, @@ -686,7 +687,7 @@ If a record already exists for the specified entity, that record is overwritten this.log.info(`${new Date().toISOString()} Calling API CreateRuleMigration`); return this.kbnClient .request({ - path: '/internal/siem_migrations/rules', + path: replaceParams('/internal/siem_migrations/rules/{migration_id}', props.params), headers: { [ELASTIC_HTTP_VERSION_HEADER]: '1', }, @@ -2267,6 +2268,7 @@ export interface CreateRuleProps { body: CreateRuleRequestBodyInput; } export interface CreateRuleMigrationProps { + params: CreateRuleMigrationRequestParamsInput; body: CreateRuleMigrationRequestBodyInput; } export interface CreateTimelinesProps { diff --git a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts index 789947150a67e..e04130e7f44d7 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts @@ -11,6 +11,8 @@ export const SIEM_MIGRATIONS_PATH = '/internal/siem_migrations' as const; export const SIEM_RULE_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/rules` as const; export const SIEM_RULE_MIGRATIONS_ALL_STATS_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/stats` as const; +export const SIEM_RULE_MIGRATION_CREATE_PATH = + `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id?}` as const; export const SIEM_RULE_MIGRATION_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}` as const; export const SIEM_RULE_MIGRATION_START_PATH = `${SIEM_RULE_MIGRATION_PATH}/start` as const; export const SIEM_RULE_MIGRATION_RETRY_PATH = `${SIEM_RULE_MIGRATION_PATH}/retry` as const; diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts index aa69f3b3c27f0..8a549e8e11817 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts @@ -17,19 +17,28 @@ import { z } from '@kbn/zod'; import { ArrayFromString } from '@kbn/zod-helpers'; +import { NonEmptyString } from '../../../../api/model/primitives.gen'; import { - OriginalRule, ElasticRulePartial, RuleMigrationTranslationResult, RuleMigrationComments, RuleMigrationTaskStats, + OriginalRule, RuleMigration, RuleMigrationTranslationStats, RuleMigrationResourceData, RuleMigrationResourceType, RuleMigrationResource, } from '../../rule_migration.gen'; -import { NonEmptyString, ConnectorId, LangSmithOptions } from '../../common.gen'; +import { ConnectorId, LangSmithOptions } from '../../common.gen'; + +export type CreateRuleMigrationRequestParams = z.infer; +export const CreateRuleMigrationRequestParams = z.object({ + migration_id: NonEmptyString.optional(), +}); +export type CreateRuleMigrationRequestParamsInput = z.input< + typeof CreateRuleMigrationRequestParams +>; export type CreateRuleMigrationRequestBody = z.infer; export const CreateRuleMigrationRequestBody = z.array(OriginalRule); diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml index a062b75d41699..8b9d264cf4104 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml @@ -4,38 +4,7 @@ info: version: '1' paths: # Rule migrations APIs - /internal/siem_migrations/rules: - post: - summary: Creates a new rule migration - operationId: CreateRuleMigration - x-codegen-enabled: true - x-internal: true - description: Creates a new SIEM rules migration using the original vendor rules provided - tags: - - SIEM Rule Migrations - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - $ref: '../../rule_migration.schema.yaml#/components/schemas/OriginalRule' - responses: - 200: - description: Indicates migration have been created correctly. - content: - application/json: - schema: - type: object - required: - - migration_id - properties: - migration_id: - description: The migration id created. - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' - put: summary: Updates rules migrations operationId: UpdateRuleMigration @@ -57,7 +26,7 @@ paths: properties: id: description: The rule migration id - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' elastic_rule: description: The migrated elastic rule attributes to update. $ref: '../../rule_migration.schema.yaml#/components/schemas/ElasticRulePartial' @@ -81,95 +50,64 @@ paths: type: boolean description: Indicates rules migrations have been updated. - /internal/siem_migrations/rules/{migration_id}/install: - post: - summary: Installs translated migration rules - operationId: InstallMigrationRules + /internal/siem_migrations/rules/stats: + get: + summary: Retrieves the stats for all rule migrations + operationId: GetAllStatsRuleMigration x-codegen-enabled: true - description: Installs migration rules + x-internal: true + description: Retrieves the rule migrations stats for all migrations stored in the system tags: - SIEM Rule Migrations - parameters: - - name: migration_id - in: path - required: true - schema: - description: The migration id to isnstall rules for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - description: The rule migration id - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' responses: 200: - description: Indicates rules migrations have been installed correctly. + description: Indicates rule migrations have been retrieved correctly. content: application/json: schema: - type: object - required: - - installed - properties: - installed: - type: boolean - description: Indicates rules migrations have been installed. + type: array + items: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationTaskStats' - /internal/siem_migrations/rules/{migration_id}/install_translated: + ## Specific rule migration APIs + + /internal/siem_migrations/rules/{migration_id}: post: - summary: Installs all translated migration rules - operationId: InstallTranslatedMigrationRules + summary: Creates a new rule migration + operationId: CreateRuleMigration x-codegen-enabled: true - description: Installs all translated migration rules + x-internal: true + description: Creates a new SIEM rules migration using the original vendor rules provided tags: - SIEM Rule Migrations parameters: - name: migration_id in: path - required: true + required: false schema: - description: The migration id to install translated rules for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + description: The migration id to create rules for + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '../../rule_migration.schema.yaml#/components/schemas/OriginalRule' responses: 200: - description: Indicates rules migrations have been installed correctly. + description: Indicates migration have been created correctly. content: application/json: schema: type: object required: - - installed + - migration_id properties: - installed: - type: boolean - description: Indicates rules migrations have been installed. - - /internal/siem_migrations/rules/stats: - get: - summary: Retrieves the stats for all rule migrations - operationId: GetAllStatsRuleMigration - x-codegen-enabled: true - x-internal: true - description: Retrieves the rule migrations stats for all migrations stored in the system - tags: - - SIEM Rule Migrations - responses: - 200: - description: Indicates rule migrations have been retrieved correctly. - content: - application/json: - schema: - type: array - items: - $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationTaskStats' - - ## Specific rule migration APIs - - /internal/siem_migrations/rules/{migration_id}: + migration_id: + description: The migration id created. + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' get: summary: Retrieves all the rules of a migration operationId: GetRuleMigration @@ -184,7 +122,7 @@ paths: required: true schema: description: The migration id to start - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - name: page in: query required: false @@ -222,6 +160,73 @@ paths: 204: description: Indicates the migration id was not found. + /internal/siem_migrations/rules/{migration_id}/install: + post: + summary: Installs translated migration rules + operationId: InstallMigrationRules + x-codegen-enabled: true + description: Installs migration rules + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + description: The migration id to install rules for + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + description: The rule migration id + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + responses: + 200: + description: Indicates rules migrations have been installed correctly. + content: + application/json: + schema: + type: object + required: + - installed + properties: + installed: + type: boolean + description: Indicates rules migrations have been installed. + + /internal/siem_migrations/rules/{migration_id}/install_translated: + post: + summary: Installs all translated migration rules + operationId: InstallTranslatedMigrationRules + x-codegen-enabled: true + description: Installs all translated migration rules + tags: + - SIEM Rule Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + description: The migration id to install translated rules for + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + responses: + 200: + description: Indicates rules migrations have been installed correctly. + content: + application/json: + schema: + type: object + required: + - installed + properties: + installed: + type: boolean + description: Indicates rules migrations have been installed. + /internal/siem_migrations/rules/{migration_id}/start: put: summary: Starts a rule migration @@ -237,7 +242,7 @@ paths: required: true schema: description: The migration id to start - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' requestBody: required: true content: @@ -282,7 +287,7 @@ paths: required: true schema: description: The migration id to fetch stats for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' responses: 200: description: Indicates the migration stats has been retrieved correctly. @@ -307,7 +312,7 @@ paths: required: true schema: description: The migration id to fetch translation stats for - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' responses: 200: description: Indicates the migration stats has been retrieved correctly. @@ -333,7 +338,7 @@ paths: required: true schema: description: The migration id to stop - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' responses: 200: description: Indicates migration task stop has been processed successfully. @@ -368,7 +373,7 @@ paths: required: true schema: description: The migration id to attach the resources - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' requestBody: required: true content: @@ -406,7 +411,7 @@ paths: required: true schema: description: The migration id to attach the resources - $ref: '../../common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - name: type in: query required: false diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts index 9b1d0756c3a3b..c6d0959cc10cf 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/common.gen.ts @@ -16,15 +16,6 @@ import { z } from '@kbn/zod'; -/** - * A string that is not empty and does not contain only whitespace - */ -export type NonEmptyString = z.infer; -export const NonEmptyString = z - .string() - .min(1) - .regex(/^(?! *$).+$/); - /** * The GenAI connector id to use. */ diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml index a50225df778ad..14a5160427f8a 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/common.schema.yaml @@ -6,11 +6,6 @@ paths: {} components: x-codegen-enabled: true schemas: - NonEmptyString: - type: string - pattern: ^(?! *$).+$ - minLength: 1 - description: A string that is not empty and does not contain only whitespace ConnectorId: type: string description: The GenAI connector id to use. diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index 61706077d9549..b52cdb1c91f19 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -16,7 +16,7 @@ import { z } from '@kbn/zod'; -import { NonEmptyString } from './common.gen'; +import { NonEmptyString } from '../../api/model/primitives.gen'; /** * The original rule vendor identifier. @@ -24,6 +24,19 @@ import { NonEmptyString } from './common.gen'; export type OriginalRuleVendor = z.infer; export const OriginalRuleVendor = z.literal('splunk'); +/** + * The original rule annotations containing additional information. + */ +export type OriginalRuleAnnotations = z.infer; +export const OriginalRuleAnnotations = z + .object({ + /** + * The original rule Mitre Attack IDs. + */ + mitre_attack: z.array(z.string()).optional(), + }) + .catchall(z.unknown()); + /** * The original rule to migrate. */ @@ -40,7 +53,7 @@ export const OriginalRule = z.object({ /** * The original rule name. */ - title: z.string(), + title: NonEmptyString, /** * The original rule description. */ @@ -48,15 +61,15 @@ export const OriginalRule = z.object({ /** * The original rule query. */ - query: z.string(), + query: z.string().min(1), /** * The original rule query language. */ query_language: z.string(), /** - * The original rule Mitre Attack technique IDs. + * The original rule annotations containing additional information. */ - mitre_attack_ids: z.array(z.string()).optional(), + annotations: OriginalRuleAnnotations.optional(), }); /** diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index fdcbb7b04515a..4c88c66fc604d 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -12,6 +12,17 @@ components: enum: - splunk + OriginalRuleAnnotations: + type: object + description: The original rule annotations containing additional information. + additionalProperties: true + properties: + mitre_attack: + type: array + description: The original rule Mitre Attack IDs. + items: + type: string + OriginalRule: type: object description: The original rule to migrate. @@ -25,27 +36,26 @@ components: properties: id: description: The original rule id. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' vendor: description: The original rule vendor identifier. $ref: '#/components/schemas/OriginalRuleVendor' title: - type: string description: The original rule name. + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' description: type: string description: The original rule description. query: type: string + minLength: 1 description: The original rule query. query_language: type: string description: The original rule query language. - mitre_attack_ids: - type: array - items: - type: string - description: The original rule Mitre Attack technique IDs. + annotations: + description: The original rule annotations containing additional information. + $ref: '#/components/schemas/OriginalRuleAnnotations' ElasticRule: type: object @@ -72,7 +82,7 @@ components: - esql prebuilt_rule_id: description: The Elastic prebuilt rule id matched. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' integration_ids: type: array items: @@ -80,7 +90,7 @@ components: description: The Elastic integration IDs related to the rule. id: description: The Elastic rule id installed as a result. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' ElasticRulePartial: description: The partial version of the migrated elastic rule. @@ -96,7 +106,7 @@ components: properties: id: description: The rule migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - $ref: '#/components/schemas/RuleMigrationData' RuleMigrationData: @@ -114,10 +124,10 @@ components: description: The moment of creation migration_id: description: The migration id. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' created_by: description: The username of the user who created the migration. - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' original_rule: description: The original rule to migrate. $ref: '#/components/schemas/OriginalRule' @@ -153,7 +163,7 @@ components: properties: id: description: The migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' status: description: Indicates if the migration task status. $ref: '#/components/schemas/RuleMigrationTaskStatus' @@ -207,7 +217,7 @@ components: properties: id: description: The migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' rules: type: object description: The rules migration translation stats. @@ -293,10 +303,10 @@ components: properties: id: description: The rule resource migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' migration_id: description: The migration id - $ref: './common.schema.yaml#/components/schemas/NonEmptyString' + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' updated_at: type: string description: The moment of the last update diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts index 93690f98b48e8..59d11314171a6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/body_config.ts @@ -13,6 +13,7 @@ import { rulesCardConfig } from './cards/rules'; import { alertsCardConfig } from './cards/alerts'; import { assistantCardConfig } from './cards/assistant'; import { aiConnectorCardConfig } from './cards/siem_migrations/ai_connector'; +import { startMigrationCardConfig } from './cards/siem_migrations/start_migration'; export const defaultBodyConfig: OnboardingGroupConfig[] = [ { @@ -43,4 +44,10 @@ export const siemMigrationsBodyConfig: OnboardingGroupConfig[] = [ }), cards: [aiConnectorCardConfig], }, + { + title: i18n.translate('xpack.securitySolution.onboarding.migrate.title', { + defaultMessage: 'Migrate rules & add data', + }), + cards: [startMigrationCardConfig], + }, ]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx index be1b01fc77081..7f3ba00593fc0 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx @@ -17,7 +17,7 @@ export const OnboardingCardContentPanel = React.memo - + {children} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx index 127e6b4d57ebd..e42834e85d488 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/ai_connector_card.tsx @@ -6,19 +6,14 @@ */ import React, { useCallback } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiText, - useEuiTheme, - COLOR_MODES_STANDARD, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useKibana } from '../../../../../../common/lib/kibana/kibana_react'; import { useDefinedLocalStorage } from '../../../../hooks/use_stored_state'; import type { OnboardingCardComponent } from '../../../../../types'; import * as i18n from './translations'; import { OnboardingCardContentPanel } from '../../common/card_content_panel'; import { ConnectorCards } from '../../common/connectors/connector_cards'; +import { CardSubduedText } from '../../common/card_subdued_text'; import type { AIConnectorCardMetadata } from './types'; import { MissingPrivilegesCallOut } from '../../common/connectors/missing_privileges'; @@ -28,9 +23,6 @@ export const AIConnectorCard: OnboardingCardComponent = setComplete, }) => { const { siemMigrations } = useKibana().services; - const { euiTheme, colorMode } = useEuiTheme(); - const isDarkMode = colorMode === COLOR_MODES_STANDARD.dark; - const [storedConnectorId, setStoredConnectorId] = useDefinedLocalStorage( siemMigrations.rules.connectorIdStorage.key, null @@ -48,18 +40,11 @@ export const AIConnectorCard: OnboardingCardComponent = const canCreateConnectors = checkCompleteMetadata?.canCreateConnectors; return ( - + {canExecuteConnectors ? ( - - {i18n.AI_CONNECTOR_CARD_DESCRIPTION} - + {i18n.AI_CONNECTOR_CARD_DESCRIPTION} = async ({ http, application, siemMigrations }) => { let isComplete = false; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts index 45080123889d5..d0b32eb1bd638 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/ai_connector/index.ts @@ -6,11 +6,11 @@ */ import React from 'react'; -import { AssistantAvatar } from '@kbn/elastic-assistant'; +import { AssistantAvatar } from '@kbn/elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar'; import type { OnboardingCardConfig } from '../../../../../types'; import { OnboardingCardId } from '../../../../../constants'; import { AI_CONNECTOR_CARD_TITLE } from './translations'; -import { checkAssistantCardComplete } from './connectors_check_complete'; +import { checkAiConnectorsCardComplete } from './connectors_check_complete'; import type { AIConnectorCardMetadata } from './types'; export const aiConnectorCardConfig: OnboardingCardConfig = { @@ -24,6 +24,6 @@ export const aiConnectorCardConfig: OnboardingCardConfig void; + closeFlyout: () => void; +} + +const StartMigrationContext = createContext(null); + +export const StartMigrationContextProvider: React.FC< + PropsWithChildren +> = React.memo(({ children, openFlyout, closeFlyout }) => { + const value = useMemo( + () => ({ openFlyout, closeFlyout }), + [openFlyout, closeFlyout] + ); + return {children}; +}); +StartMigrationContextProvider.displayName = 'StartMigrationContextProvider'; + +export const useStartMigrationContext = (): StartMigrationContextValue => { + const context = useContext(StartMigrationContext); + if (context == null) { + throw new Error('useStartMigrationContext must be used within a StartMigrationContextProvider'); + } + return context; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/images/card_header_icon.png b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/images/card_header_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b2b4848e0be1d7442fb00b012629861feec095cf GIT binary patch literal 1251 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$ zkh>GZx^prwfgF}}M_)$E)e-c@N+zL+@$B+ufw{znCq8vqzo#)w-S+mJ{f%OH^ z4(<$~ke$5vK{HX@K3 zuds_=_^he=`P&X13%_5lvov&9;ClI(!z~7aOi!+v8*47*W6awW_+8Wg-QC9R+b8`G zsa8DlPKCpzqaOTzH zfz@{{UpR#^e15{sm?|M?%>LA$$&>NSEdB>m6&1gzC#K(>KUq$N)uZuNXGGbpUn}p4 zI_b@F&i||&oE7K(v--%C&E@~p{P>HQe)#VXYJCt`_}A&g&&yR?YohEkzOJ7qQ@h({ zbvU=I;{6%bP4TNYsr9lZO%PVl-4k#;9Ov{ zQN%_mtMF5ma#P%*Z_Llbk{CN23-|+y9m7+#w(@VBxOT!$V+WqD*#!@*YKy)+6-xT_ z{oe#RVflH1qfjB{*sQ&DVx_|3)i-%-8IqeM zFIfe!t}l*{I4QonUh{m3t>UBqzPFw=uyHsU@qS$$*>Hp@?b)I#E4OWY7Pjlce69<- z9XXTujA_vXy(2!?gYEa;Hi-Fm&uqPEF+<{s@I%jDUd?^>|LgDh3qJpyc7L1NpC{jE za)i4q-d!hNGAr`i?W+ z5g0&;j0n{E$Eb6kwB?zFYD>2FK61OBl`6q)L zJ^e3aQ{q|gr{6j8ue@g4l?%8%}rcAO}inZM(Q}a&?ZuIq+ob9#I*!EaC zwpf|J@9vKozBe?@KM6$i@&jEvaox^|?8k~~mSpEWY1}9y|8kaeWkAkx_g&B2n)7Iojc`SCjMhJ!QCGXvf9opt9EH#xT1 zA3t6Wn11KP;RItl{?Z2RyeEkpMdz!x&fv~_61YKde)`l8TXs)$SAF@z<#tesl + import( + /* webpackChunkName: "onboarding_siem_migrations_start_migration_card" */ + './start_migration_card' + ) + ), + checkComplete: checkStartMigrationCardComplete, + licenseTypeRequired: 'enterprise', +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/missing_ai_connector_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/missing_ai_connector_callout.tsx new file mode 100644 index 0000000000000..324dd405d5141 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/missing_ai_connector_callout.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink } from '@elastic/eui'; +import { OnboardingCardContentPanel } from '../../common/card_content_panel'; +import { CardCallOut } from '../../common/card_callout'; +import * as i18n from './translations'; + +interface MissingAIConnectorCalloutProps { + onExpandAiConnectorsCard: () => void; +} + +export const MissingAIConnectorCallout = React.memo( + ({ onExpandAiConnectorsCard }) => ( + + + + {i18n.START_MIGRATION_CARD_CONNECTOR_MISSING_BUTTON} + + + + + + } + /> + + ) +); +MissingAIConnectorCallout.displayName = 'MissingAIConnectorCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_progress_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_progress_panel.tsx new file mode 100644 index 0000000000000..0527e1cfbdf17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_progress_panel.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiPanel, EuiProgress } from '@elastic/eui'; +import type { RuleMigrationStats } from '../../../../../../../siem_migrations/rules/types'; +import * as i18n from '../translations'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; + +export interface MigrationProgressPanelProps { + migrationStats: RuleMigrationStats; +} +export const MigrationProgressPanel = React.memo( + ({ migrationStats }) => { + const progressValue = useMemo(() => { + const finished = migrationStats.rules.completed + migrationStats.rules.failed; + return (finished / migrationStats.rules.total) * 100; + }, [migrationStats.rules]); + + return ( + + + + +

{i18n.START_MIGRATION_CARD_MIGRATION_TITLE(migrationStats.number)}

+
+
+ + +

{i18n.START_MIGRATION_CARD_PROGRESS_DESCRIPTION}

+
+
+ + + +
+
+ ); + } +); +MigrationProgressPanel.displayName = 'MigrationProgressPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_ready_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_ready_panel.tsx new file mode 100644 index 0000000000000..8603511fa2d6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_ready_panel.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButton, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; +import { useStartMigration } from '../../../../../../../siem_migrations/rules/service/hooks/use_start_migration'; +import type { RuleMigrationStats } from '../../../../../../../siem_migrations/rules/types'; +import * as i18n from '../translations'; +import { useStartMigrationContext } from '../context'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; + +export interface MigrationReadyPanelProps { + migrationStats: RuleMigrationStats; +} +export const MigrationReadyPanel = React.memo(({ migrationStats }) => { + const { openFlyout } = useStartMigrationContext(); + const onOpenFlyout = useCallback(() => { + openFlyout(migrationStats); + }, [openFlyout, migrationStats]); + + const { startMigration, isLoading } = useStartMigration(); + const onStartMigration = useCallback(() => { + startMigration(migrationStats.id); + }, [migrationStats.id, startMigration]); + + return ( + + + + + + +

{i18n.START_MIGRATION_CARD_MIGRATION_TITLE(migrationStats.number)}

+
+
+ + +

{i18n.START_MIGRATION_CARD_MIGRATION_READY_DESCRIPTION}

+
+
+
+
+ + + {i18n.START_MIGRATION_CARD_TRANSLATE_BUTTON} + + + + + {i18n.START_MIGRATION_CARD_UPLOAD_MACROS_BUTTON} + + +
+
+ ); +}); +MigrationReadyPanel.displayName = 'MigrationReadyPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_result_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_result_panel.tsx new file mode 100644 index 0000000000000..b73b3cc8b4921 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/migration_result_panel.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import moment from 'moment'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiPanel, + EuiHorizontalRule, + EuiIcon, +} from '@elastic/eui'; +import { SecurityPageName } from '@kbn/security-solution-navigation'; +import { AssistantAvatar } from '@kbn/elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar'; +import { SecuritySolutionLinkButton } from '../../../../../../../common/components/links'; +import type { RuleMigrationStats } from '../../../../../../../siem_migrations/rules/types'; +import * as i18n from '../translations'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; + +export interface MigrationResultPanelProps { + migrationStats: RuleMigrationStats; +} +export const MigrationResultPanel = React.memo(({ migrationStats }) => { + return ( + + + + + +

{i18n.START_MIGRATION_CARD_RESULT_TITLE(migrationStats.number)}

+
+
+ + +

+ {i18n.START_MIGRATION_CARD_RESULT_DESCRIPTION( + moment(migrationStats.created_at).format('MMMM Do YYYY, h:mm:ss a'), + moment(migrationStats.last_updated_at).fromNow() + )} +

+
+
+
+
+ + + + + + + + + + +

{i18n.VIEW_TRANSLATED_RULES_TITLE}

+
+
+
+
+ + + + +

{'TODO: chart'}

+
+ + + {i18n.VIEW_TRANSLATED_RULES_BUTTON} + + +
+
+
+
+
+
+ ); +}); +MigrationResultPanel.displayName = 'MigrationResultPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.styles.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.styles.ts new file mode 100644 index 0000000000000..0aef40dfeb442 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.styles.ts @@ -0,0 +1,19 @@ +/* + * 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 { css } from '@emotion/css'; + +export const useStyles = (compressed: boolean) => { + const logoSize = compressed ? '32px' : '88px'; + return css` + .siemMigrationsIcon { + width: ${logoSize}; + block-size: ${logoSize}; + inline-size: ${logoSize}; + } + `; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.tsx new file mode 100644 index 0000000000000..edcff3646c5aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/panels/upload_rules_panel.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiIcon, + EuiButton, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; +import { SiemMigrationsIcon } from '../../../../../../../siem_migrations/common/icon'; +import * as i18n from '../translations'; +import { useStartMigrationContext } from '../context'; +import { TITLE_CLASS_NAME } from '../start_migration_card.styles'; +import { useStyles } from './upload_rules_panel.styles'; + +export interface UploadRulesPanelProps { + isUploadMore?: boolean; +} +export const UploadRulesPanel = React.memo(({ isUploadMore = false }) => { + const styles = useStyles(isUploadMore); + const { openFlyout } = useStartMigrationContext(); + const onOpenFlyout = useCallback(() => { + openFlyout(); + }, [openFlyout]); + + return ( + + + + + + + {isUploadMore ? ( + +

{i18n.START_MIGRATION_CARD_UPLOAD_MORE_TITLE}

+
+ ) : ( + + + +

{i18n.START_MIGRATION_CARD_UPLOAD_TITLE}

+
+
+ + +

{i18n.START_MIGRATION_CARD_UPLOAD_DESCRIPTION}

+
+
+ + +

{i18n.START_MIGRATION_CARD_UPLOAD_READ_MORE}

+
+
+
+ )} +
+ + {isUploadMore ? ( + + {i18n.START_MIGRATION_CARD_UPLOAD_MORE_BUTTON} + + ) : ( + + {i18n.START_MIGRATION_CARD_UPLOAD_BUTTON} + + )} + +
+
+ ); +}); +UploadRulesPanel.displayName = 'UploadRulesPanel'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.styles.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.styles.ts new file mode 100644 index 0000000000000..82446ba308402 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.styles.ts @@ -0,0 +1,20 @@ +/* + * 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 { css } from '@emotion/css'; +import { useEuiTheme } from '@elastic/eui'; + +export const TITLE_CLASS_NAME = 'siemMigrationsStartTitle'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + return css` + .${TITLE_CLASS_NAME} { + font-weight: ${euiTheme.font.weight.semiBold}; + } + `; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.tsx new file mode 100644 index 0000000000000..c1e7539c8e101 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_card.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiSpacer, EuiText } from '@elastic/eui'; +import { OnboardingCardId } from '../../../../../constants'; +import type { RuleMigrationTaskStats } from '../../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { useLatestStats } from '../../../../../../siem_migrations/rules/service/hooks/use_latest_stats'; +import { MigrationDataInputFlyout } from '../../../../../../siem_migrations/rules/components/data_input_flyout'; +import { CenteredLoadingSpinner } from '../../../../../../common/components/centered_loading_spinner'; +import type { OnboardingCardComponent } from '../../../../../types'; +import { OnboardingCardContentPanel } from '../../common/card_content_panel'; +import { UploadRulesPanels } from './upload_rules_panels'; +import { StartMigrationContextProvider } from './context'; +import { useStyles } from './start_migration_card.styles'; +import * as i18n from './translations'; +import { MissingAIConnectorCallout } from './missing_ai_connector_callout'; + +export const StartMigrationCard: OnboardingCardComponent = React.memo( + ({ checkComplete, isCardComplete, setExpandedCardId }) => { + const styles = useStyles(); + const { data: migrationsStats, isLoading } = useLatestStats(); + + const [isFlyoutOpen, setIsFlyoutOpen] = useState(); + const [flyoutMigrationStats, setFlyoutMigrationStats] = useState< + RuleMigrationTaskStats | undefined + >(); + + const closeFlyout = useCallback(() => { + setIsFlyoutOpen(false); + setFlyoutMigrationStats(undefined); + if (!isCardComplete(OnboardingCardId.siemMigrationsStart)) { + checkComplete(); + } + }, [checkComplete, isCardComplete]); + + const openFlyout = useCallback((migrationStats?: RuleMigrationTaskStats) => { + setFlyoutMigrationStats(migrationStats); + setIsFlyoutOpen(true); + }, []); + + if (!isCardComplete(OnboardingCardId.siemMigrationsAiConnectors)) { + return ( + + setExpandedCardId(OnboardingCardId.siemMigrationsAiConnectors) + } + /> + ); + } + + return ( + + + {isLoading ? ( + + ) : ( + + )} + + +

{i18n.START_MIGRATION_CARD_FOOTER_NOTE}

+
+
+ {isFlyoutOpen && ( + + )} +
+ ); + } +); +StartMigrationCard.displayName = 'StartMigrationCard'; + +// eslint-disable-next-line import/no-default-export +export default StartMigrationCard; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_check_complete.ts new file mode 100644 index 0000000000000..41e65352d4bc3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/start_migration_check_complete.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { OnboardingCardCheckComplete } from '../../../../../types'; + +export const checkStartMigrationCardComplete: OnboardingCardCheckComplete = async ({ + siemMigrations, +}) => { + const migrationsStats = await siemMigrations.rules.getRuleMigrationsStats(); + const isComplete = migrationsStats.length > 0; + return isComplete; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/translations.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/translations.ts new file mode 100644 index 0000000000000..bdb3f31842549 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/translations.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const START_MIGRATION_CARD_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.title', + { defaultMessage: 'Translate your existing SIEM Rules to Elastic' } +); +export const START_MIGRATION_CARD_FOOTER_NOTE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.footerNote', + { + defaultMessage: + 'Splunk and related marks are trademarks or registered trademarks of Splunk LLC in the United States and other countries.', + } +); +export const START_MIGRATION_CARD_CONNECTOR_MISSING_TEXT = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.connectorMissingText', + { + defaultMessage: 'Rule migrations require an AI connector to be configured.', + } +); +export const START_MIGRATION_CARD_CONNECTOR_MISSING_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.connectorMissingText', + { defaultMessage: 'AI provider step' } +); + +export const START_MIGRATION_CARD_UPLOAD_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.title', + { defaultMessage: 'Export your Splunk® SIEM rules to start translation.' } +); + +export const START_MIGRATION_CARD_UPLOAD_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.description', + { + defaultMessage: + 'Upload your rules before importing data to identify the integrations, data streams, and available details of your SIEM rules. Click “Upload Rules” to view step-by-step instructions to export and uploading the rules.', + } +); + +export const START_MIGRATION_CARD_UPLOAD_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.button', + { defaultMessage: 'Upload rules' } +); + +export const START_MIGRATION_CARD_UPLOAD_MORE_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.uploadMore.title', + { defaultMessage: 'Need to migrate more rules?' } +); +export const START_MIGRATION_CARD_UPLOAD_MORE_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.uploadMore.button', + { defaultMessage: 'Upload more rules' } +); + +export const START_MIGRATION_CARD_UPLOAD_READ_MORE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.readMore', + { defaultMessage: 'Read more about our AI powered translations and other features.' } +); + +export const START_MIGRATION_CARD_UPLOAD_READ_DOCS = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.upload.readAiDocsLink', + { defaultMessage: 'Read AI docs' } +); + +export const START_MIGRATION_CARD_MIGRATION_READY_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.ready.description', + { + defaultMessage: + 'Migration is created and ready but the translation has not started yet. You can either upload macros & lookups or start the translation process', + } +); +export const START_MIGRATION_CARD_TRANSLATE_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.translate.button', + { defaultMessage: 'Start translation' } +); +export const START_MIGRATION_CARD_UPLOAD_MACROS_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.uploadMacros.button', + { defaultMessage: 'Upload macros' } +); + +export const START_MIGRATION_CARD_MIGRATION_TITLE = (number: number) => + i18n.translate('xpack.securitySolution.onboarding.startMigration.migrationTitle', { + defaultMessage: 'SIEM rules migration #{number}', + values: { number }, + }); + +export const START_MIGRATION_CARD_PROGRESS_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.progress.description', + { + defaultMessage: `This may take a few minutes & the task will work in the background. Just stay logged in and we'll notify you when done.`, + } +); + +export const START_MIGRATION_CARD_RESULT_TITLE = (number: number) => + i18n.translate('xpack.securitySolution.onboarding.startMigration.result.title', { + defaultMessage: 'SIEM rules migration #{number} complete', + values: { number }, + }); + +export const START_MIGRATION_CARD_RESULT_DESCRIPTION = (createdAt: string, finishedAt: string) => + i18n.translate('xpack.securitySolution.onboarding.startMigration.result.description', { + defaultMessage: 'Export uploaded on {createdAt} and translation finished {finishedAt}.', + values: { createdAt, finishedAt }, + }); + +export const VIEW_TRANSLATED_RULES_TITLE = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.result.translatedRules.title', + { defaultMessage: 'Translation Summary' } +); + +export const VIEW_TRANSLATED_RULES_BUTTON = i18n.translate( + 'xpack.securitySolution.onboarding.startMigration.result.translatedRules.button', + { defaultMessage: 'View translated rules' } +); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/upload_rules_panels.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/upload_rules_panels.tsx new file mode 100644 index 0000000000000..95b53d921fd1f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/upload_rules_panels.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { SiemMigrationTaskStatus } from '../../../../../../../common/siem_migrations/constants'; +import type { RuleMigrationStats } from '../../../../../../siem_migrations/rules/types'; +import { UploadRulesPanel } from './panels/upload_rules_panel'; +import { MigrationProgressPanel } from './panels/migration_progress_panel'; +import { MigrationResultPanel } from './panels/migration_result_panel'; +import { MigrationReadyPanel } from './panels/migration_ready_panel'; + +export interface UploadRulesPanelsProps { + migrationsStats: RuleMigrationStats[]; +} +export const UploadRulesPanels = React.memo(({ migrationsStats }) => { + if (migrationsStats.length === 0) { + return ; + } + + return ( + + + + + {migrationsStats.map((migrationStats) => ( + + {migrationStats.status === SiemMigrationTaskStatus.READY && ( + + )} + {migrationStats.status === SiemMigrationTaskStatus.RUNNING && ( + + )} + {migrationStats.status === SiemMigrationTaskStatus.FINISHED && ( + + )} + + ))} + + ); +}); +UploadRulesPanels.displayName = 'UploadRulesPanels'; diff --git a/x-pack/plugins/security_solution/public/onboarding/constants.ts b/x-pack/plugins/security_solution/public/onboarding/constants.ts index e360e4591bb37..94b87721513bc 100644 --- a/x-pack/plugins/security_solution/public/onboarding/constants.ts +++ b/x-pack/plugins/security_solution/public/onboarding/constants.ts @@ -21,4 +21,5 @@ export enum OnboardingCardId { // siem_migrations topic cards siemMigrationsAiConnectors = 'ai_connectors', + siemMigrationsStart = 'start', } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/common/icon/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/index.tsx new file mode 100644 index 0000000000000..c0528a9a04afe --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import SiemMigrationsIconSVG from './siem_migrations.svg'; +export const SiemMigrationsIcon = SiemMigrationsIconSVG; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/common/icon/siem_migrations.svg b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/siem_migrations.svg new file mode 100644 index 0000000000000..e8568a943f70c --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/common/icon/siem_migrations.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts index 592b93f438197..db6f0117d4a77 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/api/index.ts @@ -7,161 +7,188 @@ import { replaceParams } from '@kbn/openapi-common/shared'; +import type { LangSmithOptions } from '../../../../common/siem_migrations/model/common.gen'; import { KibanaServices } from '../../../common/lib/kibana'; import { + SIEM_RULE_MIGRATIONS_PATH, SIEM_RULE_MIGRATIONS_ALL_STATS_PATH, SIEM_RULE_MIGRATION_INSTALL_TRANSLATED_PATH, SIEM_RULE_MIGRATION_INSTALL_PATH, SIEM_RULE_MIGRATION_PATH, SIEM_RULE_MIGRATION_START_PATH, + SIEM_RULE_MIGRATION_STATS_PATH, SIEM_RULE_MIGRATION_TRANSLATION_STATS_PATH, } from '../../../../common/siem_migrations/constants'; import type { + CreateRuleMigrationRequestBody, + CreateRuleMigrationResponse, GetAllStatsRuleMigrationResponse, GetRuleMigrationResponse, GetRuleMigrationTranslationStatsResponse, InstallTranslatedMigrationRulesResponse, InstallMigrationRulesResponse, StartRuleMigrationRequestBody, + GetRuleMigrationStatsResponse, } from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; -/** - * Retrieves the stats for all the existing migrations, aggregated by `migration_id`. - * - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ +export interface GetRuleMigrationStatsParams { + /** `id` of the migration to get stats for */ + migrationId: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Retrieves the stats for all the existing migrations, aggregated by `migration_id`. */ +export const getRuleMigrationStats = async ({ + migrationId, + signal, +}: GetRuleMigrationStatsParams): Promise => { + return KibanaServices.get().http.get( + replaceParams(SIEM_RULE_MIGRATION_STATS_PATH, { migration_id: migrationId }), + { version: '1', signal } + ); +}; + +export interface GetRuleMigrationsStatsAllParams { + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Retrieves the stats for all the existing migrations, aggregated by `migration_id`. */ export const getRuleMigrationsStatsAll = async ({ signal, -}: { - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.fetch( +}: GetRuleMigrationsStatsAllParams = {}): Promise => { + return KibanaServices.get().http.get( SIEM_RULE_MIGRATIONS_ALL_STATS_PATH, - { method: 'GET', version: '1', signal } + { version: '1', signal } ); }; -/** - * Starts a new migration with the provided rules. - * - * @param migrationId `id` of the migration to start - * @param body The body containing the `connectorId` to use for the migration - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ -export const startRuleMigration = async ({ +export interface CreateRuleMigrationParams { + /** Optional `id` of migration to add the rules to. + * The id is necessary only for batching the migration creation in multiple requests */ + migrationId?: string; + /** The body containing the `connectorId` to use for the migration */ + body: CreateRuleMigrationRequestBody; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Starts a new migration with the provided rules. */ +export const createRuleMigration = async ({ migrationId, body, signal, -}: { - migrationId: string; - body: StartRuleMigrationRequestBody; - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.put( - replaceParams(SIEM_RULE_MIGRATION_START_PATH, { migration_id: migrationId }), +}: CreateRuleMigrationParams): Promise => { + return KibanaServices.get().http.post( + `${SIEM_RULE_MIGRATIONS_PATH}${migrationId ? `/${migrationId}` : ''}`, { body: JSON.stringify(body), version: '1', signal } ); }; -/** - * Retrieves the translation stats for the migraion. - * - * @param migrationId `id` of the migration to retrieve translation stats for - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ -export const getRuleMigrationTranslationStats = async ({ +export interface StartRuleMigrationParams { + /** `id` of the migration to start */ + migrationId: string; + /** The connector id to use for the migration */ + connectorId: string; + /** Optional LangSmithOptions to use for the for the migration */ + langSmithOptions?: LangSmithOptions; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Starts a new migration with the provided rules. */ +export const startRuleMigration = async ({ migrationId, + connectorId, + langSmithOptions, signal, -}: { - migrationId: string; - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.fetch( - replaceParams(SIEM_RULE_MIGRATION_TRANSLATION_STATS_PATH, { migration_id: migrationId }), - { - method: 'GET', - version: '1', - signal, - } +}: StartRuleMigrationParams): Promise => { + const body: StartRuleMigrationRequestBody = { connector_id: connectorId }; + if (langSmithOptions) { + body.langsmith_options = langSmithOptions; + } + return KibanaServices.get().http.put( + replaceParams(SIEM_RULE_MIGRATION_START_PATH, { migration_id: migrationId }), + { body: JSON.stringify(body), version: '1', signal } ); }; -/** - * Retrieves all the migration rule documents of a specific migration. - * - * @param migrationId `id` of the migration to retrieve rule documents for - * @param signal AbortSignal for cancelling request - * - * @throws An error if response is not OK - */ +export interface GetRuleMigrationParams { + /** `id` of the migration to get rules documents for */ + migrationId: string; + /** Optional page number to retrieve */ + page?: number; + /** Optional number of documents per page to retrieve */ + perPage?: number; + /** Optional search term to filter documents */ + searchTerm?: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Retrieves all the migration rule documents of a specific migration. */ export const getRuleMigrations = async ({ migrationId, page, perPage, searchTerm, signal, -}: { - migrationId: string; - page?: number; - perPage?: number; - searchTerm?: string; - signal: AbortSignal | undefined; -}): Promise => { - return KibanaServices.get().http.fetch( +}: GetRuleMigrationParams): Promise => { + return KibanaServices.get().http.get( replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }), - { - method: 'GET', - version: '1', - query: { - page, - per_page: perPage, - search_term: searchTerm, - }, - signal, - } + { version: '1', query: { page, per_page: perPage, search_term: searchTerm }, signal } ); }; -export const installMigrationRules = async ({ +export interface GetRuleMigrationTranslationStatsParams { + /** `id` of the migration to get translation stats for */ + migrationId: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** + * Retrieves the translation stats for the migration. + */ +export const getRuleMigrationTranslationStats = async ({ migrationId, - ids, signal, -}: { +}: GetRuleMigrationTranslationStatsParams): Promise => { + return KibanaServices.get().http.get( + replaceParams(SIEM_RULE_MIGRATION_TRANSLATION_STATS_PATH, { migration_id: migrationId }), + { version: '1', signal } + ); +}; + +export interface InstallRulesParams { + /** `id` of the migration to install rules for */ migrationId: string; + /** The rule ids to install */ ids: string[]; + /** Optional AbortSignal for cancelling request */ signal?: AbortSignal; -}): Promise => { - return KibanaServices.get().http.fetch( +} +/** Installs the provided rule ids for a specific migration. */ +export const installMigrationRules = async ({ + migrationId, + ids, + signal, +}: InstallRulesParams): Promise => { + return KibanaServices.get().http.post( replaceParams(SIEM_RULE_MIGRATION_INSTALL_PATH, { migration_id: migrationId }), - { - method: 'POST', - version: '1', - body: JSON.stringify(ids), - signal, - } + { version: '1', body: JSON.stringify(ids), signal } ); }; +export interface InstallTranslatedRulesParams { + /** `id` of the migration to install rules for */ + migrationId: string; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} +/** Installs all the translated rules for a specific migration. */ export const installTranslatedMigrationRules = async ({ migrationId, signal, -}: { - migrationId: string; - signal?: AbortSignal; -}): Promise => { - return KibanaServices.get().http.fetch( +}: InstallTranslatedRulesParams): Promise => { + return KibanaServices.get().http.post( replaceParams(SIEM_RULE_MIGRATION_INSTALL_TRANSLATED_PATH, { migration_id: migrationId }), - { - method: 'POST', - version: '1', - signal, - } + { version: '1', signal } ); }; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/constants.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/constants.ts new file mode 100644 index 0000000000000..aa331bf17c832 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/constants.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. + */ + +export enum DataInputStep { + rules = 'rules', + macros = 'macros', + lookups = 'lookups', +} + +export const SPL_RULES_COLUMNS = [ + 'id', + 'title', + 'search', + 'description', + 'action.escu.eli5', + 'action.correlationsearch.annotations', +] as const; + +export const RULES_SPLUNK_QUERY = `| rest /servicesNS/-/-/saved/searches +| search action.correlationsearch.enabled = "1" OR (eai:acl.app = "Splunk_Security_Essentials" AND is_scheduled=1) +| where disabled=0 +| table ${SPL_RULES_COLUMNS.join(', ')}`; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/data_input_flyout.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/data_input_flyout.tsx new file mode 100644 index 0000000000000..6a4916a5e54b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/data_input_flyout.tsx @@ -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 React, { useCallback, useState } from 'react'; +import { + EuiFlyoutResizable, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import { DataInputStep } from './constants'; +import { RulesDataInput } from './steps/rules/rules_data_input'; +import { useStartMigration } from '../../service/hooks/use_start_migration'; + +export interface MigrationDataInputFlyoutProps { + onClose: () => void; + migrationStats?: RuleMigrationTaskStats; +} +export const MigrationDataInputFlyout = React.memo( + ({ onClose, migrationStats: initialMigrationSats }) => { + const [migrationStats, setMigrationStats] = useState( + initialMigrationSats + ); + + const { startMigration, isLoading: isStartLoading } = useStartMigration(onClose); + const onStartMigration = useCallback(() => { + if (migrationStats?.id) { + startMigration(migrationStats.id); + } + }, [migrationStats, startMigration]); + + const [dataInputStep, setDataInputStep] = useState(() => { + if (migrationStats) { + return DataInputStep.macros; + } + return DataInputStep.rules; + }); + + const onMigrationCreated = useCallback( + (createdMigrationStats: RuleMigrationTaskStats) => { + if (createdMigrationStats) { + setMigrationStats(createdMigrationStats); + setDataInputStep(DataInputStep.macros); + } + }, + [setDataInputStep] + ); + + return ( + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + +
+ ); + } +); +MigrationDataInputFlyout.displayName = 'MigrationDataInputFlyout'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/index.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/index.ts new file mode 100644 index 0000000000000..709623f992f72 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MigrationDataInputFlyout, type MigrationDataInputFlyoutProps } from './data_input_flyout'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/common/sub_step_wrapper.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/common/sub_step_wrapper.tsx new file mode 100644 index 0000000000000..438134b0ad99a --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/common/sub_step_wrapper.tsx @@ -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 { EuiPanel } from '@elastic/eui'; +import { css } from '@emotion/css'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +const style = css` + .euiStep__title { + font-size: 14px; + } +`; + +export const SubStepWrapper = React.memo>(({ children }) => { + return ( + + {children} + + ); +}); +SubStepWrapper.displayName = 'SubStepWrapper'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/rules_data_input.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/rules_data_input.tsx new file mode 100644 index 0000000000000..2b20dcda0cea7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/rules_data_input.tsx @@ -0,0 +1,95 @@ +/* + * 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 { EuiStepProps, EuiStepStatus } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiStepNumber, + EuiSteps, + EuiTitle, +} from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { SubStepWrapper } from '../common/sub_step_wrapper'; +import type { OnMigrationCreated } from '../../types'; +import { useCopyExportQueryStep } from './sub_steps/copy_export_query'; +import { useRulesFileUploadStep } from './sub_steps/rules_file_upload'; +import * as i18n from './translations'; +import { useCheckResourcesStep } from './sub_steps/check_resources'; + +type Step = 1 | 2 | 3 | 4; +const getStatus = (step: Step, currentStep: Step): EuiStepStatus => { + if (step === currentStep) { + return 'current'; + } + if (step < currentStep) { + return 'complete'; + } + return 'incomplete'; +}; + +interface RulesDataInputProps { + selected: boolean; + onMigrationCreated: OnMigrationCreated; +} + +export const RulesDataInput = React.memo( + ({ selected, onMigrationCreated }) => { + const [step, setStep] = useState(1); + + const copyStep = useCopyExportQueryStep({ + status: getStatus(1, step), + onCopied: () => setStep(2), + }); + + const uploadStep = useRulesFileUploadStep({ + status: getStatus(2, step), + onMigrationCreated: (stats) => { + onMigrationCreated(stats); + setStep(3); + }, + }); + + const resourcesStep = useCheckResourcesStep({ + status: getStatus(3, step), + onComplete: () => { + setStep(4); + }, + }); + + const steps = useMemo( + () => [copyStep, uploadStep, resourcesStep], + [copyStep, uploadStep, resourcesStep] + ); + + return ( + + + + + + + + + + {i18n.RULES_DATA_INPUT_TITLE} + + + + + + + + + + + + ); + } +); +RulesDataInput.displayName = 'RulesDataInput'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/index.tsx new file mode 100644 index 0000000000000..3b081eb203267 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiText, type EuiStepProps, type EuiStepStatus } from '@elastic/eui'; +import * as i18n from './translations'; + +export interface CheckResourcesStepProps { + status: EuiStepStatus; + onComplete: () => void; +} +export const useCheckResourcesStep = ({ + status, + onComplete, +}: CheckResourcesStepProps): EuiStepProps => { + // onComplete(); // TODO: check the resources + return { + title: i18n.RULES_DATA_INPUT_CHECK_RESOURCES_TITLE, + status, + children: ( + + {i18n.RULES_DATA_INPUT_CHECK_RESOURCES_DESCRIPTION} + + ), + }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/translations.ts new file mode 100644 index 0000000000000..159b4033fafd6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/check_resources/translations.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_CHECK_RESOURCES_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.checkResources.title', + { defaultMessage: 'Check for macros and lookups' } +); + +export const RULES_DATA_INPUT_CHECK_RESOURCES_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.checkResources.description', + { + defaultMessage: `For best translation results, we will automatically review your rules for macros and lookups and ask you to upload them. Once uploaded, we'll be able to deliver a more complete rule translation for all rules using those macros or lookups.`, + } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/copy_export_query.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/copy_export_query.tsx new file mode 100644 index 0000000000000..11fb88a1cade2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/copy_export_query.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { RULES_SPLUNK_QUERY } from '../../../../constants'; +import * as i18n from './translations'; + +interface CopyExportQueryProps { + onCopied: () => void; +} +export const CopyExportQuery = React.memo(({ onCopied }) => { + const onClick: React.MouseEventHandler = useCallback( + (ev) => { + // The only button inside the element is the "copy" button. + if ((ev.target as Element).tagName === 'BUTTON') { + onCopied(); + } + }, + [onCopied] + ); + + return ( + <> + {/* The click event is also dispatched when using the keyboard actions (space or enter) for "copy" button. + No need to use keyboard specific events, disabling the a11y lint rule:*/} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} +
+ {/* onCopy react event is dispatched when the user copies text manually */} + + {RULES_SPLUNK_QUERY} + +
+ + + {i18n.RULES_DATA_INPUT_COPY_DESCRIPTION_SECTION}, + format: {'JSON'}, + }} + /> + + + ); +}); +CopyExportQuery.displayName = 'CopyExportQuery'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/index.tsx new file mode 100644 index 0000000000000..3d2adcc78857b --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/index.tsx @@ -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 React from 'react'; +import type { EuiStepProps, EuiStepStatus } from '@elastic/eui'; +import { CopyExportQuery } from './copy_export_query'; +import * as i18n from './translations'; + +export interface CopyExportQueryStepProps { + status: EuiStepStatus; + onCopied: () => void; +} +export const useCopyExportQueryStep = ({ + status, + onCopied, +}: CopyExportQueryStepProps): EuiStepProps => { + return { + title: i18n.RULES_DATA_INPUT_COPY_TITLE, + status, + children: , + }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/translations.ts new file mode 100644 index 0000000000000..d76eb71f2e378 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/copy_export_query/translations.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_COPY_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.copyExportQuery.title', + { defaultMessage: 'Copy and export query' } +); + +export const RULES_DATA_INPUT_COPY_DESCRIPTION_SECTION = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.copyExportQuery.description.section', + { defaultMessage: 'Search and Reporting' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/index.tsx new file mode 100644 index 0000000000000..ab7838b28908b --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/index.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import type { EuiStepProps, EuiStepStatus } from '@elastic/eui'; +import type { OnMigrationCreated } from '../../../../types'; +import { RulesFileUpload } from './rules_file_upload'; +import { + useCreateMigration, + type OnSuccess, +} from '../../../../../../service/hooks/use_create_migration'; +import * as i18n from './translations'; + +export interface RulesFileUploadStepProps { + status: EuiStepStatus; + onMigrationCreated: OnMigrationCreated; +} +export const useRulesFileUploadStep = ({ + status, + onMigrationCreated, +}: RulesFileUploadStepProps): EuiStepProps => { + const [isCreated, setIsCreated] = useState(false); + const onSuccess = useCallback( + (stats) => { + setIsCreated(true); + onMigrationCreated(stats); + }, + [onMigrationCreated] + ); + const { createMigration, isLoading, error } = useCreateMigration(onSuccess); + + const uploadStepStatus = useMemo(() => { + if (isLoading) { + return 'loading'; + } + if (error) { + return 'danger'; + } + return status; + }, [isLoading, error, status]); + + return { + title: i18n.RULES_DATA_INPUT_FILE_UPLOAD_TITLE, + status: uploadStepStatus, + children: ( + + ), + }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/parse_rules_file.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/parse_rules_file.ts new file mode 100644 index 0000000000000..3d5dbb32ccde8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/parse_rules_file.ts @@ -0,0 +1,79 @@ +/* + * 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 { isPlainObject } from 'lodash/fp'; +import type { OriginalRule } from '../../../../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { SPL_RULES_COLUMNS } from '../../../../constants'; +import * as i18n from './translations'; + +type SplunkResult = Partial>; +interface SplunkRow { + result: SplunkResult; +} + +export const parseContent = (fileContent: string): OriginalRule[] => { + const trimmedContent = fileContent.trim(); + let arrayContent: SplunkRow[]; + if (trimmedContent.startsWith('[')) { + arrayContent = parseJSONArray(trimmedContent); + } else { + arrayContent = parseNDJSON(trimmedContent); + } + if (arrayContent.length === 0) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.EMPTY); + } + return arrayContent.map(convertFormat); +}; + +const parseNDJSON = (fileContent: string): SplunkRow[] => { + return fileContent + .split(/\n(?=\{)/) // split at newline followed by '{'. + .filter((entry) => entry.trim() !== '') // Remove empty entries. + .map(parseJSON); // Parse each entry as JSON. +}; + +const parseJSONArray = (fileContent: string): SplunkRow[] => { + const parsedContent = parseJSON(fileContent); + if (!Array.isArray(parsedContent)) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.NOT_ARRAY); + } + return parsedContent; +}; + +const parseJSON = (fileContent: string) => { + try { + return JSON.parse(fileContent); + } catch (error) { + if (error instanceof RangeError) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.TOO_LARGE_TO_PARSE); + } + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_PARSE); + } +}; + +const convertFormat = (row: SplunkRow): OriginalRule => { + if (!isPlainObject(row) || !isPlainObject(row.result)) { + throw new Error(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.NOT_OBJECT); + } + const originalRule: Partial = { + id: row.result.id, + vendor: 'splunk', + title: row.result.title, + query: row.result.search, + query_language: 'spl', + description: row.result['action.escu.eli5']?.trim() || row.result.description, + }; + + if (row.result['action.correlationsearch.annotations']) { + try { + originalRule.annotations = JSON.parse(row.result['action.correlationsearch.annotations']); + } catch (error) { + delete originalRule.annotations; + } + } + return originalRule as OriginalRule; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/rules_file_upload.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/rules_file_upload.tsx new file mode 100644 index 0000000000000..0f9787a4ddf68 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/rules_file_upload.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFilePicker, EuiFormRow, EuiText } from '@elastic/eui'; +import type { OriginalRule } from '../../../../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { CreateMigration } from '../../../../../../service/hooks/use_create_migration'; +import { parseContent } from './parse_rules_file'; +import * as i18n from './translations'; + +export interface RulesFileUploadProps { + createMigration: CreateMigration; + apiError?: string; + isLoading?: boolean; + isCreated?: boolean; +} +export const RulesFileUpload = React.memo( + ({ createMigration, apiError, isLoading, isCreated }) => { + const [isParsing, setIsParsing] = useState(false); + const [fileError, setFileError] = useState(); + + const onChangeFile = useCallback( + (files: FileList | null) => { + if (!files) { + return; + } + + setFileError(undefined); + + const rulesFile = files[0]; + const reader = new FileReader(); + + reader.onloadstart = () => setIsParsing(true); + reader.onloadend = () => setIsParsing(false); + + reader.onload = function (e) { + // We can safely cast to string since we call `readAsText` to load the file. + const fileContent = e.target?.result as string | undefined; + + if (fileContent == null) { + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_READ); + return; + } + + if (fileContent === '' && e.loaded > 100000) { + // V8-based browsers can't handle large files and return an empty string + // instead of an error; see https://stackoverflow.com/a/61316641 + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.TOO_LARGE_TO_PARSE); + return; + } + + let data: OriginalRule[]; + try { + data = parseContent(fileContent); + createMigration(data); + } catch (err) { + setFileError(err.message); + } + }; + + const handleReaderError = function () { + const message = reader.error?.message; + if (message) { + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_READ_WITH_REASON(message)); + } else { + setFileError(i18n.RULES_DATA_INPUT_FILE_UPLOAD_ERROR.CAN_NOT_READ); + } + }; + + reader.onerror = handleReaderError; + reader.onabort = handleReaderError; + + reader.readAsText(rulesFile); + }, + [createMigration] + ); + + const error = useMemo(() => { + if (apiError) { + return apiError; + } + return fileError; + }, [apiError, fileError]); + + return ( + + {error} + + } + isInvalid={error != null} + fullWidth + > + + + {i18n.RULES_DATA_INPUT_FILE_UPLOAD_PROMPT} + + + } + accept="application/json" + onChange={onChangeFile} + display="large" + aria-label="Upload logs sample file" + isLoading={isParsing || isLoading} + disabled={isLoading || isCreated} + data-test-subj="rulesFilePicker" + data-loading={isParsing} + /> + + ); + } +); +RulesFileUpload.displayName = 'RulesFileUpload'; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/translations.ts new file mode 100644 index 0000000000000..675eed61f4973 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/sub_steps/rules_file_upload/translations.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_FILE_UPLOAD_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.title', + { defaultMessage: 'Update your rule export' } +); +export const RULES_DATA_INPUT_FILE_UPLOAD_PROMPT = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.prompt', + { defaultMessage: 'Select or drag and drop the exported JSON file' } +); + +export const RULES_DATA_INPUT_FILE_UPLOAD_ERROR = { + CAN_NOT_READ: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.canNotRead', + { defaultMessage: 'Failed to read the rules export file' } + ), + CAN_NOT_READ_WITH_REASON: (reason: string) => + i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.canNotReadWithReason', + { + defaultMessage: 'An error occurred when reading rules export file: {reason}', + values: { reason }, + } + ), + CAN_NOT_PARSE: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.canNotParse', + { defaultMessage: 'Cannot parse the rules export file as either a JSON file' } + ), + TOO_LARGE_TO_PARSE: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.tooLargeToParse', + { defaultMessage: 'This rules export file is too large to parse' } + ), + NOT_ARRAY: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.notArray', + { defaultMessage: 'The rules export file is not an array' } + ), + EMPTY: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.empty', + { defaultMessage: 'The rules export file is empty' } + ), + NOT_OBJECT: i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.notObject', + { defaultMessage: 'The rules export file contains non-object entries' } + ), + WRONG_FORMAT: (formatError: string) => { + return i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.error.wrongFormat', + { + defaultMessage: 'The rules export file has wrong format: {formatError}', + values: { formatError }, + } + ); + }, +}; + +export const RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createSuccess', + { defaultMessage: 'Rules uploaded successfully' } +); +export const RULES_DATA_INPUT_CREATE_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createError', + { defaultMessage: 'Failed to upload rules file' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/translations.ts new file mode 100644 index 0000000000000..5446180d03a75 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/steps/rules/translations.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 { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_TITLE = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.title', + { defaultMessage: 'Upload rule export and check for macros and lookups' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/types.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/types.ts new file mode 100644 index 0000000000000..16d8f60043bcb --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/data_input_flyout/types.ts @@ -0,0 +1,10 @@ +/* + * 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 { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; + +export type OnMigrationCreated = (migrationStats: RuleMigrationTaskStats) => void; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx index 728873f046d2e..3f255a49f87c2 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/components/header_buttons/index.tsx @@ -10,13 +10,13 @@ import React, { useMemo } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import * as i18n from './translations'; -import type { RuleMigrationTask } from '../../types'; +import type { RuleMigrationStats } from '../../types'; export interface HeaderButtonsProps { /** * Available rule migrations stats */ - ruleMigrationsStats: RuleMigrationTask[]; + ruleMigrationsStats: RuleMigrationStats[]; /** * Selected rule migration id diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx index 018aa5d77559e..3877a6f46cbe7 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx @@ -21,7 +21,7 @@ import { NeedAdminForUpdateRulesCallOut } from '../../../detections/components/c import { MissingPrivilegesCallOut } from '../../../detections/components/callouts/missing_privileges_callout'; import { HeaderButtons } from '../components/header_buttons'; import { UnknownMigration } from '../components/unknown_migration'; -import { useLatestStats } from '../hooks/use_latest_stats'; +import { useLatestStats } from '../service/hooks/use_latest_stats'; type MigrationRulesPageProps = RouteComponentProps<{ migrationId?: string }>; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/common/api_request_reducer.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/common/api_request_reducer.ts new file mode 100644 index 0000000000000..a68432d48bf9c --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/common/api_request_reducer.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. + */ + +export interface State { + loading: boolean; + error: Error | null; +} +export type Action = { type: 'start' } | { type: 'error'; error: Error } | { type: 'success' }; + +export const initialState: State = { loading: false, error: null }; +export const reducer = (state: State, action: Action) => { + switch (action.type) { + case 'start': + return { loading: true, error: null }; + case 'error': + return { loading: false, error: action.error }; + case 'success': + return { loading: false, error: null }; + default: + return state; + } +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/translations.ts new file mode 100644 index 0000000000000..936bc07e6576e --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/translations.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createSuccess', + { defaultMessage: 'Rules uploaded successfully' } +); +export const RULES_DATA_INPUT_CREATE_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.dataInputFlyout.rules.rulesFileUpload.createError', + { defaultMessage: 'Failed to upload rules file' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.ts new file mode 100644 index 0000000000000..94082cf59d359 --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_create_migration.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 { useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { CreateRuleMigrationRequestBody } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; +import { reducer, initialState } from './common/api_request_reducer'; + +export const RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.createRuleSuccess', + { defaultMessage: 'Rules uploaded successfully' } +); +export const RULES_DATA_INPUT_CREATE_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.createRuleError', + { defaultMessage: 'Failed to upload rules file' } +); + +export type CreateMigration = (data: CreateRuleMigrationRequestBody) => void; +export type OnSuccess = (migrationStats: RuleMigrationTaskStats) => void; + +export const useCreateMigration = (onSuccess: OnSuccess) => { + const { siemMigrations, notifications } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const createMigration = useCallback( + (data) => { + (async () => { + try { + dispatch({ type: 'start' }); + const migrationId = await siemMigrations.rules.createRuleMigration(data); + const stats = await siemMigrations.rules.getRuleMigrationStats(migrationId); + + notifications.toasts.addSuccess(RULES_DATA_INPUT_CREATE_MIGRATION_SUCCESS); + onSuccess(stats); + dispatch({ type: 'success' }); + } catch (err) { + const apiError = err.body ?? err; + notifications.toasts.addError(apiError, { + title: RULES_DATA_INPUT_CREATE_MIGRATION_ERROR, + }); + dispatch({ type: 'error', error: apiError }); + } + })(); + }, + [siemMigrations.rules, notifications.toasts, onSuccess] + ); + + return { isLoading: state.loading, error: state.error, createMigration }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/hooks/use_latest_stats.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_latest_stats.ts similarity index 91% rename from x-pack/plugins/security_solution/public/siem_migrations/rules/hooks/use_latest_stats.ts rename to x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_latest_stats.ts index c681af0d2a21c..8b692f07eb3cb 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/hooks/use_latest_stats.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_latest_stats.ts @@ -7,7 +7,7 @@ import useObservable from 'react-use/lib/useObservable'; import { useEffect, useMemo } from 'react'; -import { useKibana } from '../../../common/lib/kibana'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; export const useLatestStats = () => { const { siemMigrations } = useKibana().services; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_start_migration.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_start_migration.ts new file mode 100644 index 0000000000000..6794439d1298e --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/hooks/use_start_migration.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 { useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; +import { reducer, initialState } from './common/api_request_reducer'; + +export const RULES_DATA_INPUT_START_MIGRATION_SUCCESS = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.startMigrationSuccess', + { defaultMessage: 'Migration started successfully.' } +); +export const RULES_DATA_INPUT_START_MIGRATION_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.service.startMigrationError', + { defaultMessage: 'Error starting migration.' } +); + +export type StartMigration = (migrationId: string) => void; +export type OnSuccess = () => void; + +export const useStartMigration = (onSuccess?: OnSuccess) => { + const { siemMigrations, notifications } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const startMigration = useCallback( + (migrationId) => { + (async () => { + try { + dispatch({ type: 'start' }); + await siemMigrations.rules.startRuleMigration(migrationId); + + notifications.toasts.addSuccess(RULES_DATA_INPUT_START_MIGRATION_SUCCESS); + dispatch({ type: 'success' }); + onSuccess?.(); + } catch (err) { + const apiError = err.body ?? err; + notifications.toasts.addError(apiError, { + title: RULES_DATA_INPUT_START_MIGRATION_ERROR, + }); + dispatch({ type: 'error', error: apiError }); + } + })(); + }, + [siemMigrations.rules, notifications.toasts, onSuccess] + ); + + return { isLoading: state.loading, error: state.error, startMigration }; +}; diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts index 3162cc3d58e63..c13b0606d771d 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/rule_migrations_service.ts @@ -7,28 +7,55 @@ import { BehaviorSubject, type Observable } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; +import type { TraceOptions } from '@kbn/elastic-assistant/impl/assistant/types'; +import { + DEFAULT_ASSISTANT_NAMESPACE, + TRACE_OPTIONS_SESSION_STORAGE_KEY, +} from '@kbn/elastic-assistant/impl/assistant_context/constants'; +import type { LangSmithOptions } from '../../../../common/siem_migrations/model/common.gen'; +import type { RuleMigrationTaskStats } from '../../../../common/siem_migrations/model/rule_migration.gen'; +import type { + CreateRuleMigrationRequestBody, + GetAllStatsRuleMigrationResponse, + GetRuleMigrationStatsResponse, +} from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SiemMigrationTaskStatus } from '../../../../common/siem_migrations/constants'; import type { StartPluginsDependencies } from '../../../types'; import { ExperimentalFeaturesService } from '../../../common/experimental_features_service'; import { licenseService } from '../../../common/hooks/use_license'; -import { getRuleMigrationsStatsAll, startRuleMigration } from '../api'; -import type { RuleMigrationTask } from '../types'; +import { + createRuleMigration, + getRuleMigrationStats, + getRuleMigrationsStatsAll, + startRuleMigration, + type GetRuleMigrationsStatsAllParams, +} from '../api'; +import type { RuleMigrationStats } from '../types'; import { getSuccessToast } from './success_notification'; import { RuleMigrationsStorage } from './storage'; +import * as i18n from './translations'; + +// use the default assistant namespace since it's the only one we use +const NAMESPACE_TRACE_OPTIONS_SESSION_STORAGE_KEY = + `${DEFAULT_ASSISTANT_NAMESPACE}.${TRACE_OPTIONS_SESSION_STORAGE_KEY}` as const; + +const REQUEST_POLLING_INTERVAL_MS = 5000 as const; +const CREATE_MIGRATION_BODY_BATCH_SIZE = 50 as const; export class SiemRulesMigrationsService { - private readonly pollingInterval = 5000; - private readonly latestStats$: BehaviorSubject; - private readonly signal = new AbortController().signal; + private readonly latestStats$: BehaviorSubject; private isPolling = false; - public connectorIdStorage = new RuleMigrationsStorage('connectorId'); + public connectorIdStorage = new RuleMigrationsStorage('connectorId'); + public traceOptionsStorage = new RuleMigrationsStorage('traceOptions', { + customKey: NAMESPACE_TRACE_OPTIONS_SESSION_STORAGE_KEY, + storageType: 'session', + }); constructor( private readonly core: CoreStart, private readonly plugins: StartPluginsDependencies ) { - this.latestStats$ = new BehaviorSubject([]); + this.latestStats$ = new BehaviorSubject([]); this.plugins.spaces.getActiveSpace().then((space) => { this.connectorIdStorage.setSpaceId(space.id); @@ -36,7 +63,7 @@ export class SiemRulesMigrationsService { }); } - public getLatestStats$(): Observable { + public getLatestStats$(): Observable { return this.latestStats$.asObservable(); } @@ -48,27 +75,92 @@ export class SiemRulesMigrationsService { if (this.isPolling || !this.isAvailable()) { return; } - this.isPolling = true; - this.startStatsPolling() + this.startTaskStatsPolling() .catch((e) => { - this.core.notifications.toasts.addError(e, { - title: i18n.translate( - 'xpack.securitySolution.siemMigrations.rulesService.polling.errorTitle', - { defaultMessage: 'Error fetching rule migrations' } - ), - }); + this.core.notifications.toasts.addError(e, { title: i18n.POLLING_ERROR }); }) .finally(() => { this.isPolling = false; }); } - private async startStatsPolling(): Promise { + public async createRuleMigration(body: CreateRuleMigrationRequestBody): Promise { + if (body.length === 0) { + throw new Error(i18n.EMPTY_RULES_ERROR); + } + // Batching creation to avoid hitting the max payload size limit of the API + let migrationId: string | undefined; + for (let i = 0; i < body.length; i += CREATE_MIGRATION_BODY_BATCH_SIZE) { + const bodyBatch = body.slice(i, i + CREATE_MIGRATION_BODY_BATCH_SIZE); + const response = await createRuleMigration({ migrationId, body: bodyBatch }); + migrationId = response.migration_id; + } + return migrationId as string; + } + + public async startRuleMigration(migrationId: string): Promise { + const connectorId = this.connectorIdStorage.get(); + if (!connectorId) { + throw new Error(i18n.MISSING_CONNECTOR_ERROR); + } + + const langSmithSettings = this.traceOptionsStorage.get(); + let langSmithOptions: LangSmithOptions | undefined; + if (langSmithSettings) { + langSmithOptions = { + project_name: langSmithSettings.langSmithProject, + api_key: langSmithSettings.langSmithApiKey, + }; + } + + const result = await startRuleMigration({ migrationId, connectorId, langSmithOptions }); + this.startPolling(); + return result; + } + + public async getRuleMigrationStats(migrationId: string): Promise { + return getRuleMigrationStats({ migrationId }); + } + + public async getRuleMigrationsStats( + params: GetRuleMigrationsStatsAllParams = {} + ): Promise { + const allStats = await this.getRuleMigrationsStatsWithRetry(params); + const results = allStats.map( + // the array order (by creation) is guaranteed by the API + (stats, index) => ({ ...stats, number: index + 1 } as RuleMigrationStats) // needs cast because of the `status` enum override + ); + this.latestStats$.next(results); // Always update the latest stats + return results; + } + + private async getRuleMigrationsStatsWithRetry( + params: GetRuleMigrationsStatsAllParams = {}, + sleepSecs?: number + ): Promise { + if (sleepSecs) { + await new Promise((resolve) => setTimeout(resolve, sleepSecs * 1000)); + } + + return getRuleMigrationsStatsAll(params).catch((e) => { + // Retry only on network errors (no status) and 503s, otherwise throw + if (e.status && e.status !== 503) { + throw e; + } + const nextSleepSecs = sleepSecs ? sleepSecs * 2 : 1; // Exponential backoff + if (nextSleepSecs > 60) { + // Wait for a minutes max (two minutes total) for the API to be available again + throw e; + } + return this.getRuleMigrationsStatsWithRetry(params, nextSleepSecs); + }); + } + + private async startTaskStatsPolling(): Promise { let pendingMigrationIds: string[] = []; do { - const results = await this.fetchRuleMigrationTasksStats(); - this.latestStats$.next(results); + const results = await this.getRuleMigrationsStats(); if (pendingMigrationIds.length > 0) { // send notifications for finished migrations @@ -91,22 +183,13 @@ export class SiemRulesMigrationsService { const connectorId = this.connectorIdStorage.get(); if (connectorId) { // automatically resume stopped migrations when connector is available - await startRuleMigration({ - migrationId: result.id, - body: { connector_id: connectorId }, - signal: this.signal, - }); + await startRuleMigration({ migrationId: result.id, connectorId }); pendingMigrationIds.push(result.id); } } } - await new Promise((resolve) => setTimeout(resolve, this.pollingInterval)); + await new Promise((resolve) => setTimeout(resolve, REQUEST_POLLING_INTERVAL_MS)); } while (pendingMigrationIds.length > 0); } - - private async fetchRuleMigrationTasksStats(): Promise { - const stats = await getRuleMigrationsStatsAll({ signal: this.signal }); - return stats.map((stat, index) => ({ ...stat, number: index + 1 })); // the array order (by creation) is guaranteed by the API - } } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts index bbf53ec3a5404..874f1b05dfab6 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/storage.ts @@ -7,23 +7,37 @@ import { Storage } from '@kbn/kibana-utils-plugin/public'; -export class RuleMigrationsStorage { - private readonly storage = new Storage(localStorage); +const storages = { + local: new Storage(localStorage), + session: new Storage(sessionStorage), +} as const; + +interface Options { + customKey?: string; + storageType?: keyof typeof storages; +} + +export class RuleMigrationsStorage { + private readonly storage: Storage; public key: string; - constructor(private readonly objectName: string, spaceId?: string) { - this.key = this.getStorageKey(spaceId); + constructor(private readonly objectName: string, private readonly options?: Options) { + this.storage = storages[this.options?.storageType ?? 'local']; + this.key = this.getKey(); } - private getStorageKey(spaceId: string = 'default') { + private getKey(spaceId: string = 'default'): string { + if (this.options?.customKey) { + return this.options.customKey; + } return `siem_migrations.rules.${this.objectName}.${spaceId}`; } public setSpaceId(spaceId: string) { - this.key = this.getStorageKey(spaceId); + this.key = this.getKey(spaceId); } - public get = () => this.storage.get(this.key); - public set = (value: string) => this.storage.set(this.key, value); + public get = (): T | undefined => this.storage.get(this.key); + public set = (value: T) => this.storage.set(this.key, value); public remove = () => this.storage.remove(this.key); } diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx index 830e3c5f4a531..f87755943f830 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/success_notification.tsx @@ -17,9 +17,9 @@ import type { ToastInput } from '@kbn/core-notifications-browser'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import type { RuleMigrationTask } from '../types'; +import type { RuleMigrationStats } from '../types'; -export const getSuccessToast = (migration: RuleMigrationTask, core: CoreStart): ToastInput => ({ +export const getSuccessToast = (migration: RuleMigrationStats, core: CoreStart): ToastInput => ({ color: 'success', iconType: 'check', toastLifeTimeMs: 1000 * 60 * 30, // 30 minutes @@ -34,7 +34,7 @@ export const getSuccessToast = (migration: RuleMigrationTask, core: CoreStart): ), }); -const SuccessToastContent: React.FC<{ migration: RuleMigrationTask }> = ({ migration }) => { +const SuccessToastContent: React.FC<{ migration: RuleMigrationStats }> = ({ migration }) => { const navigation = { deepLinkId: SecurityPageName.siemMigrationsRules, path: migration.id }; const { navigateTo, getAppUrl } = useNavigation(); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/service/translations.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/translations.ts new file mode 100644 index 0000000000000..41a897a56e9df --- /dev/null +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/service/translations.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const POLLING_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rulesService.pollingError', + { defaultMessage: 'Error fetching rule migrations' } +); + +export const MISSING_CONNECTOR_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rulesService.create.missingConnectorError', + { defaultMessage: 'Connector not defined. Please set a connector ID first.' } +); + +export const EMPTY_RULES_ERROR = i18n.translate( + 'xpack.securitySolution.siemMigrations.rulesService.create.emptyRulesError', + { defaultMessage: 'Can not create a migration without rules' } +); diff --git a/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts b/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts index 4c704e97179c0..bcc11327d1051 100644 --- a/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts +++ b/x-pack/plugins/security_solution/public/siem_migrations/rules/types.ts @@ -5,9 +5,11 @@ * 2.0. */ +import type { SiemMigrationTaskStatus } from '../../../common/siem_migrations/constants'; import type { RuleMigrationTaskStats } from '../../../common/siem_migrations/model/rule_migration.gen'; -export interface RuleMigrationTask extends RuleMigrationTaskStats { +export interface RuleMigrationStats extends RuleMigrationTaskStats { + status: SiemMigrationTaskStatus; /** The sequential number of the migration */ number: number; } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts index 21a17bb7834a1..7c94631be6b65 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts @@ -8,9 +8,10 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { v4 as uuidV4 } from 'uuid'; -import { SIEM_RULE_MIGRATIONS_PATH } from '../../../../../common/siem_migrations/constants'; +import { SIEM_RULE_MIGRATION_CREATE_PATH } from '../../../../../common/siem_migrations/constants'; import { CreateRuleMigrationRequestBody, + CreateRuleMigrationRequestParams, type CreateRuleMigrationResponse, } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; @@ -23,7 +24,7 @@ export const registerSiemRuleMigrationsCreateRoute = ( ) => { router.versioned .post({ - path: SIEM_RULE_MIGRATIONS_PATH, + path: SIEM_RULE_MIGRATION_CREATE_PATH, access: 'internal', security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) @@ -31,18 +32,20 @@ export const registerSiemRuleMigrationsCreateRoute = ( { version: '1', validate: { - request: { body: buildRouteValidationWithZod(CreateRuleMigrationRequestBody) }, + request: { + body: buildRouteValidationWithZod(CreateRuleMigrationRequestBody), + params: buildRouteValidationWithZod(CreateRuleMigrationRequestParams), + }, }, }, withLicense( async (context, req, res): Promise> => { const originalRules = req.body; + const migrationId = req.params.migration_id ?? uuidV4(); try { const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const migrationId = uuidV4(); - const ruleMigrations = originalRules.map((originalRule) => ({ migration_id: migrationId, original_rule: originalRule, diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts index b9645a3de374e..dd13a75cdf83a 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts @@ -14,6 +14,7 @@ import { } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATION_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import type { RuleMigrationGetOptions } from '../data/rule_migrations_data_rules_client'; import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsGetRoute = ( @@ -43,20 +44,13 @@ export const registerSiemRuleMigrationsGetRoute = ( const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - let from = 0; - if (page && perPage) { - from = page * perPage; - } - const size = perPage; + const options: RuleMigrationGetOptions = { + filters: { searchTerm }, + size: perPage, + from: page && perPage ? page * perPage : 0, + }; - const result = await ruleMigrationsClient.data.rules.get( - { - migrationId, - searchTerm, - }, - from, - size - ); + const result = await ruleMigrationsClient.data.rules.get(migrationId, options); return res.ok({ body: result }); } catch (err) { diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts index df86a1f953656..2fce95be9dafe 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts @@ -177,10 +177,8 @@ export const installTranslated = async ({ const detectionRulesClient = securitySolutionContext.getDetectionRulesClient(); const ruleMigrationsClient = securitySolutionContext.getSiemRuleMigrationsClient(); - const { data: rulesToInstall } = await ruleMigrationsClient.data.rules.get({ - migrationId, - ids, - installable: true, + const { data: rulesToInstall } = await ruleMigrationsClient.data.rules.get(migrationId, { + filters: { ids, installable: true }, }); const { customRulesToInstall, prebuiltRulesToInstall } = rulesToInstall.reduce( diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts index 209f2e4416e16..716d19ce16cdf 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts @@ -38,32 +38,23 @@ export type UpdateRuleMigrationInput = { elastic_rule?: Partial } & export type RuleMigrationDataStats = Omit; export type RuleMigrationAllDataStats = RuleMigrationDataStats[]; -export interface RuleMigrationFilterOptions { - migrationId: string; +export interface RuleMigrationFilters { status?: SiemMigrationStatus | SiemMigrationStatus[]; ids?: string[]; installable?: boolean; searchTerm?: string; } +export interface RuleMigrationGetOptions { + filters?: RuleMigrationFilters; + from?: number; + size?: number; +} /* BULK_MAX_SIZE defines the number to break down the bulk operations by. * The 500 number was chosen as a reasonable number to avoid large payloads. It can be adjusted if needed. */ const BULK_MAX_SIZE = 500 as const; - -const getInstallableConditions = (): QueryDslQueryContainer[] => { - return [ - { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } }, - { - nested: { - path: 'elastic_rule', - query: { - bool: { must_not: { exists: { field: 'elastic_rule.id' } } }, - }, - }, - }, - ]; -}; +/* The default number of rule migrations to retrieve in a single GET request. */ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient { /** Indexes an array of rule migrations to be processed */ @@ -128,12 +119,11 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient /** Retrieves an array of rule documents of a specific migrations */ async get( - filters: RuleMigrationFilterOptions, - from?: number, - size?: number + migrationId: string, + { filters = {}, from, size }: RuleMigrationGetOptions = {} ): Promise<{ total: number; data: StoredRuleMigration[] }> { const index = await this.getIndexName(); - const query = this.getFilterQuery(filters); + const query = this.getFilterQuery(migrationId, { ...filters }); const result = await this.esClient .search({ index, query, sort: '_doc', from, size }) @@ -155,7 +145,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient */ async takePending(migrationId: string, size: number): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId, status: SiemMigrationStatus.PENDING }); + const query = this.getFilterQuery(migrationId, { status: SiemMigrationStatus.PENDING }); const storedRuleMigrations = await this.esClient .search({ index, query, sort: '_doc', size }) @@ -234,7 +224,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient { refresh = false }: { refresh?: boolean } = {} ): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId, status: statusToQuery }); + const query = this.getFilterQuery(migrationId, { status: statusToQuery }); const script = { source: `ctx._source['status'] = '${statusToUpdate}'` }; await this.esClient.updateByQuery({ index, query, script, refresh }).catch((error) => { this.logger.error(`Error updating rule migrations status: ${error.message}`); @@ -245,24 +235,11 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient /** Retrieves the translation stats for the rule migrations with the provided id */ async getTranslationStats(migrationId: string): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId }); + const query = this.getFilterQuery(migrationId); const aggregations = { - prebuilt: { - filter: { - nested: { - path: 'elastic_rule', - query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } }, - }, - }, - }, - installable: { - filter: { - bool: { - must: getInstallableConditions(), - }, - }, - }, + prebuilt: { filter: conditions.isPrebuilt() }, + installable: { filter: { bool: { must: conditions.isInstallable() } } }, }; const result = await this.esClient .search({ index, query, aggregations, _source: false }) @@ -288,7 +265,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient /** Retrieves the stats for the rule migrations with the provided id */ async getStats(migrationId: string): Promise { const index = await this.getIndexName(); - const query = this.getFilterQuery({ migrationId }); + const query = this.getFilterQuery(migrationId); const aggregations = { pending: { filter: { term: { status: SiemMigrationStatus.PENDING } } }, processing: { filter: { term: { status: SiemMigrationStatus.PROCESSING } } }, @@ -358,13 +335,10 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient })); } - private getFilterQuery({ - migrationId, - status, - ids, - installable, - searchTerm, - }: RuleMigrationFilterOptions): QueryDslQueryContainer { + private getFilterQuery( + migrationId: string, + { status, ids, installable, searchTerm }: RuleMigrationFilters = {} + ): QueryDslQueryContainer { const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; if (status) { if (Array.isArray(status)) { @@ -377,16 +351,44 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient filter.push({ terms: { _id: ids } }); } if (installable) { - filter.push(...getInstallableConditions()); + filter.push(...conditions.isInstallable()); } if (searchTerm?.length) { - filter.push({ - nested: { - path: 'elastic_rule', - query: { match: { 'elastic_rule.title': searchTerm } }, - }, - }); + filter.push(conditions.matchTitle(searchTerm)); } return { bool: { filter } }; } } + +const conditions = { + isFullyTranslated(): QueryDslQueryContainer { + return { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } }; + }, + isNotInstalled(): QueryDslQueryContainer { + return { + nested: { + path: 'elastic_rule', + query: { bool: { must_not: { exists: { field: 'elastic_rule.id' } } } }, + }, + }; + }, + isPrebuilt(): QueryDslQueryContainer { + return { + nested: { + path: 'elastic_rule', + query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } }, + }, + }; + }, + matchTitle(title: string): QueryDslQueryContainer { + return { + nested: { + path: 'elastic_rule', + query: { match: { 'elastic_rule.title': title } }, + }, + }; + }, + isInstallable(): QueryDslQueryContainer[] { + return [this.isFullyTranslated(), this.isNotInstalled()]; + }, +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts index 09a4bef34c279..f63953192844b 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts @@ -23,7 +23,8 @@ export const ruleMigrationsFieldMap: FieldMap async (state) => { - const mitreAttackIds = state.original_rule.mitre_attack_ids; + const mitreAttackIds = state.original_rule.annotations?.mitre_attack; if (!mitreAttackIds?.length) { return {}; } diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index 3561ff6fac35a..98fefc3a74aa4 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -32,7 +32,10 @@ import { CopyTimelineRequestBodyInput } from '@kbn/security-solution-plugin/comm import { CreateAlertsMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/signals_migration/create_signals_migration/create_signals_migration.gen'; import { CreateAssetCriticalityRecordRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/asset_criticality/create_asset_criticality.gen'; import { CreateRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/create_rule/create_rule_route.gen'; -import { CreateRuleMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; +import { + CreateRuleMigrationRequestParamsInput, + CreateRuleMigrationRequestBodyInput, +} from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { CreateTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/create_timelines/create_timelines_route.gen'; import { CreateUpdateProtectionUpdatesNoteRequestParamsInput, @@ -374,7 +377,12 @@ If a record already exists for the specified entity, that record is overwritten */ createRuleMigration(props: CreateRuleMigrationProps, kibanaSpace: string = 'default') { return supertest - .post(routeWithNamespace('/internal/siem_migrations/rules', kibanaSpace)) + .post( + routeWithNamespace( + replaceParams('/internal/siem_migrations/rules/{migration_id}', props.params), + kibanaSpace + ) + ) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') @@ -1595,6 +1603,7 @@ export interface CreateRuleProps { body: CreateRuleRequestBodyInput; } export interface CreateRuleMigrationProps { + params: CreateRuleMigrationRequestParamsInput; body: CreateRuleMigrationRequestBodyInput; } export interface CreateTimelinesProps {