From a80d33ff2c92df76b03a36625b3dc07304cf3ebc Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Wed, 6 Aug 2025 12:53:07 +0200 Subject: [PATCH 1/8] abstract task and data clients --- .../siem_migrations/model/common.gen.ts | 49 -- .../siem_migrations/model/common.schema.yaml | 42 -- .../model/dashboard_migration.gen.ts | 52 +- .../model/dashboard_migration.schema.yaml | 58 +- .../siem_migrations/model/migration.gen.ts | 130 +++++ .../model/migration.schema.yaml | 107 ++++ .../model/rule_migration.gen.ts | 63 +-- .../model/rule_migration.schema.yaml | 67 +-- .../common/mocks/migration_result.data.ts | 4 +- .../migration_panel_title.test.tsx | 2 +- .../migration_progress_panel.test.tsx | 4 +- .../migration_progress_panel.tsx | 11 +- .../migration_ready_panel.test.tsx | 10 +- .../migration_ready_panel.tsx | 8 +- .../scripts/langgraph/draw_graphs_script.ts | 4 +- .../common/{utils => api/util}/audit.ts | 2 +- .../common/{utils => api/util}/authz.ts | 0 .../{utils => api/util}/is_not_found_error.ts | 0 .../{rules => common}/api/util/retry.ts | 0 .../{rules => common}/api/util/tracing.ts | 0 .../common/{utils => api/util}/utils.test.ts | 0 .../{utils => api/util}/with_license.ts | 2 +- .../siem_migrations/common/data/constants.ts | 9 + .../data/siem_migrations_data_base_client.ts | 6 +- .../data/siem_migrations_data_client.ts | 54 ++ .../data/siem_migrations_data_item_client.ts | 341 ++++++++++++ ...m_migrations_data_migration_client.test.ts | 279 ++++++++++ .../siem_migrations_data_migration_client.ts | 158 ++++++ .../siem_migrations_data_resources_client.ts} | 76 ++- .../lib/siem_migrations/common/data/sort.ts | 147 +++++ .../lib/siem_migrations/common/data/types.ts | 15 + .../task/siem_migrations_task_client.test.ts | 513 ++++++++++++++++++ .../task/siem_migrations_task_client.ts | 250 +++++++++ .../siem_migrations_task_evaluator.test.ts | 311 +++++++++++ .../task/siem_migrations_task_evaluator.ts | 164 ++++++ .../task/siem_migrations_task_runner.test.ts | 382 +++++++++++++ .../task/siem_migrations_task_runner.ts | 298 ++++++++++ .../task/siem_migrations_telemetry_client.ts | 23 + .../lib/siem_migrations/common/task/types.ts | 55 ++ .../util/__mocks__/esql_knowledge_base.ts | 9 + .../common/task/util/__mocks__/mocks.ts | 20 + .../common/task/util/actions_client_chat.ts | 103 ++++ .../common/task/util/comments.ts | 22 + .../common/task/util/constants.ts | 8 + .../common/task/util/esql_knowledge_base.ts | 39 ++ .../util/nullify_missing_properties.test.ts | 85 +++ .../task/util/nullify_missing_properties.ts | 53 ++ .../lib/siem_migrations/common/types.ts | 17 + .../siem_migrations/dashboards/api/create.ts | 6 +- .../dashboards/api/dashboards/create.ts | 4 +- .../lib/siem_migrations/dashboards/api/get.ts | 6 +- .../siem_migrations/dashboards/api/start.ts | 103 ++++ .../siem_migrations/dashboards/api/stats.ts | 6 +- .../with_existing_dashboard_migration.ts} | 0 .../dashboard_migrations_dashboards_client.ts | 131 +---- .../data/dashboard_migrations_data_client.ts | 31 +- ...hboard_migrations_migration_client.test.ts | 131 ----- .../dashboard_migrations_migration_client.ts | 53 -- .../lib/siem_migrations/dashboards/types.ts | 1 + .../server/lib/siem_migrations/routes.ts | 6 +- .../lib/siem_migrations/rules/api/create.ts | 6 +- .../lib/siem_migrations/rules/api/delete.ts | 6 +- .../rules/api/evaluation/evaluate.ts | 6 +- .../lib/siem_migrations/rules/api/get.ts | 6 +- .../rules/api/get_integrations.ts | 4 +- .../rules/api/get_prebuilt_rules.ts | 4 +- .../lib/siem_migrations/rules/api/install.ts | 6 +- .../rules/api/integrations_stats.ts | 8 +- .../api/privileges/get_missing_privileges.ts | 4 +- .../rules/api/resources/get.ts | 6 +- .../rules/api/resources/missing.ts | 4 +- .../rules/api/resources/upsert.ts | 12 +- .../siem_migrations/rules/api/rules/create.ts | 12 +- .../siem_migrations/rules/api/rules/get.ts | 8 +- .../siem_migrations/rules/api/rules/update.ts | 8 +- .../lib/siem_migrations/rules/api/start.ts | 10 +- .../lib/siem_migrations/rules/api/stats.ts | 6 +- .../siem_migrations/rules/api/stats_all.ts | 4 +- .../lib/siem_migrations/rules/api/stop.ts | 6 +- .../rules/api/translation_stats.ts | 6 +- .../lib/siem_migrations/rules/api/update.ts | 6 +- .../rules/api/util/installation.ts | 8 +- .../rules/api/util/prebuilt_rules.ts | 2 +- .../rules/data/rule_migrations_data_client.ts | 47 +- .../rule_migrations_data_migration_client.ts | 146 +---- .../data/rule_migrations_data_rules_client.ts | 320 +---------- .../rules/siem_rule_migrations_service.ts | 2 +- .../rules/task/__mocks__/mocks.ts | 4 +- .../siem_migrations/rules/task/agent/graph.ts | 13 +- .../match_prebuilt_rule.ts | 4 +- .../retrieve_integrations.ts | 4 +- .../agent/sub_graphs/translate_rule/types.ts | 4 +- .../siem_migrations/rules/task/agent/types.ts | 12 +- .../task/rule_migrations_task_client.test.ts | 40 +- .../rules/task/rule_migrations_task_client.ts | 247 +-------- .../task/rule_migrations_task_evaluator.ts | 2 +- .../task/rule_migrations_task_runner.test.ts | 38 +- .../rules/task/rule_migrations_task_runner.ts | 340 ++---------- .../task/rule_migrations_task_service.ts | 7 +- .../rule_migrations_telemetry_client.test.ts | 257 --------- .../task/rule_migrations_telemetry_client.ts | 12 +- .../lib/siem_migrations/rules/task/types.ts | 4 +- 102 files changed, 4096 insertions(+), 2085 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/{utils => api/util}/audit.ts (99%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/{utils => api/util}/authz.ts (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/{utils => api/util}/is_not_found_error.ts (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules => common}/api/util/retry.ts (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules => common}/api/util/tracing.ts (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/{utils => api/util}/utils.test.ts (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/{utils => api/util}/with_license.ts (98%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/constants.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules/data/rule_migrations_data_resources_client.ts => common/data/siem_migrations_data_resources_client.ts} (75%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_telemetry_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/esql_knowledge_base.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/mocks.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/{utils/use_existing_dashboard_migration.ts => util/with_existing_dashboard_migration.ts} (100%) delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.test.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.test.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.gen.ts index 8cfd45d5386fe..ac7ab0886cb2e 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.gen.ts @@ -49,52 +49,3 @@ export const LangSmithEvaluationOptions = LangSmithOptions.merge( dataset: z.string(), }) ); - -/** - * The status of migration. - */ -export type MigrationStatus = z.infer; -export const MigrationStatus = z.enum(['pending', 'processing', 'completed', 'failed']); -export type MigrationStatusEnum = typeof MigrationStatus.enum; -export const MigrationStatusEnum = MigrationStatus.enum; - -/** - * The status of the migration task. - */ -export type MigrationTaskStatus = z.infer; -export const MigrationTaskStatus = z.enum([ - 'ready', - 'running', - 'stopped', - 'finished', - 'interrupted', -]); -export type MigrationTaskStatusEnum = typeof MigrationTaskStatus.enum; -export const MigrationTaskStatusEnum = MigrationTaskStatus.enum; - -/** - * The last execution of a migration task. - */ -export type MigrationLastExecution = z.infer; -export const MigrationLastExecution = z.object({ - /** - * The moment the last execution started. - */ - started_at: z.string().optional(), - /** - * The moment the last execution finished. - */ - finished_at: z.string().nullable().optional(), - /** - * The connector ID used for the last execution. - */ - connector_id: z.string().optional(), - /** - * The error message if the last execution failed. - */ - error: z.string().nullable().optional(), - /** - * Indicates if the last execution was stopped by the user. - */ - is_stopped: z.boolean().optional(), -}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.schema.yaml index 3c07a7b3a0a78..4251aae40ac55 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/common.schema.yaml @@ -35,45 +35,3 @@ components: dataset: type: string description: The dataset name to use for evaluations. - - MigrationStatus: - type: string - description: The status of migration. - enum: # should match SiemMigrationsStatus enum at ../constants.ts - - pending - - processing - - completed - - failed - - MigrationTaskStatus: - type: string - description: The status of the migration task. - enum: # should match SiemMigrationTaskStatus enum at ../constants.ts - - ready - - running - - stopped - - finished - - interrupted - - MigrationLastExecution: - type: object - description: The last execution of a migration task. - properties: - started_at: - type: string - description: The moment the last execution started. - finished_at: - type: string - nullable: true - description: The moment the last execution finished. - connector_id: - type: string - description: The connector ID used for the last execution. - error: - type: string - nullable: true - description: The error message if the last execution failed. - is_stopped: - type: boolean - description: Indicates if the last execution was stopped by the user. - diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts index 524af13a9b463..d82525ca93cdb 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts @@ -17,7 +17,7 @@ import { z } from '@kbn/zod'; import { NonEmptyString } from '../../api/model/primitives.gen'; -import { MigrationLastExecution, MigrationStatus, MigrationTaskStatus } from './common.gen'; +import { MigrationLastExecution, MigrationStatus, MigrationTaskStats } from './migration.gen'; import { SplunkOriginalDashboardProperties } from './vendor/dashboards/splunk.gen'; /** @@ -147,52 +147,4 @@ export const DashboardMigrationDashboard = z * The dashboard migration task stats object. */ export type DashboardMigrationTaskStats = z.infer; -export const DashboardMigrationTaskStats = z.object({ - /** - * The migration id - */ - id: NonEmptyString, - /** - * The migration name - */ - name: NonEmptyString, - /** - * Indicates if the migration task status. - */ - status: MigrationTaskStatus, - /** - * The dashboards migration stats. - */ - dashboards: z - .object({ - /** - * The total number of dashboards to migrate. - */ - total: z.number().int(), - /** - * The number of dashboards that are pending migration. - */ - pending: z.number().int(), - /** - * The number of dashboards that are being migrated. - */ - processing: z.number().int(), - /** - * The number of dashboards that have been migrated successfully. - */ - completed: z.number().int(), - /** - * The number of dashboards that have failed migration. - */ - failed: z.number().int(), - }) - .optional(), - /** - * The moment the migration was created. - */ - created_at: z.string(), - /** - * The moment of the last update. - */ - last_updated_at: z.string(), -}); +export const DashboardMigrationTaskStats = MigrationTaskStats; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml index 830571684cebf..c5562fbe31676 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml @@ -6,7 +6,6 @@ paths: {} components: x-codegen-enabled: true schemas: - DashboardMigration: description: The dashboard migration object with its settings. allOf: @@ -34,12 +33,11 @@ components: description: The user profile ID of the user who created the migration. $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' created_at: - description: The moment migration was created + description: The moment migration was created $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' last_execution: description: The last execution of the dashboard migration task. - $ref: './common.schema.yaml#/components/schemas/MigrationLastExecution' - + $ref: './migration.schema.yaml#/components/schemas/MigrationLastExecution' DashboardMigrationDashboard: description: The dashboard migration document object. @@ -77,7 +75,7 @@ components: $ref: '#/components/schemas/OriginalDashboard' status: description: The status of the dashboard migration process. - $ref: './common.schema.yaml#/components/schemas/MigrationStatus' + $ref: './migration.schema.yaml#/components/schemas/MigrationStatus' default: pending updated_at: type: string @@ -124,53 +122,5 @@ components: $ref: './vendor/dashboards/splunk.schema.yaml#/components/schemas/SplunkOriginalDashboardProperties' DashboardMigrationTaskStats: - type: object description: The dashboard migration task stats object. - required: - - id - - name - - status - - dashboard - - created_at - - last_updated_at - properties: - id: - description: The migration id - $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - name: - description: The migration name - $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - status: - description: Indicates if the migration task status. - $ref: './common.schema.yaml#/components/schemas/MigrationTaskStatus' - dashboards: - type: object - description: The dashboards migration stats. - required: - - total - - pending - - processing - - completed - - failed - properties: - total: - type: integer - description: The total number of dashboards to migrate. - pending: - type: integer - description: The number of dashboards that are pending migration. - processing: - type: integer - description: The number of dashboards that are being migrated. - completed: - type: integer - description: The number of dashboards that have been migrated successfully. - failed: - type: integer - description: The number of dashboards that have failed migration. - created_at: - type: string - description: The moment the migration was created. - last_updated_at: - type: string - description: The moment of the last update. + $ref: './migration.schema.yaml#/components/schemas/MigrationTaskStats' diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts new file mode 100644 index 0000000000000..dd1762ffaadad --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts @@ -0,0 +1,130 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: SIEM Rule Migration migrations components + * version: not applicable + */ + +import { z } from '@kbn/zod'; + +import { NonEmptyString } from '../../api/model/primitives.gen'; + +/** + * The status of migration. + */ +export type MigrationStatus = z.infer; +export const MigrationStatus = z.enum(['pending', 'processing', 'completed', 'failed']); +export type MigrationStatusEnum = typeof MigrationStatus.enum; +export const MigrationStatusEnum = MigrationStatus.enum; + +/** + * The status of the migration task. + */ +export type MigrationTaskStatus = z.infer; +export const MigrationTaskStatus = z.enum([ + 'ready', + 'running', + 'stopped', + 'finished', + 'interrupted', +]); +export type MigrationTaskStatusEnum = typeof MigrationTaskStatus.enum; +export const MigrationTaskStatusEnum = MigrationTaskStatus.enum; + +/** + * The last execution of a migration task. + */ +export type MigrationLastExecution = z.infer; +export const MigrationLastExecution = z.object({ + /** + * The moment the last execution started. + */ + started_at: z.string().optional(), + /** + * The moment the last execution finished. + */ + finished_at: z.string().nullable().optional(), + /** + * The connector ID used for the last execution. + */ + connector_id: z.string().optional(), + /** + * The error message if the last execution failed. + */ + error: z.string().nullable().optional(), + /** + * Indicates if the last execution was stopped by the user. + */ + is_stopped: z.boolean().optional(), +}); + +/** + * The migration items stats. + */ +export type MigrationTaskItemsStats = z.infer; +export const MigrationTaskItemsStats = z.object({ + /** + * The total number of items to migrate. + */ + total: z.number().int(), + /** + * The number of items that are pending migration. + */ + pending: z.number().int(), + /** + * The number of items that are being migrated. + */ + processing: z.number().int(), + /** + * The number of items that have been migrated successfully. + */ + completed: z.number().int(), + /** + * The number of items that have failed migration. + */ + failed: z.number().int(), +}); + +/** + * The migration task stats object. + */ +export type MigrationTaskStats = z.infer; +export const MigrationTaskStats = z.object({ + /** + * The migration id + */ + id: NonEmptyString, + /** + * The migration name + */ + name: NonEmptyString, + /** + * Indicates if the migration task status. + */ + status: MigrationTaskStatus, + /** + * The migration items stats. + */ + items: MigrationTaskItemsStats, + /** + * The moment the migration was created. + */ + created_at: z.string(), + /** + * The moment of the last update. + */ + last_updated_at: z.string(), + /** + * The last execution of the migration task. + */ + last_execution: MigrationLastExecution.optional(), +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml new file mode 100644 index 0000000000000..effd04af99be7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml @@ -0,0 +1,107 @@ +openapi: 3.0.3 +info: + title: SIEM Rule Migration migrations components + version: 'not applicable' +paths: {} +components: + x-codegen-enabled: true + schemas: + MigrationStatus: + type: string + description: The status of migration. + enum: # should match SiemMigrationsStatus enum at ../constants.ts + - pending + - processing + - completed + - failed + + MigrationTaskStatus: + type: string + description: The status of the migration task. + enum: # should match SiemMigrationTaskStatus enum at ../constants.ts + - ready + - running + - stopped + - finished + - interrupted + + MigrationLastExecution: + type: object + description: The last execution of a migration task. + properties: + started_at: + type: string + description: The moment the last execution started. + finished_at: + type: string + nullable: true + description: The moment the last execution finished. + connector_id: + type: string + description: The connector ID used for the last execution. + error: + type: string + nullable: true + description: The error message if the last execution failed. + is_stopped: + type: boolean + description: Indicates if the last execution was stopped by the user. + + MigrationTaskStats: + type: object + description: The migration task stats object. + required: + - id + - name + - status + - items + - created_at + - last_updated_at + properties: + id: + description: The migration id + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + name: + description: The migration name + $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + status: + description: Indicates if the migration task status. + $ref: '#/components/schemas/MigrationTaskStatus' + items: + description: The migration items stats. + $ref: '#/components/schemas/MigrationTaskItemsStats' + created_at: + type: string + description: The moment the migration was created. + last_updated_at: + type: string + description: The moment of the last update. + last_execution: + description: The last execution of the migration task. + $ref: '#/components/schemas/MigrationLastExecution' + + MigrationTaskItemsStats: + type: object + description: The migration items stats. + required: + - total + - pending + - processing + - completed + - failed + properties: + total: + type: integer + description: The total number of items to migrate. + pending: + type: integer + description: The number of items that are pending migration. + processing: + type: integer + description: The number of items that are being migrated. + completed: + type: integer + description: The number of items that have been migrated successfully. + failed: + type: integer + description: The number of items that have failed migration. diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index 468302f09f447..a1873af409ae4 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -18,7 +18,7 @@ import { z } from '@kbn/zod'; import { NonEmptyString } from '../../api/model/primitives.gen'; import { RuleResponse } from '../../api/detection_engine/model/rule_schema/rule_schemas.gen'; -import { MigrationStatus, MigrationTaskStatus, MigrationLastExecution } from './common.gen'; +import { MigrationStatus, MigrationTaskStats, MigrationLastExecution } from './migration.gen'; /** * The original rule vendor identifier. @@ -146,14 +146,14 @@ export const PrebuiltRuleVersion = z.object({ * The last execution of the rule migration task. */ export type RuleMigrationLastExecution = z.infer; -export const RuleMigrationLastExecution = z - .object({ +export const RuleMigrationLastExecution = MigrationLastExecution.merge( + z.object({ /** * Indicates if the last execution skipped matching prebuilt rules. */ skip_prebuilt_rules_matching: z.boolean().optional(), }) - .merge(MigrationLastExecution); +); /** * The rule migration object ( without Id ) with its settings. @@ -288,57 +288,14 @@ export const RuleMigrationRule = z * The rule migration task stats object. */ export type RuleMigrationTaskStats = z.infer; -export const RuleMigrationTaskStats = z.object({ - /** - * The migration id - */ - id: NonEmptyString, - /** - * The migration name - */ - name: NonEmptyString, - /** - * Indicates if the migration task status. - */ - status: MigrationTaskStatus, - /** - * The rules migration stats. - */ - rules: z.object({ - /** - * The total number of rules to migrate. - */ - total: z.number().int(), - /** - * The number of rules that are pending migration. - */ - pending: z.number().int(), - /** - * The number of rules that are being migrated. - */ - processing: z.number().int(), - /** - * The number of rules that have been migrated successfully. - */ - completed: z.number().int(), +export const RuleMigrationTaskStats = MigrationTaskStats.merge( + z.object({ /** - * The number of rules that have failed migration. + * The last execution of the rule migration task. */ - failed: z.number().int(), - }), - /** - * The moment the migration was created. - */ - created_at: z.string(), - /** - * The moment of the last update. - */ - last_updated_at: z.string(), - /** - * The last execution of the migration task. - */ - last_execution: RuleMigrationLastExecution.optional(), -}); + last_execution: RuleMigrationLastExecution.optional(), + }) +); /** * The rule migration translation stats object. diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index 29c0b02493664..ad2ae8211a7b5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -145,13 +145,12 @@ components: description: The user profile ID of the user who created the migration. $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' created_at: - description: The moment migration was created + description: The moment migration was created $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' last_execution: description: The last execution details of a rule migration task. $ref: '#/components/schemas/RuleMigrationLastExecution' - RuleMigrationRule: description: The rule migration document object. allOf: @@ -194,7 +193,7 @@ components: $ref: '#/components/schemas/RuleMigrationTranslationResult' status: description: The status of the rule migration process. - $ref: './common.schema.yaml#/components/schemas/MigrationStatus' + $ref: './migration.schema.yaml#/components/schemas/MigrationStatus' default: pending comments: description: The comments for the migration including a summary from the LLM in markdown. @@ -207,59 +206,14 @@ components: description: The user who last updated the migration RuleMigrationTaskStats: - type: object description: The rule migration task stats object. - required: - - id - - name - - status - - rules - - created_at - - last_updated_at - properties: - id: - description: The migration id - $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - name: - description: The migration name - $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' - status: - description: Indicates if the migration task status. - $ref: './common.schema.yaml#/components/schemas/MigrationTaskStatus' - rules: - type: object - description: The rules migration stats. - required: - - total - - pending - - processing - - completed - - failed + allOf: + - $ref: './migration.schema.yaml#/components/schemas/MigrationTaskStats' + - type: object properties: - total: - type: integer - description: The total number of rules to migrate. - pending: - type: integer - description: The number of rules that are pending migration. - processing: - type: integer - description: The number of rules that are being migrated. - completed: - type: integer - description: The number of rules that have been migrated successfully. - failed: - type: integer - description: The number of rules that have failed migration. - created_at: - type: string - description: The moment the migration was created. - last_updated_at: - type: string - description: The moment of the last update. - last_execution: - description: The last execution of the migration task. - $ref: '#/components/schemas/RuleMigrationLastExecution' + last_execution: + description: The last execution of the rule migration task. + $ref: '#/components/schemas/RuleMigrationLastExecution' RuleMigrationTranslationStats: type: object @@ -320,6 +274,7 @@ components: failed: type: integer description: The number of rules that have failed translation. + RuleMigrationTranslationResult: type: string description: The rule translation result. @@ -463,14 +418,12 @@ components: RuleMigrationLastExecution: description: The last execution of the rule migration task. allOf: + - $ref: './migration.schema.yaml#/components/schemas/MigrationLastExecution' - type: object - required: - - connector_id properties: skip_prebuilt_rules_matching: type: boolean description: Indicates if the last execution skipped matching prebuilt rules. - - $ref: './common.schema.yaml#/components/schemas/MigrationLastExecution' RuleMigrationTaskExecutionSettings: type: object diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/mocks/migration_result.data.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/mocks/migration_result.data.ts index ac2ad35d71b24..53d9cce15e7bc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/mocks/migration_result.data.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/common/mocks/migration_result.data.ts @@ -64,7 +64,7 @@ export const mockedMigrationLatestStatsData: RuleMigrationStats[] = [ { id: '1', status: SiemMigrationTaskStatus.FINISHED, - rules: { + items: { total: 1, pending: 0, processing: 0, @@ -78,7 +78,7 @@ export const mockedMigrationLatestStatsData: RuleMigrationStats[] = [ { id: '2', status: SiemMigrationTaskStatus.FINISHED, - rules: { + items: { total: 2, pending: 0, processing: 0, diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_panel_title.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_panel_title.test.tsx index 9cfbc839ddb56..dc0047e4d3332 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_panel_title.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_panel_title.test.tsx @@ -29,7 +29,7 @@ const mockMigrationStatsReady: RuleMigrationStats = { id: 'test-migration-id', name: 'Test Migration', status: SiemMigrationTaskStatus.READY, - rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + items: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, created_at: '2025-05-27T12:12:17.563Z', last_updated_at: '2025-05-27T12:12:17.563Z', }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.test.tsx index 49ff0bf66d9c7..39db06efe6e52 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.test.tsx @@ -23,7 +23,7 @@ const inProgressMigrationStats: RuleMigrationStats = { name: 'test-migration', status: SiemMigrationTaskStatus.RUNNING, id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3', - rules: { total: 26, pending: 6, processing: 10, completed: 9, failed: 1 }, + items: { total: 26, pending: 6, processing: 10, completed: 9, failed: 1 }, created_at: '2025-05-27T12:12:17.563Z', last_updated_at: '2025-05-27T12:12:17.563Z', }; @@ -31,7 +31,7 @@ const preparingMigrationStats: RuleMigrationStats = { ...inProgressMigrationStats, // status RUNNING and the same number of total and pending rules, means the migration is still preparing the environment status: SiemMigrationTaskStatus.RUNNING, - rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + items: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, }; const renderMigrationProgressPanel = (migrationStats: RuleMigrationStats) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.tsx index 46df0cdf6cc3e..14a0ae25f95bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_progress_panel.tsx @@ -39,10 +39,11 @@ export const MigrationProgressPanel = React.memo( stopMigration(migrationStats.id); }, [migrationStats.id, stopMigration]); - const finishedCount = migrationStats.rules.completed + migrationStats.rules.failed; - const progressValue = (finishedCount / migrationStats.rules.total) * 100; + const { items } = migrationStats; + const finishedCount = items.completed + items.failed; + const progressValue = (finishedCount / items.total) * 100; - const preparing = migrationStats.rules.pending === migrationStats.rules.total; + const preparing = items.pending === items.total; return ( @@ -53,9 +54,7 @@ export const MigrationProgressPanel = React.memo( - - {i18n.RULE_MIGRATION_PROGRESS_DESCRIPTION(migrationStats.rules.total)} - + {i18n.RULE_MIGRATION_PROGRESS_DESCRIPTION(items.total)} diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.test.tsx index e52538a1b831e..7093b7fe4a1e9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.test.tsx @@ -27,7 +27,7 @@ jest.mock('../../service/hooks/use_start_migration'); const useStartMigrationMock = useStartMigration as jest.Mock; const mockStartMigration = jest.fn(); -const mockMigrationStateWithError = { +const mockMigrationStateWithError: RuleMigrationStats = { status: SiemMigrationTaskStatus.READY, last_execution: { error: @@ -35,16 +35,16 @@ const mockMigrationStateWithError = { }, id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3', name: 'Migration 1', - rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + items: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, created_at: '2025-05-27T12:12:17.563Z', last_updated_at: '2025-05-27T12:12:17.563Z', }; -const mockMigrationStatsStopped = { +const mockMigrationStatsStopped: RuleMigrationStats = { status: SiemMigrationTaskStatus.STOPPED, id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3', name: 'Migration 1', - rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + items: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, created_at: '2025-05-27T12:12:17.563Z', last_updated_at: '2025-05-27T12:12:17.563Z', }; @@ -53,7 +53,7 @@ const mockMigrationStatsReady: RuleMigrationStats = { status: SiemMigrationTaskStatus.READY, id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3', name: 'Migration 1', - rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + items: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, created_at: '2025-05-27T12:12:17.563Z', last_updated_at: '2025-05-27T12:12:17.563Z', }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx index 5569f3a098293..59d80756817a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx @@ -56,14 +56,14 @@ export const MigrationReadyPanel = React.memo(({ migra const migrationPanelDescription = useMemo(() => { if (migrationStats.last_execution?.error) { - return i18n.RULE_MIGRATION_ERROR_DESCRIPTION(migrationStats.rules.total); + return i18n.RULE_MIGRATION_ERROR_DESCRIPTION(migrationStats.items.total); } if (isStopped) { - return i18n.RULE_MIGRATION_STOPPED_DESCRIPTION(migrationStats.rules.total); + return i18n.RULE_MIGRATION_STOPPED_DESCRIPTION(migrationStats.items.total); } - return i18n.RULE_MIGRATION_READY_DESCRIPTION(migrationStats.rules.total); - }, [migrationStats.last_execution?.error, migrationStats.rules.total, isStopped]); + return i18n.RULE_MIGRATION_READY_DESCRIPTION(migrationStats.items.total); + }, [migrationStats.last_execution?.error, migrationStats.items.total, isStopped]); return ( diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts b/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts index 95878ae1a96cc..e2c234e01a84d 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts @@ -20,7 +20,7 @@ import { getGenerateEsqlGraph as getGenerateEsqlAgent } from '../../server/assis import { getRuleMigrationAgent } from '../../server/lib/siem_migrations/rules/task/agent'; import type { RuleMigrationsRetriever } from '../../server/lib/siem_migrations/rules/task/retrievers'; import type { EsqlKnowledgeBase } from '../../server/lib/siem_migrations/rules/task/util/esql_knowledge_base'; -import type { SiemMigrationTelemetryClient } from '../../server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client'; +import type { RuleMigrationTelemetryClient } from '../../server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client'; import type { CreateLlmInstance } from '../../server/assistant/tools/esql/utils/common'; interface Drawable { @@ -40,7 +40,7 @@ const createLlmInstance = () => { async function getSiemMigrationGraph(logger: Logger): Promise { const model = createLlmInstance(); - const telemetryClient = {} as SiemMigrationTelemetryClient; + const telemetryClient = {} as RuleMigrationTelemetryClient; const graph = getRuleMigrationAgent({ model, esqlKnowledgeBase, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/audit.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/audit.ts similarity index 99% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/audit.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/audit.ts index aedefb472b4f7..7ac2f297d61ed 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/audit.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/audit.ts @@ -7,7 +7,7 @@ import type { AuditLogger, EcsEvent } from '@kbn/core/server'; import type { ArrayElement } from '@kbn/utility-types'; -import type { SecuritySolutionApiRequestHandlerContext } from '../../../..'; +import type { SecuritySolutionApiRequestHandlerContext } from '../../../../..'; export enum SiemMigrationsAuditActions { SIEM_MIGRATION_CREATED = 'siem_migration_created', diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/authz.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/authz.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/authz.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/authz.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/is_not_found_error.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/is_not_found_error.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/is_not_found_error.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/is_not_found_error.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/retry.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/retry.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/retry.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/retry.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/tracing.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/tracing.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/tracing.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/tracing.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/utils.test.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/utils.test.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/utils.test.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/with_license.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/with_license.ts similarity index 98% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/with_license.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/with_license.ts index bcba543e2537a..1cacee0bbae71 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/utils/with_license.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/with_license.ts @@ -7,7 +7,7 @@ import type { RequestHandler, RouteMethod } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; -import type { SecuritySolutionRequestHandlerContext } from '../../../../types'; +import type { SecuritySolutionRequestHandlerContext } from '../../../../../types'; const LICENSE_ERROR_MESSAGE = i18n.translate('xpack.securitySolution.api.licenseError', { defaultMessage: 'Your license does not support this feature.', diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/constants.ts new file mode 100644 index 0000000000000..89f0faf683ad0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/constants.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +/** Maximum size for searches, aggregations and terms queries */ +export const MAX_ES_SEARCH_SIZE = 10_000 as const; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_base_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_base_client.ts index c0319bea69b1c..432661434b14e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_base_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_base_client.ts @@ -23,7 +23,9 @@ import type { Stored } from '../../types'; const DEFAULT_PIT_KEEP_ALIVE: Duration = '30s' as const; -export class SiemMigrationsDataBaseClient { +export class SiemMigrationsDataBaseClient< + D extends SiemMigrationsClientDependencies = SiemMigrationsClientDependencies +> { protected esClient: ElasticsearchClient; constructor( @@ -31,7 +33,7 @@ export class SiemMigrationsDataBaseClient { protected currentUser: AuthenticatedUser, protected esScopedClient: IScopedClusterClient, protected logger: Logger, - protected dependencies: SiemMigrationsClientDependencies + protected dependencies: D ) { this.esClient = esScopedClient.asInternalUser; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts new file mode 100644 index 0000000000000..3dedac6f899f8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts @@ -0,0 +1,54 @@ +/* + * 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 { IScopedClusterClient, Logger } from '@kbn/core/server'; +import type { SiemMigrationsDataResourcesClient } from './siem_migrations_data_resources_client'; +import type { SiemMigrationsDataItemClient } from './siem_migrations_data_item_client'; +import type { ItemDocument, MigrationDocument } from '../types'; +import type { SiemMigrationsDataMigrationClient } from './siem_migrations_data_migration_client'; + +export abstract class SiemMigrationsDataClient< + M extends MigrationDocument = MigrationDocument, + I extends ItemDocument = ItemDocument +> { + protected abstract logger: Logger; + protected abstract esClient: IScopedClusterClient['asInternalUser']; + + public abstract readonly migrations: SiemMigrationsDataMigrationClient; + public abstract readonly items: SiemMigrationsDataItemClient; + public abstract readonly resources: SiemMigrationsDataResourcesClient; + + /** Deletes a migration and all its associated items and resources. */ + public async deleteMigration(migrationId: string) { + const [ + migrationDeleteOperations, + migrationItemsDeleteOperations, + migrationResourcesDeleteOperations, + ] = await Promise.all([ + this.migrations.prepareDelete({ id: migrationId }), + this.items.prepareDelete(migrationId), + this.resources.prepareDelete(migrationId), + ]); + + return this.esClient + .bulk({ + refresh: 'wait_for', + operations: [ + ...migrationDeleteOperations, + ...migrationItemsDeleteOperations, + ...migrationResourcesDeleteOperations, + ], + }) + .then(() => { + this.logger.info(`Deleted migration ${migrationId}`); + }) + .catch((error) => { + this.logger.error(`Error deleting migration ${migrationId}: ${error}`); + throw error; + }); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts new file mode 100644 index 0000000000000..60604aba3232c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts @@ -0,0 +1,341 @@ +/* + * 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 { + AggregationsAggregationContainer, + AggregationsMaxAggregate, + AggregationsMinAggregate, + AggregationsStringTermsAggregate, + AggregationsStringTermsBucket, + BulkOperationContainer, + Duration, + QueryDslQueryContainer, +} from '@elastic/elasticsearch/lib/api/types'; +import type { estypes } from '@elastic/elasticsearch'; +import type { ItemDocument, Stored } from '../types'; +import { + SiemMigrationStatus, + RuleTranslationResult, +} from '../../../../../common/siem_migrations/constants'; +import { SiemMigrationsDataBaseClient } from './siem_migrations_data_base_client'; +import { MAX_ES_SEARCH_SIZE } from './constants'; +import type { MigrationType, SiemMigrationAllDataStats, SiemMigrationDataStats } from './types'; + +export type CreateMigrationItemInput = Omit< + I, + '@timestamp' | 'id' | 'status' | 'created_by' +>; + +export interface SiemMigrationItemSort { + sortField?: string; + sortDirection?: estypes.SortOrder; +} + +export interface SiemMigrationGetItemsOptions { + filters?: F; + sort?: SiemMigrationItemSort; + 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; +/* DEFAULT_SEARCH_BATCH_SIZE defines the default number of documents to retrieve per search operation + * when retrieving search results in batches. */ +const DEFAULT_SEARCH_BATCH_SIZE = 500 as const; + +export abstract class SiemMigrationsDataItemClient< + I extends ItemDocument = ItemDocument +> extends SiemMigrationsDataBaseClient { + protected abstract type: MigrationType; + + /** Indexes an array of migration items in pending status */ + async create(items: CreateMigrationItemInput[]): Promise { + const index = await this.getIndexName(); + const profileId = await this.getProfileUid(); + + let itemsSlice: CreateMigrationItemInput[]; + const createdAt = new Date().toISOString(); + while ((itemsSlice = items.splice(0, BULK_MAX_SIZE)).length) { + await this.esClient + .bulk({ + refresh: 'wait_for', + operations: itemsSlice.flatMap((item) => [ + { create: { _index: index } }, + { + ...item, + '@timestamp': createdAt, + status: SiemMigrationStatus.PENDING, + created_by: profileId, + updated_by: profileId, + updated_at: createdAt, + }, + ]), + }) + .catch((error) => { + this.logger.error(`Error creating migration ${this.type}: ${error.message}`); + throw error; + }); + } + } + + /** Updates an array of migration items */ + async update>(itemsUpdate: U[]): Promise { + const index = await this.getIndexName(); + const profileId = await this.getProfileUid(); + + let itemsUpdateSlice: U[]; + const updatedAt = new Date().toISOString(); + while ((itemsUpdateSlice = itemsUpdate.splice(0, BULK_MAX_SIZE)).length) { + await this.esClient + .bulk({ + refresh: 'wait_for', + operations: itemsUpdateSlice.flatMap((item) => { + const { id, ...rest } = item; + return [ + { update: { _index: index, _id: id } }, + { + doc: { + ...rest, + updated_by: profileId, + updated_at: updatedAt, + }, + }, + ]; + }), + }) + .catch((error) => { + this.logger.error(`Error updating migration ${this.type}: ${error.message}`); + throw error; + }); + } + } + + /** Retrieves an array of migration items of a specific migration */ + async get( + migrationId: string, + { filters = {}, sort: sortParam = {}, from, size }: SiemMigrationGetItemsOptions = {} + ): Promise<{ total: number; data: Stored[] }> { + const index = await this.getIndexName(); + const query = this.getFilterQuery(migrationId, filters); + const sort = this.getSortOptions(sortParam); + + const result = await this.esClient + .search({ index, query, sort, from, size }) + .catch((error) => { + this.logger.error(`Error searching migration ${this.type}: ${error.message}`); + throw error; + }); + return { + total: this.getTotalHits(result), + data: this.processResponseHits(result), + }; + } + + /** Prepares bulk ES delete operations for the migration items based on migrationId. */ + public async prepareDelete(migrationId: string): Promise { + const index = await this.getIndexName(); + const itemsToBeDeleted = await this.get(migrationId, { size: MAX_ES_SEARCH_SIZE }); + const itemsToBeDeletedDocIds = itemsToBeDeleted.data.map((item) => item.id); + + return itemsToBeDeletedDocIds.map((docId) => ({ + delete: { _index: index, _id: docId }, + })); + } + + /** Returns batching functions to traverse all the migration items search results */ + public searchBatches( + migrationId: string, + options: { scroll?: Duration; size?: number; filters?: object } = {} + ) { + const { size = DEFAULT_SEARCH_BATCH_SIZE, filters = {}, scroll } = options; + const query = this.getFilterQuery(migrationId, filters); + const search = { query, sort: '_doc', scroll, size }; // sort by _doc to ensure consistent order + try { + return this.getSearchBatches(search); + } catch (error) { + this.logger.error(`Error scrolling migration ${this.type}: ${error.message}`); + throw error; + } + } + + /** Retrieves the stats for the migrations items with the provided id */ + public async getStats(migrationId: string): Promise { + const index = await this.getIndexName(); + const query = this.getFilterQuery(migrationId); + const aggregations = { + status: { terms: { field: 'status' } }, + createdAt: { min: { field: '@timestamp' } }, + lastUpdatedAt: { max: { field: 'updated_at' } }, + }; + const result = await this.esClient + .search({ index, query, aggregations, _source: false }) + .catch((error) => { + this.logger.error(`Error getting migration ${this.type} stats: ${error.message}`); + throw error; + }); + + const aggs = result.aggregations ?? {}; + return { + id: migrationId, + items: { + total: this.getTotalHits(result), + ...this.statusAggCounts(aggs.status as AggregationsStringTermsAggregate), + }, + created_at: (aggs.createdAt as AggregationsMinAggregate)?.value_as_string ?? '', + last_updated_at: (aggs.lastUpdatedAt as AggregationsMaxAggregate)?.value_as_string ?? '', + }; + } + + /** Retrieves the stats for all the migration items aggregated by migration id, in creation order */ + async getAllStats(): Promise { + const index = await this.getIndexName(); + const aggregations: { migrationIds: AggregationsAggregationContainer } = { + migrationIds: { + terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: MAX_ES_SEARCH_SIZE }, + aggregations: { + status: { terms: { field: 'status' } }, + createdAt: { min: { field: '@timestamp' } }, + lastUpdatedAt: { max: { field: 'updated_at' } }, + }, + }, + }; + const result = await this.esClient + .search({ index, aggregations, _source: false }) + .catch((error) => { + this.logger.error(`Error getting all migration ${this.type} stats: ${error.message}`); + throw error; + }); + + const migrationsAgg = result.aggregations?.migrationIds as AggregationsStringTermsAggregate; + const buckets = (migrationsAgg?.buckets as AggregationsStringTermsBucket[]) ?? []; + return buckets.map((bucket) => ({ + id: `${bucket.key}`, + items: { + total: bucket.doc_count, + ...this.statusAggCounts(bucket.status as AggregationsStringTermsAggregate), + }, + created_at: (bucket.createdAt as AggregationsMinAggregate | undefined) + ?.value_as_string as string, + last_updated_at: (bucket.lastUpdatedAt as AggregationsMaxAggregate | undefined) + ?.value_as_string as string, + })); + } + + /** Updates one migration item status to `processing` */ + public async saveProcessing(id: string): Promise { + const index = await this.getIndexName(); + const profileId = await this.getProfileUid(); + const doc = { + status: SiemMigrationStatus.PROCESSING, + updated_by: profileId, + updated_at: new Date().toISOString(), + }; + await this.esClient.update({ index, id, doc, refresh: 'wait_for' }).catch((error) => { + this.logger.error( + `Error updating migration ${this.type} status to processing: ${error.message}` + ); + throw error; + }); + } + + /** Updates one migration item with the provided data and sets the status to `completed` */ + public async saveCompleted({ id, ...migrationItem }: Stored): Promise { + const index = await this.getIndexName(); + const profileId = await this.getProfileUid(); + const doc = { + ...migrationItem, + status: SiemMigrationStatus.COMPLETED, + updated_by: profileId, + updated_at: new Date().toISOString(), + }; + await this.esClient.update({ index, id, doc, refresh: 'wait_for' }).catch((error) => { + this.logger.error( + `Error updating migration ${this.type} status to completed: ${error.message}` + ); + throw error; + }); + } + + /** Updates one migration item with the provided data and sets the status to `failed` */ + public async saveError({ id, ...migrationItem }: Stored): Promise { + const index = await this.getIndexName(); + const profileId = await this.getProfileUid(); + const doc = { + ...migrationItem, + status: SiemMigrationStatus.FAILED, + updated_by: profileId, + updated_at: new Date().toISOString(), + }; + await this.esClient.update({ index, id, doc, refresh: 'wait_for' }).catch((error) => { + this.logger.error(`Error updating migration ${this.type} status to failed: ${error.message}`); + throw error; + }); + } + + /** Updates all the migration items with the provided id with status `processing` back to `pending` */ + public async releaseProcessing(migrationId: string): Promise { + return this.updateStatus( + migrationId, + { status: SiemMigrationStatus.PROCESSING }, + SiemMigrationStatus.PENDING + ); + } + + /** Updates all the migration items with the provided id and with status `statusToQuery` to `statusToUpdate` */ + public async updateStatus( + migrationId: string, + filter: object, + statusToUpdate: SiemMigrationStatus, + { refresh = false }: { refresh?: boolean } = {} + ): Promise { + const index = await this.getIndexName(); + const query = this.getFilterQuery(migrationId, filter); + const script = { source: `ctx._source['status'] = '${statusToUpdate}'` }; + await this.esClient.updateByQuery({ index, query, script, refresh }).catch((error) => { + this.logger.error(`Error updating migration ${this.type} status: ${error.message}`); + throw error; + }); + } + + protected statusAggCounts( + statusAgg: AggregationsStringTermsAggregate + ): Record { + const buckets = statusAgg.buckets as AggregationsStringTermsBucket[]; + return { + [SiemMigrationStatus.PENDING]: + buckets.find(({ key }) => key === SiemMigrationStatus.PENDING)?.doc_count ?? 0, + [SiemMigrationStatus.PROCESSING]: + buckets.find(({ key }) => key === SiemMigrationStatus.PROCESSING)?.doc_count ?? 0, + [SiemMigrationStatus.COMPLETED]: + buckets.find(({ key }) => key === SiemMigrationStatus.COMPLETED)?.doc_count ?? 0, + [SiemMigrationStatus.FAILED]: + buckets.find(({ key }) => key === SiemMigrationStatus.FAILED)?.doc_count ?? 0, + }; + } + + protected translationResultAggCount( + resultAgg: AggregationsStringTermsAggregate + ): Record { + const buckets = resultAgg.buckets as AggregationsStringTermsBucket[]; + return { + [RuleTranslationResult.FULL]: + buckets.find(({ key }) => key === RuleTranslationResult.FULL)?.doc_count ?? 0, + [RuleTranslationResult.PARTIAL]: + buckets.find(({ key }) => key === RuleTranslationResult.PARTIAL)?.doc_count ?? 0, + [RuleTranslationResult.UNTRANSLATABLE]: + buckets.find(({ key }) => key === RuleTranslationResult.UNTRANSLATABLE)?.doc_count ?? 0, + }; + } + + protected abstract getFilterQuery( + migrationId: string, + filters?: F + ): QueryDslQueryContainer; + + protected abstract getSortOptions(sort?: SiemMigrationItemSort): estypes.Sort; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts new file mode 100644 index 0000000000000..8a6b8131d4146 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts @@ -0,0 +1,279 @@ +/* + * 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 { IScopedClusterClient } from '@kbn/core/server'; +import { SiemMigrationsDataMigrationClient } from './siem_migrations_data_migration_client'; +import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import type { AuthenticatedUser } from '@kbn/security-plugin-types-common'; +import type IndexApi from '@elastic/elasticsearch/lib/api/api'; +import type GetApi from '@elastic/elasticsearch/lib/api/api/get'; +import type SearchApi from '@elastic/elasticsearch/lib/api/api/search'; +import type { SiemMigrationsClientDependencies } from '../types'; + +describe('SiemMigrationsDataMigrationClient', () => { + let siemMigrationsDataMigrationClient: SiemMigrationsDataMigrationClient; + const esClient = + elasticsearchServiceMock.createCustomClusterClient() as unknown as IScopedClusterClient; + + const logger = loggingSystemMock.createLogger(); + const indexNameProvider = jest.fn().mockReturnValue('.kibana-siem-rule-migrations'); + const currentUser = { + userName: 'testUser', + profile_uid: 'testProfileUid', + } as unknown as AuthenticatedUser; + const dependencies = {} as unknown as SiemMigrationsClientDependencies; + + beforeEach(() => { + siemMigrationsDataMigrationClient = new SiemMigrationsDataMigrationClient( + indexNameProvider, + currentUser, + esClient, + logger, + dependencies + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + test('should create a new migration', async () => { + const index = '.kibana-siem-rule-migrations'; + const name = 'test name'; + + const result = await siemMigrationsDataMigrationClient.create(name); + + expect(result).not.toBeFalsy(); + expect(esClient.asInternalUser.create).toHaveBeenCalledWith({ + refresh: 'wait_for', + id: result, + index, + document: { + created_by: currentUser.profile_uid, + created_at: expect.any(String), + name, + }, + }); + }); + + test('should throw an error if an error occurs', async () => { + ( + esClient.asInternalUser.create as unknown as jest.MockedFn + ).mockRejectedValueOnce(new Error('Test error')); + + await expect(siemMigrationsDataMigrationClient.create('test')).rejects.toThrow('Test error'); + + expect(esClient.asInternalUser.create).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + test('should get a migration', async () => { + const index = '.kibana-siem-rule-migrations'; + const id = 'testId'; + const response = { + _index: index, + found: true, + _source: { + created_by: currentUser.profile_uid, + created_at: new Date().toISOString(), + }, + _id: id, + }; + + ( + esClient.asInternalUser.get as unknown as jest.MockedFn + ).mockResolvedValueOnce(response); + + const result = await siemMigrationsDataMigrationClient.get({ id }); + + expect(result).toEqual({ + ...response._source, + id: response._id, + }); + }); + + test('should return undefined if the migration is not found', async () => { + const id = 'testId'; + const response = { + _index: '.kibana-siem-rule-migrations', + found: false, + }; + + ( + esClient.asInternalUser.get as unknown as jest.MockedFn + ).mockRejectedValueOnce({ + message: JSON.stringify(response), + }); + + const result = await siemMigrationsDataMigrationClient.get({ id }); + + expect(result).toBeUndefined(); + }); + + test('should throw an error if an error occurs', async () => { + const id = 'testId'; + ( + esClient.asInternalUser.get as unknown as jest.MockedFn + ).mockRejectedValueOnce(new Error('Test error')); + + await expect(siemMigrationsDataMigrationClient.get({ id })).rejects.toThrow('Test error'); + + expect(esClient.asInternalUser.get).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(`Error getting migration ${id}: Error: Test error`); + }); + }); + + describe('prepareDelete', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should delete the migration and associated rules and resources', async () => { + const migrationId = 'testId'; + const index = '.kibana-siem-rule-migrations'; + + const operations = await siemMigrationsDataMigrationClient.prepareDelete({ + id: migrationId, + }); + + expect(operations).toMatchObject([ + { + delete: { + _index: index, + _id: migrationId, + }, + }, + ]); + }); + }); + + describe('getAll', () => { + it('should return all migrations', async () => { + const response = { + hits: { + hits: [ + { + _index: '.kibana-siem-rule-migrations', + _id: '1', + _source: { + created_by: currentUser.profile_uid, + created_at: new Date().toISOString(), + }, + }, + { + _index: '.kibana-siem-rule-migrations', + _id: '2', + _source: { + created_by: currentUser.profile_uid, + created_at: new Date().toISOString(), + }, + }, + ], + }, + } as unknown as ReturnType; + + ( + esClient.asInternalUser.search as unknown as jest.MockedFn + ).mockResolvedValueOnce(response); + + await siemMigrationsDataMigrationClient.getAll(); + expect(esClient.asInternalUser.search).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + size: 10000, + query: { + match_all: {}, + }, + _source: true, + }); + }); + }); + + describe('updateLastExecution', () => { + const connectorId = 'testConnector'; + it('should update `started_at` & `connector_id` when called saveAsStarted', async () => { + const migrationId = 'testId'; + + await siemMigrationsDataMigrationClient.saveAsStarted({ id: migrationId, connectorId }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + started_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + is_stopped: false, + error: null, + finished_at: null, + connector_id: connectorId, + skip_prebuilt_rules_matching: false, + }, + }, + retry_on_conflict: 1, + }); + }); + + it('should update `finished_at` when called saveAsEnded', async () => { + const migrationId = 'testId'; + + await siemMigrationsDataMigrationClient.saveAsFinished({ id: migrationId }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + finished_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }, + }, + retry_on_conflict: 1, + }); + }); + + it('should update `is_stopped` correctly when called setIsStopped', async () => { + const migrationId = 'testId'; + + await siemMigrationsDataMigrationClient.setIsStopped({ id: migrationId }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + is_stopped: true, + }, + }, + retry_on_conflict: 1, + }); + }); + + it('should update `error` params correctly when called saveAsFailed', async () => { + const migrationId = 'testId'; + + await siemMigrationsDataMigrationClient.saveAsFailed({ + id: migrationId, + error: 'Test error', + }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + error: 'Test error', + finished_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }, + }, + retry_on_conflict: 1, + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts new file mode 100644 index 0000000000000..f56aa1014a30a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidV4 } from 'uuid'; +import type { BulkOperationContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { RuleMigrationLastExecution } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { MigrationDocument, Stored } from '../types'; +import { SiemMigrationsDataBaseClient } from './siem_migrations_data_base_client'; +import { isNotFoundError } from '../api/util/is_not_found_error'; +import { MAX_ES_SEARCH_SIZE } from './constants'; + +export class SiemMigrationsDataMigrationClient< + M extends MigrationDocument = MigrationDocument +> extends SiemMigrationsDataBaseClient { + async create(name: string): Promise { + const migrationId = uuidV4(); + const index = await this.getIndexName(); + const profileUid = await this.getProfileUid(); + const createdAt = new Date().toISOString(); + + await this.esClient + .create({ + refresh: 'wait_for', + id: migrationId, + index, + document: { name, created_by: profileUid, created_at: createdAt }, + }) + .catch((error) => { + this.logger.error(`Error creating migration ${migrationId}: ${error}`); + throw error; + }); + + return migrationId; + } + + /** + * + * Gets the migration document by id or returns undefined if it does not exist. + * + * */ + async get({ id }: { id: string }): Promise | undefined> { + const index = await this.getIndexName(); + return this.esClient + .get>({ index, id }) + .then(this.processHit) + .catch((error) => { + if (isNotFoundError(error)) { + return undefined; + } + this.logger.error(`Error getting migration ${id}: ${error}`); + throw error; + }); + } + + /** + * Gets all migrations from the index. + */ + async getAll(): Promise[]> { + const index = await this.getIndexName(); + return this.esClient + .search>({ + index, + size: MAX_ES_SEARCH_SIZE, // Adjust size as needed + query: { match_all: {} }, + _source: true, + }) + .then((result) => this.processResponseHits(result)) + .catch((error) => { + this.logger.error(`Error getting all migrations:- ${error}`); + throw error; + }); + } + + /** + * + * Prepares bulk ES delete operation for a migration document based on its id. + * + */ + async prepareDelete({ id }: { id: string }): Promise { + const index = await this.getIndexName(); + const migrationDeleteOperation = { + delete: { _index: index, _id: id }, + }; + return [migrationDeleteOperation]; + } + + /** + * Saves a migration as started, updating the last execution parameters with the current timestamp. + */ + async saveAsStarted({ + id, + connectorId, + }: { id: string; connectorId: string } & Record): Promise { + await this.updateLastExecution(id, { + started_at: new Date().toISOString(), + connector_id: connectorId, + is_stopped: false, + error: null, + finished_at: null, + }); + } + + /** + * Saves a migration as ended, updating the last execution parameters with the current timestamp. + */ + async saveAsFinished({ id }: { id: string }): Promise { + await this.updateLastExecution(id, { finished_at: new Date().toISOString() }); + } + + /** + * Saves a migration as failed, updating the last execution parameters with the provided error message. + */ + async saveAsFailed({ id, error }: { id: string; error: string }): Promise { + await this.updateLastExecution(id, { error, finished_at: new Date().toISOString() }); + } + + /** + * Sets `is_stopped` flag for migration document. + * It does not update `finished_at` timestamp, `saveAsFinished` or `saveAsFailed` should be called separately. + */ + async setIsStopped({ id }: { id: string }): Promise { + await this.updateLastExecution(id, { is_stopped: true }); + } + + /** + * Updates the last execution parameters for a migration document. + */ + protected async updateLastExecution( + id: string, + lastExecutionParams: RuleMigrationLastExecution + ): Promise { + const index = await this.getIndexName(); + const doc = { last_execution: lastExecutionParams }; + await this.esClient + .update({ index, id, refresh: 'wait_for', doc, retry_on_conflict: 1 }) + .catch((error) => { + this.logger.error(`Error updating last execution for migration ${id}: ${error}`); + throw error; + }); + } + + /** + * Updates the migration document with the provided values. + */ + async update(id: string, doc: Partial>): Promise { + const index = await this.getIndexName(); + await this.esClient + .update({ index, id, doc, refresh: 'wait_for', retry_on_conflict: 1 }) + .catch((error) => { + this.logger.error(`Error updating migration: ${error}`); + throw error; + }); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_resources_client.ts similarity index 75% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_resources_client.ts index d8a2931b6315b..25aa537de3e86 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_resources_client.ts @@ -11,25 +11,28 @@ import type { Duration, BulkOperationContainer, } from '@elastic/elasticsearch/lib/api/types'; +// TODO: Use a common schema to define the common types import type { - RuleMigrationResource, - RuleMigrationResourceType, + RuleMigrationResource as SiemMigrationResource, + RuleMigrationResourceType as SiemMigrationResourceType, } from '../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { StoredRuleMigrationResource } from '../types'; -import { SiemMigrationsDataBaseClient } from '../../common/data/siem_migrations_data_base_client'; -import { MAX_ES_SEARCH_SIZE } from '../constants'; +import type { Stored } from '../types'; +import { SiemMigrationsDataBaseClient } from './siem_migrations_data_base_client'; +import { MAX_ES_SEARCH_SIZE } from './constants'; -export type CreateRuleMigrationResourceInput = Pick< - RuleMigrationResource, +export type StoredSiemMigrationResource = Stored; + +export type CreateSiemMigrationResourceInput = Pick< + SiemMigrationResource, 'migration_id' | 'type' | 'name' | 'content' | 'metadata' >; -export interface RuleMigrationResourceFilters { - type?: RuleMigrationResourceType; +export interface SiemMigrationResourceFilters { + type?: SiemMigrationResourceType; names?: string[]; hasContent?: boolean; } -export interface RuleMigrationResourceGetOptions { - filters?: RuleMigrationResourceFilters; +export interface SiemMigrationResourceGetOptions { + filters?: SiemMigrationResourceFilters; size?: number; from?: number; } @@ -42,12 +45,12 @@ const BULK_MAX_SIZE = 500 as const; * when retrieving search results in batches. */ const DEFAULT_SEARCH_BATCH_SIZE = 500 as const; -export class RuleMigrationsDataResourcesClient extends SiemMigrationsDataBaseClient { - public async upsert(resources: CreateRuleMigrationResourceInput[]): Promise { +export class SiemMigrationsDataResourcesClient extends SiemMigrationsDataBaseClient { + public async upsert(resources: CreateSiemMigrationResourceInput[]): Promise { const index = await this.getIndexName(); const profileId = await this.getProfileUid(); - let resourcesSlice: CreateRuleMigrationResourceInput[]; + let resourcesSlice: CreateSiemMigrationResourceInput[]; const createdAt = new Date().toISOString(); while ((resourcesSlice = resources.splice(0, BULK_MAX_SIZE)).length > 0) { @@ -75,11 +78,11 @@ export class RuleMigrationsDataResourcesClient extends SiemMigrationsDataBaseCli } /** Creates the resources in the index only if they do not exist */ - public async create(resources: CreateRuleMigrationResourceInput[]): Promise { + public async create(resources: CreateSiemMigrationResourceInput[]): Promise { const index = await this.getIndexName(); const profileId = await this.getProfileUid(); - let resourcesSlice: CreateRuleMigrationResourceInput[]; + let resourcesSlice: CreateSiemMigrationResourceInput[]; const createdAt = new Date().toISOString(); while ((resourcesSlice = resources.splice(0, BULK_MAX_SIZE)).length > 0) { await this.esClient @@ -104,14 +107,14 @@ export class RuleMigrationsDataResourcesClient extends SiemMigrationsDataBaseCli public async get( migrationId: string, - options: RuleMigrationResourceGetOptions = {} - ): Promise { + options: SiemMigrationResourceGetOptions = {} + ): Promise { const { filters, size, from } = options; const index = await this.getIndexName(); const query = this.getFilterQuery(migrationId, filters); return this.esClient - .search({ index, query, size, from }) + .search({ index, query, size, from }) .then(this.processResponseHits.bind(this)) .catch((error) => { this.logger.error(`Error searching resources: ${error.message}`); @@ -120,9 +123,9 @@ export class RuleMigrationsDataResourcesClient extends SiemMigrationsDataBaseCli } /** Returns batching functions to traverse all the migration resources search results */ - searchBatches( + public searchBatches( migrationId: string, - options: { scroll?: Duration; size?: number; filters?: RuleMigrationResourceFilters } = {} + options: { scroll?: Duration; size?: number; filters?: SiemMigrationResourceFilters } = {} ) { const { size = DEFAULT_SEARCH_BATCH_SIZE, filters = {}, scroll } = options; const query = this.getFilterQuery(migrationId, filters); @@ -135,14 +138,24 @@ export class RuleMigrationsDataResourcesClient extends SiemMigrationsDataBaseCli } } - private createId(resource: CreateRuleMigrationResourceInput): string { + /** Prepares bulk ES delete operations for the resources of a given migrationId. */ + public async prepareDelete(migrationId: string): Promise { + const index = await this.getIndexName(); + const resourcesToBeDeleted = await this.get(migrationId, { size: MAX_ES_SEARCH_SIZE }); + const resourcesToBeDeletedDocIds = resourcesToBeDeleted.map((resource) => resource.id); + return resourcesToBeDeletedDocIds.map((docId) => ({ + delete: { _id: docId, _index: index }, + })); + } + + private createId(resource: CreateSiemMigrationResourceInput): string { const key = `${resource.migration_id}-${resource.type}-${resource.name}`; return sha256.create().update(key).hex(); } private getFilterQuery( migrationId: string, - filters: RuleMigrationResourceFilters = {} + filters: SiemMigrationResourceFilters = {} ): QueryDslQueryContainer { const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; if (filters.type) { @@ -161,21 +174,4 @@ export class RuleMigrationsDataResourcesClient extends SiemMigrationsDataBaseCli } return { bool: { filter } }; } - - /** - * - * Prepares bulk ES delete operations for the resources of a given migrationId. - * - */ - async prepareDelete(migrationId: string): Promise { - const index = await this.getIndexName(); - const resourcesToBeDeleted = await this.get(migrationId, { size: MAX_ES_SEARCH_SIZE }); - const resourcesToBeDeletedDocIds = resourcesToBeDeleted.map((resource) => resource.id); - return resourcesToBeDeletedDocIds.map((docId) => ({ - delete: { - _id: docId, - _index: index, - }, - })); - } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts new file mode 100644 index 0000000000000..c2b2c56f5b0f3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts @@ -0,0 +1,147 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; + +export interface ItemMigrationSort { + sortField?: string; + sortDirection?: estypes.SortOrder; +} + +const sortMissingValue = (direction: estypes.SortOrder = 'asc') => + direction === 'desc' ? '_last' : '_first'; + +const sortingOptions = { + matchedPrebuiltRule(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { + return [ + { + 'elastic_rule.prebuilt_rule_id': { + order: direction, + missing: sortMissingValue(direction), + }, + }, + ]; + }, + severity(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { + const field = 'elastic_rule.severity'; + return [ + { + _script: { + order: direction, + type: 'number', + script: { + source: ` + if (doc.containsKey('${field}') && !doc['${field}'].empty) { + def value = doc['${field}'].value.toLowerCase(); + if (value == 'critical') { return 3 } + if (value == 'high') { return 2 } + if (value == 'medium') { return 1 } + if (value == 'low') { return 0 } + } + return -1; + `, + lang: 'painless', + }, + }, + }, + ]; + }, + riskScore(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { + return [{ 'elastic_rule.risk_score': direction }]; + }, + status(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { + const field = 'translation_result'; + const installedRuleField = 'elastic_rule.id'; + return [ + { + _script: { + order: direction, + type: 'number', + script: { + source: ` + if (doc.containsKey('${field}') && !doc['${field}'].empty) { + def value = doc['${field}'].value.toLowerCase(); + if (value == 'full') { return 2 } + if (value == 'partial') { return 1 } + if (value == 'untranslatable') { return 0 } + } + return -1; + `, + lang: 'painless', + }, + }, + }, + { + _script: { + order: direction, + type: 'number', + script: { + source: ` + if (doc.containsKey('${installedRuleField}') && !doc['${installedRuleField}'].empty) { + return 0; + } + return -1; + `, + lang: 'painless', + }, + }, + }, + ]; + }, + updated(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { + return [{ updated_at: direction }]; + }, + name(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { + return [{ 'elastic_rule.title.keyword': direction }]; + }, +}; + +const DEFAULT_SORTING: estypes.Sort = [ + ...sortingOptions.status('desc'), + ...sortingOptions.matchedPrebuiltRule('desc'), + ...sortingOptions.severity(), + ...sortingOptions.riskScore('desc'), + ...sortingOptions.updated(), +]; + +const sortingOptionsMap: { + [key: string]: (direction?: estypes.SortOrder) => estypes.SortCombinations[]; +} = { + 'elastic_rule.title': sortingOptions.name, + 'elastic_rule.severity': (direction?: estypes.SortOrder) => [ + ...sortingOptions.severity(direction), + ...sortingOptions.riskScore(direction), + ...sortingOptions.status('desc'), + ...sortingOptions.matchedPrebuiltRule('desc'), + ], + 'elastic_rule.risk_score': (direction?: estypes.SortOrder) => [ + ...sortingOptions.riskScore(direction), + ...sortingOptions.severity(direction), + ...sortingOptions.status('desc'), + ...sortingOptions.matchedPrebuiltRule('desc'), + ], + 'elastic_rule.prebuilt_rule_id': (direction?: estypes.SortOrder) => [ + ...sortingOptions.matchedPrebuiltRule(direction), + ...sortingOptions.status('desc'), + ...sortingOptions.severity('desc'), + ...sortingOptions.riskScore(direction), + ], + translation_result: (direction?: estypes.SortOrder) => [ + ...sortingOptions.status(direction), + ...sortingOptions.matchedPrebuiltRule('desc'), + ...sortingOptions.severity('desc'), + ...sortingOptions.riskScore(direction), + ], + updated_at: sortingOptions.updated, +}; + +export const getSortingOptions = (sort?: ItemMigrationSort): estypes.Sort => { + if (!sort?.sortField) { + return DEFAULT_SORTING; + } + return sortingOptionsMap[sort.sortField]?.(sort.sortDirection) ?? DEFAULT_SORTING; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts new file mode 100644 index 0000000000000..8f4c134b145ee --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DashboardMigrationTaskStats } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; + +export type MigrationType = 'rule' | 'dashboard'; + +export type SiemMigrationTaskStats = RuleMigrationTaskStats | DashboardMigrationTaskStats; +export type SiemMigrationDataStats = Omit; +export type SiemMigrationAllDataStats = SiemMigrationDataStats[]; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts new file mode 100644 index 0000000000000..b85fb934e4d6d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts @@ -0,0 +1,513 @@ +/* + * 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 { AuthenticatedUser } from '@kbn/core/server'; +import type { MigrationsRunning } from './siem_migrations_task_client'; +import { SiemMigrationsTaskClient } from './siem_migrations_task_client'; +import { + SiemMigrationStatus, + SiemMigrationTaskStatus, +} from '../../../../../common/siem_migrations/constants'; +import { RuleMigrationTaskRunner } from './siem_migrations_task_runner'; +import type { MockedLogger } from '@kbn/logging-mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { StoredSiemMigration } from '../types'; +import type { SiemMigrationTaskStartParams } from './types'; +import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; +import type { SiemMigrationDataStats } from '../data/siem_migrations_data_item_client'; +import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; +import type { SiemMigrationsClientDependencies } from '../types'; + +jest.mock('./rule_migrations_task_runner', () => { + return { + RuleMigrationTaskRunner: jest.fn().mockImplementation(() => { + return { + setup: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue(undefined), + abortController: { abort: jest.fn() }, + }; + }), + }; +}); + +const currentUser = {} as AuthenticatedUser; +const dependencies = {} as SiemMigrationsClientDependencies; +const migrationId = 'migration1'; + +describe('RuleMigrationsTaskClient', () => { + let migrationsRunning: MigrationsRunning; + let logger: MockedLogger; + let data: ReturnType; + const params: SiemMigrationTaskStartParams = { + migrationId, + connectorId: 'connector1', + invocationConfig: {}, + }; + + beforeEach(() => { + migrationsRunning = new Map(); + logger = loggerMock.create(); + + data = createRuleMigrationsDataClientMock(); + // @ts-expect-error resetting private property for each test. + SiemMigrationsTaskClient.migrationsLastError = new Map(); + jest.clearAllMocks(); + }); + + describe('start', () => { + it('should not start if migration is already running', async () => { + // Pre-populate with the migration id. + migrationsRunning.set(migrationId, {} as RuleMigrationTaskRunner); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(result).toEqual({ exists: true, started: false }); + expect(data.rules.updateStatus).not.toHaveBeenCalled(); + }); + + it('should not start if there are no rules to migrate (total = 0)', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 0, pending: 0, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(data.rules.updateStatus).toHaveBeenCalledWith( + migrationId, + { status: SiemMigrationStatus.PROCESSING }, + SiemMigrationStatus.PENDING, + { refresh: true } + ); + expect(result).toEqual({ exists: false, started: false }); + }); + + it('should not start if there are no pending rules', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 0, completed: 10, failed: 0 }, + } as SiemMigrationDataStats); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(result).toEqual({ exists: true, started: false }); + }); + + it('should start migration successfully', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const mockedRunnerInstance = { + setup: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue(undefined), + abortController: { abort: jest.fn() }, + }; + // Use our custom mock for this test. + (RuleMigrationTaskRunner as jest.Mock).mockImplementationOnce(() => mockedRunnerInstance); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(result).toEqual({ exists: true, started: true }); + expect(logger.get).toHaveBeenCalledWith(migrationId); + expect(mockedRunnerInstance.setup).toHaveBeenCalledWith(params.connectorId); + expect(logger.get(migrationId).info).toHaveBeenCalledWith('Starting migration'); + expect(migrationsRunning.has(migrationId)).toBe(true); + + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + expect(migrationsRunning.has(migrationId)).toBe(false); + // @ts-expect-error check private property + expect(SiemMigrationsTaskClient.migrationsLastError.has(migrationId)).toBe(false); + }); + + it('should throw error if a race condition occurs after setup', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const mockedRunnerInstance = { + setup: jest.fn().mockImplementationOnce(() => { + // Simulate a race condition by setting the migration as running during setup. + migrationsRunning.set(migrationId, {} as RuleMigrationTaskRunner); + return Promise.resolve(); + }), + run: jest.fn().mockResolvedValue(undefined), + abortController: { abort: jest.fn() }, + }; + (RuleMigrationTaskRunner as jest.Mock).mockImplementation(() => mockedRunnerInstance); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + await expect(client.start(params)).rejects.toThrow('Task already running for this migration'); + }); + + it('should mark migration as started by calling saveAsStarted', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + await client.start(params); + expect(data.migrations.saveAsStarted).toHaveBeenCalledWith({ + id: migrationId, + connectorId: params.connectorId, + }); + }); + + it('should mark migration as ended by calling saveAsEnded if run completes successfully', async () => { + migrationsRunning = new Map(); + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + await client.start(params); + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + expect(data.migrations.saveAsFinished).toHaveBeenCalledWith({ id: migrationId }); + }); + }); + + describe('updateToRetry', () => { + it('should not update if migration is currently running', async () => { + migrationsRunning.set(migrationId, {} as RuleMigrationTaskRunner); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const filter: RuleMigrationFilters = { fullyTranslated: true }; + const result = await client.updateToRetry(migrationId, filter); + expect(result).toEqual({ updated: false }); + expect(data.rules.updateStatus).not.toHaveBeenCalled(); + }); + + it('should update to retry if migration is not running', async () => { + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const filter: RuleMigrationFilters = { fullyTranslated: true }; + const result = await client.updateToRetry(migrationId, filter); + expect(filter.installed).toBe(false); + expect(data.rules.updateStatus).toHaveBeenCalledWith( + migrationId, + { fullyTranslated: true, installed: false }, + SiemMigrationStatus.PENDING, + { refresh: true } + ); + expect(result).toEqual({ updated: true }); + }); + }); + + describe('getStats', () => { + it('should return RUNNING status if migration is running', async () => { + migrationsRunning.set(migrationId, {} as RuleMigrationTaskRunner); // migration is running + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 5, completed: 3, failed: 2 }, + } as SiemMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredSiemMigration); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.RUNNING); + }); + + it('should return READY status if pending equals total', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredSiemMigration); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.READY); + }); + + it('should return FINISHED status if completed+failed equals total', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 0, completed: 5, failed: 5 }, + } as SiemMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredSiemMigration); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.FINISHED); + }); + + it('should return STOPPED status for other cases', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 2, completed: 3, failed: 2 }, + } as SiemMigrationDataStats); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.INTERRUPTED); + }); + + it('should include error if one exists', async () => { + const errorMessage = 'Test error'; + data.rules.getStats.mockResolvedValue({ + id: 'migration-1', + rules: { total: 10, pending: 2, completed: 3, failed: 2 }, + } as SiemMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: 'migration-1', + name: 'Test Migration', + created_at: new Date().toISOString(), + created_by: 'test-user', + last_execution: { + error: errorMessage, + }, + }); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + last_execution: { + error: 'Test error', + }, + } as unknown as StoredSiemMigration); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.last_execution?.error).toEqual('Test error'); + }); + }); + + describe('getAllStats', () => { + it('should return combined stats for all migrations', async () => { + const statsArray = [ + { + id: 'm1', + rules: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats, + { + id: 'm2', + rules: { total: 10, pending: 2, completed: 3, failed: 2 }, + } as SiemMigrationDataStats, + ]; + const migrations = [{ id: 'm1' }, { id: 'm2' }] as unknown as StoredSiemMigration[]; + data.rules.getAllStats.mockResolvedValue(statsArray); + data.migrations.getAll.mockResolvedValue(migrations); + // Mark migration m1 as running. + migrationsRunning.set('m1', {} as RuleMigrationTaskRunner); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const allStats = await client.getAllStats(); + const m1Stats = allStats.find((s) => s.id === 'm1'); + const m2Stats = allStats.find((s) => s.id === 'm2'); + expect(m1Stats?.status).toEqual(SiemMigrationTaskStatus.RUNNING); + expect(m2Stats?.status).toEqual(SiemMigrationTaskStatus.INTERRUPTED); + }); + }); + + describe('stop', () => { + it('should stop a running migration', async () => { + const abortMock = jest.fn(); + const migrationRunner = { + abortController: { abort: abortMock }, + } as unknown as RuleMigrationTaskRunner; + migrationsRunning.set(migrationId, migrationRunner); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: true, stopped: true }); + expect(abortMock).toHaveBeenCalled(); + }); + + it('should return stopped even if migration is already stopped', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: true, stopped: true }); + }); + + it('should return exists false if migration is not running and total equals 0', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 0, pending: 0, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: false, stopped: true }); + }); + + it('should catch errors and return exists true, stopped false', async () => { + const error = new Error('Stop error'); + data.rules.getStats.mockRejectedValue(error); + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: true, stopped: false }); + expect(logger.error).toHaveBeenCalledWith( + `Error stopping migration ID:${migrationId}`, + error + ); + }); + + it('should mark migration task as stopped when manually stopping a running migration', async () => { + const abortMock = jest.fn(); + const migrationRunner = { + abortController: { abort: abortMock }, + } as unknown as RuleMigrationTaskRunner; + migrationsRunning.set(migrationId, migrationRunner); + data.migrations.setIsStopped.mockResolvedValue(undefined); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + await client.stop(migrationId); + expect(data.migrations.setIsStopped).toHaveBeenCalledWith({ id: migrationId }); + }); + }); + describe('task error', () => { + it('should call saveAsFailed when there has been an error during the migration', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const error = new Error('Migration error'); + + const mockedRunnerInstance = { + setup: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockRejectedValue(error), + } as unknown as RuleMigrationTaskRunner; + + (RuleMigrationTaskRunner as jest.Mock).mockImplementation(() => mockedRunnerInstance); + + const client = new SiemMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + const response = await client.start(params); + + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + + expect(response).toEqual({ exists: true, started: true }); + + expect(data.migrations.saveAsFailed).toHaveBeenCalledWith({ + id: migrationId, + error: error.message, + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts new file mode 100644 index 0000000000000..6553f76893402 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts @@ -0,0 +1,250 @@ +/* + * 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 { AuthenticatedUser, Logger } from '@kbn/core/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { MigrationTaskItemsStats } from '../../../../../common/siem_migrations/model/migration.gen'; +import { + SiemMigrationStatus, + SiemMigrationTaskStatus, +} from '../../../../../common/siem_migrations/constants'; +import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; +import type { SiemMigrationsDataClient } from '../data/siem_migrations_data_client'; +import type { SiemMigrationTaskStats } from '../data/types'; +import type { + StoredSiemMigration, + SiemMigrationsClientDependencies, + MigrationDocument, + ItemDocument, +} from '../types'; +import type { + SiemMigrationTaskStartParams, + SiemMigrationTaskStartResult, + SiemMigrationTaskStopResult, +} from './types'; +import type { SiemMigrationTaskRunner } from './siem_migrations_task_runner'; + +export abstract class SiemMigrationsTaskClient< + M extends MigrationDocument = StoredSiemMigration, + I extends ItemDocument = ItemDocument, + C extends object = {} +> { + protected abstract readonly TaskRunnerClass: typeof SiemMigrationTaskRunner; + + constructor( + protected migrationsRunning: Map>, + private logger: Logger, + private data: SiemMigrationsDataClient, + private currentUser: AuthenticatedUser, + private dependencies: SiemMigrationsClientDependencies + ) {} + + /** Starts a rule migration task */ + async start(params: SiemMigrationTaskStartParams): Promise { + const { migrationId, connectorId, invocationConfig } = params; + if (this.migrationsRunning.has(migrationId)) { + return { exists: true, started: false }; + } + // Just in case some previous execution was interrupted without cleaning up + await this.data.items.updateStatus( + migrationId, + { status: SiemMigrationStatus.PROCESSING }, + SiemMigrationStatus.PENDING, + { refresh: true } + ); + + const { items } = await this.data.items.getStats(migrationId); + if (items.total === 0) { + return { exists: false, started: false }; + } + if (items.pending === 0) { + return { exists: true, started: false }; + } + + const migrationLogger = this.logger.get(migrationId); + const abortController = new AbortController(); + const migrationTaskRunner = new this.TaskRunnerClass( + migrationId, + this.currentUser, + abortController, + this.data, + migrationLogger, + this.dependencies + ); + + await migrationTaskRunner.setup(connectorId); + + if (this.migrationsRunning.has(migrationId)) { + // Just to prevent a race condition in the setup + throw new Error('Task already running for this migration'); + } + + migrationLogger.info('Starting migration'); + + this.migrationsRunning.set(migrationId, migrationTaskRunner); + + await this.data.migrations.saveAsStarted({ + id: migrationId, + connectorId, + ...this.getLastExecutionConfig(invocationConfig), + // skipPrebuiltRulesMatching: invocationConfig.configurable?.skipPrebuiltRulesMatching, + }); + + // run the migration in the background without awaiting and resolve the `start` promise + migrationTaskRunner + .run(invocationConfig) + .then(() => { + // The task runner has finished normally. Abort errors are also handled here, it's an expected finish scenario, nothing special should be done. + migrationLogger.debug('Migration execution task finished'); + this.data.migrations.saveAsFinished({ id: migrationId }).catch((error) => { + migrationLogger.error(`Error saving migration as finished: ${error}`); + }); + }) + .catch((error) => { + // Unexpected errors, no use in throwing them since the `start` promise is long gone. Just log and store the error message + migrationLogger.error(`Error executing migration task: ${error}`); + this.data.migrations + .saveAsFailed({ id: migrationId, error: error.message }) + .catch((saveError) => { + migrationLogger.error(`Error saving migration as failed: ${saveError}`); + }); + }) + .finally(() => { + this.migrationsRunning.delete(migrationId); + }); + + return { exists: true, started: true }; + } + + /** Updates all the rules in a migration to be re-executed */ + public async updateToRetry( + migrationId: string, + filter: RuleMigrationFilters + ): Promise<{ updated: boolean }> { + if (this.migrationsRunning.has(migrationId)) { + // not update migrations that are currently running + return { updated: false }; + } + filter.installed = false; // only retry rules that are not installed + await this.data.items.updateStatus(migrationId, filter, SiemMigrationStatus.PENDING, { + refresh: true, + }); + return { updated: true }; + } + + /** Returns the stats of a migration */ + public async getStats(migrationId: string): Promise { + const migration = await this.data.migrations.get({ id: migrationId }); + if (!migration) { + throw new Error(`Migration with ID ${migrationId} not found`); + } + const dataStats = await this.data.items.getStats(migrationId); + const taskStats = this.getTaskStats(migration, dataStats.items); + return { ...taskStats, ...dataStats, name: migration.name }; + } + + /** Returns the stats of all migrations */ + async getAllStats(): Promise { + const allDataStats = await this.data.items.getAllStats(); + const allMigrations = await this.data.migrations.getAll(); + const allMigrationsMap = new Map( + allMigrations.map((migration) => [migration.id, migration]) + ); + + const allStats: SiemMigrationTaskStats[] = []; + + for (const dataStats of allDataStats) { + const migration = allMigrationsMap.get(dataStats.id); + if (migration) { + const tasksStats = this.getTaskStats(migration, dataStats.items); + allStats.push({ name: migration.name, ...tasksStats, ...dataStats }); + } + } + return allStats; + } + + private getTaskStats( + migration: StoredSiemMigration, + dataStats: MigrationTaskItemsStats + ): Pick { + return { + status: this.getTaskStatus(migration, dataStats), + last_execution: migration.last_execution, + }; + } + + private getTaskStatus( + migration: StoredSiemMigration, + dataStats: MigrationTaskItemsStats + ): SiemMigrationTaskStatus { + const { id: migrationId, last_execution: lastExecution } = migration; + if (this.migrationsRunning.has(migrationId)) { + return SiemMigrationTaskStatus.RUNNING; + } + if (dataStats.completed + dataStats.failed === dataStats.total) { + return SiemMigrationTaskStatus.FINISHED; + } + if (lastExecution?.is_stopped) { + return SiemMigrationTaskStatus.STOPPED; + } + if (dataStats.pending === dataStats.total) { + return SiemMigrationTaskStatus.READY; + } + return SiemMigrationTaskStatus.INTERRUPTED; + } + + // Overridable method to get the last execution config + protected getLastExecutionConfig(_invocationConfig: RunnableConfig): Record { + return {}; + } + + /** Stops one running migration */ + async stop(migrationId: string): Promise { + try { + const migrationRunning = this.migrationsRunning.get(migrationId); + if (migrationRunning) { + migrationRunning.abortController.abort('Stopped by user'); + await this.data.migrations.setIsStopped({ id: migrationId }); + return { exists: true, stopped: true }; + } + + const { items } = await this.data.items.getStats(migrationId); + if (items.total > 0) { + return { exists: true, stopped: true }; + } + return { exists: false, stopped: true }; + } catch (err) { + this.logger.error(`Error stopping migration ID:${migrationId}`, err); + return { exists: true, stopped: false }; + } + } + + /** Creates a new evaluator for the rule migration task */ + async evaluate(params: RuleMigrationTaskEvaluateParams): Promise { + // const { evaluationId, langsmithOptions, connectorId, invocationConfig, abortController } = + // params; + // const migrationLogger = this.logger.get('evaluate'); + // const migrationTaskEvaluator = new RuleMigrationTaskEvaluator( + // evaluationId, + // this.currentUser, + // abortController, + // this.data, + // migrationLogger, + // this.dependencies + // ); + // await migrationTaskEvaluator.evaluate({ + // connectorId, + // langsmithOptions, + // invocationConfig, + // }); + } + + /** Returns if a migration is running or not */ + isMigrationRunning(migrationId: string): boolean { + return this.migrationsRunning.has(migrationId); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts new file mode 100644 index 0000000000000..116f7648ca415 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts @@ -0,0 +1,311 @@ +/* + * 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 { CustomEvaluator } from './siem_migrations_task_evaluator'; +import { RuleMigrationTaskEvaluator } from './siem_migrations_task_evaluator'; +import type { Run, Example } from 'langsmith/schemas'; +import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { AuthenticatedUser } from '@kbn/core/server'; +import type { SiemMigrationsClientDependencies } from '../../common/types'; + +// Mock dependencies +jest.mock('langsmith/evaluation', () => ({ + evaluate: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@kbn/langchain/server/tracers/langsmith', () => ({ + isLangSmithEnabled: jest.fn(() => true), +})); + +jest.mock('langsmith', () => ({ + Client: jest.fn().mockImplementation(() => ({ + listExamples: jest.fn(() => [{ id: 'example-1' }, { id: 'example-2' }]), + })), +})); + +describe('RuleMigrationTaskEvaluator', () => { + let taskEvaluator: RuleMigrationTaskEvaluator; + let mockRuleMigrationsDataClient: ReturnType; + let abortController: AbortController; + + const mockLogger = loggerMock.create(); + const mockDependencies: jest.Mocked = { + rulesClient: {}, + savedObjectsClient: {}, + inferenceClient: {}, + actionsClient: { + get: jest.fn().mockResolvedValue({ id: 'test-connector-id', name: 'Test Connector' }), + }, + telemetry: {}, + } as unknown as SiemMigrationsClientDependencies; + + const mockUser = {} as unknown as AuthenticatedUser; + + beforeAll(() => { + mockRuleMigrationsDataClient = createRuleMigrationsDataClientMock(); + abortController = new AbortController(); + + taskEvaluator = new RuleMigrationTaskEvaluator( + 'test-migration-id', + mockUser, + abortController, + mockRuleMigrationsDataClient, + mockLogger, + mockDependencies + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('evaluators', () => { + let evaluator: CustomEvaluator; + // Helper to access private evaluator methods + const setEvaluator = (name: string) => { + // @ts-expect-error (accessing private property) + evaluator = taskEvaluator.evaluators[name]; + }; + + describe('translation_result evaluator', () => { + beforeAll(() => { + setEvaluator('translation_result'); + }); + + it('should return true score when translation results match', () => { + const mockRun = { outputs: { translation_result: 'full' } } as unknown as Run; + const mockExample = { outputs: { translation_result: 'full' } } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: true, + comment: 'Correct', + }); + }); + + it('should return false score when translation results do not match', () => { + const mockRun = { outputs: { translation_result: 'full' } } as unknown as Run; + const mockExample = { outputs: { translation_result: 'partial' } } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: false, + comment: 'Incorrect, expected "partial" but got "full"', + }); + }); + + it('should ignore score when expected result is missing', () => { + const mockRun = { outputs: { translation_result: 'full' } } as unknown as Run; + const mockExample = { outputs: {} } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + comment: 'No translation result expected', + }); + }); + + it('should return false score when run result is missing', () => { + const mockRun = { outputs: {} } as unknown as Run; + const mockExample = { outputs: { translation_result: 'full' } } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: false, + comment: 'No translation result received', + }); + }); + }); + + describe('custom_query_accuracy evaluator', () => { + beforeAll(() => { + setEvaluator('custom_query_accuracy'); + }); + + it('should return perfect score when queries are identical', () => { + const mockRun = { + outputs: { elastic_rule: { query: 'process.name:test AND user.name:admin' } }, + } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { query: 'process.name:test AND user.name:admin' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: 1, + comment: 'Distance: 0', + }); + }); + + it('should calculate similarity score when queries are different', () => { + const mockRun = { + outputs: { elastic_rule: { query: 'process.name:testing' } }, + } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { query: 'process.name:testing AND user.name:admin' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + // Expected distance would be the length of " AND user.name:admin" which is 20 characters + // Total length of expected query is 40 characters + // Similarity = 1 - (20/40) = 0.5 + expect(result.score).toEqual(0.5); + expect(result.comment).toEqual('Distance: 20'); + }); + + it('should calculate similarity score with a precision of 3 decimals', () => { + const mockRun = { + outputs: { elastic_rule: { query: 'process.name:test' } }, + } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { query: 'process.name:test AND user.name:admin' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + // Similarity = 1 - (20/37) = 0.45945945945945943 + expect(result.score).toEqual(0.459); + }); + + it('should calculate similarity score with a precision of 3 decimals rounded correctly', () => { + const mockRun = { + outputs: { elastic_rule: { query: 'process.name:tests' } }, + } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { query: 'process.name:tests AND user.name:admin' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + // Similarity = 1 - (20/38) = 0.4736842105263158 + expect(result.score).toEqual(0.474); + }); + + it('should ignore score when no custom query is expected', () => { + const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; + const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + comment: 'No custom translation expected', + }); + }); + + it('should handle case when no custom query is expected but one is received', () => { + const mockRun = { + outputs: { elastic_rule: { query: 'process.name:tests' } }, + } as unknown as Run; + const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: 0, + comment: 'No custom translation expected, but received', + }); + }); + + it('should handle case when no custom query is returned but one was expected', () => { + const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { query: 'process.name:test' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: 0, + comment: 'Custom translation expected, but not received', + }); + }); + }); + + describe('prebuilt_rule_match evaluator', () => { + beforeAll(() => { + setEvaluator('prebuilt_rule_match'); + }); + + it('should return success when prebuilt rule IDs match', () => { + const mockRun = { + outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, + } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: true, + comment: 'Correct match', + }); + }); + + it('should return failure when prebuilt rule IDs do not match', () => { + const mockRun = { + outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, + } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { prebuilt_rule_id: 'rule-456' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: false, + comment: 'Incorrect match, expected ID is "rule-456" but got "rule-123"', + }); + }); + + it('should handle case when no prebuilt rule is expected', () => { + const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; + const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + comment: 'No prebuilt rule expected', + }); + }); + + it('should handle case when no prebuilt rule is expected but one is received', () => { + const mockRun = { + outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, + } as unknown as Run; + const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: false, + comment: 'No prebuilt rule expected, but received', + }); + }); + + it('should handle case when no prebuilt rule is returned but one was expected', () => { + const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; + const mockExample = { + outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, + } as unknown as Example; + + const result = evaluator({ run: mockRun, example: mockExample }); + + expect(result).toEqual({ + score: false, + comment: 'Prebuilt rule expected, but not received', + }); + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts new file mode 100644 index 0000000000000..d4a43d3d83cfd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts @@ -0,0 +1,164 @@ +/* + * 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 { EvaluationResult } from 'langsmith/evaluation'; +import type { Run, Example } from 'langsmith/schemas'; +import { evaluate } from 'langsmith/evaluation'; +import { isLangSmithEnabled } from '@kbn/langchain/server/tracers/langsmith'; +import { Client } from 'langsmith'; +import { distance } from 'fastest-levenshtein'; +import type { LangSmithEvaluationOptions } from '../../../../../common/siem_migrations/model/common.gen'; +import { RuleMigrationTaskRunner } from './siem_migrations_task_runner'; +import type { MigrateRuleGraphConfig, MigrateRuleState } from './agent/types'; + +export interface EvaluateParams { + connectorId: string; + langsmithOptions: LangSmithEvaluationOptions; + invocationConfig?: MigrateRuleGraphConfig; +} + +export type Evaluator = (args: { run: Run; example: Example }) => EvaluationResult; +type CustomEvaluatorResult = Omit; +export type CustomEvaluator = (args: { run: Run; example: Example }) => CustomEvaluatorResult; + +export class RuleMigrationTaskEvaluator extends RuleMigrationTaskRunner { + public async evaluate({ connectorId, langsmithOptions, invocationConfig }: EvaluateParams) { + if (!isLangSmithEnabled()) { + throw Error('LangSmith is not enabled'); + } + + const client = new Client({ apiKey: langsmithOptions.api_key }); + + // Make sure the dataset exists + const dataset: Example[] = []; + for await (const example of client.listExamples({ datasetName: langsmithOptions.dataset })) { + dataset.push(example); + } + if (dataset.length === 0) { + throw Error(`LangSmith dataset not found: ${langsmithOptions.dataset}`); + } + + // Initialize the the task runner first, this may take some time + await this.initialize(); + + // Check if the connector exists and user has privileges to read it + const connector = await this.dependencies.actionsClient.get({ id: connectorId }); + if (!connector) { + throw Error(`Connector with id ${connectorId} not found`); + } + + // for each connector, setup the evaluator + await this.setup(connectorId); + + // create the migration task after setup + const migrateRuleTask = this.createMigrateRuleTask(invocationConfig); + const evaluators = this.getEvaluators(); + + evaluate(migrateRuleTask, { + data: langsmithOptions.dataset, + experimentPrefix: connector.name, + evaluators, + client, + maxConcurrency: 3, + }) + .then(() => { + this.logger.info('Evaluation finished'); + }) + .catch((err) => { + this.logger.error(`Evaluation error:\n ${JSON.stringify(err, null, 2)}`); + }); + } + + private getEvaluators(): Evaluator[] { + return Object.entries(this.evaluators).map(([key, evaluator]) => { + return (args) => { + const result = evaluator(args); + return { key, ...result }; + }; + }); + } + + /** + * This is a map of custom evaluators that are used to evaluate rule migration tasks + * The object keys are used for the `key` property of the evaluation result, and the value is a function that takes a the `run` and `example` + * and returns a `score` and a `comment` (and any other data needed for the evaluation) + **/ + private readonly evaluators: Record = { + translation_result: ({ run, example }) => { + const runResult = (run?.outputs as MigrateRuleState)?.translation_result; + const expectedResult = (example?.outputs as MigrateRuleState)?.translation_result; + + if (!expectedResult) { + return { comment: 'No translation result expected' }; + } + if (!runResult) { + return { score: false, comment: 'No translation result received' }; + } + + if (runResult === expectedResult) { + return { score: true, comment: 'Correct' }; + } + + return { + score: false, + comment: `Incorrect, expected "${expectedResult}" but got "${runResult}"`, + }; + }, + + custom_query_accuracy: ({ run, example }) => { + const runQuery = (run?.outputs as MigrateRuleState)?.elastic_rule?.query; + const expectedQuery = (example?.outputs as MigrateRuleState)?.elastic_rule?.query; + + if (!expectedQuery) { + if (runQuery) { + return { score: 0, comment: 'No custom translation expected, but received' }; + } + return { comment: 'No custom translation expected' }; + } + if (!runQuery) { + return { score: 0, comment: 'Custom translation expected, but not received' }; + } + + // calculate the levenshtein distance between the two queries: + // The distance is the minimum number of single-character edits required to change one word into the other. + // So, the distance is a number between 0 and the length of the longest string. + const queryDistance = distance(runQuery, expectedQuery); + const maxDistance = Math.max(expectedQuery.length, runQuery.length); + // The similarity is a number between 0 and 1 (score), where 1 means the two strings are identical. + const similarity = 1 - queryDistance / maxDistance; + + return { + score: Math.round(similarity * 1000) / 1000, // round to 3 decimal places + comment: `Distance: ${queryDistance}`, + }; + }, + + prebuilt_rule_match: ({ run, example }) => { + const runPrebuiltRuleId = (run?.outputs as MigrateRuleState)?.elastic_rule?.prebuilt_rule_id; + const expectedPrebuiltRuleId = (example?.outputs as MigrateRuleState)?.elastic_rule + ?.prebuilt_rule_id; + + if (!expectedPrebuiltRuleId) { + if (runPrebuiltRuleId) { + return { score: false, comment: 'No prebuilt rule expected, but received' }; + } + return { comment: 'No prebuilt rule expected' }; + } + if (!runPrebuiltRuleId) { + return { score: false, comment: 'Prebuilt rule expected, but not received' }; + } + + if (runPrebuiltRuleId === expectedPrebuiltRuleId) { + return { score: true, comment: 'Correct match' }; + } + return { + score: false, + comment: `Incorrect match, expected ID is "${expectedPrebuiltRuleId}" but got "${runPrebuiltRuleId}"`, + }; + }, + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts new file mode 100644 index 0000000000000..688c0b3a33ade --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts @@ -0,0 +1,382 @@ +/* + * 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 { RuleMigrationTaskRunner } from './siem_migrations_task_runner'; +import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; +import type { AuthenticatedUser } from '@kbn/core/server'; +import type { StoredRuleMigration } from '../types'; +import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { SiemMigrationsClientDependencies } from '../../common/types'; + +jest.mock('./rule_migrations_telemetry_client'); + +const mockRetrieverInitialize = jest.fn().mockResolvedValue(undefined); +jest.mock('./retrievers', () => ({ + ...jest.requireActual('./retrievers'), + RuleMigrationsRetriever: jest.fn().mockImplementation(() => ({ + initialize: mockRetrieverInitialize, + resources: { + getResources: jest.fn(() => ({})), + }, + })), +})); + +const mockCreateModel = jest.fn(() => ({ model: 'test-model' })); +jest.mock('./util/actions_client_chat', () => ({ + ...jest.requireActual('./util/actions_client_chat'), + ActionsClientChat: jest.fn().mockImplementation(() => ({ createModel: mockCreateModel })), +})); + +const mockInvoke = jest.fn().mockResolvedValue({}); +jest.mock('./agent', () => ({ + ...jest.requireActual('./agent'), + getRuleMigrationAgent: () => ({ invoke: mockInvoke }), +})); + +// Mock dependencies +const mockLogger = loggerMock.create(); + +const mockDependencies: jest.Mocked = { + rulesClient: {}, + savedObjectsClient: {}, + inferenceClient: {}, + actionsClient: {}, + telemetry: {}, +} as unknown as SiemMigrationsClientDependencies; + +const mockUser = {} as unknown as AuthenticatedUser; +const ruleId = 'test-rule-id'; + +jest.useFakeTimers(); +jest.spyOn(global, 'setTimeout'); +const mockTimeout = setTimeout as unknown as jest.Mock; +mockTimeout.mockImplementation((cb) => { + // never actually wait, we'll check the calls manually + cb(); +}); + +describe('RuleMigrationTaskRunner', () => { + let taskRunner: RuleMigrationTaskRunner; + let abortController: AbortController; + let mockRuleMigrationsDataClient: ReturnType; + + beforeEach(() => { + mockRetrieverInitialize.mockResolvedValue(undefined); // Reset the mock + mockInvoke.mockResolvedValue({}); // Reset the mock + mockRuleMigrationsDataClient = createRuleMigrationsDataClientMock(); + jest.clearAllMocks(); + + abortController = new AbortController(); + taskRunner = new RuleMigrationTaskRunner( + 'test-migration-id', + mockUser, + abortController, + mockRuleMigrationsDataClient, + mockLogger, + mockDependencies + ); + }); + + describe('setup', () => { + it('should create the agent and tools', async () => { + await expect(taskRunner.setup('test-connector-id')).resolves.toBeUndefined(); + // @ts-expect-error (checking private properties) + expect(taskRunner.agent).toBeDefined(); + // @ts-expect-error (checking private properties) + expect(taskRunner.retriever).toBeDefined(); + // @ts-expect-error (checking private properties) + expect(taskRunner.telemetry).toBeDefined(); + }); + + it('should throw if an error occurs', async () => { + const errorMessage = 'Test error'; + mockCreateModel.mockImplementationOnce(() => { + throw new Error(errorMessage); + }); + + await expect(taskRunner.setup('test-connector-id')).rejects.toThrowError(errorMessage); + }); + }); + + describe('run', () => { + let runPromise: Promise; + beforeEach(async () => { + await taskRunner.setup('test-connector-id'); + }); + + it('should handle the migration successfully', async () => { + mockRuleMigrationsDataClient.rules.get.mockResolvedValue({ total: 0, data: [] }); + mockRuleMigrationsDataClient.rules.get.mockResolvedValueOnce({ + total: 1, + data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + }); + + await taskRunner.setup('test-connector-id'); + await expect(taskRunner.run({})).resolves.toBeUndefined(); + + expect(mockRuleMigrationsDataClient.rules.saveProcessing).toHaveBeenCalled(); + expect(mockTimeout).toHaveBeenCalledTimes(1); // random execution sleep + expect(mockTimeout).toHaveBeenNthCalledWith(1, expect.any(Function), expect.any(Number)); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalled(); + expect(mockRuleMigrationsDataClient.rules.get).toHaveBeenCalledTimes(2); // One with data, one without + expect(mockLogger.info).toHaveBeenCalledWith('Migration completed successfully'); + }); + + describe('when error occurs', () => { + const errorMessage = 'Test error message'; + + describe('during initialization', () => { + it('should handle abort error correctly', async () => { + runPromise = taskRunner.run({}); + abortController.abort(); // Trigger the abort signal + + await expect(runPromise).resolves.toBeUndefined(); // Ensure the function handles abort gracefully + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Abort signal received, stopping initialization' + ); + }); + + it('should handle other errors correctly', async () => { + mockRetrieverInitialize.mockRejectedValueOnce(new Error(errorMessage)); + + runPromise = taskRunner.run({}); + await expect(runPromise).rejects.toEqual( + Error('Migration initialization failed. Error: Test error message') + ); + }); + }); + + describe('during migration', () => { + beforeEach(() => { + mockRuleMigrationsDataClient.rules.get.mockRestore(); + mockRuleMigrationsDataClient.rules.get + .mockResolvedValue({ total: 0, data: [] }) + .mockResolvedValueOnce({ + total: 1, + data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + }); + }); + + it('should handle abort error correctly', async () => { + runPromise = taskRunner.run({}); + // Wait for the initialization to complete, needs 2 ticks + await Promise.resolve(); + await Promise.resolve(); + + abortController.abort(); // Trigger the abort signal + + await expect(runPromise).resolves.toBeUndefined(); // Ensure the function handles abort gracefully + expect(mockLogger.info).toHaveBeenCalledWith('Abort signal received, stopping migration'); + expect(mockRuleMigrationsDataClient.rules.releaseProcessing).toHaveBeenCalled(); + }); + + it('should handle other errors correctly', async () => { + mockInvoke.mockRejectedValue(new Error(errorMessage)); + + runPromise = taskRunner.run({}); + await expect(runPromise).resolves.toBeUndefined(); + + expect(mockLogger.error).toHaveBeenCalledWith( + `Error translating rule \"${ruleId}\" with error: ${errorMessage}` + ); + expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalled(); + }); + + describe('during rate limit errors', () => { + const rule2Id = 'test-rule-id-2'; + const error = new Error('429. You did way too many requests to this random LLM API bud'); + + beforeEach(async () => { + mockRuleMigrationsDataClient.rules.get.mockRestore(); + mockRuleMigrationsDataClient.rules.get + .mockResolvedValue({ total: 0, data: [] }) + .mockResolvedValueOnce({ + total: 2, + data: [ + { id: ruleId, status: SiemMigrationStatus.PENDING }, + { id: rule2Id, status: SiemMigrationStatus.PENDING }, + ] as StoredRuleMigration[], + }); + }); + + it('should retry with exponential backoff', async () => { + mockInvoke + .mockResolvedValue({}) // Successful calls from here on + .mockRejectedValueOnce(error) // First failed call for rule 1 + .mockRejectedValueOnce(error) // First failed call for rule 2 + .mockRejectedValueOnce(error) // Second failed call for rule 1 + .mockRejectedValueOnce(error); // Third failed call for rule 1 + + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + /** + * Invoke calls: + * rule 1 -> failure -> start backoff retries + * rule 2 -> failure -> await for rule 1 backoff + * then: + * rule 1 retry 1 -> failure + * rule 1 retry 2 -> failure + * rule 1 retry 3 -> success + * then: + * rule 2 -> success + */ + expect(mockInvoke).toHaveBeenCalledTimes(6); + expect(mockTimeout).toHaveBeenCalledTimes(6); // 2 execution sleeps + 3 backoff sleeps + 1 execution sleep + expect(mockTimeout).toHaveBeenNthCalledWith( + 1, + expect.any(Function), + expect.any(Number) // exec random sleep + ); + expect(mockTimeout).toHaveBeenNthCalledWith( + 2, + expect.any(Function), + expect.any(Number) // exec random sleep + ); + expect(mockTimeout).toHaveBeenNthCalledWith(3, expect.any(Function), 1000); + expect(mockTimeout).toHaveBeenNthCalledWith(4, expect.any(Function), 2000); + expect(mockTimeout).toHaveBeenNthCalledWith(5, expect.any(Function), 4000); + expect(mockTimeout).toHaveBeenNthCalledWith( + 6, + expect.any(Function), + expect.any(Number) // exec random sleep + ); + + expect(mockLogger.debug).toHaveBeenCalledWith( + `Awaiting backoff task for rule "${rule2Id}"` + ); + expect(mockInvoke).toHaveBeenCalledTimes(6); // 3 retries + 3 executions + expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalledTimes(2); // 2 rules + }); + + it('should fail when reached maxRetries', async () => { + mockInvoke.mockRejectedValue(error); + + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + // maxRetries = 8 + expect(mockInvoke).toHaveBeenCalledTimes(10); // 8 retries + 2 executions + expect(mockTimeout).toHaveBeenCalledTimes(10); // 2 execution sleeps + 8 backoff sleeps + + expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalledTimes(2); // 2 rules + }); + + it('should fail when reached max recovery attempts', async () => { + const rule3Id = 'test-rule-id-3'; + const rule4Id = 'test-rule-id-4'; + mockRuleMigrationsDataClient.rules.get.mockRestore(); + mockRuleMigrationsDataClient.rules.get + .mockResolvedValue({ total: 0, data: [] }) + .mockResolvedValueOnce({ + total: 4, + data: [ + { id: ruleId, status: SiemMigrationStatus.PENDING }, + { id: rule2Id, status: SiemMigrationStatus.PENDING }, + { id: rule3Id, status: SiemMigrationStatus.PENDING }, + { id: rule4Id, status: SiemMigrationStatus.PENDING }, + ] as StoredRuleMigration[], + }); + + // max recovery attempts = 3 + mockInvoke + .mockResolvedValue({}) // should never reach this + .mockRejectedValueOnce(error) // 1st failed call for rule 1 + .mockRejectedValueOnce(error) // 1st failed call for rule 2 + .mockRejectedValueOnce(error) // 1st failed call for rule 3 + .mockRejectedValueOnce(error) // 1st failed call for rule 4 + .mockResolvedValueOnce({}) // Successful call for the rule 1 backoff + .mockRejectedValueOnce(error) // 2nd failed call for the rule 2 recover + .mockRejectedValueOnce(error) // 2nd failed call for the rule 3 recover + .mockRejectedValueOnce(error) // 2nd failed call for the rule 4 recover + .mockResolvedValueOnce({}) // Successful call for the rule 2 backoff + .mockRejectedValueOnce(error) // 3rd failed call for the rule 3 recover + .mockRejectedValueOnce(error) // 3rd failed call for the rule 4 recover + .mockResolvedValueOnce({}) // Successful call for the rule 3 backoff + .mockRejectedValueOnce(error); // 4th failed call for the rule 4 recover (max attempts failure) + + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalledTimes(3); // rules 1, 2 and 3 + expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalledTimes(1); // rule 4 + }); + + it('should increase the executor sleep time when rate limited', async () => { + const getResponse = { + total: 1, + data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + }; + mockRuleMigrationsDataClient.rules.get.mockRestore(); + mockRuleMigrationsDataClient.rules.get + .mockResolvedValue({ total: 0, data: [] }) + .mockResolvedValueOnce(getResponse) + .mockResolvedValueOnce({ total: 0, data: [] }) + .mockResolvedValueOnce(getResponse) + .mockResolvedValueOnce({ total: 0, data: [] }) + .mockResolvedValueOnce(getResponse) + .mockResolvedValueOnce({ total: 0, data: [] }) + .mockResolvedValueOnce(getResponse) + .mockResolvedValueOnce({ total: 0, data: [] }) + .mockResolvedValueOnce(getResponse) + .mockResolvedValueOnce({ total: 0, data: [] }) + .mockResolvedValueOnce(getResponse) + .mockResolvedValueOnce({ total: 0, data: [] }); + + /** + * Current EXECUTOR_SLEEP settings: + * initialValueSeconds: 3, multiplier: 2, limitSeconds: 96, // 1m36s (5 increases) + */ + + // @ts-expect-error (checking private properties) + expect(taskRunner.executorSleepMultiplier).toBe(3); + + mockInvoke.mockResolvedValue({}).mockRejectedValueOnce(error); // rate limit and recovery + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + // @ts-expect-error (checking private properties) + expect(taskRunner.executorSleepMultiplier).toBe(6); + + mockInvoke.mockResolvedValue({}).mockRejectedValueOnce(error); // rate limit and recovery + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + // @ts-expect-error (checking private properties) + expect(taskRunner.executorSleepMultiplier).toBe(12); + + mockInvoke.mockResolvedValue({}).mockRejectedValueOnce(error); // rate limit and recovery + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + // @ts-expect-error (checking private properties) + expect(taskRunner.executorSleepMultiplier).toBe(24); + + mockInvoke.mockResolvedValue({}).mockRejectedValueOnce(error); // rate limit and recovery + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + // @ts-expect-error (checking private properties) + expect(taskRunner.executorSleepMultiplier).toBe(48); + + mockInvoke.mockResolvedValue({}).mockRejectedValueOnce(error); // rate limit and recovery + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + // @ts-expect-error (checking private properties) + expect(taskRunner.executorSleepMultiplier).toBe(96); + + mockInvoke.mockResolvedValue({}).mockRejectedValueOnce(error); // rate limit and recovery + await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + + // @ts-expect-error (checking private properties) + expect(taskRunner.executorSleepMultiplier).toBe(96); // limit reached + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Executor sleep reached the maximum value' + ); + }); + }); + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts new file mode 100644 index 0000000000000..228793745af92 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts @@ -0,0 +1,298 @@ +/* + * 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 assert from 'assert'; +import type { AuthenticatedUser, Logger } from '@kbn/core/server'; +import { abortSignalToPromise, AbortError } from '@kbn/kibana-utils-plugin/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; +import { initPromisePool } from '../../../../utils/promise_pool'; +import type { SiemMigrationsDataClient } from '../data/siem_migrations_data_client'; +import type { MigrationState, MigrationTask, MigrationTaskInvoke } from './types'; +import { generateAssistantComment } from './util/comments'; +import type { + ItemDocument, + MigrationDocument, + SiemMigrationsClientDependencies, + Stored, +} from '../types'; +import { ActionsClientChat } from './util/actions_client_chat'; +import type { SiemMigrationTelemetryClient } from './siem_migrations_telemetry_client'; + +/** Number of concurrent item translations in the pool */ +const TASK_CONCURRENCY = 10 as const; +/** Number of items loaded in memory to be translated in the pool */ +const TASK_BATCH_SIZE = 100 as const; +/** The timeout of each individual agent invocation in minutes */ +const AGENT_INVOKE_TIMEOUT_MIN = 3 as const; + +/** Exponential backoff configuration to handle rate limit errors */ +const RETRY_CONFIG = { + initialRetryDelaySeconds: 1, + backoffMultiplier: 2, + maxRetries: 8, + // max waiting time 4m15s (1*2^8 = 256s) +} as const; + +/** Executor sleep configuration + * A sleep time applied at the beginning of each single item translation in the execution pool, + * The objective of this sleep is to spread the load of concurrent translations, and prevent hitting the rate limit repeatedly. + * The sleep time applied is a random number between [0-value]. Every time we hit rate limit the value is increased by the multiplier, up to the limit. + */ +const EXECUTOR_SLEEP = { + initialValueSeconds: 3, + multiplier: 2, + limitSeconds: 96, // 1m36s (5 increases) +} as const; + +/** This limit should never be reached, it's a safety net to prevent infinite loops. + * It represents the max number of consecutive rate limit recovery & failure attempts. + * This can only happen when the API can not process TASK_CONCURRENCY translations at a time, + * even after the executor sleep is increased on every attempt. + **/ +const EXECUTOR_RECOVER_MAX_ATTEMPTS = 3 as const; + +export class SiemMigrationTaskRunner< + M extends MigrationDocument = MigrationDocument, // The migration document type (rule migrations and dashboard migrations very similar but have differences) + I extends ItemDocument = ItemDocument, // The rule or dashboard document type + C extends object = {} // The migration task config to be passed to the agent, includes parameters for the agent invocation +> { + protected telemetry?: SiemMigrationTelemetryClient; + protected task?: MigrationTask; + declare actionsClientChat: ActionsClientChat; + private abort: ReturnType; + private executorSleepMultiplier: number = EXECUTOR_SLEEP.initialValueSeconds; + public isWaiting: boolean = false; + + constructor( + public readonly migrationId: string, + public readonly startedBy: AuthenticatedUser, + public readonly abortController: AbortController, + protected readonly data: SiemMigrationsDataClient, + protected readonly logger: Logger, + protected readonly dependencies: SiemMigrationsClientDependencies + ) { + this.actionsClientChat = new ActionsClientChat(this.dependencies.actionsClient, this.logger); + this.abort = abortSignalToPromise(this.abortController.signal); + } + + /** Retrieves the connector and creates the migration agent */ + public async setup(_connectorId: string): Promise {} + + protected async initialize() {} + + public async run(invocationConfig: RunnableConfig): Promise { + assert(this.telemetry, 'telemetry is missing please call setup() first'); + const { telemetry, migrationId } = this; + + const migrationTaskTelemetry = telemetry.startSiemMigrationTask(); + + try { + this.logger.debug('Initializing migration'); + await this.withAbort(this.initialize()); + } catch (error) { + migrationTaskTelemetry.failure(error); + if (error instanceof AbortError) { + this.logger.info('Abort signal received, stopping initialization'); + return; + } else { + throw new Error(`Migration initialization failed. ${error}`); + } + } + + const migrateItemTask = this.createMigrateItemTask(invocationConfig); + this.logger.debug(`Started translations. Concurrency is: ${TASK_CONCURRENCY}`); + + try { + do { + const { data: migrationItems } = await this.data.items.get(migrationId, { + filters: { status: SiemMigrationStatus.PENDING }, + size: TASK_BATCH_SIZE, // keep these items in memory and process them in the promise pool with concurrency limit + }); + if (migrationItems.length === 0) { + break; + } + + this.logger.debug(`Start processing batch of ${migrationItems.length} items`); + + const { errors } = await initPromisePool({ + concurrency: TASK_CONCURRENCY, + abortSignal: this.abortController.signal, + items: migrationItems, + executor: async (migrationItem) => { + const itemTranslationTelemetry = migrationTaskTelemetry.startItemTranslation(); + try { + await this.saveItemProcessing(migrationItem); + + const migrationResult = await migrateItemTask(migrationItem); + + await this.saveItemCompleted(migrationItem, migrationResult); + itemTranslationTelemetry.success(migrationResult); + } catch (error) { + if (this.abortController.signal.aborted) { + throw new AbortError(); + } + itemTranslationTelemetry.failure(error); + await this.saveItemFailed(migrationItem, error); + } + }, + }); + + if (errors.length > 0) { + throw errors[0].error; // Only AbortError is thrown from the pool. The task was aborted + } + + this.logger.debug('Batch processed successfully'); + } while (true); + + migrationTaskTelemetry.success(); + this.logger.info('Migration completed successfully'); + } catch (error) { + await this.data.items.releaseProcessing(migrationId); + + if (error instanceof AbortError) { + migrationTaskTelemetry.aborted(error); + this.logger.info('Abort signal received, stopping migration'); + } else { + migrationTaskTelemetry.failure(error); + throw new Error(`Error processing migration: ${error}`); + } + } finally { + this.abort.cleanup(); + } + } + + protected createMigrateItemTask(invocationConfig?: RunnableConfig) { + const config: RunnableConfig = { + timeout: AGENT_INVOKE_TIMEOUT_MIN * 60 * 1000, // milliseconds timeout + ...invocationConfig, + signal: this.abortController.signal, + }; + + // Invokes the item translation with exponential backoff, should be called only when the rate limit has been hit + const invokeWithBackoff = async ( + invoke: MigrationTaskInvoke + ): Promise> => { + this.logger.debug('Rate limit backoff started'); + let retriesLeft: number = RETRY_CONFIG.maxRetries; + while (true) { + try { + await this.sleepRetry(retriesLeft); + retriesLeft--; + const result = await invoke(); + this.logger.info( + `Rate limit backoff completed successfully after ${ + RETRY_CONFIG.maxRetries - retriesLeft + } retries` + ); + return result; + } catch (error) { + if (!this.isRateLimitError(error) || retriesLeft === 0) { + const logMessage = + retriesLeft === 0 + ? `Rate limit backoff completed unsuccessfully` + : `Rate limit backoff interrupted. ${error} `; + this.logger.debug(logMessage); + throw error; + } + this.logger.debug(`Rate limit backoff not completed, retries left: ${retriesLeft}`); + } + } + }; + + let backoffPromise: Promise> | undefined; + // Migrates one item, this function will be called concurrently by the promise pool. + // Handles rate limit errors and ensures only one task is executing the backoff retries at a time, the rest of translation will await. + const migrateItem = async (migrationItem: I): Promise> => { + assert(this.task, 'task is not initialized'); + const invoke = await this.task.prepare(migrationItem, config); + + let recoverAttemptsLeft: number = EXECUTOR_RECOVER_MAX_ATTEMPTS; + while (true) { + try { + await this.executorSleep(); // Random sleep, increased every time we hit the rate limit. + return await invoke(); + } catch (error) { + if (!this.isRateLimitError(error) || recoverAttemptsLeft === 0) { + throw error; + } + if (!backoffPromise) { + // only one translation handles the rate limit backoff retries, the rest will await it and try again when it's resolved + backoffPromise = invokeWithBackoff(invoke); + this.isWaiting = true; + return backoffPromise.finally(() => { + backoffPromise = undefined; + this.increaseExecutorSleep(); + this.isWaiting = false; + }); + } + this.logger.debug(`Awaiting backoff task for document "${migrationItem.id}"`); + await backoffPromise.catch(() => { + throw error; // throw the original error + }); + recoverAttemptsLeft--; + } + } + }; + + return migrateItem; + } + + private isRateLimitError(error: Error) { + return error.message.match(/\b429\b/); // "429" (whole word in the error message): Too Many Requests. + } + + private async withAbort(promise: Promise): Promise { + return Promise.race([promise, this.abort.promise]); + } + + private async sleep(seconds: number) { + await this.withAbort(new Promise((resolve) => setTimeout(resolve, seconds * 1000))); + } + + // Exponential backoff implementation + private async sleepRetry(retriesLeft: number) { + const seconds = + RETRY_CONFIG.initialRetryDelaySeconds * + Math.pow(RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxRetries - retriesLeft); + this.logger.debug(`Retry sleep: ${seconds}s`); + await this.sleep(seconds); + } + + private executorSleep = async () => { + const seconds = Math.random() * this.executorSleepMultiplier; + this.logger.debug(`Executor sleep: ${seconds.toFixed(3)}s`); + await this.sleep(seconds); + }; + + private increaseExecutorSleep = () => { + const increasedMultiplier = this.executorSleepMultiplier * EXECUTOR_SLEEP.multiplier; + if (increasedMultiplier > EXECUTOR_SLEEP.limitSeconds) { + this.logger.warn('Executor sleep reached the maximum value'); + return; + } + this.executorSleepMultiplier = increasedMultiplier; + }; + + protected async saveItemProcessing(migrationItem: Stored) { + this.logger.debug(`Starting translation of document "${migrationItem.id}"`); + return this.data.items.saveProcessing(migrationItem.id); + } + + protected async saveItemCompleted(migrationItem: Stored, migrationResult: MigrationState) { + this.logger.debug(`Translation of document "${migrationItem.id}" succeeded`); + return this.data.items.saveCompleted(migrationResult as Stored); + } + + protected async saveItemFailed(migrationItem: Stored, error: Error) { + this.logger.error( + `Error translating document "${migrationItem.id}" with error: ${error.message}` + ); + const comments = [generateAssistantComment(`Error migrating document: ${error.message}`)]; + return this.data.items.saveError({ ...migrationItem, comments }); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_telemetry_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_telemetry_client.ts new file mode 100644 index 0000000000000..a7a20ba1d88e3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_telemetry_client.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 type { ItemDocument } from '../types'; +import type { MigrationState } from './types'; + +interface StartMigrationTaskTelemetry { + startItemTranslation: () => { + success: (migrationResult: MigrationState) => void; + failure: (error: Error) => void; + }; + success: () => void; + failure: (error: Error) => void; + aborted: (error: Error) => void; +} + +export abstract class SiemMigrationTelemetryClient { + public abstract startSiemMigrationTask(): StartMigrationTaskTelemetry; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts new file mode 100644 index 0000000000000..7d3c7ce8a8740 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AuthenticatedUser } from '@kbn/core/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { LangSmithEvaluationOptions } from '../../../../../common/siem_migrations/model/common.gen'; +import type { SiemMigrationsDataClient } from '../data/siem_migrations_data_client'; +import type { ItemDocument, SiemMigrationsClientDependencies } from '../types'; +import type { Stored } from '../../types'; + +export interface SiemMigrationTaskCreateClientParams { + currentUser: AuthenticatedUser; + dataClient: SiemMigrationsDataClient; + dependencies: SiemMigrationsClientDependencies; +} + +export interface SiemMigrationTaskStartParams { + migrationId: string; + connectorId: string; + invocationConfig: RunnableConfig; +} + +export interface SiemMigrationTaskStartResult { + started: boolean; + exists: boolean; +} + +export interface SiemMigrationTaskStopResult { + stopped: boolean; + exists: boolean; +} + +export interface SiemMigrationTaskEvaluateParams { + evaluationId: string; + connectorId: string; + langsmithOptions: LangSmithEvaluationOptions; + invocationConfig: RunnableConfig; + abortController: AbortController; +} + +export type MigrationState = Partial>; +export type MigrationTaskInvoke = () => Promise< + MigrationState +>; +export interface MigrationTask { + prepare: (item: Stored, config: RunnableConfig) => Promise>; +} + +export interface RuleMigrationAgentRunOptions { + skipPrebuiltRulesMatching: boolean; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/esql_knowledge_base.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/esql_knowledge_base.ts new file mode 100644 index 0000000000000..73f5e8a2930f5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/esql_knowledge_base.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockEsqlKnowledgeBase } from './mocks'; +export const EsqlKnowledgeBase = MockEsqlKnowledgeBase; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/mocks.ts new file mode 100644 index 0000000000000..978ff356fa29b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/__mocks__/mocks.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 type { EsqlKnowledgeBase } from '../esql_knowledge_base'; +import type { PublicMethodsOf } from '@kbn/utility-types'; + +export const createEsqlKnowledgeBaseMock = () => { + return { + translate: jest.fn().mockResolvedValue(''), + } as jest.Mocked>; +}; + +// Factory function for the mock class +export const MockEsqlKnowledgeBase = jest + .fn() + .mockImplementation(() => createEsqlKnowledgeBaseMock()); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts new file mode 100644 index 0000000000000..4cea0b06655d6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts @@ -0,0 +1,103 @@ +/* + * 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 { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Logger } from '@kbn/core/server'; +import type { ActionsClientSimpleChatModel } from '@kbn/langchain/server'; +import { + ActionsClientBedrockChatModel, + ActionsClientChatOpenAI, + ActionsClientChatVertexAI, +} from '@kbn/langchain/server'; +import type { CustomChatModelInput as ActionsClientBedrockChatModelParams } from '@kbn/langchain/server/language_models/bedrock_chat'; +import type { ActionsClientChatOpenAIParams } from '@kbn/langchain/server/language_models/chat_openai'; +import type { CustomChatModelInput as ActionsClientChatVertexAIParams } from '@kbn/langchain/server/language_models/gemini_chat'; +import type { CustomChatModelInput as ActionsClientSimpleChatModelParams } from '@kbn/langchain/server/language_models/simple_chat_model'; +import { TELEMETRY_SIEM_MIGRATION_ID } from './constants'; + +export type ChatModel = + | ActionsClientSimpleChatModel + | ActionsClientChatOpenAI + | ActionsClientBedrockChatModel + | ActionsClientChatVertexAI; + +export type ActionsClientChatModelClass = + | typeof ActionsClientSimpleChatModel + | typeof ActionsClientChatOpenAI + | typeof ActionsClientBedrockChatModel + | typeof ActionsClientChatVertexAI; + +export type ChatModelParams = Partial & + Partial & + Partial & + Partial; + +const llmTypeDictionary: Record = { + [`.gen-ai`]: `openai`, + [`.bedrock`]: `bedrock`, + [`.gemini`]: `gemini`, + [`.inference`]: `inference`, +}; + +interface CreateModelParams { + migrationId: string; + connectorId: string; + abortController: AbortController; +} + +export class ActionsClientChat { + constructor(private readonly actionsClient: ActionsClient, private readonly logger: Logger) {} + + public async createModel({ + migrationId, + connectorId, + abortController, + }: CreateModelParams): Promise { + const connector = await this.actionsClient.get({ id: connectorId }); + if (!connector) { + throw new Error(`Connector not found: ${connectorId}`); + } + + const llmType = this.getLLMType(connector.actionTypeId); + const ChatModelClass = this.getLLMClass(llmType); + + const model = new ChatModelClass({ + actionsClient: this.actionsClient, + connectorId, + llmType, + model: connector.config?.defaultModel, + streaming: false, + convertSystemMessageToHumanContent: false, + temperature: 0.05, + maxRetries: 1, // Only retry once inside the model, we will handle backoff retries in the task runner + telemetryMetadata: { pluginId: TELEMETRY_SIEM_MIGRATION_ID, aggregateBy: migrationId }, + signal: abortController.signal, + logger: this.logger, + }); + return model; + } + + private getLLMType(actionTypeId: string): string | undefined { + if (llmTypeDictionary[actionTypeId]) { + return llmTypeDictionary[actionTypeId]; + } + throw new Error(`Unknown LLM type for action type ID: ${actionTypeId}`); + } + + private getLLMClass(llmType?: string): ActionsClientChatModelClass { + switch (llmType) { + case 'bedrock': + return ActionsClientBedrockChatModel; + case 'gemini': + return ActionsClientChatVertexAI; + case 'openai': + case 'inference': + default: + return ActionsClientChatOpenAI; + } + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts new file mode 100644 index 0000000000000..291e8c9bcf094 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts @@ -0,0 +1,22 @@ +/* + * 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 { SIEM_MIGRATIONS_ASSISTANT_USER } from '../../../../../../common/siem_migrations/constants'; +import type { RuleMigrationComment } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; + +export const cleanMarkdown = (markdown: string): string => { + // Use languages known by the code block plugin + return markdown.replaceAll('```esql', '```sql').replaceAll('```spl', '```splunk-spl'); +}; + +export const generateAssistantComment = (message: string): RuleMigrationComment => { + return { + message, + created_at: new Date().toISOString(), + created_by: SIEM_MIGRATIONS_ASSISTANT_USER, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts new file mode 100644 index 0000000000000..5ca00bbab4561 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.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 const TELEMETRY_SIEM_MIGRATION_ID = 'siem_migrations'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts new file mode 100644 index 0000000000000..fb8378a393fec --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { naturalLanguageToEsql } from '@kbn/inference-plugin/server'; +import type { InferenceClient } from '@kbn/inference-common'; +import { lastValueFrom } from 'rxjs'; +import { TELEMETRY_SIEM_MIGRATION_ID } from './constants'; + +export class EsqlKnowledgeBase { + constructor( + private readonly connectorId: string, + private readonly migrationId: string, + private readonly client: InferenceClient, + private readonly logger: Logger + ) {} + + public async translate(input: string): Promise { + const { content } = await lastValueFrom( + naturalLanguageToEsql({ + client: this.client, + connectorId: this.connectorId, + input, + logger: this.logger, + metadata: { + connectorTelemetry: { + pluginId: TELEMETRY_SIEM_MIGRATION_ID, + aggregateBy: this.migrationId, + }, + }, + }) + ); + return content; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts new file mode 100644 index 0000000000000..e73195fe9751f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { z } from '@kbn/zod'; +import type { ElasticRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { nullifyElasticRule, nullifyMissingPropertiesInObject } from './nullify_missing_properties'; + +describe('nullify missing values in object', () => { + describe('nullifyMissingPropertiesInObject', () => { + const someZodObject = z.object({ + foo: z.string(), + bar: z.number().optional(), + baz: z.object({ + qux: z.boolean().optional(), + }), + }); + + const val: z.infer = { + foo: 'test', + baz: { + qux: true, + }, + }; + it('should correctly nullify missing values in zod object at first level', () => { + const result = nullifyMissingPropertiesInObject(someZodObject, val); + expect(result).toMatchObject({ + foo: 'test', + bar: null, + baz: { + qux: true, + }, + }); + }); + + it('should throw if object does not conform to the schema', () => { + const invalidVal = { + foo: 'test', + // Missing 'baz' property + }; + + expect(() => + nullifyMissingPropertiesInObject(someZodObject, invalidVal as z.infer) + ).toThrow(); + }); + }); + + describe('nullifyElasticRule', () => { + it('should return an object with nullified empty values', () => { + const elasticRule: ElasticRule = { + title: 'Some Title', + }; + + const result = nullifyElasticRule(elasticRule); + + expect(result).toMatchObject({ + title: 'Some Title', + description: null, + severity: null, + risk_score: null, + query: null, + query_language: null, + prebuilt_rule_id: null, + integration_ids: null, + id: null, + }); + }); + + it('should return original object and call error callback in case of error', () => { + const elasticRule = { + hero: 'Some Title', + } as unknown as ElasticRule; + + const errorMock = jest.fn(); + + const result = nullifyElasticRule(elasticRule, errorMock); + + expect(result).toMatchObject(elasticRule); + expect(errorMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts new file mode 100644 index 0000000000000..0942fc67983ad --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts @@ -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 type { z } from '@kbn/zod'; +import type { ElasticRule as ElasticRuleType } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { ElasticRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; + +type Nullable = { [K in keyof T]: T[K] | null }; + +/** + * This function takes a Zod schema and an object, and returns a new object + * where any missing values of `only first-level keys` in the object are set to null, according to the schema. + * + * Raises an error if the object does not conform to the schema. + * + * This is specially beneficial for `unsetting` fields in Elasticsearch documents. + */ +export const nullifyMissingPropertiesInObject = ( + zodType: T, + obj: z.infer +): Nullable> => { + const schemaWithNullValues = zodType.transform((value: z.infer) => { + const result: Nullable> = { ...value }; + Object.keys(zodType.shape).forEach((key) => { + if (!(key in value)) { + result[key as keyof z.infer] = null; + } + }); + return result; + }); + + return schemaWithNullValues.parse(obj); +}; + +/** + * This function takes an ElasticRule object and returns a new object + * where any missing values are set to null, according to the ElasticRule schema. + * + * If an error occurs during the transformation, it calls the onError callback + * with the error and returns the original object. + */ +export const nullifyElasticRule = (obj: ElasticRuleType, onError?: (error: Error) => void) => { + try { + return nullifyMissingPropertiesInObject(ElasticRule, obj); + } catch (error) { + onError?.(error); + return obj; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts index 3482e8ff156e6..9a8258cbf6f88 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts @@ -14,6 +14,14 @@ import type { } from '@kbn/core/server'; import type { PackageService } from '@kbn/fleet-plugin/server'; import type { InferenceClient } from '@kbn/inference-common'; +import type { + DashboardMigration, + DashboardMigrationDashboard, +} from '../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { + RuleMigration, + RuleMigrationRule, +} from '../../../../common/siem_migrations/model/rule_migration.gen'; export interface SiemMigrationsClientDependencies { inferenceClient: InferenceClient; @@ -32,3 +40,12 @@ export interface SiemMigrationsCreateClientParams { } export type SiemMigrationsIndexNameProvider = () => Promise; + +export type Stored = T & { id: string }; + +// TODO: replace these with the schemas for the common properties of the migrations and items +export type MigrationDocument = RuleMigration | DashboardMigration; +export type ItemDocument = RuleMigrationRule | DashboardMigrationDashboard; + +export type StoredSiemMigration = Stored; +export type StoredSiemMigrationItem = Stored; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/create.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/create.ts index c0b99bce900e9..a3bf7bb8a7bbe 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/create.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/create.ts @@ -10,11 +10,11 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import type { IKibanaResponse } from '@kbn/core/server'; import { SIEM_DASHBOARD_MIGRATIONS_PATH } from '../../../../../common/siem_migrations/dashboards/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; +import { authz } from '../../common/api/util/authz'; import type { CreateDashboardMigrationResponse } from '../../../../../common/siem_migrations/model/api/dashboards/dashboard_migration.gen'; import { CreateDashboardMigrationRequestBody } from '../../../../../common/siem_migrations/model/api/dashboards/dashboard_migration.gen'; -import { withLicense } from '../../common/utils/with_license'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; +import { withLicense } from '../../common/api/util/with_license'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; export const registerSiemDashboardMigrationsCreateRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts index 82216fbefede7..5c2f1ea170e92 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts @@ -14,8 +14,8 @@ import { } from '../../../../../../common/siem_migrations/model/api/dashboards/dashboard_migration.gen'; import { SIEM_DASHBOARD_MIGRATION_DASHBOARDS_PATH } from '../../../../../../common/siem_migrations/dashboards/constants'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import { authz } from '../../../common/utils/authz'; -import { withLicense } from '../../../common/utils/with_license'; +import { authz } from '../../../common/api/util/authz'; +import { withLicense } from '../../../common/api/util/with_license'; export const registerSiemDashboardMigrationsCreateDashboardsRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/get.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/get.ts index 058231eaec6dc..330bf5f8a320e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/get.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/get.ts @@ -11,9 +11,9 @@ import type { GetDashboardMigrationResponse } from '../../../../../common/siem_m import { GetDashboardMigrationRequestParams } from '../../../../../common/siem_migrations/model/api/dashboards/dashboard_migration.gen'; import { SIEM_DASHBOARD_MIGRATION_PATH } from '../../../../../common/siem_migrations/dashboards/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; -import { withLicense } from '../../common/utils/with_license'; +import { authz } from '../../common/api/util/authz'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { withLicense } from '../../common/api/util/with_license'; import { MIGRATION_ID_NOT_FOUND } from '../../common/translations'; export const registerSiemDashboardMigrationsGetRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts new file mode 100644 index 0000000000000..2ea39ea739629 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts @@ -0,0 +1,103 @@ +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { SIEM_RULE_MIGRATION_START_PATH } from '../../../../../common/siem_migrations/constants'; +import { + StartRuleMigrationRequestBody, + StartRuleMigrationRequestParams, + type StartRuleMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { getRetryFilter } from '../../common/api/util/retry'; +import { withLicense } from '../../common/api/util/with_license'; +import { createTracersCallbacks } from '../../common/api/util/tracing'; +import { withExistingDashboardMigration } from './util/with_existing_dashboard_migration'; + +export const registerSiemRuleMigrationsStartRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .post({ + path: SIEM_RULE_MIGRATION_START_PATH, + access: 'internal', + security: { authz }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + params: buildRouteValidationWithZod(StartRuleMigrationRequestParams), + body: buildRouteValidationWithZod(StartRuleMigrationRequestBody), + }, + }, + }, + withLicense( + withExistingDashboardMigration( + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + const { + langsmith_options: langsmithOptions, + settings: { + connector_id: connectorId, + skip_prebuilt_rules_matching: skipPrebuiltRulesMatching = false, + }, + retry, + } = req.body; + + const siemMigrationAuditLogger = new SiemMigrationAuditLogger(context.securitySolution); + try { + const ctx = await context.resolve(['actions', 'securitySolution']); + + // Check if the connector exists and user has permissions to read it + const connector = await ctx.actions.getActionsClient().get({ id: connectorId }); + if (!connector) { + return res.badRequest({ body: `Connector with id ${connectorId} not found` }); + } + + const dashboardMigrationsClient = + ctx.securitySolution.siemMigrations.getDashboardsClient(); + if (retry) { + const { updated } = await dashboardMigrationsClient.task.updateToRetry( + migrationId, + getRetryFilter(retry) + ); + if (!updated) { + return res.ok({ body: { started: false } }); + } + } + + const callbacks = createTracersCallbacks(langsmithOptions, logger); + + const { exists, started } = await dashboardMigrationsClient.task.start({ + migrationId, + connectorId, + invocationConfig: { callbacks, configurable: { skipPrebuiltRulesMatching } }, + }); + + if (!exists) { + return res.notFound(); + } + + await siemMigrationAuditLogger.logStart({ migrationId }); + + return res.ok({ body: { started } }); + } catch (error) { + logger.error(error); + await siemMigrationAuditLogger.logStart({ migrationId, error }); + return res.customError({ statusCode: 500, body: error.message }); + } + } + ) + ) + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts index 3585c44e64f61..370d0874611e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts @@ -11,10 +11,10 @@ import type { GetDashboardMigrationStatsResponse } from '../../../../../common/s import { GetDashboardMigrationStatsRequestParams } from '../../../../../common/siem_migrations/model/api/dashboards/dashboard_migration.gen'; import { SIEM_DASHBOARD_MIGRATION_STATS_PATH } from '../../../../../common/siem_migrations/dashboards/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { withLicense } from '../../common/utils/with_license'; -import { authz } from '../../common/utils/authz'; +import { withLicense } from '../../common/api/util/with_license'; +import { authz } from '../../common/api/util/authz'; import { MIGRATION_ID_NOT_FOUND } from '../../common/translations'; -import { withExistingDashboardMigration } from './utils/use_existing_dashboard_migration'; +import { withExistingDashboardMigration } from './util/with_existing_dashboard_migration'; export const registerSiemDashboardMigrationsStatsRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/utils/use_existing_dashboard_migration.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/util/with_existing_dashboard_migration.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/utils/use_existing_dashboard_migration.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/util/with_existing_dashboard_migration.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts index 143fe211d6cd7..3dbcf9006041d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts @@ -5,124 +5,21 @@ * 2.0. */ -import type { - AggregationsMaxAggregate, - AggregationsMinAggregate, - AggregationsStringTermsAggregate, - AggregationsStringTermsBucket, - QueryDslQueryContainer, -} from '@elastic/elasticsearch/lib/api/types'; -import { MigrationTaskStatusEnum } from '../../../../../common/siem_migrations/model/common.gen'; -import type { SplunkOriginalDashboardExport } from '../../../../../common/siem_migrations/model/vendor/dashboards/splunk.gen'; -import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; -import { SiemMigrationsDataBaseClient } from '../../common/data/siem_migrations_data_base_client'; -import { - type DashboardMigrationDashboard, - type DashboardMigrationTaskStats, -} from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; - -/* 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; - -export class DashboardMigrationsDataDashboardsClient extends SiemMigrationsDataBaseClient { - /** Indexes an array of dashboards to be processed as a part of single migration */ - async create( - migrationId: string, - originalDashboards: SplunkOriginalDashboardExport[] - ): Promise { - const index = await this.getIndexName(); - const profileId = await this.getProfileUid(); - - let originalDashboardsMaxBatch: SplunkOriginalDashboardExport[]; - const createdAt = new Date().toISOString(); - while ((originalDashboardsMaxBatch = originalDashboards.splice(0, BULK_MAX_SIZE)).length) { - await this.esClient - .bulk({ - refresh: 'wait_for', - operations: originalDashboardsMaxBatch.flatMap(({ result: { ...originalDashboard } }) => [ - { create: { _index: index } }, - { - migration_id: migrationId, - '@timestamp': createdAt, - status: SiemMigrationStatus.PENDING, - created_by: profileId, - updated_by: profileId, - updated_at: createdAt, - original_dashboard: { - id: originalDashboard.id, - title: originalDashboard.label ?? originalDashboard.title, - description: originalDashboard.description ?? '', - data: originalDashboard?.['eai:data'], - format: 'xml', - vendor: 'splunk', - last_updated: originalDashboard.updated, - splunk_properties: { - app: originalDashboard['eai:acl.app'], - owner: originalDashboard['eai:acl.owner'], - sharing: originalDashboard['eai:acl.sharing'], - }, - }, - }, - ]), - }) - .catch((error) => { - this.logger.error( - `Error adding dashboards to migration (id:${migrationId}) : ${error.message}` - ); - throw error; - }); - } - } - - async getStats(migrationId: string): Promise> { - const index = await this.getIndexName(); - - const migrationIdFilter: QueryDslQueryContainer = { term: { migration_id: migrationId } }; - const query = { - bool: { - filter: migrationIdFilter, - }, - }; - const aggregations = { - status: { terms: { field: 'status' } }, - createdAt: { min: { field: '@timestamp' } }, - lastUpdatedAt: { max: { field: 'updated_at' } }, - }; - const result = await this.esClient - .search({ index, query, aggregations, _source: false }) - .catch((error) => { - this.logger.error(`Error getting dashboard migration stats: ${error.message}`); - throw error; - }); - - const aggs = result.aggregations ?? {}; - - return { - id: migrationId, - dashboards: { - total: this.getTotalHits(result), - ...this.statusAggCounts(aggs.status as AggregationsStringTermsAggregate), - }, - created_at: (aggs.createdAt as AggregationsMinAggregate)?.value_as_string ?? '', - last_updated_at: (aggs.lastUpdatedAt as AggregationsMaxAggregate)?.value_as_string ?? '', - status: MigrationTaskStatusEnum.ready, - }; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { estypes } from '@elastic/elasticsearch'; +import type { DashboardMigrationDashboard } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { SiemMigrationItemSort } from '../../common/data/siem_migrations_data_item_client'; +import { SiemMigrationsDataItemClient } from '../../common/data/siem_migrations_data_item_client'; + +export class DashboardMigrationsDataDashboardsClient extends SiemMigrationsDataItemClient { + protected type = 'dashboard' as const; + + protected getFilterQuery(migrationId: string, _filters?: object): QueryDslQueryContainer { + const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; + return { bool: { filter } }; } - private statusAggCounts( - statusAgg: AggregationsStringTermsAggregate - ): Record { - const buckets = statusAgg.buckets as AggregationsStringTermsBucket[]; - return { - [SiemMigrationStatus.PENDING]: - buckets.find(({ key }) => key === SiemMigrationStatus.PENDING)?.doc_count ?? 0, - [SiemMigrationStatus.PROCESSING]: - buckets.find(({ key }) => key === SiemMigrationStatus.PROCESSING)?.doc_count ?? 0, - [SiemMigrationStatus.COMPLETED]: - buckets.find(({ key }) => key === SiemMigrationStatus.COMPLETED)?.doc_count ?? 0, - [SiemMigrationStatus.FAILED]: - buckets.find(({ key }) => key === SiemMigrationStatus.FAILED)?.doc_count ?? 0, - }; + protected getSortOptions(sort: SiemMigrationItemSort = {}): estypes.Sort { + return []; } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts index 3fec05f0e8f99..2198ee69e4ec4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts @@ -7,14 +7,21 @@ import type { Logger } from '@kbn/logging'; import type { AuthenticatedUser, IScopedClusterClient } from '@kbn/core/server'; +import type { DashboardMigration } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import { SiemMigrationsDataMigrationClient } from '../../common/data/siem_migrations_data_migration_client'; import { DashboardMigrationsDataDashboardsClient } from './dashboard_migrations_dashboards_client'; -import { DashboardMigrationsDataMigrationClient } from './dashboard_migrations_migration_client'; import type { DashboardMigrationIndexNameProviders } from '../types'; import type { SiemMigrationsClientDependencies } from '../../common/types'; +import { SiemMigrationsDataClient } from '../../common/data/siem_migrations_data_client'; +import { SiemMigrationsDataResourcesClient } from '../../common/data/siem_migrations_data_resources_client'; -export class DashboardMigrationsDataClient { - public readonly migrations: DashboardMigrationsDataMigrationClient; - public readonly dashboards: DashboardMigrationsDataDashboardsClient; +export class DashboardMigrationsDataClient extends SiemMigrationsDataClient { + protected logger: Logger; + protected esClient: IScopedClusterClient['asInternalUser']; + + public readonly migrations: SiemMigrationsDataMigrationClient; + public readonly items: DashboardMigrationsDataDashboardsClient; + public readonly resources: SiemMigrationsDataResourcesClient; constructor( indexNameProviders: DashboardMigrationIndexNameProviders, @@ -24,19 +31,31 @@ export class DashboardMigrationsDataClient { spaceId: string, dependencies: SiemMigrationsClientDependencies ) { - this.migrations = new DashboardMigrationsDataMigrationClient( + super(); + + this.logger = logger; + this.esClient = esScopedClient.asInternalUser; + + this.migrations = new SiemMigrationsDataMigrationClient( indexNameProviders.migrations, currentUser, esScopedClient, logger, dependencies ); - this.dashboards = new DashboardMigrationsDataDashboardsClient( + this.items = new DashboardMigrationsDataDashboardsClient( indexNameProviders.dashboards, currentUser, esScopedClient, logger, dependencies ); + this.resources = new SiemMigrationsDataResourcesClient( + indexNameProviders.resources, + currentUser, + esScopedClient, + logger, + dependencies + ); } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.test.ts deleted file mode 100644 index 2bda2f54ef672..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; -import { DashboardMigrationsDataMigrationClient } from './dashboard_migrations_migration_client'; -import type { AuthenticatedUser, IScopedClusterClient } from '@kbn/core/server'; -import type { SiemMigrationsClientDependencies } from '../../common/types'; -import type IndexApi from '@elastic/elasticsearch/lib/api/api'; -import expect from 'expect'; -import type GetApi from '@elastic/elasticsearch/lib/api/api/get'; -import type { GetGetResult } from '@elastic/elasticsearch/lib/api/types'; -import type { DashboardMigration } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; - -const INDEX_NAME = '.kibana-siem-dashboard-migrations'; - -describe('Dashboard Migrations Client', () => { - let dashboardMigrationsDataMigrationClient: DashboardMigrationsDataMigrationClient; - - const esClientMock = - elasticsearchServiceMock.createCustomClusterClient() as unknown as IScopedClusterClient; - const logger = loggingSystemMock.createLogger(); - const indexNameProvider = jest.fn().mockResolvedValue(INDEX_NAME); - const currentUser = { - userName: 'testUser', - profile_uid: 'testProfileUid', - } as unknown as AuthenticatedUser; - - const dependencies = {} as unknown as SiemMigrationsClientDependencies; - - beforeEach(() => { - dashboardMigrationsDataMigrationClient = new DashboardMigrationsDataMigrationClient( - indexNameProvider, - currentUser, - esClientMock, - logger, - dependencies - ); - }); - - describe('create', () => { - it('should be able to create a migration doc', async () => { - const name = 'test name'; - - const result = await dashboardMigrationsDataMigrationClient.create(name); - - expect(result).not.toBeFalsy(); - expect(esClientMock.asInternalUser.create).toHaveBeenCalledWith({ - refresh: 'wait_for', - id: result, - index: INDEX_NAME, - document: { - created_by: currentUser.profile_uid, - created_at: expect.any(String), - name, - }, - }); - }); - - it('should throw an error if create fails', async () => { - ( - esClientMock.asInternalUser.create as unknown as jest.MockedFn - ).mockRejectedValue(new Error('Create failed')); - - await expect(dashboardMigrationsDataMigrationClient.create('test name')).rejects.toThrow( - 'Create failed' - ); - }); - }); - - describe('get', () => { - it('should be able to get a migration', async () => { - const id = 'test-id'; - const mockResponse = { - _id: id, - _index: INDEX_NAME, - _source: { - created_by: currentUser.profile_uid, - created_at: '2023-10-01T00:00:00Z', - name: 'Test Migration', - }, - } as GetGetResult; - - ( - esClientMock.asInternalUser.get as unknown as jest.MockedFn - ).mockResolvedValue(mockResponse); - - const result = await dashboardMigrationsDataMigrationClient.get(id); - expect(result).toEqual({ - id, - created_by: currentUser.profile_uid, - created_at: '2023-10-01T00:00:00Z', - name: 'Test Migration', - }); - - expect(esClientMock.asInternalUser.get).toHaveBeenCalledWith({ - index: INDEX_NAME, - id, - }); - }); - - it('should return undefined if the migration does not exist', async () => { - const mockResponse = { - _index: INDEX_NAME, - found: false, - }; - - ( - esClientMock.asInternalUser.get as unknown as jest.MockedFn - ).mockRejectedValue({ - message: JSON.stringify(mockResponse), - }); - - const result = await dashboardMigrationsDataMigrationClient.get('non-existent-id'); - expect(result).toBeUndefined(); - }); - - it('should throw an error if get fails', async () => { - ( - esClientMock.asInternalUser.get as unknown as jest.MockedFn - ).mockRejectedValue(new Error('Get failed')); - - await expect(dashboardMigrationsDataMigrationClient.get('test-id')).rejects.toThrow( - 'Get failed' - ); - }); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.ts deleted file mode 100644 index e2be6f489f4d6..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_migration_client.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { v4 as uuidV4 } from 'uuid'; -import type { DashboardMigration } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; -import { SiemMigrationsDataBaseClient } from '../../common/data/siem_migrations_data_base_client'; -import { isNotFoundError } from '../../common/utils/is_not_found_error'; - -export class DashboardMigrationsDataMigrationClient extends SiemMigrationsDataBaseClient { - async create(name: string): Promise { - const migrationId = uuidV4(); - const index = await this.getIndexName(); - const profileUid = await this.getProfileUid(); - const createdAt = new Date().toISOString(); - - await this.esClient - .create({ - refresh: 'wait_for', - id: migrationId, - index, - document: { - created_by: profileUid, - created_at: createdAt, - name, - }, - }) - .catch((error) => { - this.logger.error(`Error creating migration ${migrationId}: ${error}`); - throw error; - }); - - return migrationId; - } - - /** Gets the migration document by id or returns undefined if it does not exist. */ - async get(id: string): Promise { - const index = await this.getIndexName(); - return this.esClient - .get({ index, id }) - .then(this.processHit) - .catch((error) => { - if (isNotFoundError(error)) { - return undefined; - } - this.logger.error(`Error getting migration ${id}: ${error}`); - throw error; - }); - } -} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts index 8e2f950a8496f..9f446fcf66af8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts @@ -10,6 +10,7 @@ import type { IndexPatternAdapter } from '@kbn/index-adapter'; export interface DashboardMigrationAdapters { migrations: IndexPatternAdapter; dashboards: IndexPatternAdapter; + resources: IndexPatternAdapter; } export type DashboardMigrationAdapterId = keyof DashboardMigrationAdapters; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts index 8acf9b10c6808..820f1daa7f1c4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts @@ -18,9 +18,9 @@ export const registerSiemMigrationsRoutes = ( ) => { if (!config.experimentalFeatures.siemMigrationsDisabled) { registerSiemRuleMigrationsRoutes(router, config, logger); - } - if (config.experimentalFeatures.automaticDashboardsMigration) { - registerSiemDashboardMigrationsRoutes(router, logger); + if (config.experimentalFeatures.automaticDashboardsMigration) { + registerSiemDashboardMigrationsRoutes(router, logger); + } } }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts index 40aa2e2e88938..d4e52c9492345 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts @@ -13,9 +13,9 @@ import { CreateRuleMigrationRequestBody, } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; export const registerSiemRuleMigrationsCreateRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/delete.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/delete.ts index 39cdcb1e0f967..0b8192b5341b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/delete.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/delete.ts @@ -10,9 +10,9 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { SIEM_RULE_MIGRATION_PATH } from '../../../../../common/siem_migrations/constants'; import { GetRuleMigrationRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; import { withExistingMigration } from './util/with_existing_migration_id'; export const registerSiemRuleMigrationsDeleteRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts index f773e9b2300ba..37879cf6318f2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts @@ -12,10 +12,10 @@ import { z } from '@kbn/zod'; import { RuleMigrationTaskExecutionSettings } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; import { LangSmithEvaluationOptions } from '../../../../../../common/siem_migrations/model/common.gen'; import { SIEM_RULE_MIGRATION_EVALUATE_PATH } from '../../../../../../common/siem_migrations/constants'; -import { createTracersCallbacks } from '../util/tracing'; +import { createTracersCallbacks } from '../../../common/api/util/tracing'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import { authz } from '../../../common/utils/authz'; -import { withLicense } from '../../../common/utils/with_license'; +import { authz } from '../../../common/api/util/authz'; +import { withLicense } from '../../../common/api/util/with_license'; import type { MigrateRuleGraphConfig } from '../../task/agent/types'; const REQUEST_TIMEOUT = 10 * 60 * 1000; // 10 minutes diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts index 67eeec952b90e..f022bd8954b2a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts @@ -11,9 +11,9 @@ import { SIEM_RULE_MIGRATION_PATH } from '../../../../../common/siem_migrations/ import type { GetRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { GetRuleMigrationRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; import { MIGRATION_ID_NOT_FOUND } from '../../common/translations'; export const registerSiemRuleMigrationsGetRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_integrations.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_integrations.ts index ddbdb7d529814..ad476eae401e5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_integrations.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_integrations.ts @@ -10,8 +10,8 @@ import type { RelatedIntegration } from '../../../../../common/api/detection_eng import { type GetRuleMigrationIntegrationsResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATIONS_INTEGRATIONS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; export const registerSiemRuleMigrationsIntegrationsRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_prebuilt_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_prebuilt_rules.ts index 7ed8c9d4dedc0..7b7ed9b175d49 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_prebuilt_rules.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get_prebuilt_rules.ts @@ -11,8 +11,8 @@ import type { GetRuleMigrationPrebuiltRulesResponse } from '../../../../../commo import { GetRuleMigrationPrebuiltRulesRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATIONS_PREBUILT_RULES_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; import { getPrebuiltRulesForMigration } from './util/prebuilt_rules'; export const registerSiemRuleMigrationsPrebuiltRulesRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/install.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/install.ts index 9d40d027f451a..b6f81dad7e340 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/install.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/install.ts @@ -14,10 +14,10 @@ import { InstallMigrationRulesRequestParams, } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; import { installTranslated } from './util/installation'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; export const registerSiemRuleMigrationsInstallRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/integrations_stats.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/integrations_stats.ts index 1c6db8b675925..41eaa865bffa7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/integrations_stats.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/integrations_stats.ts @@ -9,9 +9,9 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { type GetRuleMigrationIntegrationsStatsResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; export const registerSiemRuleMigrationsIntegrationsStatsRoute = ( router: SecuritySolutionPluginRouter, @@ -41,7 +41,7 @@ export const registerSiemRuleMigrationsIntegrationsStatsRoute = ( await siemMigrationAuditLogger.logGetAllIntegrationsStats(); const allIntegrationsStats = - await ruleMigrationsClient.data.rules.getAllIntegrationsStats(); + await ruleMigrationsClient.data.items.getAllIntegrationsStats(); return res.ok({ body: allIntegrationsStats }); } catch (error) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/privileges/get_missing_privileges.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/privileges/get_missing_privileges.ts index 656263e304566..dedf72953ffe6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/privileges/get_missing_privileges.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/privileges/get_missing_privileges.ts @@ -13,8 +13,8 @@ import { LOOKUPS_INDEX_PREFIX, } from '../../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import { authz } from '../../../common/utils/authz'; -import { withLicense } from '../../../common/utils/with_license'; +import { authz } from '../../../common/api/util/authz'; +import { withLicense } from '../../../common/api/util/with_license'; export const registerSiemRuleMigrationsGetMissingPrivilegesRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts index 5618d2cf6eef3..6c3daba5b5d18 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts @@ -14,9 +14,9 @@ import { type GetRuleMigrationResourcesResponse, } from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import { SiemMigrationAuditLogger } from '../../../common/utils/audit'; -import { authz } from '../../../common/utils/authz'; -import { withLicense } from '../../../common/utils/with_license'; +import { SiemMigrationAuditLogger } from '../../../common/api/util/audit'; +import { authz } from '../../../common/api/util/authz'; +import { withLicense } from '../../../common/api/util/with_license'; export const registerSiemRuleMigrationsResourceGetRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/missing.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/missing.ts index ced119bb54cf5..06f5abf8abf19 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/missing.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/missing.ts @@ -14,8 +14,8 @@ import { } from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATION_RESOURCES_MISSING_PATH } from '../../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import { authz } from '../../../common/utils/authz'; -import { withLicense } from '../../../common/utils/with_license'; +import { authz } from '../../../common/api/util/authz'; +import { withLicense } from '../../../common/api/util/with_license'; export const registerSiemRuleMigrationsResourceGetMissingRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts index 2567e9314c95a..b53b5b3bbcca7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts @@ -16,11 +16,11 @@ import { } from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { ResourceIdentifier } from '../../../../../../common/siem_migrations/rules/resources'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import type { CreateRuleMigrationResourceInput } from '../../data/rule_migrations_data_resources_client'; -import { SiemMigrationAuditLogger } from '../../../common/utils/audit'; -import { authz } from '../../../common/utils/authz'; +import { SiemMigrationAuditLogger } from '../../../common/api/util/audit'; +import { authz } from '../../../common/api/util/authz'; import { processLookups } from '../util/lookups'; -import { withLicense } from '../../../common/utils/with_license'; +import { withLicense } from '../../../common/api/util/with_license'; +import type { CreateSiemMigrationResourceInput } from '../../../common/data/siem_migrations_data_resources_client'; export const registerSiemRuleMigrationsResourceUpsertRoute = ( router: SecuritySolutionPluginRouter, @@ -59,7 +59,7 @@ export const registerSiemRuleMigrationsResourceUpsertRoute = ( await siemMigrationAuditLogger.logUploadResources({ migrationId }); // Check if the migration exists - const { data } = await ruleMigrationsClient.data.rules.get(migrationId, { size: 1 }); + const { data } = await ruleMigrationsClient.data.items.get(migrationId, { size: 1 }); const [rule] = data; if (!rule) { return res.notFound({ body: { message: 'Migration not found' } }); @@ -79,7 +79,7 @@ export const registerSiemRuleMigrationsResourceUpsertRoute = ( const resourceIdentifier = new ResourceIdentifier(rule.original_rule.vendor); const resourcesToCreate = resourceIdentifier .fromResources(resources) - .map((resource) => ({ + .map((resource) => ({ ...resource, migration_id: migrationId, })); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/create.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/create.ts index b975e7906b7bc..9ad2f81a0e37b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/create.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/create.ts @@ -15,11 +15,11 @@ import { } from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { ResourceIdentifier } from '../../../../../../common/siem_migrations/rules/resources'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import type { AddRuleMigrationRulesInput } from '../../data/rule_migrations_data_rules_client'; -import { SiemMigrationAuditLogger } from '../../../common/utils/audit'; -import { authz } from '../../../common/utils/authz'; +import type { CreateRuleMigrationRulesInput } from '../../data/rule_migrations_data_rules_client'; +import { SiemMigrationAuditLogger } from '../../../common/api/util/audit'; +import { authz } from '../../../common/api/util/authz'; import { withExistingMigration } from '../util/with_existing_migration_id'; -import { withLicense } from '../../../common/utils/with_license'; +import { withLicense } from '../../../common/api/util/with_license'; export const registerSiemRuleMigrationsCreateRulesRoute = ( router: SecuritySolutionPluginRouter, @@ -60,14 +60,14 @@ export const registerSiemRuleMigrationsCreateRulesRoute = ( count: rulesCount, }); - const ruleMigrations = originalRules.map( + const ruleMigrations = originalRules.map( (originalRule) => ({ migration_id: migrationId, original_rule: originalRule, }) ); - await ruleMigrationsClient.data.rules.create(ruleMigrations); + await ruleMigrationsClient.data.items.create(ruleMigrations); // Create identified resource documents without content to keep track of them const resourceIdentifier = new ResourceIdentifier(firstOriginalRule.vendor); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/get.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/get.ts index 55386d9bdfa2e..0b8cbf02806d0 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/get.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/get.ts @@ -15,9 +15,9 @@ import { } from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; import type { RuleMigrationGetRulesOptions } from '../../data/rule_migrations_data_rules_client'; -import { SiemMigrationAuditLogger } from '../../../common/utils/audit'; -import { authz } from '../../../common/utils/authz'; -import { withLicense } from '../../../common/utils/with_license'; +import { SiemMigrationAuditLogger } from '../../../common/api/util/audit'; +import { authz } from '../../../common/api/util/authz'; +import { withLicense } from '../../../common/api/util/with_license'; import { withExistingMigration } from '../util/with_existing_migration_id'; export const registerSiemRuleMigrationsGetRulesRoute = ( @@ -67,7 +67,7 @@ export const registerSiemRuleMigrationsGetRulesRoute = ( from: page && size ? page * size : 0, }; - const result = await ruleMigrationsClient.data.rules.get(migrationId, options); + const result = await ruleMigrationsClient.data.items.get(migrationId, options); await siemMigrationAuditLogger.logGetMigrationRules({ migrationId }); return res.ok({ body: result }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/update.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/update.ts index 6ed7b47d22539..043fbd05a9c43 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/update.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/rules/update.ts @@ -14,10 +14,10 @@ import { UpdateRuleMigrationRulesRequestParams, } from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import { authz } from '../../../common/utils/authz'; -import { SiemMigrationAuditLogger } from '../../../common/utils/audit'; +import { authz } from '../../../common/api/util/authz'; +import { SiemMigrationAuditLogger } from '../../../common/api/util/audit'; import { transformToInternalUpdateRuleMigrationData } from '../util/update_rules'; -import { withLicense } from '../../../common/utils/with_license'; +import { withLicense } from '../../../common/api/util/with_license'; import { withExistingMigration } from '../util/with_existing_migration_id'; export const registerSiemRuleMigrationsUpdateRulesRoute = ( @@ -61,7 +61,7 @@ export const registerSiemRuleMigrationsUpdateRulesRoute = ( const transformedRuleToUpdate = rulesToUpdate.map( transformToInternalUpdateRuleMigrationData ); - await ruleMigrationsClient.data.rules.update(transformedRuleToUpdate); + await ruleMigrationsClient.data.items.update(transformedRuleToUpdate); return res.ok({ body: { updated: true } }); } catch (error) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts index 2841643f5202f..5264d84423bdc 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts @@ -14,11 +14,11 @@ import { type StartRuleMigrationResponse, } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; -import { authz } from '../../common/utils/authz'; -import { getRetryFilter } from './util/retry'; -import { withLicense } from '../../common/utils/with_license'; -import { createTracersCallbacks } from './util/tracing'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { getRetryFilter } from '../../common/api/util/retry'; +import { withLicense } from '../../common/api/util/with_license'; +import { createTracersCallbacks } from '../../common/api/util/tracing'; import { withExistingMigration } from './util/with_existing_migration_id'; export const registerSiemRuleMigrationsStartRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts index 8e8e46ed44f87..f25cf22a3f339 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts @@ -13,8 +13,8 @@ import { } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATION_STATS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; import { withExistingMigration } from './util/with_existing_migration_id'; export const registerSiemRuleMigrationsStatsRoute = ( @@ -44,7 +44,7 @@ export const registerSiemRuleMigrationsStatsRoute = ( const stats = await ruleMigrationsClient.task.getStats(migrationId); - if (stats.rules.total === 0) { + if (stats.items.total === 0) { return res.noContent(); } return res.ok({ body: stats }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts index 5d9151ac6b524..d169caa82b57a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts @@ -9,8 +9,8 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import type { GetAllStatsRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATIONS_ALL_STATS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; export const registerSiemRuleMigrationsStatsAllRoute = ( router: SecuritySolutionPluginRouter, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts index a317943162b65..bb1d1b4060fb8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts @@ -13,9 +13,9 @@ import { type StopRuleMigrationResponse, } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; import { withExistingMigration } from './util/with_existing_migration_id'; export const registerSiemRuleMigrationsStopRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/translation_stats.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/translation_stats.ts index 7c67c3c3a4a32..4ab1c04988b11 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/translation_stats.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/translation_stats.ts @@ -11,8 +11,8 @@ import type { GetRuleMigrationTranslationStatsResponse } from '../../../../../co import { GetRuleMigrationTranslationStatsRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATION_TRANSLATION_STATS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; import { withExistingMigration } from './util/with_existing_migration_id'; export const registerSiemRuleMigrationsTranslationStatsRoute = ( @@ -46,7 +46,7 @@ export const registerSiemRuleMigrationsTranslationStatsRoute = ( const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.siemMigrations.getRulesClient(); - const stats = await ruleMigrationsClient.data.rules.getTranslationStats(migrationId); + const stats = await ruleMigrationsClient.data.items.getTranslationStats(migrationId); if (stats.rules.total === 0) { return res.noContent(); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/update.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/update.ts index 75b192720c103..4418991e96216 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/update.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/update.ts @@ -13,9 +13,9 @@ import { UpdateRuleMigrationRequestParams, } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import { SiemMigrationAuditLogger } from '../../common/utils/audit'; -import { authz } from '../../common/utils/authz'; -import { withLicense } from '../../common/utils/with_license'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; import { withExistingMigration } from './util/with_existing_migration_id'; export const registerSiemRuleMigrationsUpdateRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts index 7968176928a93..9e96bde0ec686 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts @@ -179,7 +179,7 @@ export const installTranslated = async ({ const installationErrors: Error[] = []; // Install rules that matched Elastic prebuilt rules - const prebuiltRuleBatches = ruleMigrationsClient.data.rules.searchBatches(migrationId, { + const prebuiltRuleBatches = ruleMigrationsClient.data.items.searchBatches(migrationId, { filters: { ids, installable: true, prebuilt: true }, }); let prebuiltRulesToInstall = await prebuiltRuleBatches.next(); @@ -194,7 +194,7 @@ export const installTranslated = async ({ ); installedCount += rulesToUpdate.length; installationErrors.push(...errors); - await ruleMigrationsClient.data.rules.update(rulesToUpdate); + await ruleMigrationsClient.data.items.update(rulesToUpdate); prebuiltRulesToInstall = await prebuiltRuleBatches.next(); } @@ -205,7 +205,7 @@ export const installTranslated = async ({ } // Install rules with custom translation - const customRuleBatches = ruleMigrationsClient.data.rules.searchBatches(migrationId, { + const customRuleBatches = ruleMigrationsClient.data.items.searchBatches(migrationId, { filters: { ids, installable: true, prebuilt: false }, }); let customRulesToInstall = await customRuleBatches.next(); @@ -217,7 +217,7 @@ export const installTranslated = async ({ ); installedCount += rulesToUpdate.length; installationErrors.push(...errors); - await ruleMigrationsClient.data.rules.update(rulesToUpdate); + await ruleMigrationsClient.data.items.update(rulesToUpdate); customRulesToInstall = await customRuleBatches.next(); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/prebuilt_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/prebuilt_rules.ts index efdbb255f980c..278cb13930a3e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/prebuilt_rules.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/prebuilt_rules.ts @@ -99,7 +99,7 @@ export const getPrebuiltRulesForMigration = async ( savedObjectsClient: SavedObjectsClientContract ): Promise> => { const options = { filters: { prebuilt: true } }; - const batches = ruleMigrationsClient.data.rules.searchBatches(migrationId, options); + const batches = ruleMigrationsClient.data.items.searchBatches(migrationId, options); const rulesIds = new Set(); let results = await batches.next(); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts index ad389dd21ccf6..d55501a0aa814 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts @@ -8,20 +8,21 @@ import type { AuthenticatedUser, IScopedClusterClient, Logger } from '@kbn/core/server'; import { RuleMigrationsDataIntegrationsClient } from './rule_migrations_data_integrations_client'; import { RuleMigrationsDataPrebuiltRulesClient } from './rule_migrations_data_prebuilt_rules_client'; -import { RuleMigrationsDataResourcesClient } from './rule_migrations_data_resources_client'; import { RuleMigrationsDataRulesClient } from './rule_migrations_data_rules_client'; import { RuleMigrationsDataLookupsClient } from './rule_migrations_data_lookups_client'; import type { RuleMigrationIndexNameProviders } from '../types'; import { RuleMigrationsDataMigrationClient } from './rule_migrations_data_migration_client'; import type { SiemMigrationsClientDependencies } from '../../common/types'; +import { SiemMigrationsDataClient } from '../../common/data/siem_migrations_data_client'; +import { SiemMigrationsDataResourcesClient } from '../../common/data/siem_migrations_data_resources_client'; -export class RuleMigrationsDataClient { +export class RuleMigrationsDataClient extends SiemMigrationsDataClient { protected logger: Logger; protected esClient: IScopedClusterClient['asInternalUser']; public readonly migrations: RuleMigrationsDataMigrationClient; - public readonly rules: RuleMigrationsDataRulesClient; - public readonly resources: RuleMigrationsDataResourcesClient; + public readonly items: RuleMigrationsDataRulesClient; + public readonly resources: SiemMigrationsDataResourcesClient; public readonly integrations: RuleMigrationsDataIntegrationsClient; public readonly prebuiltRules: RuleMigrationsDataPrebuiltRulesClient; public readonly lookups: RuleMigrationsDataLookupsClient; @@ -34,6 +35,8 @@ export class RuleMigrationsDataClient { spaceId: string, dependencies: SiemMigrationsClientDependencies ) { + super(); + this.migrations = new RuleMigrationsDataMigrationClient( indexNameProviders.migrations, currentUser, @@ -41,14 +44,14 @@ export class RuleMigrationsDataClient { logger, dependencies ); - this.rules = new RuleMigrationsDataRulesClient( + this.items = new RuleMigrationsDataRulesClient( indexNameProviders.rules, currentUser, esScopedClient, logger, dependencies ); - this.resources = new RuleMigrationsDataResourcesClient( + this.resources = new SiemMigrationsDataResourcesClient( indexNameProviders.resources, currentUser, esScopedClient, @@ -79,36 +82,4 @@ export class RuleMigrationsDataClient { this.logger = logger; this.esClient = esScopedClient.asInternalUser; } - - /** - * - * Deletes a migration and all its associated rules and resources. - * - */ - async deleteMigration(migrationId: string) { - const migrationDeleteOperations = await this.migrations.prepareDelete({ - id: migrationId, - }); - - const rulesByMigrationIdDeleteOperations = await this.rules.prepareDelete(migrationId); - - const resourcesByMigrationIdDeleteOperations = await this.resources.prepareDelete(migrationId); - - return this.esClient - .bulk({ - refresh: 'wait_for', - operations: [ - ...migrationDeleteOperations, - ...rulesByMigrationIdDeleteOperations, - ...resourcesByMigrationIdDeleteOperations, - ], - }) - .then(() => { - this.logger.info(`Deleted migration ${migrationId}`); - }) - .catch((error) => { - this.logger.error(`Error deleting migration ${migrationId}: ${error}`); - throw error; - }); - } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts index 3acece32299c8..6d860ecc1bf54 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts @@ -5,97 +5,15 @@ * 2.0. */ -import { v4 as uuidV4 } from 'uuid'; -import type { BulkOperationContainer } from '@elastic/elasticsearch/lib/api/types'; -import type { RuleMigrationLastExecution } from '../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { StoredSiemMigration } from '../types'; -import { SiemMigrationsDataBaseClient } from '../../common/data/siem_migrations_data_base_client'; -import { isNotFoundError } from '../../common/utils/is_not_found_error'; -import { MAX_ES_SEARCH_SIZE } from '../constants'; - -export class RuleMigrationsDataMigrationClient extends SiemMigrationsDataBaseClient { - async create(name: string): Promise { - const migrationId = uuidV4(); - const index = await this.getIndexName(); - const profileUid = await this.getProfileUid(); - const createdAt = new Date().toISOString(); - - await this.esClient - .create({ - refresh: 'wait_for', - id: migrationId, - index, - document: { - created_by: profileUid, - created_at: createdAt, - name, - }, - }) - .catch((error) => { - this.logger.error(`Error creating migration ${migrationId}: ${error}`); - throw error; - }); - - return migrationId; - } - - /** - * - * Gets the migration document by id or returns undefined if it does not exist. - * - * */ - async get({ id }: { id: string }): Promise { - const index = await this.getIndexName(); - return this.esClient - .get({ index, id }) - .then(this.processHit) - .catch((error) => { - if (isNotFoundError(error)) { - return undefined; - } - this.logger.error(`Error getting migration ${id}: ${error}`); - throw error; - }); - } - - /** - * Gets all migrations from the index. - */ - async getAll(): Promise { - const index = await this.getIndexName(); - return this.esClient - .search({ - index, - size: MAX_ES_SEARCH_SIZE, // Adjust size as needed - query: { match_all: {} }, - _source: true, - }) - .then((result) => this.processResponseHits(result)) - .catch((error) => { - this.logger.error(`Error getting all migrations:- ${error}`); - throw error; - }); - } +import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import { SiemMigrationsDataMigrationClient } from '../../common/data/siem_migrations_data_migration_client'; +export class RuleMigrationsDataMigrationClient extends SiemMigrationsDataMigrationClient { /** + * Saves a migration as started. * - * Prepares bulk ES delete operation for a migration document based on its id. - * - */ - async prepareDelete({ id }: { id: string }): Promise { - const index = await this.getIndexName(); - const migrationDeleteOperation = { - delete: { - _index: index, - _id: id, - }, - }; - - return [migrationDeleteOperation]; - } - - /** - * Saves a migration as started, updating the last execution parameters with the current timestamp. + * Overloads the `saveAsStarted` method of the SiemMigrationsDataMigrationClient class + * to receive and store the `skipPrebuiltRulesMatching` value which is specific of rule migrations. */ async saveAsStarted({ id, @@ -115,56 +33,4 @@ export class RuleMigrationsDataMigrationClient extends SiemMigrationsDataBaseCli skip_prebuilt_rules_matching: skipPrebuiltRulesMatching, }); } - - /** - * Saves a migration as ended, updating the last execution parameters with the current timestamp. - */ - async saveAsFinished({ id }: { id: string }): Promise { - await this.updateLastExecution(id, { finished_at: new Date().toISOString() }); - } - - /** - * Saves a migration as failed, updating the last execution parameters with the provided error message. - */ - async saveAsFailed({ id, error }: { id: string; error: string }): Promise { - await this.updateLastExecution(id, { error, finished_at: new Date().toISOString() }); - } - - /** - * Sets `is_stopped` flag for migration document. - * It does not update `finished_at` timestamp, `saveAsFinished` or `saveAsFailed` should be called separately. - */ - async setIsStopped({ id }: { id: string }): Promise { - await this.updateLastExecution(id, { is_stopped: true }); - } - - /** - * Updates the last execution parameters for a migration document. - */ - private async updateLastExecution( - id: string, - lastExecutionParams: RuleMigrationLastExecution - ): Promise { - const index = await this.getIndexName(); - const doc = { last_execution: lastExecutionParams }; - await this.esClient - .update({ index, id, refresh: 'wait_for', doc, retry_on_conflict: 1 }) - .catch((error) => { - this.logger.error(`Error updating last execution for migration ${id}: ${error}`); - throw error; - }); - } - - /** - * Updates the migration document with the provided values. - */ - async update(id: string, doc: Partial): Promise { - const index = await this.getIndexName(); - await this.esClient - .update({ index, id, doc, refresh: 'wait_for', retry_on_conflict: 1 }) - .catch((error) => { - this.logger.error(`Error updating migration: ${error}`); - throw error; - }); - } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts index f14932a2eaae5..2bef9cb41e502 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts @@ -8,20 +8,13 @@ import type { AggregationsAggregationContainer, AggregationsFilterAggregate, - AggregationsMaxAggregate, - AggregationsMinAggregate, AggregationsStringTermsAggregate, AggregationsStringTermsBucket, QueryDslQueryContainer, - Duration, - BulkOperationContainer, } from '@elastic/elasticsearch/lib/api/types'; +import type { estypes } from '@elastic/elasticsearch'; import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; -import type { InternalUpdateRuleMigrationRule, StoredRuleMigration } from '../types'; -import { - SiemMigrationStatus, - RuleTranslationResult, -} from '../../../../../common/siem_migrations/constants'; +import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import { type RuleMigrationTaskStats, type RuleMigrationTranslationStats, @@ -30,13 +23,14 @@ import { } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { getSortingOptions, type RuleMigrationSort } from './sort'; import { conditions as searchConditions } from './search'; -import { SiemMigrationsDataBaseClient } from '../../common/data/siem_migrations_data_base_client'; import { MAX_ES_SEARCH_SIZE } from '../constants'; +import type { + CreateMigrationItemInput, + SiemMigrationItemSort, +} from '../../common/data/siem_migrations_data_item_client'; +import { SiemMigrationsDataItemClient } from '../../common/data/siem_migrations_data_item_client'; -export type AddRuleMigrationRulesInput = Omit< - RuleMigrationRule, - '@timestamp' | 'id' | 'status' | 'created_by' ->; +export type CreateRuleMigrationRulesInput = CreateMigrationItemInput; export type RuleMigrationDataStats = Omit; export type RuleMigrationAllDataStats = RuleMigrationDataStats[]; @@ -47,187 +41,11 @@ export interface RuleMigrationGetRulesOptions { 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; -/* DEFAULT_SEARCH_BATCH_SIZE defines the default number of documents to retrieve per search operation - * when retrieving search results in batches. */ -const DEFAULT_SEARCH_BATCH_SIZE = 500 as const; - -export class RuleMigrationsDataRulesClient extends SiemMigrationsDataBaseClient { - /** Indexes an array of rule migrations to be processed */ - async create(ruleMigrations: AddRuleMigrationRulesInput[]): Promise { - const index = await this.getIndexName(); - const profileId = await this.getProfileUid(); - - let ruleMigrationsSlice: AddRuleMigrationRulesInput[]; - const createdAt = new Date().toISOString(); - while ((ruleMigrationsSlice = ruleMigrations.splice(0, BULK_MAX_SIZE)).length) { - await this.esClient - .bulk({ - refresh: 'wait_for', - operations: ruleMigrationsSlice.flatMap((ruleMigration) => [ - { create: { _index: index } }, - { - ...ruleMigration, - '@timestamp': createdAt, - status: SiemMigrationStatus.PENDING, - created_by: profileId, - updated_by: profileId, - updated_at: createdAt, - }, - ]), - }) - .catch((error) => { - this.logger.error(`Error creating rule migrations: ${error.message}`); - throw error; - }); - } - } - - /** Updates an array of rule migrations to be processed */ - async update(ruleMigrations: InternalUpdateRuleMigrationRule[]): Promise { - const index = await this.getIndexName(); - const profileId = await this.getProfileUid(); - - let ruleMigrationsSlice: InternalUpdateRuleMigrationRule[]; - const updatedAt = new Date().toISOString(); - while ((ruleMigrationsSlice = ruleMigrations.splice(0, BULK_MAX_SIZE)).length) { - await this.esClient - .bulk({ - refresh: 'wait_for', - operations: ruleMigrationsSlice.flatMap((ruleMigration) => { - const { id, ...rest } = ruleMigration; - return [ - { update: { _index: index, _id: id } }, - { - doc: { - ...rest, - updated_by: profileId, - updated_at: updatedAt, - }, - }, - ]; - }), - }) - .catch((error) => { - this.logger.error(`Error updating rule migrations: ${error.message}`); - throw error; - }); - } - } - - /** Retrieves an array of rule documents of a specific migrations */ - async get( - migrationId: string, - { filters = {}, sort: sortParam = {}, from, size }: RuleMigrationGetRulesOptions = {} - ): Promise<{ total: number; data: StoredRuleMigration[] }> { - const index = await this.getIndexName(); - const query = this.getFilterQuery(migrationId, filters); - const sort = sortParam.sortField ? getSortingOptions(sortParam) : undefined; - - const result = await this.esClient - .search({ index, query, sort, from, size }) - .catch((error) => { - this.logger.error(`Error searching rule migrations: ${error.message}`); - throw error; - }); - return { - total: this.getTotalHits(result), - data: this.processResponseHits(result), - }; - } - - /** Returns batching functions to traverse all the migration rules search results */ - searchBatches( - migrationId: string, - options: { scroll?: Duration; size?: number; filters?: RuleMigrationFilters } = {} - ) { - const { size = DEFAULT_SEARCH_BATCH_SIZE, filters = {}, scroll } = options; - const query = this.getFilterQuery(migrationId, filters); - const search = { query, sort: '_doc', scroll, size }; // sort by _doc to ensure consistent order - try { - return this.getSearchBatches(search); - } catch (error) { - this.logger.error(`Error scrolling rule migrations: ${error.message}`); - throw error; - } - } - - /** Updates one rule migration status to `processing` */ - async saveProcessing(id: string): Promise { - const index = await this.getIndexName(); - const profileId = await this.getProfileUid(); - const doc = { - status: SiemMigrationStatus.PROCESSING, - updated_by: profileId, - updated_at: new Date().toISOString(), - }; - await this.esClient.update({ index, id, doc, refresh: 'wait_for' }).catch((error) => { - this.logger.error(`Error updating rule migration status to processing: ${error.message}`); - throw error; - }); - } - - /** Updates one rule migration with the provided data and sets the status to `completed` */ - async saveCompleted({ id, ...ruleMigration }: StoredRuleMigration): Promise { - const index = await this.getIndexName(); - const profileId = await this.getProfileUid(); - const doc = { - ...ruleMigration, - status: SiemMigrationStatus.COMPLETED, - updated_by: profileId, - updated_at: new Date().toISOString(), - }; - await this.esClient.update({ index, id, doc, refresh: 'wait_for' }).catch((error) => { - this.logger.error(`Error updating rule migration status to completed: ${error.message}`); - throw error; - }); - } - - /** Updates one rule migration with the provided data and sets the status to `failed` */ - async saveError({ id, ...ruleMigration }: StoredRuleMigration): Promise { - const index = await this.getIndexName(); - const profileId = await this.getProfileUid(); - const doc = { - ...ruleMigration, - status: SiemMigrationStatus.FAILED, - updated_by: profileId, - updated_at: new Date().toISOString(), - }; - await this.esClient.update({ index, id, doc, refresh: 'wait_for' }).catch((error) => { - this.logger.error(`Error updating rule migration status to failed: ${error.message}`); - throw error; - }); - } - - /** Updates all the rule migration with the provided id with status `processing` back to `pending` */ - async releaseProcessing(migrationId: string): Promise { - return this.updateStatus( - migrationId, - { status: SiemMigrationStatus.PROCESSING }, - SiemMigrationStatus.PENDING - ); - } - - /** Updates all the rule migration with the provided id and with status `statusToQuery` to `statusToUpdate` */ - async updateStatus( - migrationId: string, - filter: RuleMigrationFilters, - statusToUpdate: SiemMigrationStatus, - { refresh = false }: { refresh?: boolean } = {} - ): Promise { - const index = await this.getIndexName(); - const query = this.getFilterQuery(migrationId, filter); - 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}`); - throw error; - }); - } +export class RuleMigrationsDataRulesClient extends SiemMigrationsDataItemClient { + protected type = 'rule' as const; /** Retrieves the translation stats for the rule migrations with the provided id */ - async getTranslationStats(migrationId: string): Promise { + public async getTranslationStats(migrationId: string): Promise { const index = await this.getIndexName(); const query = this.getFilterQuery(migrationId); @@ -269,72 +87,8 @@ export class RuleMigrationsDataRulesClient extends SiemMigrationsDataBaseClient }; } - /** 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 aggregations = { - status: { terms: { field: 'status' } }, - createdAt: { min: { field: '@timestamp' } }, - lastUpdatedAt: { max: { field: 'updated_at' } }, - }; - const result = await this.esClient - .search({ index, query, aggregations, _source: false }) - .catch((error) => { - this.logger.error(`Error getting rule migrations stats: ${error.message}`); - throw error; - }); - - const aggs = result.aggregations ?? {}; - - return { - id: migrationId, - rules: { - total: this.getTotalHits(result), - ...this.statusAggCounts(aggs.status as AggregationsStringTermsAggregate), - }, - created_at: (aggs.createdAt as AggregationsMinAggregate)?.value_as_string ?? '', - last_updated_at: (aggs.lastUpdatedAt as AggregationsMaxAggregate)?.value_as_string ?? '', - }; - } - - /** Retrieves the stats for all the rule migrations aggregated by migration id, in creation order */ - async getAllStats(): Promise { - const index = await this.getIndexName(); - const aggregations: { migrationIds: AggregationsAggregationContainer } = { - migrationIds: { - terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: MAX_ES_SEARCH_SIZE }, - aggregations: { - status: { terms: { field: 'status' } }, - createdAt: { min: { field: '@timestamp' } }, - lastUpdatedAt: { max: { field: 'updated_at' } }, - }, - }, - }; - const result = await this.esClient - .search({ index, aggregations, _source: false }) - .catch((error) => { - this.logger.error(`Error getting all rule migrations stats: ${error.message}`); - throw error; - }); - - const migrationsAgg = result.aggregations?.migrationIds as AggregationsStringTermsAggregate; - const buckets = (migrationsAgg?.buckets as AggregationsStringTermsBucket[]) ?? []; - return buckets.map((bucket) => ({ - id: `${bucket.key}`, - rules: { - total: bucket.doc_count, - ...this.statusAggCounts(bucket.status as AggregationsStringTermsAggregate), - }, - created_at: (bucket.createdAt as AggregationsMinAggregate | undefined) - ?.value_as_string as string, - last_updated_at: (bucket.lastUpdatedAt as AggregationsMaxAggregate | undefined) - ?.value_as_string as string, - })); - } - /** Retrieves the stats for the integrations of all the migration rules */ - async getAllIntegrationsStats(): Promise { + public async getAllIntegrationsStats(): Promise { const index = await this.getIndexName(); const aggregations: { integrationIds: AggregationsAggregationContainer } = { integrationIds: { @@ -360,37 +114,7 @@ export class RuleMigrationsDataRulesClient extends SiemMigrationsDataBaseClient })); } - private statusAggCounts( - statusAgg: AggregationsStringTermsAggregate - ): Record { - const buckets = statusAgg.buckets as AggregationsStringTermsBucket[]; - return { - [SiemMigrationStatus.PENDING]: - buckets.find(({ key }) => key === SiemMigrationStatus.PENDING)?.doc_count ?? 0, - [SiemMigrationStatus.PROCESSING]: - buckets.find(({ key }) => key === SiemMigrationStatus.PROCESSING)?.doc_count ?? 0, - [SiemMigrationStatus.COMPLETED]: - buckets.find(({ key }) => key === SiemMigrationStatus.COMPLETED)?.doc_count ?? 0, - [SiemMigrationStatus.FAILED]: - buckets.find(({ key }) => key === SiemMigrationStatus.FAILED)?.doc_count ?? 0, - }; - } - - private translationResultAggCount( - resultAgg: AggregationsStringTermsAggregate - ): Record { - const buckets = resultAgg.buckets as AggregationsStringTermsBucket[]; - return { - [RuleTranslationResult.FULL]: - buckets.find(({ key }) => key === RuleTranslationResult.FULL)?.doc_count ?? 0, - [RuleTranslationResult.PARTIAL]: - buckets.find(({ key }) => key === RuleTranslationResult.PARTIAL)?.doc_count ?? 0, - [RuleTranslationResult.UNTRANSLATABLE]: - buckets.find(({ key }) => key === RuleTranslationResult.UNTRANSLATABLE)?.doc_count ?? 0, - }; - } - - private getFilterQuery( + protected getFilterQuery( migrationId: string, filters: RuleMigrationFilters = {} ): QueryDslQueryContainer { @@ -446,21 +170,7 @@ export class RuleMigrationsDataRulesClient extends SiemMigrationsDataBaseClient return { bool: { filter } }; } - /** - * - * Prepares bulk ES delete operations for the rules based on migrationId. - * - * */ - async prepareDelete(migrationId: string): Promise { - const index = await this.getIndexName(); - const rulesToBeDeleted = await this.get(migrationId, { size: MAX_ES_SEARCH_SIZE }); - const rulesToBeDeletedDocIds = rulesToBeDeleted.data.map((rule) => rule.id); - - return rulesToBeDeletedDocIds.map((docId) => ({ - delete: { - _id: docId, - _index: index, - }, - })); + protected getSortOptions(sort: SiemMigrationItemSort = {}): estypes.Sort { + return getSortingOptions(sort); } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts index beee88dfb42d4..4f0aa1344091d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts @@ -11,8 +11,8 @@ import type { LoggerFactory, IClusterClient, Logger } from '@kbn/core/server'; import { RuleMigrationsDataService } from './data/rule_migrations_data_service'; import type { RuleMigrationsDataClient } from './data/rule_migrations_data_client'; import type { RuleMigrationsTaskClient } from './task/rule_migrations_task_client'; -import { RuleMigrationsTaskService } from './task/rule_migrations_task_service'; import type { SiemMigrationsCreateClientParams } from '../common/types'; +import { RuleMigrationsTaskService } from './task/rule_migrations_task_service'; export interface SiemRulesMigrationsSetupParams { esClusterClient: IClusterClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts index 22f47d820ddec..219a4a72e3505 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts @@ -7,7 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { FakeLLM } from '@langchain/core/utils/testing'; import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons'; -import type { SiemMigrationTelemetryClient } from '../rule_migrations_telemetry_client'; +import type { RuleMigrationTelemetryClient } from '../rule_migrations_telemetry_client'; import type { BaseLLMParams } from '@langchain/core/language_models/llms'; export const createSiemMigrationTelemetryClientMock = () => { @@ -32,7 +32,7 @@ export const createSiemMigrationTelemetryClientMock = () => { reportIntegrationsMatch: jest.fn(), reportPrebuiltRulesMatch: jest.fn(), startSiemMigrationTask: jest.fn().mockReturnValue(mockStartSiemMigrationTaskReturn), - } as jest.Mocked>; + } as jest.Mocked>; }; // Factory function for the mock class diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts index b78aabce394f4..fce206bea969e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts @@ -10,7 +10,11 @@ import { getCreateSemanticQueryNode } from './nodes/create_semantic_query'; import { getMatchPrebuiltRuleNode } from './nodes/match_prebuilt_rule'; import { migrateRuleConfigSchema, migrateRuleState } from './state'; import { getTranslateRuleGraph } from './sub_graphs/translate_rule'; -import type { MigrateRuleGraphConfig, MigrateRuleGraphParams, MigrateRuleState } from './types'; +import type { + MigrateRuleGraphConfig, + MigrateRuleGraphParams, + MigrateRuleGraphState, +} from './types'; export function getRuleMigrationAgent({ model, @@ -57,14 +61,17 @@ export function getRuleMigrationAgent({ return graph; } -const skipPrebuiltRuleConditional = (_state: MigrateRuleState, config: MigrateRuleGraphConfig) => { +const skipPrebuiltRuleConditional = ( + _state: MigrateRuleGraphState, + config: MigrateRuleGraphConfig +) => { if (config.configurable?.skipPrebuiltRulesMatching) { return 'translationSubGraph'; } return 'matchPrebuiltRule'; }; -const matchedPrebuiltRuleConditional = (state: MigrateRuleState) => { +const matchedPrebuiltRuleConditional = (state: MigrateRuleGraphState) => { if (state.elastic_rule?.prebuilt_rule_id) { return END; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts index 8ec7870eaa2b8..830f2ef917aff 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts @@ -9,7 +9,7 @@ import type { Logger } from '@kbn/core/server'; import { JsonOutputParser } from '@langchain/core/output_parsers'; import { RuleTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; import type { RuleMigrationsRetriever } from '../../../retrievers'; -import type { SiemMigrationTelemetryClient } from '../../../rule_migrations_telemetry_client'; +import type { RuleMigrationTelemetryClient } from '../../../rule_migrations_telemetry_client'; import type { ChatModel } from '../../../util/actions_client_chat'; import { cleanMarkdown, generateAssistantComment } from '../../../util/comments'; import type { GraphNode } from '../../types'; @@ -22,7 +22,7 @@ import { interface GetMatchPrebuiltRuleNodeParams { model: ChatModel; logger: Logger; - telemetryClient: SiemMigrationTelemetryClient; + telemetryClient: RuleMigrationTelemetryClient; ruleMigrationsRetriever: RuleMigrationsRetriever; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts index 68046667f01ae..639d61d28e021 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts @@ -7,7 +7,7 @@ import { JsonOutputParser } from '@langchain/core/output_parsers'; import type { RuleMigrationsRetriever } from '../../../../../retrievers'; -import type { SiemMigrationTelemetryClient } from '../../../../../rule_migrations_telemetry_client'; +import type { RuleMigrationTelemetryClient } from '../../../../../rule_migrations_telemetry_client'; import type { ChatModel } from '../../../../../util/actions_client_chat'; import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments'; import type { GraphNode } from '../../types'; @@ -15,7 +15,7 @@ import { MATCH_INTEGRATION_PROMPT } from './prompts'; interface GetRetrieveIntegrationsNodeParams { model: ChatModel; - telemetryClient: SiemMigrationTelemetryClient; + telemetryClient: RuleMigrationTelemetryClient; ruleMigrationsRetriever: RuleMigrationsRetriever; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts index e9ac2eccf6f1d..80585f35d737e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts @@ -9,7 +9,7 @@ import type { Logger } from '@kbn/core/server'; import type { RunnableConfig } from '@langchain/core/runnables'; import type { EsqlKnowledgeBase } from '../../../util/esql_knowledge_base'; import type { RuleMigrationsRetriever } from '../../../retrievers'; -import type { SiemMigrationTelemetryClient } from '../../../rule_migrations_telemetry_client'; +import type { RuleMigrationTelemetryClient } from '../../../rule_migrations_telemetry_client'; import type { ChatModel } from '../../../util/actions_client_chat'; import type { translateRuleState } from './state'; import type { migrateRuleConfigSchema } from '../../state'; @@ -25,7 +25,7 @@ export interface TranslateRuleGraphParams { model: ChatModel; esqlKnowledgeBase: EsqlKnowledgeBase; ruleMigrationsRetriever: RuleMigrationsRetriever; - telemetryClient: SiemMigrationTelemetryClient; + telemetryClient: RuleMigrationTelemetryClient; logger: Logger; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts index 756bf87ab7f42..240f6edb5e80a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts @@ -7,14 +7,18 @@ import type { Logger } from '@kbn/core/server'; import type { RunnableConfig } from '@langchain/core/runnables'; +import type { RuleMigrationRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; import type { RuleMigrationsRetriever } from '../retrievers'; import type { EsqlKnowledgeBase } from '../util/esql_knowledge_base'; -import type { SiemMigrationTelemetryClient } from '../rule_migrations_telemetry_client'; import type { ChatModel } from '../util/actions_client_chat'; import type { migrateRuleConfigSchema, migrateRuleState } from './state'; +import type { RuleMigrationTelemetryClient } from '../rule_migrations_telemetry_client'; +import type { MigrationState } from '../../../common/task/types'; -export type MigrateRuleState = typeof migrateRuleState.State; -export type MigrateRuleGraphConfig = RunnableConfig<(typeof migrateRuleConfigSchema)['State']>; +export type MigrateRuleGraphState = typeof migrateRuleState.State; +export type MigrateRuleState = MigrationState; +export type MigrateRuleConfigSchema = (typeof migrateRuleConfigSchema)['State']; +export type MigrateRuleGraphConfig = RunnableConfig; export type GraphNode = ( state: MigrateRuleState, config: MigrateRuleGraphConfig @@ -29,5 +33,5 @@ export interface MigrateRuleGraphParams { model: ChatModel; ruleMigrationsRetriever: RuleMigrationsRetriever; logger: Logger; - telemetryClient: SiemMigrationTelemetryClient; + telemetryClient: RuleMigrationTelemetryClient; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts index 32ff2354bbc1a..b88f44b4d6da2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts @@ -71,11 +71,11 @@ describe('RuleMigrationsTaskClient', () => { ); const result = await client.start(params); expect(result).toEqual({ exists: true, started: false }); - expect(data.rules.updateStatus).not.toHaveBeenCalled(); + expect(data.items.updateStatus).not.toHaveBeenCalled(); }); it('should not start if there are no rules to migrate (total = 0)', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 0, pending: 0, completed: 0, failed: 0 }, } as RuleMigrationDataStats); const client = new RuleMigrationsTaskClient( @@ -86,7 +86,7 @@ describe('RuleMigrationsTaskClient', () => { dependencies ); const result = await client.start(params); - expect(data.rules.updateStatus).toHaveBeenCalledWith( + expect(data.items.updateStatus).toHaveBeenCalledWith( migrationId, { status: SiemMigrationStatus.PROCESSING }, SiemMigrationStatus.PENDING, @@ -96,7 +96,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should not start if there are no pending rules', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 0, completed: 10, failed: 0 }, } as RuleMigrationDataStats); const client = new RuleMigrationsTaskClient( @@ -111,7 +111,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should start migration successfully', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 5, completed: 0, failed: 0 }, } as RuleMigrationDataStats); const mockedRunnerInstance = { @@ -144,7 +144,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should throw error if a race condition occurs after setup', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 5, completed: 0, failed: 0 }, } as RuleMigrationDataStats); const mockedRunnerInstance = { @@ -169,7 +169,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should mark migration as started by calling saveAsStarted', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 5, completed: 0, failed: 0 }, } as RuleMigrationDataStats); @@ -190,7 +190,7 @@ describe('RuleMigrationsTaskClient', () => { it('should mark migration as ended by calling saveAsEnded if run completes successfully', async () => { migrationsRunning = new Map(); - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 5, completed: 0, failed: 0 }, } as RuleMigrationDataStats); @@ -222,7 +222,7 @@ describe('RuleMigrationsTaskClient', () => { const filter: RuleMigrationFilters = { fullyTranslated: true }; const result = await client.updateToRetry(migrationId, filter); expect(result).toEqual({ updated: false }); - expect(data.rules.updateStatus).not.toHaveBeenCalled(); + expect(data.items.updateStatus).not.toHaveBeenCalled(); }); it('should update to retry if migration is not running', async () => { @@ -236,7 +236,7 @@ describe('RuleMigrationsTaskClient', () => { const filter: RuleMigrationFilters = { fullyTranslated: true }; const result = await client.updateToRetry(migrationId, filter); expect(filter.installed).toBe(false); - expect(data.rules.updateStatus).toHaveBeenCalledWith( + expect(data.items.updateStatus).toHaveBeenCalledWith( migrationId, { fullyTranslated: true, installed: false }, SiemMigrationStatus.PENDING, @@ -249,7 +249,7 @@ describe('RuleMigrationsTaskClient', () => { describe('getStats', () => { it('should return RUNNING status if migration is running', async () => { migrationsRunning.set(migrationId, {} as RuleMigrationTaskRunner); // migration is running - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 5, completed: 3, failed: 2 }, } as RuleMigrationDataStats); @@ -269,7 +269,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should return READY status if pending equals total', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 10, completed: 0, failed: 0 }, } as RuleMigrationDataStats); data.migrations.get.mockResolvedValue({ @@ -288,7 +288,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should return FINISHED status if completed+failed equals total', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 0, completed: 5, failed: 5 }, } as RuleMigrationDataStats); @@ -307,7 +307,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should return STOPPED status for other cases', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 2, completed: 3, failed: 2 }, } as RuleMigrationDataStats); const client = new RuleMigrationsTaskClient( @@ -323,7 +323,7 @@ describe('RuleMigrationsTaskClient', () => { it('should include error if one exists', async () => { const errorMessage = 'Test error'; - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ id: 'migration-1', rules: { total: 10, pending: 2, completed: 3, failed: 2 }, } as RuleMigrationDataStats); @@ -370,7 +370,7 @@ describe('RuleMigrationsTaskClient', () => { } as RuleMigrationDataStats, ]; const migrations = [{ id: 'm1' }, { id: 'm2' }] as unknown as StoredSiemMigration[]; - data.rules.getAllStats.mockResolvedValue(statsArray); + data.items.getAllStats.mockResolvedValue(statsArray); data.migrations.getAll.mockResolvedValue(migrations); // Mark migration m1 as running. migrationsRunning.set('m1', {} as RuleMigrationTaskRunner); @@ -409,7 +409,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should return stopped even if migration is already stopped', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 10, completed: 0, failed: 0 }, } as RuleMigrationDataStats); const client = new RuleMigrationsTaskClient( @@ -424,7 +424,7 @@ describe('RuleMigrationsTaskClient', () => { }); it('should return exists false if migration is not running and total equals 0', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 0, pending: 0, completed: 0, failed: 0 }, } as RuleMigrationDataStats); const client = new RuleMigrationsTaskClient( @@ -440,7 +440,7 @@ describe('RuleMigrationsTaskClient', () => { it('should catch errors and return exists true, stopped false', async () => { const error = new Error('Stop error'); - data.rules.getStats.mockRejectedValue(error); + data.items.getStats.mockRejectedValue(error); const client = new RuleMigrationsTaskClient( migrationsRunning, logger, @@ -477,7 +477,7 @@ describe('RuleMigrationsTaskClient', () => { }); describe('task error', () => { it('should call saveAsFailed when there has been an error during the migration', async () => { - data.rules.getStats.mockResolvedValue({ + data.items.getStats.mockResolvedValue({ rules: { total: 10, pending: 10, completed: 0, failed: 0 }, } as RuleMigrationDataStats); const error = new Error('Migration error'); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts index ad00c9c4081f4..27ecdad6e08d9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts @@ -5,236 +5,29 @@ * 2.0. */ -import type { AuthenticatedUser, Logger } from '@kbn/core/server'; -import { - SiemMigrationStatus, - SiemMigrationTaskStatus, -} from '../../../../../common/siem_migrations/constants'; -import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; -import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; -import type { RuleMigrationDataStats } from '../data/rule_migrations_data_rules_client'; -import type { StoredSiemMigration } from '../types'; +import type { RunnableConfig } from '@langchain/core/runnables'; import type { - RuleMigrationTaskEvaluateParams, - RuleMigrationTaskStartParams, - RuleMigrationTaskStartResult, - RuleMigrationTaskStopResult, -} from './types'; + RuleMigration, + RuleMigrationRule, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; -import { RuleMigrationTaskEvaluator } from './rule_migrations_task_evaluator'; -import type { SiemMigrationsClientDependencies } from '../../common/types'; - -export type MigrationsRunning = Map; - -export class RuleMigrationsTaskClient { - constructor( - private migrationsRunning: MigrationsRunning, - private logger: Logger, - private data: RuleMigrationsDataClient, - private currentUser: AuthenticatedUser, - private dependencies: SiemMigrationsClientDependencies - ) {} - - /** Starts a rule migration task */ - async start(params: RuleMigrationTaskStartParams): Promise { - const { migrationId, connectorId, invocationConfig } = params; - if (this.migrationsRunning.has(migrationId)) { - return { exists: true, started: false }; - } - // Just in case some previous execution was interrupted without cleaning up - await this.data.rules.updateStatus( - migrationId, - { status: SiemMigrationStatus.PROCESSING }, - SiemMigrationStatus.PENDING, - { refresh: true } - ); - - const { rules } = await this.data.rules.getStats(migrationId); - if (rules.total === 0) { - return { exists: false, started: false }; - } - if (rules.pending === 0) { - return { exists: true, started: false }; - } - - const migrationLogger = this.logger.get(migrationId); - const abortController = new AbortController(); - const migrationTaskRunner = new RuleMigrationTaskRunner( - migrationId, - this.currentUser, - abortController, - this.data, - migrationLogger, - this.dependencies - ); - - await migrationTaskRunner.setup(connectorId); - - if (this.migrationsRunning.has(migrationId)) { - // Just to prevent a race condition in the setup - throw new Error('Task already running for this migration'); - } - - migrationLogger.info('Starting migration'); - - this.migrationsRunning.set(migrationId, migrationTaskRunner); - - await this.data.migrations.saveAsStarted({ - id: migrationId, - connectorId, - skipPrebuiltRulesMatching: invocationConfig.configurable?.skipPrebuiltRulesMatching, - }); - - // run the migration in the background without awaiting and resolve the `start` promise - migrationTaskRunner - .run(invocationConfig) - .then(() => { - // The task runner has finished normally. Abort errors are also handled here, it's an expected finish scenario, nothing special should be done. - migrationLogger.debug('Migration execution task finished'); - this.data.migrations.saveAsFinished({ id: migrationId }).catch((error) => { - migrationLogger.error(`Error saving migration as finished: ${error}`); - }); - }) - .catch((error) => { - // Unexpected errors, no use in throwing them since the `start` promise is long gone. Just log and store the error message - migrationLogger.error(`Error executing migration task: ${error}`); - this.data.migrations - .saveAsFailed({ id: migrationId, error: error.message }) - .catch((saveError) => { - migrationLogger.error(`Error saving migration as failed: ${saveError}`); - }); - }) - .finally(() => { - this.migrationsRunning.delete(migrationId); - }); - - return { exists: true, started: true }; - } - - /** Updates all the rules in a migration to be re-executed */ - public async updateToRetry( - migrationId: string, - filter: RuleMigrationFilters - ): Promise<{ updated: boolean }> { - if (this.migrationsRunning.has(migrationId)) { - // not update migrations that are currently running - return { updated: false }; - } - filter.installed = false; // only retry rules that are not installed - await this.data.rules.updateStatus(migrationId, filter, SiemMigrationStatus.PENDING, { - refresh: true, - }); - return { updated: true }; - } - - /** Returns the stats of a migration */ - public async getStats(migrationId: string): Promise { - const migration = await this.data.migrations.get({ id: migrationId }); - if (!migration) { - throw new Error(`Migration with ID ${migrationId} not found`); - } - const dataStats = await this.data.rules.getStats(migrationId); - const taskStats = this.getTaskStats(migration, dataStats.rules); - return { ...taskStats, ...dataStats, name: migration.name }; - } - - /** Returns the stats of all migrations */ - async getAllStats(): Promise { - const allDataStats = await this.data.rules.getAllStats(); - const allMigrations = await this.data.migrations.getAll(); - const allMigrationsMap = new Map( - allMigrations.map((migration) => [migration.id, migration]) - ); - - const allStats: RuleMigrationTaskStats[] = []; - - for (const dataStats of allDataStats) { - const migration = allMigrationsMap.get(dataStats.id); - if (migration) { - const tasksStats = this.getTaskStats(migration, dataStats.rules); - allStats.push({ ...tasksStats, ...dataStats, name: migration.name }); - } - } - return allStats; - } - - private getTaskStats( - migration: StoredSiemMigration, - dataStats: RuleMigrationDataStats['rules'] - ): Pick { +import { SiemMigrationsTaskClient } from '../../common/task/siem_migrations_task_client'; +import type { MigrateRuleConfigSchema } from './agent/types'; + +export type RuleMigrationsRunning = Map; +export class RuleMigrationsTaskClient extends SiemMigrationsTaskClient< + RuleMigration, + RuleMigrationRule, + MigrateRuleConfigSchema +> { + protected readonly TaskRunnerClass = RuleMigrationTaskRunner; + + // Rules specific last_execution config + protected getLastExecutionConfig( + invocationConfig: RunnableConfig + ): Record { return { - status: this.getTaskStatus(migration, dataStats), - last_execution: migration.last_execution, + skipPrebuiltRulesMatching: invocationConfig.configurable?.skipPrebuiltRulesMatching ?? false, }; } - - private getTaskStatus( - migration: StoredSiemMigration, - dataStats: RuleMigrationDataStats['rules'] - ): SiemMigrationTaskStatus { - const { id: migrationId, last_execution: lastExecution } = migration; - if (this.migrationsRunning.has(migrationId)) { - return SiemMigrationTaskStatus.RUNNING; - } - if (dataStats.completed + dataStats.failed === dataStats.total) { - return SiemMigrationTaskStatus.FINISHED; - } - if (lastExecution?.is_stopped) { - return SiemMigrationTaskStatus.STOPPED; - } - if (dataStats.pending === dataStats.total) { - return SiemMigrationTaskStatus.READY; - } - return SiemMigrationTaskStatus.INTERRUPTED; - } - - /** Stops one running migration */ - async stop(migrationId: string): Promise { - try { - const migrationRunning = this.migrationsRunning.get(migrationId); - if (migrationRunning) { - migrationRunning.abortController.abort('Stopped by user'); - await this.data.migrations.setIsStopped({ id: migrationId }); - return { exists: true, stopped: true }; - } - - const { rules } = await this.data.rules.getStats(migrationId); - if (rules.total > 0) { - return { exists: true, stopped: true }; - } - return { exists: false, stopped: true }; - } catch (err) { - this.logger.error(`Error stopping migration ID:${migrationId}`, err); - return { exists: true, stopped: false }; - } - } - - /** Creates a new evaluator for the rule migration task */ - async evaluate(params: RuleMigrationTaskEvaluateParams): Promise { - const { evaluationId, langsmithOptions, connectorId, invocationConfig, abortController } = - params; - - const migrationLogger = this.logger.get('evaluate'); - - const migrationTaskEvaluator = new RuleMigrationTaskEvaluator( - evaluationId, - this.currentUser, - abortController, - this.data, - migrationLogger, - this.dependencies - ); - - await migrationTaskEvaluator.evaluate({ - connectorId, - langsmithOptions, - invocationConfig, - }); - } - - /** Returns if a migration is running or not */ - isMigrationRunning(migrationId: string): boolean { - return this.migrationsRunning.has(migrationId); - } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts index c7fbadd5139bf..8457cfcb09889 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts @@ -55,7 +55,7 @@ export class RuleMigrationTaskEvaluator extends RuleMigrationTaskRunner { await this.setup(connectorId); // create the migration task after setup - const migrateRuleTask = this.createMigrateRuleTask(invocationConfig); + const migrateRuleTask = this.createMigrateItemTask(invocationConfig); const evaluators = this.getEvaluators(); evaluate(migrateRuleTask, { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts index d950c856c699d..de37b14c9f91f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts @@ -110,8 +110,8 @@ describe('RuleMigrationTaskRunner', () => { }); it('should handle the migration successfully', async () => { - mockRuleMigrationsDataClient.rules.get.mockResolvedValue({ total: 0, data: [] }); - mockRuleMigrationsDataClient.rules.get.mockResolvedValueOnce({ + mockRuleMigrationsDataClient.items.get.mockResolvedValue({ total: 0, data: [] }); + mockRuleMigrationsDataClient.items.get.mockResolvedValueOnce({ total: 1, data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], }); @@ -119,13 +119,13 @@ describe('RuleMigrationTaskRunner', () => { await taskRunner.setup('test-connector-id'); await expect(taskRunner.run({})).resolves.toBeUndefined(); - expect(mockRuleMigrationsDataClient.rules.saveProcessing).toHaveBeenCalled(); + expect(mockRuleMigrationsDataClient.items.saveProcessing).toHaveBeenCalled(); expect(mockTimeout).toHaveBeenCalledTimes(1); // random execution sleep expect(mockTimeout).toHaveBeenNthCalledWith(1, expect.any(Function), expect.any(Number)); expect(mockInvoke).toHaveBeenCalledTimes(1); - expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalled(); - expect(mockRuleMigrationsDataClient.rules.get).toHaveBeenCalledTimes(2); // One with data, one without + expect(mockRuleMigrationsDataClient.items.saveCompleted).toHaveBeenCalled(); + expect(mockRuleMigrationsDataClient.items.get).toHaveBeenCalledTimes(2); // One with data, one without expect(mockLogger.info).toHaveBeenCalledWith('Migration completed successfully'); }); @@ -156,8 +156,8 @@ describe('RuleMigrationTaskRunner', () => { describe('during migration', () => { beforeEach(() => { - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + mockRuleMigrationsDataClient.items.get.mockRestore(); + mockRuleMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce({ total: 1, @@ -175,7 +175,7 @@ describe('RuleMigrationTaskRunner', () => { await expect(runPromise).resolves.toBeUndefined(); // Ensure the function handles abort gracefully expect(mockLogger.info).toHaveBeenCalledWith('Abort signal received, stopping migration'); - expect(mockRuleMigrationsDataClient.rules.releaseProcessing).toHaveBeenCalled(); + expect(mockRuleMigrationsDataClient.items.releaseProcessing).toHaveBeenCalled(); }); it('should handle other errors correctly', async () => { @@ -187,7 +187,7 @@ describe('RuleMigrationTaskRunner', () => { expect(mockLogger.error).toHaveBeenCalledWith( `Error translating rule \"${ruleId}\" with error: ${errorMessage}` ); - expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalled(); + expect(mockRuleMigrationsDataClient.items.saveError).toHaveBeenCalled(); }); describe('during rate limit errors', () => { @@ -195,8 +195,8 @@ describe('RuleMigrationTaskRunner', () => { const error = new Error('429. You did way too many requests to this random LLM API bud'); beforeEach(async () => { - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + mockRuleMigrationsDataClient.items.get.mockRestore(); + mockRuleMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce({ total: 2, @@ -253,7 +253,7 @@ describe('RuleMigrationTaskRunner', () => { `Awaiting backoff task for rule "${rule2Id}"` ); expect(mockInvoke).toHaveBeenCalledTimes(6); // 3 retries + 3 executions - expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalledTimes(2); // 2 rules + expect(mockRuleMigrationsDataClient.items.saveCompleted).toHaveBeenCalledTimes(2); // 2 rules }); it('should fail when reached maxRetries', async () => { @@ -265,14 +265,14 @@ describe('RuleMigrationTaskRunner', () => { expect(mockInvoke).toHaveBeenCalledTimes(10); // 8 retries + 2 executions expect(mockTimeout).toHaveBeenCalledTimes(10); // 2 execution sleeps + 8 backoff sleeps - expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalledTimes(2); // 2 rules + expect(mockRuleMigrationsDataClient.items.saveError).toHaveBeenCalledTimes(2); // 2 rules }); it('should fail when reached max recovery attempts', async () => { const rule3Id = 'test-rule-id-3'; const rule4Id = 'test-rule-id-4'; - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + mockRuleMigrationsDataClient.items.get.mockRestore(); + mockRuleMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce({ total: 4, @@ -303,8 +303,8 @@ describe('RuleMigrationTaskRunner', () => { await expect(taskRunner.run({})).resolves.toBeUndefined(); // success - expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalledTimes(3); // rules 1, 2 and 3 - expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalledTimes(1); // rule 4 + expect(mockRuleMigrationsDataClient.items.saveCompleted).toHaveBeenCalledTimes(3); // rules 1, 2 and 3 + expect(mockRuleMigrationsDataClient.items.saveError).toHaveBeenCalledTimes(1); // rule 4 }); it('should increase the executor sleep time when rate limited', async () => { @@ -312,8 +312,8 @@ describe('RuleMigrationTaskRunner', () => { total: 1, data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], }; - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + mockRuleMigrationsDataClient.items.get.mockRestore(); + mockRuleMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce(getResponse) .mockResolvedValueOnce({ total: 0, data: [] }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts index 00469ddff709e..a84a05503aa4a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts @@ -5,94 +5,65 @@ * 2.0. */ -import assert from 'assert'; import type { AuthenticatedUser, Logger } from '@kbn/core/server'; -import { abortSignalToPromise, AbortError } from '@kbn/kibana-utils-plugin/server'; -import { type ElasticRule } from '../../../../../common/siem_migrations/model/rule_migration.gen'; -import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; -import { initPromisePool } from '../../../../utils/promise_pool'; +import type { + RuleMigration, + RuleMigrationRule, + ElasticRule, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; -import type { MigrateRuleGraphConfig, MigrateRuleState } from './agent/types'; +import type { MigrateRuleConfigSchema, MigrateRuleGraphConfig } from './agent/types'; import { getRuleMigrationAgent } from './agent'; import { RuleMigrationsRetriever } from './retrievers'; -import { SiemMigrationTelemetryClient } from './rule_migrations_telemetry_client'; -import type { MigrationAgent, RuleMigrationInput } from './types'; -import { generateAssistantComment } from './util/comments'; +import type { RuleMigrationInput } from './types'; import type { StoredRuleMigration } from '../types'; -import { ActionsClientChat } from './util/actions_client_chat'; import { EsqlKnowledgeBase } from './util/esql_knowledge_base'; import { nullifyElasticRule } from './util/nullify_missing_properties'; import type { SiemMigrationsClientDependencies } from '../../common/types'; - -/** Number of concurrent rule translations in the pool */ -const TASK_CONCURRENCY = 10 as const; -/** Number of rules loaded in memory to be translated in the pool */ -const TASK_BATCH_SIZE = 100 as const; -/** The timeout of each individual agent invocation in minutes */ -const AGENT_INVOKE_TIMEOUT_MIN = 3 as const; - -/** Exponential backoff configuration to handle rate limit errors */ -const RETRY_CONFIG = { - initialRetryDelaySeconds: 1, - backoffMultiplier: 2, - maxRetries: 8, - // max waiting time 4m15s (1*2^8 = 256s) -} as const; - -/** Executor sleep configuration - * A sleep time applied at the beginning of each single rule translation in the execution pool, - * The objective of this sleep is to spread the load of concurrent translations, and prevent hitting the rate limit repeatedly. - * The sleep time applied is a random number between [0-value]. Every time we hit rate limit the value is increased by the multiplier, up to the limit. - */ -const EXECUTOR_SLEEP = { - initialValueSeconds: 3, - multiplier: 2, - limitSeconds: 96, // 1m36s (5 increases) -} as const; - -/** This limit should never be reached, it's a safety net to prevent infinite loops. - * It represents the max number of consecutive rate limit recovery & failure attempts. - * This can only happen when the API can not process TASK_CONCURRENCY translations at a time, - * even after the executor sleep is increased on every attempt. - **/ -const EXECUTOR_RECOVER_MAX_ATTEMPTS = 3 as const; - -export class RuleMigrationTaskRunner { - private telemetry?: SiemMigrationTelemetryClient; - protected agent?: MigrationAgent; +import { SiemMigrationTaskRunner } from '../../common/task/siem_migrations_task_runner'; +import { RuleMigrationTelemetryClient } from './rule_migrations_telemetry_client'; +import type { MigrationState, MigrationTask, MigrationTaskInvoke } from '../../common/task/types'; + +export class RuleMigrationTaskRunner extends SiemMigrationTaskRunner< + RuleMigration, + RuleMigrationRule, + MigrateRuleConfigSchema +> { + declare task?: MigrationTask; private retriever: RuleMigrationsRetriever; - private actionsClientChat: ActionsClientChat; - private abort: ReturnType; - private executorSleepMultiplier: number = EXECUTOR_SLEEP.initialValueSeconds; - public isWaiting: boolean = false; constructor( public readonly migrationId: string, public readonly startedBy: AuthenticatedUser, public readonly abortController: AbortController, - private readonly data: RuleMigrationsDataClient, + protected readonly data: RuleMigrationsDataClient, protected readonly logger: Logger, protected readonly dependencies: SiemMigrationsClientDependencies ) { - this.actionsClientChat = new ActionsClientChat(this.dependencies.actionsClient, this.logger); + super(migrationId, startedBy, abortController, data, logger, dependencies); this.retriever = new RuleMigrationsRetriever(this.migrationId, { data: this.data, rules: this.dependencies.rulesClient, savedObjects: this.dependencies.savedObjectsClient, }); - this.abort = abortSignalToPromise(this.abortController.signal); } /** Retrieves the connector and creates the migration agent */ - public async setup(connectorId: string) { + public async setup(connectorId: string): Promise { const { inferenceClient } = this.dependencies; - const model = await this.actionsClientChat.createModel({ connectorId, migrationId: this.migrationId, abortController: this.abortController, }); + const telemetryClient = new RuleMigrationTelemetryClient( + this.dependencies.telemetry, + this.logger, + this.migrationId, + model.model + ); + const esqlKnowledgeBase = new EsqlKnowledgeBase( connectorId, this.migrationId, @@ -100,20 +71,31 @@ export class RuleMigrationTaskRunner { this.logger ); - this.telemetry = new SiemMigrationTelemetryClient( - this.dependencies.telemetry, - this.logger, - this.migrationId, - model.model - ); - - this.agent = getRuleMigrationAgent({ - model, + const agent = getRuleMigrationAgent({ esqlKnowledgeBase, + model, ruleMigrationsRetriever: this.retriever, - telemetryClient: this.telemetry, logger: this.logger, + telemetryClient, }); + + this.telemetry = telemetryClient; + this.task = { + prepare: async ( + migrationRule: StoredRuleMigration, + config: MigrateRuleGraphConfig + ): Promise> => { + const resources = await this.retriever.resources.getResources(migrationRule); + const input: RuleMigrationInput = { + id: migrationRule.id, + original_rule: migrationRule.original_rule, + resources, + }; + return async () => { + return agent.invoke(input, config) as Promise>; + }; + }, + }; } /** Initializes the retriever populating ELSER indices. It may take a few minutes */ @@ -121,222 +103,10 @@ export class RuleMigrationTaskRunner { await this.retriever.initialize(); } - public async run(invocationConfig: MigrateRuleGraphConfig): Promise { - assert(this.telemetry, 'telemetry is missing please call setup() first'); - const { telemetry, migrationId } = this; - - const migrationTaskTelemetry = telemetry.startSiemMigrationTask(); - - try { - // TODO: track the duration of the initialization alone in the telemetry - this.logger.debug('Initializing migration'); - await this.withAbort(this.initialize()); // long running operation - } catch (error) { - migrationTaskTelemetry.failure(error); - if (error instanceof AbortError) { - this.logger.info('Abort signal received, stopping initialization'); - return; - } else { - throw new Error(`Migration initialization failed. ${error}`); - } - } - - const migrateRuleTask = this.createMigrateRuleTask(invocationConfig); - this.logger.debug(`Started rule translations. Concurrency is: ${TASK_CONCURRENCY}`); - - try { - do { - const { data: ruleMigrations } = await this.data.rules.get(migrationId, { - filters: { status: SiemMigrationStatus.PENDING }, - size: TASK_BATCH_SIZE, // keep these rules in memory and process them in the promise pool with concurrency limit - }); - if (ruleMigrations.length === 0) { - break; - } - - this.logger.debug(`Start processing batch of ${ruleMigrations.length} rules`); - - const { errors } = await initPromisePool({ - concurrency: TASK_CONCURRENCY, - abortSignal: this.abortController.signal, - items: ruleMigrations, - executor: async (ruleMigration) => { - const ruleTranslationTelemetry = migrationTaskTelemetry.startRuleTranslation(); - try { - await this.saveRuleProcessing(ruleMigration); - - const resources = await this.retriever.resources.getResources(ruleMigration); - - const migrationResult = await migrateRuleTask({ - id: ruleMigration.id, - original_rule: ruleMigration.original_rule, - resources, - }); - - await this.saveRuleCompleted(ruleMigration, migrationResult); - ruleTranslationTelemetry.success(migrationResult); - } catch (error) { - if (this.abortController.signal.aborted) { - throw new AbortError(); - } - ruleTranslationTelemetry.failure(error); - await this.saveRuleFailed(ruleMigration, error); - } - }, - }); - - if (errors.length > 0) { - throw errors[0].error; // Only AbortError is thrown from the pool. The task was aborted - } - - this.logger.debug('Batch processed successfully'); - } while (true); - - migrationTaskTelemetry.success(); - this.logger.info('Migration completed successfully'); - } catch (error) { - await this.data.rules.releaseProcessing(migrationId); - - if (error instanceof AbortError) { - migrationTaskTelemetry.aborted(error); - this.logger.info('Abort signal received, stopping migration'); - } else { - migrationTaskTelemetry.failure(error); - throw new Error(`Error processing migration: ${error}`); - } - } finally { - this.abort.cleanup(); - } - } - - protected createMigrateRuleTask(invocationConfig?: MigrateRuleGraphConfig) { - assert(this.agent, 'agent is missing please call setup() first'); - const { agent } = this; - const config: MigrateRuleGraphConfig = { - timeout: AGENT_INVOKE_TIMEOUT_MIN * 60 * 1000, // milliseconds timeout - ...invocationConfig, - signal: this.abortController.signal, - }; - - // Prepare the invocation with specific config - const invoke = async (input: RuleMigrationInput): Promise => - agent.invoke(input, config); - - // Invokes the rule translation with exponential backoff, should be called only when the rate limit has been hit - const invokeWithBackoff = async ( - ruleMigration: RuleMigrationInput - ): Promise => { - this.logger.debug(`Rate limit backoff started for rule "${ruleMigration.id}"`); - let retriesLeft: number = RETRY_CONFIG.maxRetries; - while (true) { - try { - await this.sleepRetry(retriesLeft); - retriesLeft--; - const result = await invoke(ruleMigration); - this.logger.info( - `Rate limit backoff completed successfully for rule "${ruleMigration.id}" after ${ - RETRY_CONFIG.maxRetries - retriesLeft - } retries` - ); - return result; - } catch (error) { - if (!this.isRateLimitError(error) || retriesLeft === 0) { - this.logger.debug( - `Rate limit backoff completed unsuccessfully for rule "${ruleMigration.id}"` - ); - const logMessage = - retriesLeft === 0 - ? `Rate limit backoff completed unsuccessfully for rule "${ruleMigration.id}"` - : `Rate limit backoff interrupted for rule "${ruleMigration.id}". ${error} `; - this.logger.debug(logMessage); - throw error; - } - this.logger.debug( - `Rate limit backoff not completed for rule "${ruleMigration.id}", retries left: ${retriesLeft}` - ); - } - } - }; - - let backoffPromise: Promise | undefined; - // Migrates one rule, this function will be called concurrently by the promise pool. - // Handles rate limit errors and ensures only one task is executing the backoff retries at a time, the rest of translation will await. - const migrateRule = async (ruleMigration: RuleMigrationInput): Promise => { - let recoverAttemptsLeft: number = EXECUTOR_RECOVER_MAX_ATTEMPTS; - while (true) { - try { - await this.executorSleep(); // Random sleep, increased every time we hit the rate limit. - return await invoke(ruleMigration); - } catch (error) { - if (!this.isRateLimitError(error) || recoverAttemptsLeft === 0) { - throw error; - } - if (!backoffPromise) { - // only one translation handles the rate limit backoff retries, the rest will await it and try again when it's resolved - backoffPromise = invokeWithBackoff(ruleMigration); - this.isWaiting = true; - return backoffPromise.finally(() => { - backoffPromise = undefined; - this.increaseExecutorSleep(); - this.isWaiting = false; - }); - } - this.logger.debug(`Awaiting backoff task for rule "${ruleMigration.id}"`); - await backoffPromise.catch(() => { - throw error; // throw the original error - }); - recoverAttemptsLeft--; - } - } - }; - - return migrateRule; - } - - private isRateLimitError(error: Error) { - return error.message.match(/\b429\b/); // "429" (whole word in the error message): Too Many Requests. - } - - private async withAbort(promise: Promise): Promise { - return Promise.race([promise, this.abort.promise]); - } - - private async sleep(seconds: number) { - await this.withAbort(new Promise((resolve) => setTimeout(resolve, seconds * 1000))); - } - - // Exponential backoff implementation - private async sleepRetry(retriesLeft: number) { - const seconds = - RETRY_CONFIG.initialRetryDelaySeconds * - Math.pow(RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxRetries - retriesLeft); - this.logger.debug(`Retry sleep: ${seconds}s`); - await this.sleep(seconds); - } - - private executorSleep = async () => { - const seconds = Math.random() * this.executorSleepMultiplier; - this.logger.debug(`Executor sleep: ${seconds.toFixed(3)}s`); - await this.sleep(seconds); - }; - - private increaseExecutorSleep = () => { - const increasedMultiplier = this.executorSleepMultiplier * EXECUTOR_SLEEP.multiplier; - if (increasedMultiplier > EXECUTOR_SLEEP.limitSeconds) { - this.logger.warn('Executor sleep reached the maximum value'); - return; - } - this.executorSleepMultiplier = increasedMultiplier; - }; - - private async saveRuleProcessing(ruleMigration: StoredRuleMigration) { - this.logger.debug(`Starting translation of rule "${ruleMigration.id}"`); - return this.data.rules.saveProcessing(ruleMigration.id); - } - - private async saveRuleCompleted( + /** Overload to nullify elastic rule specific properties */ + protected async saveItemCompleted( ruleMigration: StoredRuleMigration, - migrationResult: MigrateRuleState + migrationResult: Partial ) { this.logger.debug(`Translation of rule "${ruleMigration.id}" succeeded`); const nullifiedElasticRule = nullifyElasticRule( @@ -349,12 +119,6 @@ export class RuleMigrationTaskRunner { translation_result: migrationResult.translation_result, comments: migrationResult.comments, }; - return this.data.rules.saveCompleted(ruleMigrationTranslated); - } - - private async saveRuleFailed(ruleMigration: StoredRuleMigration, error: Error) { - this.logger.error(`Error translating rule "${ruleMigration.id}" with error: ${error.message}`); - const comments = [generateAssistantComment(`Error migrating rule: ${error.message}`)]; - return this.data.rules.saveError({ ...ruleMigration, comments }); + return super.saveItemCompleted(ruleMigration, ruleMigrationTranslated); } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts index 709e63ff49828..8b678056f1404 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts @@ -7,10 +7,13 @@ import type { Logger } from '@kbn/core/server'; import type { RuleMigrationTaskCreateClientParams } from './types'; -import { RuleMigrationsTaskClient, type MigrationsRunning } from './rule_migrations_task_client'; +import { + RuleMigrationsTaskClient, + type RuleMigrationsRunning, +} from './rule_migrations_task_client'; export class RuleMigrationsTaskService { - private migrationsRunning: MigrationsRunning; + private migrationsRunning: RuleMigrationsRunning; constructor(private logger: Logger) { this.migrationsRunning = new Map(); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.test.ts deleted file mode 100644 index adeca580860a5..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { coreMock } from '@kbn/core/server/mocks'; -import { loggerMock } from '@kbn/logging-mocks'; -import { SiemMigrationTelemetryClient } from './rule_migrations_telemetry_client'; -import type { RuleMigrationIntegration, RuleMigrationPrebuiltRule } from '../types'; -import type { MigrateRuleState } from './agent/types'; - -const translationResultWithMatchMock = { - translation_result: 'full', - elastic_rule: { prebuilt_rule_id: 'testprebuiltid' }, -} as MigrateRuleState; -const translationResultMock = { - translation_result: 'partial', -} as MigrateRuleState; -const preFilterRulesMock: RuleMigrationPrebuiltRule[] = [ - { - rule_id: 'rule1id', - name: 'rule1', - description: 'rule1description', - elser_embedding: 'rule1embedding', - mitre_attack_ids: ['MitreID1'], - }, - { - rule_id: 'rule2id', - name: 'rule2', - description: 'rule2description', - elser_embedding: 'rule1embedding', - }, -]; - -const postFilterRuleMock: RuleMigrationPrebuiltRule = { - rule_id: 'rule1id', - name: 'rule1', - description: 'rule1description', - elser_embedding: 'rule1embedding', - mitre_attack_ids: ['MitreID1'], -}; - -const postFilterIntegrationMocks: RuleMigrationIntegration = { - id: 'testIntegration1', - title: 'testIntegration1', - description: 'testDescription1', - data_streams: [{ dataset: 'testds1', title: 'testds1', index_pattern: 'testds1-pattern' }], - elser_embedding: 'testEmbedding', -}; - -const preFilterIntegrationMocks: RuleMigrationIntegration[] = [ - { - id: 'testIntegration1', - title: 'testIntegration1', - description: 'testDescription1', - data_streams: [{ dataset: 'testds1', title: 'testds1', index_pattern: 'testds1-pattern' }], - elser_embedding: 'testEmbedding', - }, - { - id: 'testIntegration2', - title: 'testIntegration2', - description: 'testDescription2', - data_streams: [{ dataset: 'testds2', title: 'testds2', index_pattern: 'testds2-pattern' }], - elser_embedding: 'testEmbedding', - }, -]; - -const mockTelemetry = coreMock.createSetup().analytics; -const mockLogger = loggerMock.create(); -const siemTelemetryClient = new SiemMigrationTelemetryClient( - mockTelemetry, - mockLogger, - 'testmigration', - 'testModel' -); - -describe('siemMigrationTelemetry', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - beforeAll(() => { - jest.useFakeTimers(); - const date = '2024-01-28T04:20:02.394Z'; - jest.setSystemTime(new Date(date)); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - it('start/end migration with error', async () => { - const error = 'test error message'; - const siemMigrationTaskTelemetry = siemTelemetryClient.startSiemMigrationTask(); - const ruleTranslationTelemetry = siemMigrationTaskTelemetry.startRuleTranslation(); - - // 2 success and 2 failures - ruleTranslationTelemetry.success(translationResultMock); - ruleTranslationTelemetry.success(translationResultMock); - ruleTranslationTelemetry.failure(new Error('test')); - ruleTranslationTelemetry.failure(new Error('test')); - - siemMigrationTaskTelemetry.failure(new Error(error)); - - expect(mockTelemetry.reportEvent).toHaveBeenNthCalledWith( - 5, - 'siem_migrations_migration_failure', - { - completed: 2, - duration: 0, - error, - failed: 2, - migrationId: 'testmigration', - model: 'testModel', - total: 4, - eventName: 'Migration failure', - } - ); - }); - it('start/end migration success', async () => { - const siemMigrationTaskTelemetry = siemTelemetryClient.startSiemMigrationTask(); - const ruleTranslationTelemetry = siemMigrationTaskTelemetry.startRuleTranslation(); - - // 2 success and 2 failures - ruleTranslationTelemetry.success(translationResultMock); - ruleTranslationTelemetry.success(translationResultMock); - ruleTranslationTelemetry.failure(new Error('test')); - ruleTranslationTelemetry.failure(new Error('test')); - - siemMigrationTaskTelemetry.success(); - - expect(mockTelemetry.reportEvent).toHaveBeenNthCalledWith( - 5, - 'siem_migrations_migration_success', - { - completed: 2, - duration: 0, - failed: 2, - migrationId: 'testmigration', - model: 'testModel', - total: 4, - eventName: 'Migration success', - } - ); - }); - it('start/end rule translation with error', async () => { - const error = 'test error message'; - const siemMigrationTaskTelemetry = siemTelemetryClient.startSiemMigrationTask(); - const ruleTranslationTelemetry = siemMigrationTaskTelemetry.startRuleTranslation(); - - ruleTranslationTelemetry.failure(new Error(error)); - - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith( - 'siem_migrations_rule_translation_failure', - { error, migrationId: 'testmigration', model: 'testModel', eventName: 'Translation failure' } - ); - }); - it('start/end rule translation success with prebuilt', async () => { - const siemMigrationTaskTelemetry = siemTelemetryClient.startSiemMigrationTask(); - const ruleTranslationTelemetry = siemMigrationTaskTelemetry.startRuleTranslation(); - - ruleTranslationTelemetry.success(translationResultWithMatchMock); - - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith( - 'siem_migrations_rule_translation_success', - { - migrationId: 'testmigration', - model: 'testModel', - duration: 0, - prebuiltMatch: true, - translationResult: 'full', - eventName: 'Translation success', - } - ); - }); - it('start/end rule translation success without prebuilt', async () => { - const siemMigrationTaskTelemetry = siemTelemetryClient.startSiemMigrationTask(); - const ruleTranslationTelemetry = siemMigrationTaskTelemetry.startRuleTranslation(); - - ruleTranslationTelemetry.success(translationResultMock); - - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith( - 'siem_migrations_rule_translation_success', - { - migrationId: 'testmigration', - model: 'testModel', - prebuiltMatch: false, - translationResult: 'partial', - duration: 0, - eventName: 'Translation success', - } - ); - }); - it('reportIntegrationMatch with a match', async () => { - siemTelemetryClient.reportIntegrationsMatch({ - preFilterIntegrations: preFilterIntegrationMocks, - postFilterIntegration: postFilterIntegrationMocks, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('siem_migrations_integration_match', { - migrationId: 'testmigration', - model: 'testModel', - postFilterIntegrationCount: 1, - postFilterIntegrationName: 'testIntegration1', - preFilterIntegrationCount: 2, - preFilterIntegrationNames: ['testIntegration1', 'testIntegration2'], - eventName: 'Integrations match', - }); - }); - it('reportIntegrationMatch without postFilter matches', async () => { - siemTelemetryClient.reportIntegrationsMatch({ - preFilterIntegrations: preFilterIntegrationMocks, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('siem_migrations_integration_match', { - migrationId: 'testmigration', - model: 'testModel', - postFilterIntegrationCount: 0, - postFilterIntegrationName: '', - preFilterIntegrationCount: 2, - preFilterIntegrationNames: ['testIntegration1', 'testIntegration2'], - eventName: 'Integrations match', - }); - }); - it('reportPrebuiltRulesMatch with a match', async () => { - siemTelemetryClient.reportPrebuiltRulesMatch({ - preFilterRules: preFilterRulesMock, - postFilterRule: postFilterRuleMock, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('siem_migrations_prebuilt_rules_match', { - migrationId: 'testmigration', - model: 'testModel', - postFilterRuleCount: 1, - postFilterRuleName: 'rule1id', - preFilterRuleCount: 2, - preFilterRuleNames: ['rule1id', 'rule2id'], - eventName: 'Prebuilt rules match', - }); - }); - it('reportPrebuiltRulesMatch without postFilter matches', async () => { - siemTelemetryClient.reportPrebuiltRulesMatch({ - preFilterRules: preFilterRulesMock, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('siem_migrations_prebuilt_rules_match', { - migrationId: 'testmigration', - model: 'testModel', - postFilterRuleCount: 0, - postFilterRuleName: '', - preFilterRuleCount: 2, - preFilterRuleNames: ['rule1id', 'rule2id'], - eventName: 'Prebuilt rules match', - }); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.ts index 74ffb958bbfd8..d76e8aac06f9d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client.ts @@ -6,6 +6,7 @@ */ import type { AnalyticsServiceSetup, Logger, EventTypeOpts } from '@kbn/core/server'; +import type { RuleMigrationRule } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { SIEM_MIGRATIONS_INTEGRATIONS_MATCH, SIEM_MIGRATIONS_MIGRATION_ABORTED, @@ -15,10 +16,11 @@ import { SIEM_MIGRATIONS_RULE_TRANSLATION_FAILURE, SIEM_MIGRATIONS_RULE_TRANSLATION_SUCCESS, } from '../../../telemetry/event_based/events'; -import type { RuleMigrationIntegration, RuleSemanticSearchResult } from '../types'; -import type { MigrateRuleState } from './agent/types'; import { siemMigrationEventNames } from '../../../telemetry/event_based/event_meta'; import { SiemMigrationsEventTypes } from '../../../telemetry/event_based/types'; +import type { RuleMigrationIntegration, RuleSemanticSearchResult } from '../types'; +import type { MigrateRuleState } from './agent/types'; +import type { SiemMigrationTelemetryClient } from '../../common/task/siem_migrations_telemetry_client'; interface IntegrationMatchEvent { preFilterIntegrations: RuleMigrationIntegration[]; @@ -30,7 +32,9 @@ interface PrebuiltRuleMatchEvent { postFilterRule?: RuleSemanticSearchResult; } -export class SiemMigrationTelemetryClient { +export class RuleMigrationTelemetryClient + implements SiemMigrationTelemetryClient +{ constructor( private readonly telemetry: AnalyticsServiceSetup, private readonly logger: Logger, @@ -81,7 +85,7 @@ export class SiemMigrationTelemetryClient { const stats = { completed: 0, failed: 0 }; return { - startRuleTranslation: () => { + startItemTranslation: () => { const ruleStartTime = Date.now(); return { success: (migrationResult: MigrateRuleState) => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts index d3f82d989b11d..e60cd543186d2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts @@ -10,7 +10,7 @@ import type { LangSmithEvaluationOptions } from '../../../../../common/siem_migr import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; import type { StoredRuleMigration } from '../types'; import type { getRuleMigrationAgent } from './agent'; -import type { SiemMigrationTelemetryClient } from './rule_migrations_telemetry_client'; +import type { RuleMigrationTelemetryClient } from './rule_migrations_telemetry_client'; import type { ChatModel } from './util/actions_client_chat'; import type { RuleMigrationResources } from './retrievers/rule_resource_retriever'; import type { RuleMigrationsRetriever } from './retrievers'; @@ -43,7 +43,7 @@ export interface RuleMigrationTaskRunParams extends RuleMigrationTaskStartParams export interface RuleMigrationTaskCreateAgentParams { connectorId: string; retriever: RuleMigrationsRetriever; - telemetryClient: SiemMigrationTelemetryClient; + telemetryClient: RuleMigrationTelemetryClient; model: ChatModel; } From e52c10392d50df3967f4847723d7665a4002605d Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 7 Aug 2025 10:18:10 +0200 Subject: [PATCH 2/8] data service changes --- .../model/dashboard_migration.gen.ts | 11 +- .../model/dashboard_migration.schema.yaml | 3 + .../siem_migrations/model/migration.gen.ts | 8 + .../model/migration.schema.yaml | 8 + .../model/rule_migration.gen.ts | 17 +- .../model/rule_migration.schema.yaml | 10 +- .../tabs/translation/callout.tsx | 8 +- .../rules/utils/translation_results/index.ts | 6 +- .../siem_migrations/common/data/field_maps.ts | 32 +++ .../data/siem_migrations_data_client.ts | 2 +- ...m_migrations_data_migration_client.test.ts | 4 +- .../siem_migrations_data_migration_client.ts | 10 +- .../lib/siem_migrations/common/data/sort.ts | 147 ------------ .../common/siem_migrations_base_service.ts | 21 +- .../task/siem_migrations_task_client.ts | 43 ++-- .../task/siem_migrations_task_evaluator.ts | 215 ++++++++---------- .../task/siem_migrations_task_runner.test.ts | 2 +- .../common/task/util/constants.ts | 8 - .../common/task/util/esql_knowledge_base.ts | 39 ---- .../util/nullify_missing_properties.test.ts | 85 ------- .../task/util/nullify_missing_properties.ts | 53 ----- .../data/dashboard_migrations_data_service.ts | 13 +- .../dashboards/data/field_maps.ts | 1 + .../lib/siem_migrations/rules/api/get.ts | 4 +- .../rules/api/util/update_rules.ts | 16 +- .../api/util/with_existing_migration_id.ts | 4 +- ...migrations_field_maps.ts => field_maps.ts} | 0 ...e_migrations_data_migration_client.test.ts | 10 +- .../data/rule_migrations_data_service.test.ts | 5 +- .../data/rule_migrations_data_service.ts | 10 +- .../match_prebuilt_rule.ts | 2 +- .../nodes/ecs_mapping/ecs_mapping.ts | 2 +- .../nodes/inline_query/inline_query.ts | 2 +- .../retrieve_integrations.ts | 2 +- .../nodes/translate_rule/translate_rule.ts | 2 +- .../siem_migrations/rules/task/agent/types.ts | 4 +- .../rules/task/rule_migrations_task_client.ts | 2 + .../task/rule_migrations_task_evaluator.ts | 108 +-------- .../rules/task/rule_migrations_task_runner.ts | 2 +- .../rules/task/util/actions_client_chat.ts | 103 --------- .../rules/task/util/comments.ts | 22 -- .../server/lib/siem_migrations/rules/types.ts | 4 +- 42 files changed, 266 insertions(+), 784 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/field_maps.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/{rule_migrations_field_maps.ts => field_maps.ts} (100%) delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/comments.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts index d82525ca93cdb..34fd94f116a60 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts @@ -17,7 +17,12 @@ import { z } from '@kbn/zod'; import { NonEmptyString } from '../../api/model/primitives.gen'; -import { MigrationLastExecution, MigrationStatus, MigrationTaskStats } from './migration.gen'; +import { + MigrationLastExecution, + MigrationTranslationResult, + MigrationStatus, + MigrationTaskStats, +} from './migration.gen'; import { SplunkOriginalDashboardProperties } from './vendor/dashboards/splunk.gen'; /** @@ -116,6 +121,10 @@ export const DashboardMigrationDashboardData = z.object({ * The original dashboard to migrate. */ original_dashboard: OriginalDashboard, + /** + * The rule translation result. + */ + translation_result: MigrationTranslationResult.optional(), /** * The status of the dashboard migration process. */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml index c5562fbe31676..c73bff0b7bbe5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml @@ -73,6 +73,9 @@ components: original_dashboard: description: The original dashboard to migrate. $ref: '#/components/schemas/OriginalDashboard' + translation_result: + description: The rule translation result. + $ref: './migration.schema.yaml#/components/schemas/MigrationTranslationResult' status: description: The status of the dashboard migration process. $ref: './migration.schema.yaml#/components/schemas/MigrationStatus' diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts index dd1762ffaadad..77613baf43148 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts @@ -67,6 +67,14 @@ export const MigrationLastExecution = z.object({ is_stopped: z.boolean().optional(), }); +/** + * The migration translation result. + */ +export type MigrationTranslationResult = z.infer; +export const MigrationTranslationResult = z.enum(['full', 'partial', 'untranslatable']); +export type MigrationTranslationResultEnum = typeof MigrationTranslationResult.enum; +export const MigrationTranslationResultEnum = MigrationTranslationResult.enum; + /** * The migration items stats. */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml index effd04af99be7..c8a2a8ad9cbbd 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml @@ -47,6 +47,14 @@ components: type: boolean description: Indicates if the last execution was stopped by the user. + MigrationTranslationResult: + type: string + description: The migration translation result. + enum: # should match RuleTranslationResult enum at ../constants.ts TODO: refactor enum to MigrationTranslationResult + - full + - partial + - untranslatable + MigrationTaskStats: type: object description: The migration task stats object. diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index a1873af409ae4..8861e7ae34bc0 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -18,7 +18,12 @@ import { z } from '@kbn/zod'; import { NonEmptyString } from '../../api/model/primitives.gen'; import { RuleResponse } from '../../api/detection_engine/model/rule_schema/rule_schemas.gen'; -import { MigrationStatus, MigrationTaskStats, MigrationLastExecution } from './migration.gen'; +import { + MigrationTranslationResult, + MigrationStatus, + MigrationTaskStats, + MigrationLastExecution, +} from './migration.gen'; /** * The original rule vendor identifier. @@ -191,14 +196,6 @@ export const RuleMigration = z }) .merge(RuleMigrationData); -/** - * The rule translation result. - */ -export type RuleMigrationTranslationResult = z.infer; -export const RuleMigrationTranslationResult = z.enum(['full', 'partial', 'untranslatable']); -export type RuleMigrationTranslationResultEnum = typeof RuleMigrationTranslationResult.enum; -export const RuleMigrationTranslationResultEnum = RuleMigrationTranslationResult.enum; - /** * The comment for the migration */ @@ -252,7 +249,7 @@ export const RuleMigrationRuleData = z.object({ /** * The rule translation result. */ - translation_result: RuleMigrationTranslationResult.optional(), + translation_result: MigrationTranslationResult.optional(), /** * The status of the rule migration process. */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index ad2ae8211a7b5..b3eed1977fce1 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -190,7 +190,7 @@ components: $ref: '#/components/schemas/ElasticRule' translation_result: description: The rule translation result. - $ref: '#/components/schemas/RuleMigrationTranslationResult' + $ref: './migration.schema.yaml#/components/schemas/MigrationTranslationResult' status: description: The status of the rule migration process. $ref: './migration.schema.yaml#/components/schemas/MigrationStatus' @@ -275,14 +275,6 @@ components: type: integer description: The number of rules that have failed translation. - RuleMigrationTranslationResult: - type: string - description: The rule translation result. - enum: # should match SiemMigrationRuleTranslationResult enum at ../constants.ts - - full - - partial - - untranslatable - RuleMigrationComment: type: object description: The comment for the migration diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/callout.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/callout.tsx index 1c2106dbe48fd..3c5416a809f0c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/callout.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rule_details_flyout/tabs/translation/callout.tsx @@ -9,13 +9,11 @@ import type { FC } from 'react'; import React from 'react'; import type { IconType } from '@elastic/eui'; import { EuiCallOut } from '@elastic/eui'; -import { - type RuleMigrationTranslationResult, - type RuleMigrationRule, -} from '../../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { type MigrationTranslationResult } from '../../../../../../../common/siem_migrations/model/migration.gen'; +import { type RuleMigrationRule } from '../../../../../../../common/siem_migrations/model/rule_migration.gen'; import * as i18n from './translations'; -type RuleMigrationTranslationCallOutMode = RuleMigrationTranslationResult | 'mapped'; +type RuleMigrationTranslationCallOutMode = MigrationTranslationResult | 'mapped'; const getCallOutInfo = ( mode: RuleMigrationTranslationCallOutMode diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/utils/translation_results/index.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/utils/translation_results/index.ts index 29c99a11dfa0d..ce52794b22b21 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/utils/translation_results/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/utils/translation_results/index.ts @@ -7,7 +7,7 @@ import { useEuiTheme } from '@elastic/eui'; import { RuleTranslationResult } from '../../../../../common/siem_migrations/constants'; -import type { RuleMigrationTranslationResult } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { MigrationTranslationResult } from '../../../../../common/siem_migrations/model/migration.gen'; import * as i18n from './translations'; const COLORS = { @@ -31,7 +31,7 @@ export const useResultVisColors = () => { return COLORS; }; -export const convertTranslationResultIntoColor = (status?: RuleMigrationTranslationResult) => { +export const convertTranslationResultIntoColor = (status?: MigrationTranslationResult) => { switch (status) { case RuleTranslationResult.FULL: return COLORS[RuleTranslationResult.FULL]; @@ -44,7 +44,7 @@ export const convertTranslationResultIntoColor = (status?: RuleMigrationTranslat } }; -export const convertTranslationResultIntoText = (status?: RuleMigrationTranslationResult) => { +export const convertTranslationResultIntoText = (status?: MigrationTranslationResult) => { switch (status) { case RuleTranslationResult.FULL: return i18n.SIEM_TRANSLATION_RESULT_FULL_LABEL; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/field_maps.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/field_maps.ts new file mode 100644 index 0000000000000..fdc051882d949 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/field_maps.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FieldMap, SchemaFieldMapKeys } from '@kbn/data-stream-adapter'; + +// TODO: Extract RuleMigrationResource -> MigrationResource schema to the generic migration.schema +import type { RuleMigrationResource } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { MigrationDocument } from '../types'; + +export const migrationsFieldMaps: FieldMap< + SchemaFieldMapKeys> +> = { + name: { type: 'keyword', required: true }, + created_at: { type: 'date', required: true }, + created_by: { type: 'keyword', required: true }, +}; + +export const migrationResourcesFieldMap: FieldMap< + SchemaFieldMapKeys> +> = { + migration_id: { type: 'keyword', required: true }, + type: { type: 'keyword', required: true }, + name: { type: 'keyword', required: true }, + content: { type: 'text', required: false }, + metadata: { type: 'object', required: false }, + updated_at: { type: 'date', required: false }, + updated_by: { type: 'keyword', required: false }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts index 3dedac6f899f8..8f7749a365a28 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts @@ -29,7 +29,7 @@ export abstract class SiemMigrationsDataClient< migrationItemsDeleteOperations, migrationResourcesDeleteOperations, ] = await Promise.all([ - this.migrations.prepareDelete({ id: migrationId }), + this.migrations.prepareDelete(migrationId), this.items.prepareDelete(migrationId), this.resources.prepareDelete(migrationId), ]); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts index 8a6b8131d4146..2f3bac761dbac 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.test.ts @@ -137,9 +137,7 @@ describe('SiemMigrationsDataMigrationClient', () => { const migrationId = 'testId'; const index = '.kibana-siem-rule-migrations'; - const operations = await siemMigrationsDataMigrationClient.prepareDelete({ - id: migrationId, - }); + const operations = await siemMigrationsDataMigrationClient.prepareDelete(migrationId); expect(operations).toMatchObject([ { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts index f56aa1014a30a..35b5bc8a43b8f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_migration_client.ts @@ -42,16 +42,16 @@ export class SiemMigrationsDataMigrationClient< * Gets the migration document by id or returns undefined if it does not exist. * * */ - async get({ id }: { id: string }): Promise | undefined> { + async get(migrationId: string): Promise | undefined> { const index = await this.getIndexName(); return this.esClient - .get>({ index, id }) + .get>({ index, id: migrationId }) .then(this.processHit) .catch((error) => { if (isNotFoundError(error)) { return undefined; } - this.logger.error(`Error getting migration ${id}: ${error}`); + this.logger.error(`Error getting migration ${migrationId}: ${error}`); throw error; }); } @@ -80,10 +80,10 @@ export class SiemMigrationsDataMigrationClient< * Prepares bulk ES delete operation for a migration document based on its id. * */ - async prepareDelete({ id }: { id: string }): Promise { + async prepareDelete(migrationId: string): Promise { const index = await this.getIndexName(); const migrationDeleteOperation = { - delete: { _index: index, _id: id }, + delete: { _index: index, _id: migrationId }, }; return [migrationDeleteOperation]; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts deleted file mode 100644 index c2b2c56f5b0f3..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/sort.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { estypes } from '@elastic/elasticsearch'; - -export interface ItemMigrationSort { - sortField?: string; - sortDirection?: estypes.SortOrder; -} - -const sortMissingValue = (direction: estypes.SortOrder = 'asc') => - direction === 'desc' ? '_last' : '_first'; - -const sortingOptions = { - matchedPrebuiltRule(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { - return [ - { - 'elastic_rule.prebuilt_rule_id': { - order: direction, - missing: sortMissingValue(direction), - }, - }, - ]; - }, - severity(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { - const field = 'elastic_rule.severity'; - return [ - { - _script: { - order: direction, - type: 'number', - script: { - source: ` - if (doc.containsKey('${field}') && !doc['${field}'].empty) { - def value = doc['${field}'].value.toLowerCase(); - if (value == 'critical') { return 3 } - if (value == 'high') { return 2 } - if (value == 'medium') { return 1 } - if (value == 'low') { return 0 } - } - return -1; - `, - lang: 'painless', - }, - }, - }, - ]; - }, - riskScore(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { - return [{ 'elastic_rule.risk_score': direction }]; - }, - status(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { - const field = 'translation_result'; - const installedRuleField = 'elastic_rule.id'; - return [ - { - _script: { - order: direction, - type: 'number', - script: { - source: ` - if (doc.containsKey('${field}') && !doc['${field}'].empty) { - def value = doc['${field}'].value.toLowerCase(); - if (value == 'full') { return 2 } - if (value == 'partial') { return 1 } - if (value == 'untranslatable') { return 0 } - } - return -1; - `, - lang: 'painless', - }, - }, - }, - { - _script: { - order: direction, - type: 'number', - script: { - source: ` - if (doc.containsKey('${installedRuleField}') && !doc['${installedRuleField}'].empty) { - return 0; - } - return -1; - `, - lang: 'painless', - }, - }, - }, - ]; - }, - updated(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { - return [{ updated_at: direction }]; - }, - name(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] { - return [{ 'elastic_rule.title.keyword': direction }]; - }, -}; - -const DEFAULT_SORTING: estypes.Sort = [ - ...sortingOptions.status('desc'), - ...sortingOptions.matchedPrebuiltRule('desc'), - ...sortingOptions.severity(), - ...sortingOptions.riskScore('desc'), - ...sortingOptions.updated(), -]; - -const sortingOptionsMap: { - [key: string]: (direction?: estypes.SortOrder) => estypes.SortCombinations[]; -} = { - 'elastic_rule.title': sortingOptions.name, - 'elastic_rule.severity': (direction?: estypes.SortOrder) => [ - ...sortingOptions.severity(direction), - ...sortingOptions.riskScore(direction), - ...sortingOptions.status('desc'), - ...sortingOptions.matchedPrebuiltRule('desc'), - ], - 'elastic_rule.risk_score': (direction?: estypes.SortOrder) => [ - ...sortingOptions.riskScore(direction), - ...sortingOptions.severity(direction), - ...sortingOptions.status('desc'), - ...sortingOptions.matchedPrebuiltRule('desc'), - ], - 'elastic_rule.prebuilt_rule_id': (direction?: estypes.SortOrder) => [ - ...sortingOptions.matchedPrebuiltRule(direction), - ...sortingOptions.status('desc'), - ...sortingOptions.severity('desc'), - ...sortingOptions.riskScore(direction), - ], - translation_result: (direction?: estypes.SortOrder) => [ - ...sortingOptions.status(direction), - ...sortingOptions.matchedPrebuiltRule('desc'), - ...sortingOptions.severity('desc'), - ...sortingOptions.riskScore(direction), - ], - updated_at: sortingOptions.updated, -}; - -export const getSortingOptions = (sort?: ItemMigrationSort): estypes.Sort => { - if (!sort?.sortField) { - return DEFAULT_SORTING; - } - return sortingOptionsMap[sort.sortField]?.(sort.sortDirection) ?? DEFAULT_SORTING; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/siem_migrations_base_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/siem_migrations_base_service.ts index 8572f54005498..4bf1c752ef51b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/siem_migrations_base_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/siem_migrations_base_service.ts @@ -5,21 +5,28 @@ * 2.0. */ -import type { FieldMap } from '@kbn/index-adapter'; +import type { FieldMap, InstallParams } from '@kbn/index-adapter'; import { IndexAdapter, IndexPatternAdapter } from '@kbn/index-adapter'; +import type { ElasticsearchClient } from '@kbn/core/server'; import type { SiemMigrationsIndexNameProvider } from './types'; const TOTAL_FIELDS_LIMIT = 2500; +export interface SetupParams extends Omit { + esClient: ElasticsearchClient; +} + interface CreateAdapterParams { name: string; fieldMap: FieldMap; } -export class SiemMigrationsBaseDataService { +export abstract class SiemMigrationsBaseDataService { + protected abstract readonly baseIndexName: string; + constructor(protected kibanaVersion: string) {} - public createIndexPatternAdapter({ name, fieldMap }: CreateAdapterParams) { + protected createIndexPatternAdapter({ name, fieldMap }: CreateAdapterParams) { const adapter = new IndexPatternAdapter(name, { kibanaVersion: this.kibanaVersion, totalFieldsLimit: TOTAL_FIELDS_LIMIT, @@ -29,7 +36,7 @@ export class SiemMigrationsBaseDataService { return adapter; } - public createIndexAdapter({ name, fieldMap }: CreateAdapterParams) { + protected createIndexAdapter({ name, fieldMap }: CreateAdapterParams) { const adapter = new IndexAdapter(name, { kibanaVersion: this.kibanaVersion, totalFieldsLimit: TOTAL_FIELDS_LIMIT, @@ -39,7 +46,11 @@ export class SiemMigrationsBaseDataService { return adapter; } - public createIndexNameProvider( + protected getAdapterIndexName(adapterId: string) { + return `${this.baseIndexName}-${adapterId}`; + } + + protected createIndexNameProvider( adapter: IndexPatternAdapter, spaceId: string ): SiemMigrationsIndexNameProvider { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts index 6553f76893402..8f573ccbc960f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts @@ -22,11 +22,13 @@ import type { ItemDocument, } from '../types'; import type { + SiemMigrationTaskEvaluateParams, SiemMigrationTaskStartParams, SiemMigrationTaskStartResult, SiemMigrationTaskStopResult, } from './types'; import type { SiemMigrationTaskRunner } from './siem_migrations_task_runner'; +import type { SiemMigrationTaskEvaluator } from './siem_migrations_task_evaluator'; export abstract class SiemMigrationsTaskClient< M extends MigrationDocument = StoredSiemMigration, @@ -34,6 +36,7 @@ export abstract class SiemMigrationsTaskClient< C extends object = {} > { protected abstract readonly TaskRunnerClass: typeof SiemMigrationTaskRunner; + protected abstract readonly EvaluatorClass?: SiemMigrationTaskEvaluator; constructor( protected migrationsRunning: Map>, @@ -138,7 +141,7 @@ export abstract class SiemMigrationsTaskClient< /** Returns the stats of a migration */ public async getStats(migrationId: string): Promise { - const migration = await this.data.migrations.get({ id: migrationId }); + const migration = await this.data.migrations.get(migrationId); if (!migration) { throw new Error(`Migration with ID ${migrationId} not found`); } @@ -224,23 +227,27 @@ export abstract class SiemMigrationsTaskClient< } /** Creates a new evaluator for the rule migration task */ - async evaluate(params: RuleMigrationTaskEvaluateParams): Promise { - // const { evaluationId, langsmithOptions, connectorId, invocationConfig, abortController } = - // params; - // const migrationLogger = this.logger.get('evaluate'); - // const migrationTaskEvaluator = new RuleMigrationTaskEvaluator( - // evaluationId, - // this.currentUser, - // abortController, - // this.data, - // migrationLogger, - // this.dependencies - // ); - // await migrationTaskEvaluator.evaluate({ - // connectorId, - // langsmithOptions, - // invocationConfig, - // }); + async evaluate(params: SiemMigrationTaskEvaluateParams): Promise { + if (!this.EvaluatorClass) { + throw new Error('Evaluator class needs to be defined to use evaluate method'); + } + + const { evaluationId, langsmithOptions, connectorId, invocationConfig, abortController } = + params; + const migrationLogger = this.logger.get('evaluate'); + const migrationTaskEvaluator = new this.EvaluatorClass( + evaluationId, + this.currentUser, + abortController, + this.data, + migrationLogger, + this.dependencies + ); + await migrationTaskEvaluator.evaluate({ + connectorId, + langsmithOptions, + invocationConfig, + }); } /** Returns if a migration is running or not */ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts index d4a43d3d83cfd..0062d06959f5b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts @@ -6,159 +6,122 @@ */ import type { EvaluationResult } from 'langsmith/evaluation'; +import type { RunnableConfig } from '@langchain/core/runnables'; import type { Run, Example } from 'langsmith/schemas'; import { evaluate } from 'langsmith/evaluation'; import { isLangSmithEnabled } from '@kbn/langchain/server/tracers/langsmith'; import { Client } from 'langsmith'; -import { distance } from 'fastest-levenshtein'; import type { LangSmithEvaluationOptions } from '../../../../../common/siem_migrations/model/common.gen'; -import { RuleMigrationTaskRunner } from './siem_migrations_task_runner'; -import type { MigrateRuleGraphConfig, MigrateRuleState } from './agent/types'; +import type { SiemMigrationTaskRunner } from './siem_migrations_task_runner'; +import type { MigrationDocument, ItemDocument } from '../types'; -export interface EvaluateParams { +export interface EvaluateParams { connectorId: string; langsmithOptions: LangSmithEvaluationOptions; - invocationConfig?: MigrateRuleGraphConfig; + invocationConfig?: RunnableConfig; } export type Evaluator = (args: { run: Run; example: Example }) => EvaluationResult; type CustomEvaluatorResult = Omit; export type CustomEvaluator = (args: { run: Run; example: Example }) => CustomEvaluatorResult; -export class RuleMigrationTaskEvaluator extends RuleMigrationTaskRunner { - public async evaluate({ connectorId, langsmithOptions, invocationConfig }: EvaluateParams) { - if (!isLangSmithEnabled()) { - throw Error('LangSmith is not enabled'); - } +export type SiemMigrationTaskEvaluator< + M extends MigrationDocument = MigrationDocument, + I extends ItemDocument = ItemDocument, + C extends object = {} +> = ReturnType>; - const client = new Client({ apiKey: langsmithOptions.api_key }); +/** + * Mixin to create a task evaluator based on a concrete implementation of a SiemMigrationTaskRunner. + * @param TaskRunnerConcreteClass: The concrete class that extends SiemMigrationTaskRunner. + * @returns the class that extends the TaskRunnerConcreteClass with evaluation capabilities. + */ +export function SiemMigrationTaskEvaluable< + M extends MigrationDocument = MigrationDocument, + I extends ItemDocument = ItemDocument, + C extends object = {} +>(TaskRunnerConcreteClass: typeof SiemMigrationTaskRunner) { + return class extends TaskRunnerConcreteClass { + protected evaluators!: Record; + private genericEvaluators: Record = { + translation_result: ({ run, example }) => { + const runResult = (run?.outputs as ItemDocument)?.translation_result; + const expectedResult = (example?.outputs as ItemDocument)?.translation_result; + + if (!expectedResult) { + return { comment: 'No translation result expected' }; + } + if (!runResult) { + return { score: false, comment: 'No translation result received' }; + } - // Make sure the dataset exists - const dataset: Example[] = []; - for await (const example of client.listExamples({ datasetName: langsmithOptions.dataset })) { - dataset.push(example); - } - if (dataset.length === 0) { - throw Error(`LangSmith dataset not found: ${langsmithOptions.dataset}`); - } + if (runResult === expectedResult) { + return { score: true, comment: 'Correct' }; + } - // Initialize the the task runner first, this may take some time - await this.initialize(); + return { + score: false, + comment: `Incorrect, expected "${expectedResult}" but got "${runResult}"`, + }; + }, + }; - // Check if the connector exists and user has privileges to read it - const connector = await this.dependencies.actionsClient.get({ id: connectorId }); - if (!connector) { - throw Error(`Connector with id ${connectorId} not found`); - } + public async evaluate({ connectorId, langsmithOptions, invocationConfig }: EvaluateParams) { + if (!isLangSmithEnabled()) { + throw Error('LangSmith is not enabled'); + } - // for each connector, setup the evaluator - await this.setup(connectorId); - - // create the migration task after setup - const migrateRuleTask = this.createMigrateRuleTask(invocationConfig); - const evaluators = this.getEvaluators(); - - evaluate(migrateRuleTask, { - data: langsmithOptions.dataset, - experimentPrefix: connector.name, - evaluators, - client, - maxConcurrency: 3, - }) - .then(() => { - this.logger.info('Evaluation finished'); - }) - .catch((err) => { - this.logger.error(`Evaluation error:\n ${JSON.stringify(err, null, 2)}`); - }); - } - - private getEvaluators(): Evaluator[] { - return Object.entries(this.evaluators).map(([key, evaluator]) => { - return (args) => { - const result = evaluator(args); - return { key, ...result }; - }; - }); - } - - /** - * This is a map of custom evaluators that are used to evaluate rule migration tasks - * The object keys are used for the `key` property of the evaluation result, and the value is a function that takes a the `run` and `example` - * and returns a `score` and a `comment` (and any other data needed for the evaluation) - **/ - private readonly evaluators: Record = { - translation_result: ({ run, example }) => { - const runResult = (run?.outputs as MigrateRuleState)?.translation_result; - const expectedResult = (example?.outputs as MigrateRuleState)?.translation_result; - - if (!expectedResult) { - return { comment: 'No translation result expected' }; + const client = new Client({ apiKey: langsmithOptions.api_key }); + + // Make sure the dataset exists + const dataset: Example[] = []; + for await (const example of client.listExamples({ datasetName: langsmithOptions.dataset })) { + dataset.push(example); } - if (!runResult) { - return { score: false, comment: 'No translation result received' }; + if (dataset.length === 0) { + throw Error(`LangSmith dataset not found: ${langsmithOptions.dataset}`); } - if (runResult === expectedResult) { - return { score: true, comment: 'Correct' }; + // Initialize the task runner first, this may take some time + await this.initialize(); + + // Check if the connector exists and user has privileges to read it + const connector = await this.dependencies.actionsClient.get({ id: connectorId }); + if (!connector) { + throw Error(`Connector with id ${connectorId} not found`); } - return { - score: false, - comment: `Incorrect, expected "${expectedResult}" but got "${runResult}"`, - }; - }, + // for each connector, setup the evaluator + await this.setup(connectorId); - custom_query_accuracy: ({ run, example }) => { - const runQuery = (run?.outputs as MigrateRuleState)?.elastic_rule?.query; - const expectedQuery = (example?.outputs as MigrateRuleState)?.elastic_rule?.query; + // create the migration task after setup + const migrateItemTask = this.createMigrateItemTask(invocationConfig); + const evaluators = this.getEvaluators(); - if (!expectedQuery) { - if (runQuery) { - return { score: 0, comment: 'No custom translation expected, but received' }; - } - return { comment: 'No custom translation expected' }; - } - if (!runQuery) { - return { score: 0, comment: 'Custom translation expected, but not received' }; - } + evaluate(migrateItemTask, { + data: langsmithOptions.dataset, + experimentPrefix: connector.name, + evaluators, + client, + maxConcurrency: 3, + }) + .then(() => { + this.logger.info('Evaluation finished'); + }) + .catch((err) => { + this.logger.error(`Evaluation error:\n ${JSON.stringify(err, null, 2)}`); + }); + } - // calculate the levenshtein distance between the two queries: - // The distance is the minimum number of single-character edits required to change one word into the other. - // So, the distance is a number between 0 and the length of the longest string. - const queryDistance = distance(runQuery, expectedQuery); - const maxDistance = Math.max(expectedQuery.length, runQuery.length); - // The similarity is a number between 0 and 1 (score), where 1 means the two strings are identical. - const similarity = 1 - queryDistance / maxDistance; - - return { - score: Math.round(similarity * 1000) / 1000, // round to 3 decimal places - comment: `Distance: ${queryDistance}`, - }; - }, - - prebuilt_rule_match: ({ run, example }) => { - const runPrebuiltRuleId = (run?.outputs as MigrateRuleState)?.elastic_rule?.prebuilt_rule_id; - const expectedPrebuiltRuleId = (example?.outputs as MigrateRuleState)?.elastic_rule - ?.prebuilt_rule_id; - - if (!expectedPrebuiltRuleId) { - if (runPrebuiltRuleId) { - return { score: false, comment: 'No prebuilt rule expected, but received' }; + private getEvaluators(): Evaluator[] { + return Object.entries({ ...this.genericEvaluators, ...this.evaluators }).map( + ([key, evaluator]) => { + return (args) => { + const result = evaluator(args); + return { key, ...result }; + }; } - return { comment: 'No prebuilt rule expected' }; - } - if (!runPrebuiltRuleId) { - return { score: false, comment: 'Prebuilt rule expected, but not received' }; - } - - if (runPrebuiltRuleId === expectedPrebuiltRuleId) { - return { score: true, comment: 'Correct match' }; - } - return { - score: false, - comment: `Incorrect match, expected ID is "${expectedPrebuiltRuleId}" but got "${runPrebuiltRuleId}"`, - }; - }, + ); + } }; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts index 688c0b3a33ade..7cf4f182d1039 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts @@ -11,7 +11,7 @@ import type { AuthenticatedUser } from '@kbn/core/server'; import type { StoredRuleMigration } from '../types'; import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; import { loggerMock } from '@kbn/logging-mocks'; -import type { SiemMigrationsClientDependencies } from '../../common/types'; +import type { SiemMigrationsClientDependencies } from '../types'; jest.mock('./rule_migrations_telemetry_client'); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts deleted file mode 100644 index 5ca00bbab4561..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const TELEMETRY_SIEM_MIGRATION_ID = 'siem_migrations'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts deleted file mode 100644 index fb8378a393fec..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger } from '@kbn/core/server'; -import { naturalLanguageToEsql } from '@kbn/inference-plugin/server'; -import type { InferenceClient } from '@kbn/inference-common'; -import { lastValueFrom } from 'rxjs'; -import { TELEMETRY_SIEM_MIGRATION_ID } from './constants'; - -export class EsqlKnowledgeBase { - constructor( - private readonly connectorId: string, - private readonly migrationId: string, - private readonly client: InferenceClient, - private readonly logger: Logger - ) {} - - public async translate(input: string): Promise { - const { content } = await lastValueFrom( - naturalLanguageToEsql({ - client: this.client, - connectorId: this.connectorId, - input, - logger: this.logger, - metadata: { - connectorTelemetry: { - pluginId: TELEMETRY_SIEM_MIGRATION_ID, - aggregateBy: this.migrationId, - }, - }, - }) - ); - return content; - } -} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts deleted file mode 100644 index e73195fe9751f..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { z } from '@kbn/zod'; -import type { ElasticRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import { nullifyElasticRule, nullifyMissingPropertiesInObject } from './nullify_missing_properties'; - -describe('nullify missing values in object', () => { - describe('nullifyMissingPropertiesInObject', () => { - const someZodObject = z.object({ - foo: z.string(), - bar: z.number().optional(), - baz: z.object({ - qux: z.boolean().optional(), - }), - }); - - const val: z.infer = { - foo: 'test', - baz: { - qux: true, - }, - }; - it('should correctly nullify missing values in zod object at first level', () => { - const result = nullifyMissingPropertiesInObject(someZodObject, val); - expect(result).toMatchObject({ - foo: 'test', - bar: null, - baz: { - qux: true, - }, - }); - }); - - it('should throw if object does not conform to the schema', () => { - const invalidVal = { - foo: 'test', - // Missing 'baz' property - }; - - expect(() => - nullifyMissingPropertiesInObject(someZodObject, invalidVal as z.infer) - ).toThrow(); - }); - }); - - describe('nullifyElasticRule', () => { - it('should return an object with nullified empty values', () => { - const elasticRule: ElasticRule = { - title: 'Some Title', - }; - - const result = nullifyElasticRule(elasticRule); - - expect(result).toMatchObject({ - title: 'Some Title', - description: null, - severity: null, - risk_score: null, - query: null, - query_language: null, - prebuilt_rule_id: null, - integration_ids: null, - id: null, - }); - }); - - it('should return original object and call error callback in case of error', () => { - const elasticRule = { - hero: 'Some Title', - } as unknown as ElasticRule; - - const errorMock = jest.fn(); - - const result = nullifyElasticRule(elasticRule, errorMock); - - expect(result).toMatchObject(elasticRule); - expect(errorMock).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts deleted file mode 100644 index 0942fc67983ad..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { z } from '@kbn/zod'; -import type { ElasticRule as ElasticRuleType } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import { ElasticRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; - -type Nullable = { [K in keyof T]: T[K] | null }; - -/** - * This function takes a Zod schema and an object, and returns a new object - * where any missing values of `only first-level keys` in the object are set to null, according to the schema. - * - * Raises an error if the object does not conform to the schema. - * - * This is specially beneficial for `unsetting` fields in Elasticsearch documents. - */ -export const nullifyMissingPropertiesInObject = ( - zodType: T, - obj: z.infer -): Nullable> => { - const schemaWithNullValues = zodType.transform((value: z.infer) => { - const result: Nullable> = { ...value }; - Object.keys(zodType.shape).forEach((key) => { - if (!(key in value)) { - result[key as keyof z.infer] = null; - } - }); - return result; - }); - - return schemaWithNullValues.parse(obj); -}; - -/** - * This function takes an ElasticRule object and returns a new object - * where any missing values are set to null, according to the ElasticRule schema. - * - * If an error occurs during the transformation, it calls the onError callback - * with the error and returns the original object. - */ -export const nullifyElasticRule = (obj: ElasticRuleType, onError?: (error: Error) => void) => { - try { - return nullifyMissingPropertiesInObject(ElasticRule, obj); - } catch (error) { - onError?.(error); - return obj; - } -}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts index 7cfd5093f7dc3..163260dc883f4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts @@ -21,7 +21,7 @@ import { SiemMigrationsBaseDataService } from '../../common/siem_migrations_base import { dashboardMigrationsDashboardsFieldMap, dashboardMigrationsFieldMap } from './field_maps'; import { DashboardMigrationsDataClient } from './dashboard_migrations_data_client'; import type { SiemMigrationsClientDependencies } from '../../common/types'; -export const INDEX_PATTERN = '.kibana-siem-dashboard-migrations'; +import { migrationResourcesFieldMap } from '../../common/data/field_maps'; interface CreateClientParams { spaceId: string; @@ -39,6 +39,8 @@ export interface SetupParams extends Omit { } export class DashboardMigrationsDataService extends SiemMigrationsBaseDataService { + protected readonly baseIndexName = '.kibana-siem-dashboard-migrations'; + private readonly adapters: DashboardMigrationAdapters; constructor(private logger: Logger, protected kibanaVersion: string) { @@ -52,13 +54,13 @@ export class DashboardMigrationsDataService extends SiemMigrationsBaseDataServic adapterId: 'dashboards', fieldMap: dashboardMigrationsDashboardsFieldMap, }), + resources: this.createDashboardIndexPatternAdapter({ + adapterId: 'resources', + fieldMap: migrationResourcesFieldMap, + }), }; } - private getAdapterIndexName(adapterId: DashboardMigrationAdapterId) { - return `${INDEX_PATTERN}-${adapterId}`; - } - private createDashboardIndexPatternAdapter({ adapterId, fieldMap, @@ -82,6 +84,7 @@ export class DashboardMigrationsDataService extends SiemMigrationsBaseDataServic const indexNameProviders: DashboardMigrationIndexNameProviders = { dashboards: this.createIndexNameProvider(this.adapters.dashboards, spaceId), migrations: this.createIndexNameProvider(this.adapters.migrations, spaceId), + resources: this.createIndexNameProvider(this.adapters.resources, spaceId), }; return new DashboardMigrationsDataClient( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/field_maps.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/field_maps.ts index 34b9f4e82fed7..43d7c88f450bf 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/field_maps.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/field_maps.ts @@ -26,6 +26,7 @@ export const dashboardMigrationsDashboardsFieldMap: FieldMap< migration_id: { type: 'keyword', required: true }, created_by: { type: 'keyword', required: true }, status: { type: 'keyword', required: true }, + translation_result: { type: 'keyword', required: true }, updated_at: { type: 'date', required: true }, updated_by: { type: 'keyword', required: true }, original_dashboard: { type: 'object', required: true }, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts index f022bd8954b2a..f2d91f9c1057d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts @@ -44,9 +44,7 @@ export const registerSiemRuleMigrationsGetRoute = ( const ruleMigrationsClient = ctx.securitySolution.siemMigrations.getRulesClient(); await siemMigrationAuditLogger.logGetMigration({ migrationId }); - const storedMigration = await ruleMigrationsClient.data.migrations.get({ - id: migrationId, - }); + const storedMigration = await ruleMigrationsClient.data.migrations.get(migrationId); if (!storedMigration) { return res.notFound({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/update_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/update_rules.ts index 77ead9cb4082a..db6cb5ec1f3b7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/update_rules.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/update_rules.ts @@ -6,11 +6,9 @@ */ import { parseEsqlQuery } from '@kbn/securitysolution-utils'; -import { - RuleMigrationTranslationResultEnum, - type RuleMigrationTranslationResult, - type UpdateRuleMigrationRule, -} from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { MigrationTranslationResult } from '../../../../../../common/siem_migrations/model/migration.gen'; +import { MigrationTranslationResultEnum } from '../../../../../../common/siem_migrations/model/migration.gen'; +import { type UpdateRuleMigrationRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; import type { InternalUpdateRuleMigrationRule } from '../../types'; export const isValidEsqlQuery = (esqlQuery: string) => { @@ -31,13 +29,13 @@ export const isValidEsqlQuery = (esqlQuery: string) => { export const convertEsqlQueryToTranslationResult = ( esqlQuery: string -): RuleMigrationTranslationResult | undefined => { +): MigrationTranslationResult | undefined => { if (esqlQuery === '') { - return RuleMigrationTranslationResultEnum.untranslatable; + return MigrationTranslationResultEnum.untranslatable; } return isValidEsqlQuery(esqlQuery) - ? RuleMigrationTranslationResultEnum.full - : RuleMigrationTranslationResultEnum.partial; + ? MigrationTranslationResultEnum.full + : MigrationTranslationResultEnum.partial; }; export const transformToInternalUpdateRuleMigrationData = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_existing_migration_id.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_existing_migration_id.ts index 6da298f5392d6..90012ff419f34 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_existing_migration_id.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_existing_migration_id.ts @@ -28,9 +28,7 @@ export const withExistingMigration = < const { migration_id: migrationId } = req.params; const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.siemMigrations.getRulesClient(); - const storedMigration = await ruleMigrationsClient.data.migrations.get({ - id: migrationId, - }); + const storedMigration = await ruleMigrationsClient.data.migrations.get(migrationId); if (!storedMigration) { return res.notFound({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/field_maps.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/field_maps.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts index 48e80f59905db..6f054193701b3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts @@ -91,7 +91,7 @@ describe('RuleMigrationsDataMigrationClient', () => { esClient.asInternalUser.get as unknown as jest.MockedFn ).mockResolvedValueOnce(response); - const result = await ruleMigrationsDataMigrationClient.get({ id }); + const result = await ruleMigrationsDataMigrationClient.get(id); expect(result).toEqual({ ...response._source, @@ -112,7 +112,7 @@ describe('RuleMigrationsDataMigrationClient', () => { message: JSON.stringify(response), }); - const result = await ruleMigrationsDataMigrationClient.get({ id }); + const result = await ruleMigrationsDataMigrationClient.get(id); expect(result).toBeUndefined(); }); @@ -123,7 +123,7 @@ describe('RuleMigrationsDataMigrationClient', () => { esClient.asInternalUser.get as unknown as jest.MockedFn ).mockRejectedValueOnce(new Error('Test error')); - await expect(ruleMigrationsDataMigrationClient.get({ id })).rejects.toThrow('Test error'); + await expect(ruleMigrationsDataMigrationClient.get(id)).rejects.toThrow('Test error'); expect(esClient.asInternalUser.get).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledWith(`Error getting migration ${id}: Error: Test error`); @@ -137,9 +137,7 @@ describe('RuleMigrationsDataMigrationClient', () => { const migrationId = 'testId'; const index = '.kibana-siem-rule-migrations'; - const operations = await ruleMigrationsDataMigrationClient.prepareDelete({ - id: migrationId, - }); + const operations = await ruleMigrationsDataMigrationClient.prepareDelete(migrationId); expect(operations).toMatchObject([ { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts index 6a6115d7b19f3..8ecb1a9a44d9f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts @@ -12,7 +12,7 @@ import { IndexPatternAdapter, IndexAdapter } from '@kbn/index-adapter'; import { Subject } from 'rxjs'; import type { RuleMigrationIndexNameProviders } from '../types'; import type { SetupParams } from './rule_migrations_data_service'; -import { INDEX_PATTERN, RuleMigrationsDataService } from './rule_migrations_data_service'; +import { RuleMigrationsDataService } from './rule_migrations_data_service'; import { RuleMigrationIndexMigrator } from '../index_migrators'; import type { SiemMigrationsClientDependencies } from '../../common/types'; @@ -28,6 +28,9 @@ jest.mock('./rule_migrations_data_client', () => ({ }), })); +// @ts-expect-error accessing protected property +const INDEX_PATTERN = new RuleMigrationsDataService().baseIndexName; + const MockedIndexPatternAdapter = IndexPatternAdapter as unknown as jest.MockedClass< typeof IndexPatternAdapter >; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts index 2445865fe424e..fef3c0d1485bc 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts @@ -24,13 +24,11 @@ import { migrationsFieldMaps, ruleMigrationResourcesFieldMap, ruleMigrationsFieldMap, -} from './rule_migrations_field_maps'; +} from './field_maps'; import { RuleMigrationIndexMigrator } from '../index_migrators'; import { SiemMigrationsBaseDataService } from '../../common/siem_migrations_base_service'; import type { SiemMigrationsClientDependencies } from '../../common/types'; -export const INDEX_PATTERN = '.kibana-siem-rule-migrations'; - interface CreateClientParams { spaceId: string; currentUser: AuthenticatedUser; @@ -47,6 +45,8 @@ export interface SetupParams extends Omit { } export class RuleMigrationsDataService extends SiemMigrationsBaseDataService { + protected readonly baseIndexName = '.kibana-siem-rule-migrations'; + private readonly adapters: RuleMigrationAdapters; constructor(private logger: Logger, protected kibanaVersion: string, elserInferenceId?: string) { @@ -75,10 +75,6 @@ export class RuleMigrationsDataService extends SiemMigrationsBaseDataService { }; } - private getAdapterIndexName(adapterId: RuleMigrationAdapterId) { - return `${INDEX_PATTERN}-${adapterId}`; - } - private createRuleIndexPatternAdapter({ adapterId, fieldMap }: CreateRuleAdapterParams) { const name = this.getAdapterIndexName(adapterId); return this.createIndexPatternAdapter({ name, fieldMap }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts index 830f2ef917aff..e0b9542046c20 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts @@ -11,7 +11,7 @@ import { RuleTranslationResult } from '../../../../../../../../common/siem_migra import type { RuleMigrationsRetriever } from '../../../retrievers'; import type { RuleMigrationTelemetryClient } from '../../../rule_migrations_telemetry_client'; import type { ChatModel } from '../../../util/actions_client_chat'; -import { cleanMarkdown, generateAssistantComment } from '../../../util/comments'; +import { cleanMarkdown, generateAssistantComment } from '../../../../../common/task/util/comments'; import type { GraphNode } from '../../types'; import { MATCH_PREBUILT_RULE_PROMPT } from './prompts'; import { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts index 91d836e21ce81..10d8dcb359c68 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts @@ -10,7 +10,7 @@ import type { EsqlKnowledgeBase } from '../../../../../util/esql_knowledge_base' import type { GraphNode } from '../../types'; import { SIEM_RULE_MIGRATION_CIM_ECS_MAP } from './cim_ecs_map'; import { ESQL_TRANSLATE_ECS_MAPPING_PROMPT } from './prompts'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments'; +import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; interface GetEcsMappingNodeParams { esqlKnowledgeBase: EsqlKnowledgeBase; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts index 1232bdd326bc8..92b49b39aee90 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts @@ -11,7 +11,7 @@ import { isEmpty } from 'lodash/fp'; import type { ChatModel } from '../../../../../util/actions_client_chat'; import type { GraphNode } from '../../../../types'; import { REPLACE_QUERY_RESOURCE_PROMPT, getResourcesContext } from './prompts'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments'; +import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; interface GetInlineQueryNodeParams { model: ChatModel; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts index 639d61d28e021..bfc24e1b69eb1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts @@ -9,7 +9,7 @@ import { JsonOutputParser } from '@langchain/core/output_parsers'; import type { RuleMigrationsRetriever } from '../../../../../retrievers'; import type { RuleMigrationTelemetryClient } from '../../../../../rule_migrations_telemetry_client'; import type { ChatModel } from '../../../../../util/actions_client_chat'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments'; +import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; import type { GraphNode } from '../../types'; import { MATCH_INTEGRATION_PROMPT } from './prompts'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts index 6cfbdfa0b957f..de12e7e82fe46 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts @@ -6,7 +6,7 @@ */ import type { Logger } from '@kbn/core/server'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments'; +import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; import type { EsqlKnowledgeBase } from '../../../../../util/esql_knowledge_base'; import type { GraphNode } from '../../types'; import { ESQL_SYNTAX_TRANSLATION_PROMPT } from './prompts'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts index 240f6edb5e80a..77f0e63ce8d88 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts @@ -10,10 +10,10 @@ import type { RunnableConfig } from '@langchain/core/runnables'; import type { RuleMigrationRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; import type { RuleMigrationsRetriever } from '../retrievers'; import type { EsqlKnowledgeBase } from '../util/esql_knowledge_base'; -import type { ChatModel } from '../util/actions_client_chat'; +import type { ChatModel } from '../../../common/task/util/actions_client_chat'; +import type { MigrationState } from '../../../common/task/types'; import type { migrateRuleConfigSchema, migrateRuleState } from './state'; import type { RuleMigrationTelemetryClient } from '../rule_migrations_telemetry_client'; -import type { MigrationState } from '../../../common/task/types'; export type MigrateRuleGraphState = typeof migrateRuleState.State; export type MigrateRuleState = MigrationState; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts index 27ecdad6e08d9..5a8dce3932a62 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts @@ -13,6 +13,7 @@ import type { import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; import { SiemMigrationsTaskClient } from '../../common/task/siem_migrations_task_client'; import type { MigrateRuleConfigSchema } from './agent/types'; +import { RuleMigrationTaskEvaluator } from './rule_migrations_task_evaluator'; export type RuleMigrationsRunning = Map; export class RuleMigrationsTaskClient extends SiemMigrationsTaskClient< @@ -21,6 +22,7 @@ export class RuleMigrationsTaskClient extends SiemMigrationsTaskClient< MigrateRuleConfigSchema > { protected readonly TaskRunnerClass = RuleMigrationTaskRunner; + protected readonly EvaluatorClass = RuleMigrationTaskEvaluator; // Rules specific last_execution config protected getLastExecutionConfig( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts index 8457cfcb09889..16783dd3ebe02 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts @@ -7,108 +7,24 @@ import type { EvaluationResult } from 'langsmith/evaluation'; import type { Run, Example } from 'langsmith/schemas'; -import { evaluate } from 'langsmith/evaluation'; -import { isLangSmithEnabled } from '@kbn/langchain/server/tracers/langsmith'; -import { Client } from 'langsmith'; import { distance } from 'fastest-levenshtein'; -import type { LangSmithEvaluationOptions } from '../../../../../common/siem_migrations/model/common.gen'; +import type { + RuleMigration, + RuleMigrationRule, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; -import type { MigrateRuleGraphConfig, MigrateRuleState } from './agent/types'; +import type { MigrateRuleConfigSchema, MigrateRuleState } from './agent/types'; +import { SiemMigrationTaskEvaluable } from '../../common/task/siem_migrations_task_evaluator'; -export interface EvaluateParams { - connectorId: string; - langsmithOptions: LangSmithEvaluationOptions; - invocationConfig?: MigrateRuleGraphConfig; -} - -export type Evaluator = (args: { run: Run; example: Example }) => EvaluationResult; type CustomEvaluatorResult = Omit; export type CustomEvaluator = (args: { run: Run; example: Example }) => CustomEvaluatorResult; -export class RuleMigrationTaskEvaluator extends RuleMigrationTaskRunner { - public async evaluate({ connectorId, langsmithOptions, invocationConfig }: EvaluateParams) { - if (!isLangSmithEnabled()) { - throw Error('LangSmith is not enabled'); - } - - const client = new Client({ apiKey: langsmithOptions.api_key }); - - // Make sure the dataset exists - const dataset: Example[] = []; - for await (const example of client.listExamples({ datasetName: langsmithOptions.dataset })) { - dataset.push(example); - } - if (dataset.length === 0) { - throw Error(`LangSmith dataset not found: ${langsmithOptions.dataset}`); - } - - // Initialize the the task runner first, this may take some time - await this.initialize(); - - // Check if the connector exists and user has privileges to read it - const connector = await this.dependencies.actionsClient.get({ id: connectorId }); - if (!connector) { - throw Error(`Connector with id ${connectorId} not found`); - } - - // for each connector, setup the evaluator - await this.setup(connectorId); - - // create the migration task after setup - const migrateRuleTask = this.createMigrateItemTask(invocationConfig); - const evaluators = this.getEvaluators(); - - evaluate(migrateRuleTask, { - data: langsmithOptions.dataset, - experimentPrefix: connector.name, - evaluators, - client, - maxConcurrency: 3, - }) - .then(() => { - this.logger.info('Evaluation finished'); - }) - .catch((err) => { - this.logger.error(`Evaluation error:\n ${JSON.stringify(err, null, 2)}`); - }); - } - - private getEvaluators(): Evaluator[] { - return Object.entries(this.evaluators).map(([key, evaluator]) => { - return (args) => { - const result = evaluator(args); - return { key, ...result }; - }; - }); - } - - /** - * This is a map of custom evaluators that are used to evaluate rule migration tasks - * The object keys are used for the `key` property of the evaluation result, and the value is a function that takes a the `run` and `example` - * and returns a `score` and a `comment` (and any other data needed for the evaluation) - **/ - private readonly evaluators: Record = { - translation_result: ({ run, example }) => { - const runResult = (run?.outputs as MigrateRuleState)?.translation_result; - const expectedResult = (example?.outputs as MigrateRuleState)?.translation_result; - - if (!expectedResult) { - return { comment: 'No translation result expected' }; - } - if (!runResult) { - return { score: false, comment: 'No translation result received' }; - } - - if (runResult === expectedResult) { - return { score: true, comment: 'Correct' }; - } - - return { - score: false, - comment: `Incorrect, expected "${expectedResult}" but got "${runResult}"`, - }; - }, - +export class RuleMigrationTaskEvaluator extends SiemMigrationTaskEvaluable< + RuleMigration, + RuleMigrationRule, + MigrateRuleConfigSchema +>(RuleMigrationTaskRunner) { + protected readonly evaluators: Record = { custom_query_accuracy: ({ run, example }) => { const runQuery = (run?.outputs as MigrateRuleState)?.elastic_rule?.query; const expectedQuery = (example?.outputs as MigrateRuleState)?.elastic_rule?.query; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts index a84a05503aa4a..5096032fdcee8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts @@ -29,7 +29,7 @@ export class RuleMigrationTaskRunner extends SiemMigrationTaskRunner< RuleMigrationRule, MigrateRuleConfigSchema > { - declare task?: MigrationTask; + protected declare task?: MigrationTask; private retriever: RuleMigrationsRetriever; constructor( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts deleted file mode 100644 index 4cea0b06655d6..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/actions_client_chat.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import type { Logger } from '@kbn/core/server'; -import type { ActionsClientSimpleChatModel } from '@kbn/langchain/server'; -import { - ActionsClientBedrockChatModel, - ActionsClientChatOpenAI, - ActionsClientChatVertexAI, -} from '@kbn/langchain/server'; -import type { CustomChatModelInput as ActionsClientBedrockChatModelParams } from '@kbn/langchain/server/language_models/bedrock_chat'; -import type { ActionsClientChatOpenAIParams } from '@kbn/langchain/server/language_models/chat_openai'; -import type { CustomChatModelInput as ActionsClientChatVertexAIParams } from '@kbn/langchain/server/language_models/gemini_chat'; -import type { CustomChatModelInput as ActionsClientSimpleChatModelParams } from '@kbn/langchain/server/language_models/simple_chat_model'; -import { TELEMETRY_SIEM_MIGRATION_ID } from './constants'; - -export type ChatModel = - | ActionsClientSimpleChatModel - | ActionsClientChatOpenAI - | ActionsClientBedrockChatModel - | ActionsClientChatVertexAI; - -export type ActionsClientChatModelClass = - | typeof ActionsClientSimpleChatModel - | typeof ActionsClientChatOpenAI - | typeof ActionsClientBedrockChatModel - | typeof ActionsClientChatVertexAI; - -export type ChatModelParams = Partial & - Partial & - Partial & - Partial; - -const llmTypeDictionary: Record = { - [`.gen-ai`]: `openai`, - [`.bedrock`]: `bedrock`, - [`.gemini`]: `gemini`, - [`.inference`]: `inference`, -}; - -interface CreateModelParams { - migrationId: string; - connectorId: string; - abortController: AbortController; -} - -export class ActionsClientChat { - constructor(private readonly actionsClient: ActionsClient, private readonly logger: Logger) {} - - public async createModel({ - migrationId, - connectorId, - abortController, - }: CreateModelParams): Promise { - const connector = await this.actionsClient.get({ id: connectorId }); - if (!connector) { - throw new Error(`Connector not found: ${connectorId}`); - } - - const llmType = this.getLLMType(connector.actionTypeId); - const ChatModelClass = this.getLLMClass(llmType); - - const model = new ChatModelClass({ - actionsClient: this.actionsClient, - connectorId, - llmType, - model: connector.config?.defaultModel, - streaming: false, - convertSystemMessageToHumanContent: false, - temperature: 0.05, - maxRetries: 1, // Only retry once inside the model, we will handle backoff retries in the task runner - telemetryMetadata: { pluginId: TELEMETRY_SIEM_MIGRATION_ID, aggregateBy: migrationId }, - signal: abortController.signal, - logger: this.logger, - }); - return model; - } - - private getLLMType(actionTypeId: string): string | undefined { - if (llmTypeDictionary[actionTypeId]) { - return llmTypeDictionary[actionTypeId]; - } - throw new Error(`Unknown LLM type for action type ID: ${actionTypeId}`); - } - - private getLLMClass(llmType?: string): ActionsClientChatModelClass { - switch (llmType) { - case 'bedrock': - return ActionsClientBedrockChatModel; - case 'gemini': - return ActionsClientChatVertexAI; - case 'openai': - case 'inference': - default: - return ActionsClientChatOpenAI; - } - } -} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/comments.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/comments.ts deleted file mode 100644 index 291e8c9bcf094..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/comments.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SIEM_MIGRATIONS_ASSISTANT_USER } from '../../../../../../common/siem_migrations/constants'; -import type { RuleMigrationComment } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; - -export const cleanMarkdown = (markdown: string): string => { - // Use languages known by the code block plugin - return markdown.replaceAll('```esql', '```sql').replaceAll('```spl', '```splunk-spl'); -}; - -export const generateAssistantComment = (message: string): RuleMigrationComment => { - return { - message, - created_at: new Date().toISOString(), - created_by: SIEM_MIGRATIONS_ASSISTANT_USER, - }; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts index 738fa8f740baf..9410b4d35de71 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts @@ -6,10 +6,10 @@ */ import type { IndexAdapter, IndexPatternAdapter } from '@kbn/index-adapter'; +import type { MigrationTranslationResult } from '../../../../common/siem_migrations/model/migration.gen'; import type { RuleMigration, RuleMigrationRule, - RuleMigrationTranslationResult, UpdateRuleMigrationRule, RuleMigrationResource, } from '../../../../common/siem_migrations/model/rule_migration.gen'; @@ -41,7 +41,7 @@ export interface RuleMigrationPrebuiltRule { export type RuleSemanticSearchResult = RuleMigrationPrebuiltRule & RuleVersions; export type InternalUpdateRuleMigrationRule = UpdateRuleMigrationRule & { - translation_result?: RuleMigrationTranslationResult; + translation_result?: MigrationTranslationResult; }; /** From 52e1ec0fe6861c5cae1cf50c94b30f99c3840df4 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 7 Aug 2025 11:42:02 +0200 Subject: [PATCH 3/8] filter queries --- .../model/dashboard_migration.gen.ts | 2 +- .../model/dashboard_migration.schema.yaml | 1 - .../model/vendor/dashboards/splunk.gen.ts | 6 +- .../vendor/dashboards/splunk.schema.yaml | 4 ++ .../search.ts => common/data/dsl_queries.ts} | 31 ++------- .../data/siem_migrations_data_item_client.ts | 44 +++++++++++-- .../lib/siem_migrations/common/data/types.ts | 13 ++++ .../siem_migrations_task_evaluator.test.ts | 22 +++++-- .../dashboards/api/dashboards/create.ts | 30 ++++++++- .../siem_migrations/dashboards/api/stats.ts | 20 +----- .../siem_migrations/rules/data/dsl_queries.ts | 40 ++++++++++++ .../data/rule_migrations_data_rules_client.ts | 63 +++++-------------- .../retrieve_integrations.ts | 7 ++- .../lib/siem_migrations/rules/task/types.ts | 2 +- 14 files changed, 172 insertions(+), 113 deletions(-) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules/data/search.ts => common/data/dsl_queries.ts} (51%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts index 34fd94f116a60..2ff0b7d82f99c 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts @@ -89,7 +89,7 @@ export const OriginalDashboard = z.object({ /** * The last updated timestamp of the dashboard */ - last_updated: z.string(), + last_updated: z.string().optional(), /** * The format of the dashboard (e.g., 'json', 'xml') */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml index c73bff0b7bbe5..2d1d9d433fba5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml @@ -95,7 +95,6 @@ components: - title - description - data - - last_updated - format description: The raw dashboard object from different vendors properties: diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.gen.ts index a2b1ba684c0ef..d8115fcc480cc 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.gen.ts @@ -26,7 +26,7 @@ export const SplunkOriginalDashboardExportProperties = z.object({ /** * The unique identifier for the dashboard */ - id: z.string().optional(), + id: z.string(), /** * The label of the dashboard */ @@ -34,7 +34,7 @@ export const SplunkOriginalDashboardExportProperties = z.object({ /** * The title of the dashboard */ - title: z.string().optional(), + title: z.string(), /** * The description of the dashboard */ @@ -42,7 +42,7 @@ export const SplunkOriginalDashboardExportProperties = z.object({ /** * The EAI data of the dashboard, typically in XML format */ - 'eai:data': z.string().optional(), + 'eai:data': z.string(), /** * The application associated with the EAI ACL */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.schema.yaml index f4e89dd4150de..f2be5ee3b515e 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/vendor/dashboards/splunk.schema.yaml @@ -21,6 +21,10 @@ components: SplunkOriginalDashboardExportProperties: type: object description: Properties of the original dashboard + required: + - id + - title + - eai:data properties: id: type: string diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/search.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/dsl_queries.ts similarity index 51% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/search.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/dsl_queries.ts index 40281c77da412..0b8442a6b4421 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/search.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/dsl_queries.ts @@ -11,50 +11,29 @@ import { SiemMigrationStatus, } from '../../../../../common/siem_migrations/constants'; -export const conditions = { +export const dsl = { isFullyTranslated(): QueryDslQueryContainer { return { term: { translation_result: RuleTranslationResult.FULL } }; }, isNotFullyTranslated(): QueryDslQueryContainer { - return { bool: { must_not: conditions.isFullyTranslated() } }; + return { bool: { must_not: dsl.isFullyTranslated() } }; }, isPartiallyTranslated(): QueryDslQueryContainer { return { term: { translation_result: RuleTranslationResult.PARTIAL } }; }, isNotPartiallyTranslated(): QueryDslQueryContainer { - return { bool: { must_not: conditions.isPartiallyTranslated() } }; + return { bool: { must_not: dsl.isPartiallyTranslated() } }; }, isUntranslatable(): QueryDslQueryContainer { return { term: { translation_result: RuleTranslationResult.UNTRANSLATABLE } }; }, isNotUntranslatable(): QueryDslQueryContainer { - return { bool: { must_not: conditions.isUntranslatable() } }; - }, - isInstalled(): QueryDslQueryContainer { - return { exists: { field: 'elastic_rule.id' } }; - }, - isNotInstalled(): QueryDslQueryContainer { - return { bool: { must_not: conditions.isInstalled() } }; - }, - isPrebuilt(): QueryDslQueryContainer { - return { exists: { field: 'elastic_rule.prebuilt_rule_id' } }; - }, - isCustom(): QueryDslQueryContainer { - return { bool: { must_not: conditions.isPrebuilt() } }; - }, - matchTitle(title: string): QueryDslQueryContainer { - return { match: { 'elastic_rule.title': title } }; - }, - isInstallable(): QueryDslQueryContainer[] { - return [conditions.isFullyTranslated(), conditions.isNotInstalled()]; - }, - isNotInstallable(): QueryDslQueryContainer[] { - return [conditions.isNotFullyTranslated(), conditions.isInstalled()]; + return { bool: { must_not: dsl.isUntranslatable() } }; }, isFailed(): QueryDslQueryContainer { return { term: { status: SiemMigrationStatus.FAILED } }; }, isNotFailed(): QueryDslQueryContainer { - return { bool: { must_not: conditions.isFailed() } }; + return { bool: { must_not: dsl.isFailed() } }; }, }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts index 60604aba3232c..572ce9d533982 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_item_client.ts @@ -22,11 +22,17 @@ import { } from '../../../../../common/siem_migrations/constants'; import { SiemMigrationsDataBaseClient } from './siem_migrations_data_base_client'; import { MAX_ES_SEARCH_SIZE } from './constants'; -import type { MigrationType, SiemMigrationAllDataStats, SiemMigrationDataStats } from './types'; +import type { + MigrationType, + SiemMigrationAllDataStats, + SiemMigrationDataStats, + SiemMigrationFilters, +} from './types'; +import { dsl } from './dsl_queries'; export type CreateMigrationItemInput = Omit< I, - '@timestamp' | 'id' | 'status' | 'created_by' + '@timestamp' | 'id' | 'status' | 'created_by' | 'updated_by' | 'updated_at' >; export interface SiemMigrationItemSort { @@ -332,10 +338,38 @@ export abstract class SiemMigrationsDataItemClient< }; } - protected abstract getFilterQuery( + protected getFilterQuery( migrationId: string, - filters?: F - ): QueryDslQueryContainer; + filters: SiemMigrationFilters = {} + ): { bool: { filter: QueryDslQueryContainer[] } } { + const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; + + if (filters.status) { + if (Array.isArray(filters.status)) { + filter.push({ terms: { status: filters.status } }); + } else { + filter.push({ term: { status: filters.status } }); + } + } + if (filters.ids) { + filter.push({ terms: { _id: filters.ids } }); + } + if (filters.failed != null) { + filter.push(filters.failed ? dsl.isFailed() : dsl.isNotFailed()); + } + if (filters.fullyTranslated != null) { + filter.push(filters.fullyTranslated ? dsl.isFullyTranslated() : dsl.isNotFullyTranslated()); + } + if (filters.partiallyTranslated != null) { + filter.push( + filters.partiallyTranslated ? dsl.isPartiallyTranslated() : dsl.isNotPartiallyTranslated() + ); + } + if (filters.untranslatable != null) { + filter.push(filters.untranslatable ? dsl.isUntranslatable() : dsl.isNotUntranslatable()); + } + return { bool: { filter } }; + } protected abstract getSortOptions(sort?: SiemMigrationItemSort): estypes.Sort; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts index 8f4c134b145ee..6a05babd54c0e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import type { DashboardMigrationTaskStats } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen'; @@ -13,3 +14,15 @@ export type MigrationType = 'rule' | 'dashboard'; export type SiemMigrationTaskStats = RuleMigrationTaskStats | DashboardMigrationTaskStats; export type SiemMigrationDataStats = Omit; export type SiemMigrationAllDataStats = SiemMigrationDataStats[]; + +export interface SiemMigrationFilters { + status?: SiemMigrationStatus | SiemMigrationStatus[]; + ids?: string[]; + installed?: boolean; + installable?: boolean; + failed?: boolean; + fullyTranslated?: boolean; + partiallyTranslated?: boolean; + untranslatable?: boolean; + searchTerm?: string; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts index 116f7648ca415..16f6c5600f9d4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts @@ -6,12 +6,13 @@ */ import type { CustomEvaluator } from './siem_migrations_task_evaluator'; -import { RuleMigrationTaskEvaluator } from './siem_migrations_task_evaluator'; +import { SiemMigrationTaskEvaluable } from './siem_migrations_task_evaluator'; import type { Run, Example } from 'langsmith/schemas'; import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import type { AuthenticatedUser } from '@kbn/core/server'; -import type { SiemMigrationsClientDependencies } from '../../common/types'; +import type { SiemMigrationsClientDependencies } from '../types'; +import { SiemMigrationTaskRunner } from './siem_migrations_task_runner'; // Mock dependencies jest.mock('langsmith/evaluation', () => ({ @@ -28,8 +29,17 @@ jest.mock('langsmith', () => ({ })), })); -describe('RuleMigrationTaskEvaluator', () => { - let taskEvaluator: RuleMigrationTaskEvaluator; +// Create generic task evaluator class using the generic task runner +class SiemMigrationTaskEvaluator extends SiemMigrationTaskEvaluable(SiemMigrationTaskRunner) { + protected evaluators: Record = { + custom_query_accuracy: () => { + return { score: 50, comment: 'this is a mock evaluation' }; + }, + }; +} + +describe('SiemMigrationTaskEvaluator', () => { + let taskEvaluator: SiemMigrationTaskEvaluator; let mockRuleMigrationsDataClient: ReturnType; let abortController: AbortController; @@ -50,7 +60,7 @@ describe('RuleMigrationTaskEvaluator', () => { mockRuleMigrationsDataClient = createRuleMigrationsDataClientMock(); abortController = new AbortController(); - taskEvaluator = new RuleMigrationTaskEvaluator( + taskEvaluator = new SiemMigrationTaskEvaluator( 'test-migration-id', mockUser, abortController, @@ -68,7 +78,7 @@ describe('RuleMigrationTaskEvaluator', () => { let evaluator: CustomEvaluator; // Helper to access private evaluator methods const setEvaluator = (name: string) => { - // @ts-expect-error (accessing private property) + // @ts-expect-error accessing protected property evaluator = taskEvaluator.evaluators[name]; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts index 5c2f1ea170e92..a841bb8dcf0d5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/dashboards/create.ts @@ -8,6 +8,7 @@ import type { Logger } from '@kbn/logging'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import type { IKibanaResponse } from '@kbn/core/server'; +import type { DashboardMigrationDashboard } from '../../../../../../common/siem_migrations/model/dashboard_migration.gen'; import { CreateDashboardMigrationDashboardsRequestBody, CreateDashboardMigrationDashboardsRequestParams, @@ -16,6 +17,9 @@ import { SIEM_DASHBOARD_MIGRATION_DASHBOARDS_PATH } from '../../../../../../comm import type { SecuritySolutionPluginRouter } from '../../../../../types'; import { authz } from '../../../common/api/util/authz'; import { withLicense } from '../../../common/api/util/with_license'; +import type { CreateMigrationItemInput } from '../../../common/data/siem_migrations_data_item_client'; + +type CreateMigrationDashboardInput = CreateMigrationItemInput; export const registerSiemDashboardMigrationsCreateDashboardsRoute = ( router: SecuritySolutionPluginRouter, @@ -50,10 +54,30 @@ export const registerSiemDashboardMigrationsCreateDashboardsRoute = ( const ctx = await context.resolve(['securitySolution']); const dashboardMigrationsClient = ctx.securitySolution.siemMigrations.getDashboardsClient(); - await dashboardMigrationsClient.data.dashboards.create( - migrationId, - originalDashboardsExport + + // Convert the original splunk dashboards format to the migration dashboard item document format + const items = originalDashboardsExport.map( + ({ result: { ...originalDashboard } }) => ({ + migration_id: migrationId, + original_dashboard: { + id: originalDashboard.id, + title: originalDashboard.label ?? originalDashboard.title, + description: originalDashboard.description ?? '', + data: originalDashboard['eai:data'], + format: 'xml', + vendor: 'splunk', + last_updated: originalDashboard.updated, + splunk_properties: { + app: originalDashboard['eai:acl.app'], + owner: originalDashboard['eai:acl.owner'], + sharing: originalDashboard['eai:acl.sharing'], + }, + }, + }) ); + + await dashboardMigrationsClient.data.items.create(items); + return res.ok(); } catch (error) { logger.error(`Error creating dashboards for migration ID ${migrationId}: ${error}`); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts index 370d0874611e2..fb7e01e4ac0ab 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts @@ -46,26 +46,12 @@ export const registerSiemDashboardMigrationsStatsRoute = ( const dashboardMigrationClient = ctx.securitySolution.siemMigrations.getDashboardsClient(); - const [stats, migration] = await Promise.all([ - dashboardMigrationClient.data.dashboards.getStats(migrationId), - dashboardMigrationClient.data.migrations.get(migrationId), - ]); + const stats = await dashboardMigrationClient.task.getStats(migrationId); - if (!migration) { - return res.notFound({ - body: MIGRATION_ID_NOT_FOUND(migrationId), - }); - } - - if (stats.dashboards?.total === 0) { + if (stats.items.total === 0) { return res.noContent(); } - return res.ok({ - body: { - ...stats, - name: migration.name, - }, - }); + return res.ok({ body: stats }); } catch (err) { logger.error(err); return res.badRequest({ body: err.message }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts new file mode 100644 index 0000000000000..df743f115864a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts @@ -0,0 +1,40 @@ +/* + * 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; +import { dsl as genericDsl } from '../../common/data/dsl_queries'; + +export const dsl = { + isInstalled(): QueryDslQueryContainer { + return { exists: { field: 'elastic_rule.id' } }; + }, + isNotInstalled(): QueryDslQueryContainer { + return { bool: { must_not: dsl.isInstalled() } }; + }, + isPrebuilt(): QueryDslQueryContainer { + return { exists: { field: 'elastic_rule.prebuilt_rule_id' } }; + }, + isCustom(): QueryDslQueryContainer { + return { bool: { must_not: dsl.isPrebuilt() } }; + }, + matchTitle(title: string): QueryDslQueryContainer { + return { match: { 'elastic_rule.title': title } }; + }, + isInstallable(): QueryDslQueryContainer[] { + return [genericDsl.isFullyTranslated(), dsl.isNotInstalled()]; + }, + isNotInstallable(): QueryDslQueryContainer[] { + return [genericDsl.isNotFullyTranslated(), dsl.isInstalled()]; + }, + isFailed(): QueryDslQueryContainer { + return { term: { status: SiemMigrationStatus.FAILED } }; + }, + isNotFailed(): QueryDslQueryContainer { + return { bool: { must_not: dsl.isFailed() } }; + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts index 2bef9cb41e502..5508e98756fb0 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts @@ -22,7 +22,7 @@ import { type RuleMigrationRule, } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { getSortingOptions, type RuleMigrationSort } from './sort'; -import { conditions as searchConditions } from './search'; +import { dsl } from './dsl_queries'; import { MAX_ES_SEARCH_SIZE } from '../constants'; import type { CreateMigrationItemInput, @@ -54,8 +54,8 @@ export class RuleMigrationsDataRulesClient extends SiemMigrationsDataItemClient< filter: { term: { status: SiemMigrationStatus.COMPLETED } }, aggs: { result: { terms: { field: 'translation_result' } }, - installable: { filter: { bool: { must: searchConditions.isInstallable() } } }, - prebuilt: { filter: searchConditions.isPrebuilt() }, + installable: { filter: { bool: { must: dsl.isInstallable() } } }, + prebuilt: { filter: dsl.isPrebuilt() }, }, }, failed: { filter: { term: { status: SiemMigrationStatus.FAILED } } }, @@ -117,56 +117,23 @@ export class RuleMigrationsDataRulesClient extends SiemMigrationsDataItemClient< protected getFilterQuery( migrationId: string, filters: RuleMigrationFilters = {} - ): QueryDslQueryContainer { - const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; - if (filters.status) { - if (Array.isArray(filters.status)) { - filter.push({ terms: { status: filters.status } }); - } else { - filter.push({ term: { status: filters.status } }); - } - } - if (filters.ids) { - filter.push({ terms: { _id: filters.ids } }); - } + ): { bool: { filter: QueryDslQueryContainer[] } } { + const { filter } = super.getFilterQuery(migrationId, filters).bool; + + // Rules specific filters if (filters.searchTerm?.length) { - filter.push(searchConditions.matchTitle(filters.searchTerm)); - } - if (filters.installed === true) { - filter.push(searchConditions.isInstalled()); - } else if (filters.installed === false) { - filter.push(searchConditions.isNotInstalled()); + filter.push(dsl.matchTitle(filters.searchTerm)); } - if (filters.installable === true) { - filter.push(...searchConditions.isInstallable()); - } else if (filters.installable === false) { - filter.push(...searchConditions.isNotInstallable()); + if (filters.installed != null) { + filter.push(filters.installed ? dsl.isInstalled() : dsl.isNotInstalled()); } - if (filters.prebuilt === true) { - filter.push(searchConditions.isPrebuilt()); - } else if (filters.prebuilt === false) { - filter.push(searchConditions.isCustom()); + if (filters.installable != null) { + filter.push(...(filters.installable ? dsl.isInstallable() : dsl.isNotInstallable())); } - if (filters.failed === true) { - filter.push(searchConditions.isFailed()); - } else if (filters.failed === false) { - filter.push(searchConditions.isNotFailed()); - } - if (filters.fullyTranslated === true) { - filter.push(searchConditions.isFullyTranslated()); - } else if (filters.fullyTranslated === false) { - filter.push(searchConditions.isNotFullyTranslated()); - } - if (filters.partiallyTranslated === true) { - filter.push(searchConditions.isPartiallyTranslated()); - } else if (filters.partiallyTranslated === false) { - filter.push(searchConditions.isNotPartiallyTranslated()); - } - if (filters.untranslatable === true) { - filter.push(searchConditions.isUntranslatable()); - } else if (filters.untranslatable === false) { - filter.push(searchConditions.isNotUntranslatable()); + if (filters.prebuilt != null) { + filter.push(filters.prebuilt ? dsl.isPrebuilt() : dsl.isCustom()); } + return { bool: { filter } }; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts index bfc24e1b69eb1..5f6e579308b95 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/retrieve_integrations/retrieve_integrations.ts @@ -8,8 +8,11 @@ import { JsonOutputParser } from '@langchain/core/output_parsers'; import type { RuleMigrationsRetriever } from '../../../../../retrievers'; import type { RuleMigrationTelemetryClient } from '../../../../../rule_migrations_telemetry_client'; -import type { ChatModel } from '../../../../../util/actions_client_chat'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; +import type { ChatModel } from '../../../../../../../common/task/util/actions_client_chat'; +import { + cleanMarkdown, + generateAssistantComment, +} from '../../../../../../../common/task/util/comments'; import type { GraphNode } from '../../types'; import { MATCH_INTEGRATION_PROMPT } from './prompts'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts index e60cd543186d2..cad7c25b41ada 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts @@ -11,7 +11,7 @@ import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_clie import type { StoredRuleMigration } from '../types'; import type { getRuleMigrationAgent } from './agent'; import type { RuleMigrationTelemetryClient } from './rule_migrations_telemetry_client'; -import type { ChatModel } from './util/actions_client_chat'; +import type { ChatModel } from '../../common/task/util/actions_client_chat'; import type { RuleMigrationResources } from './retrievers/rule_resource_retriever'; import type { RuleMigrationsRetriever } from './retrievers'; import type { MigrateRuleGraphConfig } from './agent/types'; From c819a19ef3c0becac3867e65e9799991cf0ec0cd Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 7 Aug 2025 12:08:02 +0200 Subject: [PATCH 4/8] dsl queries --- .../server/lib/siem_migrations/common/data/dsl_queries.ts | 1 + .../data/dashboard_migrations_dashboards_client.ts | 6 ------ .../server/lib/siem_migrations/rules/data/dsl_queries.ts | 7 ------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/dsl_queries.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/dsl_queries.ts index 0b8442a6b4421..e042cf880ae42 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/dsl_queries.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/dsl_queries.ts @@ -7,6 +7,7 @@ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { + // TODO: RuleTranslationResult -> TranslationResult RuleTranslationResult, SiemMigrationStatus, } from '../../../../../common/siem_migrations/constants'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts index 3dbcf9006041d..f2f4dcbe1aee9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { estypes } from '@elastic/elasticsearch'; import type { DashboardMigrationDashboard } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; import type { SiemMigrationItemSort } from '../../common/data/siem_migrations_data_item_client'; @@ -14,11 +13,6 @@ import { SiemMigrationsDataItemClient } from '../../common/data/siem_migrations_ export class DashboardMigrationsDataDashboardsClient extends SiemMigrationsDataItemClient { protected type = 'dashboard' as const; - protected getFilterQuery(migrationId: string, _filters?: object): QueryDslQueryContainer { - const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; - return { bool: { filter } }; - } - protected getSortOptions(sort: SiemMigrationItemSort = {}): estypes.Sort { return []; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts index df743f115864a..b271de659368b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/dsl_queries.ts @@ -6,7 +6,6 @@ */ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; -import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import { dsl as genericDsl } from '../../common/data/dsl_queries'; export const dsl = { @@ -31,10 +30,4 @@ export const dsl = { isNotInstallable(): QueryDslQueryContainer[] { return [genericDsl.isNotFullyTranslated(), dsl.isInstalled()]; }, - isFailed(): QueryDslQueryContainer { - return { term: { status: SiemMigrationStatus.FAILED } }; - }, - isNotFailed(): QueryDslQueryContainer { - return { bool: { must_not: dsl.isFailed() } }; - }, }; From f4f1162b89250d257326c01f487b926a9c97f2d5 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Fri, 8 Aug 2025 18:40:06 +0200 Subject: [PATCH 5/8] agent tools extracted --- .../shared/kbn-utility-types/index.ts | 5 + .../common/siem_migrations/constants.ts | 2 + .../dashboards/resources/index.ts | 7 + .../resources/resource_identifier.ts | 23 + .../siem_migrations/dashboards/types.ts | 14 + .../model/dashboard_migration.gen.ts | 42 +- .../model/dashboard_migration.schema.yaml | 35 +- .../siem_migrations/model/migration.gen.ts | 31 ++ .../model/migration.schema.yaml | 33 +- .../model/rule_migration.gen.ts | 30 +- .../model/rule_migration.schema.yaml | 29 +- .../resources/resource_identifier.ts | 78 +++ .../resources/splunk/index.ts} | 3 +- .../splunk/splunk_identifier.test.ts | 0 .../resources/splunk/splunk_identifier.ts | 7 +- .../{rules => }/resources/types.ts | 8 +- .../siem_migrations/rules/resources/index.ts | 72 +-- .../rules/resources/resource_identifier.ts | 21 + .../rules/resources/splunk/index.ts | 19 - .../common/siem_migrations/rules/types.ts | 15 + .../common/siem_migrations/types.ts | 21 +- .../public/siem_migrations/rules/api/index.ts | 2 +- .../rules/components/rules_table/index.tsx | 2 +- .../components/rules_table/utils/filters.ts | 2 +- .../rules/components/status_badge/index.tsx | 8 +- .../rules/logic/use_get_migration_rules.ts | 2 +- .../scripts/langgraph/draw_graphs_script.ts | 2 +- .../siem_migrations/common/api/util/retry.ts | 2 +- .../common/data/__mocks__/mocks.ts | 84 +++ .../__mocks__/siem_migrations_data_client.ts | 9 + ...m_migrations_data_prebuilt_rules_client.ts | 9 + .../siem_migrations_data_resources_client.ts | 9 + .../siem_migrations_data_rules_client.ts | 9 + .../__mocks__/siem_migrations_data_service.ts | 9 + .../common/task/__mocks__/mocks.ts | 125 +++++ .../__mocks__/siem_migrations_task_client.ts | 9 + .../__mocks__/siem_migrations_task_service.ts | 9 + .../siem_migrations_telemetry_client.ts | 9 + .../cim_ecs_map.ts | 2 +- .../convert_esql_schema_cim_to_ecs.ts | 70 +++ .../convert_esql_schema_cim_to_ecs/index.ts | 7 + .../prompts.ts | 6 +- .../fix_esql_query_errors.ts | 49 ++ .../tools/fix_esql_query_errors/index.ts | 7 + .../tools/fix_esql_query_errors}/prompts.ts | 0 .../agent/tools/inline_spl_query/index.ts | 7 + .../inline_spl_query/inline_spl_query.ts | 92 ++++ .../agent/tools/inline_spl_query}/prompts.ts | 4 +- .../tools/translate_spl_to_esql/index.ts | 8 + .../tools/translate_spl_to_esql}/prompts.ts | 36 +- .../translate_spl_to_esql/translate_rule.ts | 68 +++ .../common/task/agent/tools/types.ts | 11 + .../task/agent/tools/validate_esql/index.ts | 7 + .../agent/tools/validate_esql/validation.ts | 59 ++ .../retrievers/resource_retriever.test.ts | 138 +++++ .../task/retrievers/resource_retriever.ts | 113 ++++ .../task/siem_migrations_task_client.test.ts | 2 +- .../task/siem_migrations_task_client.ts | 6 +- .../siem_migrations_task_evaluator.test.ts | 198 +------ .../task/siem_migrations_task_evaluator.ts | 66 ++- .../task/siem_migrations_task_runner.test.ts | 218 ++++---- .../task/siem_migrations_task_runner.ts | 61 ++- .../lib/siem_migrations/common/task/types.ts | 14 +- .../common/task/util/comments.ts | 4 +- .../{rules => common}/task/util/constants.ts | 0 .../task/util/esql_knowledge_base.ts | 0 .../util/nullify_missing_properties.test.ts | 50 ++ .../task/util/nullify_missing_properties.ts | 26 + .../lib/siem_migrations/common/types.ts | 1 + .../dashboards/data/__mocks__/mocks.ts | 2 +- .../data/dashboard_migrations_data_client.ts | 2 +- ...migrations_data_dashboards_client.test.ts} | 2 +- ...oard_migrations_data_dashboards_client.ts} | 0 .../siem_dashboard_migration_service.ts | 13 +- .../dashboards/task/__mocks__/mocks.ts | 127 +++++ .../__mocks__/rule_migrations_task_client.ts | 9 + .../__mocks__/rule_migrations_task_service.ts | 9 + .../rule_migrations_telemetry_client.ts | 9 + .../dashboards/task/agent/index.ts | 11 + .../dashboards/task/agent/types.ts | 9 + .../dashboard_migrations_task_client.test.ts | 515 ++++++++++++++++++ .../task/dashboard_migrations_task_client.ts | 36 ++ .../dashboard_migrations_task_evaluator.ts | 22 + .../task/dashboard_migrations_task_runner.ts | 115 ++++ .../task/dashboard_migrations_task_service.ts | 43 ++ .../dashboard_migrations_telemetry_client.ts | 112 ++++ .../task/retrievers/__mocks__/mocks.ts | 39 ++ .../__mocks__/rule_migrations_retriever.ts | 9 + .../dashboard_migrations_retriever.ts | 26 + .../dashboard_resource_retriever.ts | 14 + .../dashboards/task/retrievers/index.ts | 8 + .../siem_migrations/dashboards/task/types.ts | 67 +++ .../lib/siem_migrations/dashboards/types.ts | 8 + .../rules/api/evaluation/evaluate.ts | 4 +- .../rules/api/util/installation.ts | 6 +- .../data/rule_migrations_data_rules_client.ts | 2 +- ...igrations_per_space_index_migrator.test.ts | 8 +- ...ule_migrations_per_space_index_migrator.ts | 10 +- .../rules/task/agent/graph.test.ts | 2 +- .../siem_migrations/rules/task/agent/graph.ts | 10 +- .../create_semantic_query.ts | 2 +- .../match_prebuilt_rule.ts | 2 +- .../siem_migrations/rules/task/agent/state.ts | 4 +- .../nodes/ecs_mapping/cim_ecs_map.ts | 181 ------ .../nodes/ecs_mapping/ecs_mapping.ts | 59 +- .../fix_query_errors/fix_query_errors.ts | 34 +- .../nodes/inline_query/inline_query.ts | 76 +-- .../nodes/translate_rule/translate_rule.ts | 44 +- .../translation_result/translation_result.ts | 2 +- .../nodes/validation/validation.ts | 51 +- .../agent/sub_graphs/translate_rule/state.ts | 4 +- .../agent/sub_graphs/translate_rule/types.ts | 4 +- .../siem_migrations/rules/task/agent/types.ts | 11 +- .../retrievers/rule_migrations_retriever.ts | 2 +- .../retrievers/rule_resource_retriever.ts | 114 +--- .../task/rule_migrations_task_client.test.ts | 14 +- .../rule_migrations_task_evaluator.test.ts | 56 +- .../task/rule_migrations_task_evaluator.ts | 23 +- .../task/rule_migrations_task_runner.test.ts | 12 +- .../rules/task/rule_migrations_task_runner.ts | 76 ++- .../lib/siem_migrations/rules/task/types.ts | 14 +- .../rules/task/util/__mocks__/mocks.ts | 20 - .../util/nullify_missing_properties.test.ts | 85 --- .../task/util/nullify_missing_properties.ts | 53 -- .../server/lib/siem_migrations/rules/types.ts | 5 +- 125 files changed, 2950 insertions(+), 1373 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/resource_identifier.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/types.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/resource_identifier.ts rename x-pack/solutions/security/plugins/security_solution/{server/lib/siem_migrations/rules/task/util/__mocks__/esql_knowledge_base.ts => common/siem_migrations/resources/splunk/index.ts} (70%) rename x-pack/solutions/security/plugins/security_solution/common/siem_migrations/{rules => }/resources/splunk/splunk_identifier.test.ts (100%) rename x-pack/solutions/security/plugins/security_solution/common/siem_migrations/{rules => }/resources/splunk/splunk_identifier.ts (89%) rename x-pack/solutions/security/plugins/security_solution/common/siem_migrations/{rules => }/resources/types.ts (67%) create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/resource_identifier.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/types.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/mocks.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_prebuilt_rules_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_resources_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_rules_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_service.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/mocks.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_service.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_telemetry_client.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule => common/task/agent/tools/convert_esql_schema_cim_to_ecs}/cim_ecs_map.ts (99%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/convert_esql_schema_cim_to_ecs.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/index.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping => common/task/agent/tools/convert_esql_schema_cim_to_ecs}/prompts.ts (97%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/fix_esql_query_errors.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/index.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules/task/agent/sub_graphs/translate_rule/nodes/fix_query_errors => common/task/agent/tools/fix_esql_query_errors}/prompts.ts (100%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules/task/agent/sub_graphs/translate_rule/nodes/inline_query => common/task/agent/tools/inline_spl_query}/prompts.ts (96%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/index.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule => common/task/agent/tools/translate_spl_to_esql}/prompts.ts (58%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/translate_rule.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/types.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules => common}/task/util/constants.ts (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/{rules => common}/task/util/esql_knowledge_base.ts (100%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/{dashboard_migrations_dashboards_client.test.ts => dashboard_migrations_data_dashboards_client.test.ts} (99%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/{dashboard_migrations_dashboards_client.ts => dashboard_migrations_data_dashboards_client.ts} (100%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_service.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_telemetry_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_evaluator.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_service.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_telemetry_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/rule_migrations_retriever.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_resource_retriever.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/types.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/cim_ecs_map.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/__mocks__/mocks.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.test.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.ts diff --git a/src/platform/packages/shared/kbn-utility-types/index.ts b/src/platform/packages/shared/kbn-utility-types/index.ts index 78626bc7fdc55..d7e44ab1048e5 100644 --- a/src/platform/packages/shared/kbn-utility-types/index.ts +++ b/src/platform/packages/shared/kbn-utility-types/index.ts @@ -172,3 +172,8 @@ export type RecursivePartial = { : RecursivePartial; }; type NonAny = number | boolean | string | symbol | null; + +/** + * Utility type for making all properties of an object nullable. + */ +export type Nullable = { [K in keyof T]: T[K] | null }; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts index 5210f7087f7b7..a072c876f0f97 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts @@ -8,6 +8,8 @@ export const SIEM_MIGRATIONS_ASSISTANT_USER = 'assistant'; export const SIEM_MIGRATIONS_PATH = '/internal/siem_migrations' as const; + +// TODO: Move `SIEM_RULE_MIGRATIONS_PATH` and composed paths to rules/constants.ts 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; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/index.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/index.ts new file mode 100644 index 0000000000000..d636cecbed06f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/index.ts @@ -0,0 +1,7 @@ +/* + * 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 * from './resource_identifier'; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/resource_identifier.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/resource_identifier.ts new file mode 100644 index 0000000000000..1b3612d8c4410 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/resources/resource_identifier.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. + */ +// TODO: move resource related types to migration.gen.ts +import type { RuleMigrationResourceBase } from '../../model/rule_migration.gen'; +import type { DashboardMigrationDashboard } from '../../model/dashboard_migration.gen'; +import { ResourceIdentifier } from '../../resources/resource_identifier'; +import type { SiemMigrationVendor } from '../../types'; + +export class DashboardResourceIdentifier extends ResourceIdentifier { + protected getVendor(): SiemMigrationVendor { + return this.item.original_dashboard.vendor; + } + + public fromOriginal(rule?: DashboardMigrationDashboard): RuleMigrationResourceBase[] { + const originalDashboard = rule?.original_dashboard ?? this.item.original_dashboard; + const queries: string[] = []; // TODO: Parse the originalDashboard to extract the queries + return queries.flatMap((query) => this.identifier(query)); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/types.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/types.ts new file mode 100644 index 0000000000000..6756a42cdb834 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SiemMigrationFilters } from '../types'; + +export interface DashboardMigrationFilters extends SiemMigrationFilters { + searchTerm?: string; + installed?: boolean; + installable?: boolean; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts index 2ff0b7d82f99c..7864bd16d12c5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts @@ -25,6 +25,12 @@ import { } from './migration.gen'; import { SplunkOriginalDashboardProperties } from './vendor/dashboards/splunk.gen'; +/** + * The original dashboard vendor identifier. + */ +export type OriginalDashboardVendor = z.infer; +export const OriginalDashboardVendor = z.literal('splunk'); + /** * The dashboard migration object ( without Id ) with its settings. */ @@ -70,10 +76,7 @@ export const OriginalDashboard = z.object({ * The unique identifier for the dashboard */ id: z.string(), - /** - * The vendor of the dashboard (e.g., 'splunk') - */ - vendor: z.string(), + vendor: OriginalDashboardVendor, /** * The title of the dashboard */ @@ -83,7 +86,7 @@ export const OriginalDashboard = z.object({ */ description: z.string(), /** - * The data of the dashboard, typically in JSON format + * The data of the dashboard in the specified format */ data: z.object({}), /** @@ -91,7 +94,7 @@ export const OriginalDashboard = z.object({ */ last_updated: z.string().optional(), /** - * The format of the dashboard (e.g., 'json', 'xml') + * The format of the dashboard data (e.g., 'json', 'xml') */ format: z.string(), /** @@ -100,6 +103,29 @@ export const OriginalDashboard = z.object({ splunk_properties: SplunkOriginalDashboardProperties.optional(), }); +/** + * The elastic dashboard translation. + */ +export type ElasticDashboard = z.infer; +export const ElasticDashboard = z.object({ + /** + * The unique identifier for the dashboard installed Saved Object + */ + id: z.string().optional(), + /** + * The title of the dashboard + */ + title: z.string(), + /** + * The description of the dashboard + */ + description: z.string().optional(), + /** + * The data of the dashboard Saved Object + */ + data: z.object({}).optional(), +}); + /** * The dashboard migration document object. */ @@ -121,6 +147,10 @@ export const DashboardMigrationDashboardData = z.object({ * The original dashboard to migrate. */ original_dashboard: OriginalDashboard, + /** + * The translated elastic dashboard. + */ + elastic_dashboard: ElasticDashboard.optional(), /** * The rule translation result. */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml index 2d1d9d433fba5..3b119a9aafe38 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml @@ -6,6 +6,12 @@ paths: {} components: x-codegen-enabled: true schemas: + OriginalDashboardVendor: + type: string + description: The original dashboard vendor identifier. + enum: + - splunk + DashboardMigration: description: The dashboard migration object with its settings. allOf: @@ -73,6 +79,9 @@ components: original_dashboard: description: The original dashboard to migrate. $ref: '#/components/schemas/OriginalDashboard' + elastic_dashboard: + description: The translated elastic dashboard. + $ref: '#/components/schemas/ElasticDashboard' translation_result: description: The rule translation result. $ref: './migration.schema.yaml#/components/schemas/MigrationTranslationResult' @@ -102,8 +111,7 @@ components: type: string description: The unique identifier for the dashboard vendor: - type: string - description: The vendor of the dashboard (e.g., 'splunk') + $ref: '#/components/schemas/OriginalDashboardVendor' title: type: string description: The title of the dashboard @@ -112,17 +120,36 @@ components: description: The description of the dashboard data: type: object - description: The data of the dashboard, typically in JSON format + description: The data of the dashboard in the specified format last_updated: type: string description: The last updated timestamp of the dashboard format: type: string - description: The format of the dashboard (e.g., 'json', 'xml') + description: The format of the dashboard data (e.g., 'json', 'xml') splunk_properties: description: Additional properties specific to the splunk $ref: './vendor/dashboards/splunk.schema.yaml#/components/schemas/SplunkOriginalDashboardProperties' + ElasticDashboard: + type: object + description: The elastic dashboard translation. + required: + - title + properties: + id: + type: string + description: The unique identifier for the dashboard installed Saved Object + title: + type: string + description: The title of the dashboard + description: + type: string + description: The description of the dashboard + data: + type: object + description: The data of the dashboard Saved Object + DashboardMigrationTaskStats: description: The dashboard migration task stats object. $ref: './migration.schema.yaml#/components/schemas/MigrationTaskStats' diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts index 77613baf43148..07cea07a74499 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.gen.ts @@ -18,6 +18,37 @@ import { z } from '@kbn/zod'; import { NonEmptyString } from '../../api/model/primitives.gen'; +/** + * The vendor identifier. + */ +export type Vendor = z.infer; +export const Vendor = z.literal('splunk'); + +/** + * A comment for the migration process + */ +export type MigrationComment = z.infer; +export const MigrationComment = z.object({ + /** + * The message of the migration comment + */ + message: z.string(), + /** + * The moment of creation of the comment + */ + created_at: z.string(), + /** + * The user profile ID of the user who created the comment, or `assistant` if it was generated by the LLM + */ + created_by: z.string(), +}); + +/** + * The comments for the migration including a summary from the LLM in markdown. + */ +export type MigrationComments = z.infer; +export const MigrationComments = z.array(MigrationComment); + /** * The status of migration. */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml index c8a2a8ad9cbbd..c8e4a4897aea9 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/migration.schema.yaml @@ -6,10 +6,41 @@ paths: {} components: x-codegen-enabled: true schemas: + Vendor: + type: string + description: The vendor identifier. + enum: + - splunk + + MigrationComment: + type: object + description: A comment for the migration process + required: + - message + - created_at + - created_by + properties: + message: + type: string + description: The message of the migration comment + created_at: + type: string + description: The moment of creation of the comment + created_by: + type: string + description: The user profile ID of the user who created the comment, or `assistant` if it was generated by the LLM + + MigrationComments: + type: array + description: The comments for the migration including a summary from the LLM in markdown. + items: + description: The comments for the migration process + $ref: '#/components/schemas/MigrationComment' + MigrationStatus: type: string description: The status of migration. - enum: # should match SiemMigrationsStatus enum at ../constants.ts + enum: # should match SiemMigrationStatus enum at ../constants.ts - pending - processing - completed diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index 8861e7ae34bc0..512b58afdbd66 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -21,6 +21,7 @@ import { RuleResponse } from '../../api/detection_engine/model/rule_schema/rule_ import { MigrationTranslationResult, MigrationStatus, + MigrationComments, MigrationTaskStats, MigrationLastExecution, } from './migration.gen'; @@ -196,31 +197,6 @@ export const RuleMigration = z }) .merge(RuleMigrationData); -/** - * The comment for the migration - */ -export type RuleMigrationComment = z.infer; -export const RuleMigrationComment = z.object({ - /** - * The comment for the migration - */ - message: z.string(), - /** - * The moment of creation - */ - created_at: z.string(), - /** - * The user profile ID of the user who created the comment or `assistant` if it was generated by the LLM - */ - created_by: z.string(), -}); - -/** - * The comments for the migration including a summary from the LLM in markdown. - */ -export type RuleMigrationComments = z.infer; -export const RuleMigrationComments = z.array(RuleMigrationComment); - /** * The rule migration document object. */ @@ -257,7 +233,7 @@ export const RuleMigrationRuleData = z.object({ /** * The comments for the migration including a summary from the LLM in markdown. */ - comments: RuleMigrationComments.optional(), + comments: MigrationComments.optional(), /** * The moment of the last update */ @@ -368,7 +344,7 @@ export const UpdateRuleMigrationRule = z.object({ /** * The comments for the migration including a summary from the LLM in markdown. */ - comments: RuleMigrationComments.optional(), + comments: MigrationComments.optional(), }); /** diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index b3eed1977fce1..3f63321babf8b 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -197,7 +197,7 @@ components: default: pending comments: description: The comments for the migration including a summary from the LLM in markdown. - $ref: '#/components/schemas/RuleMigrationComments' + $ref: './migration.schema.yaml#/components/schemas/MigrationComments' updated_at: type: string description: The moment of the last update @@ -275,31 +275,6 @@ components: type: integer description: The number of rules that have failed translation. - RuleMigrationComment: - type: object - description: The comment for the migration - required: - - message - - created_at - - created_by - properties: - message: - type: string - description: The comment for the migration - created_at: - type: string - description: The moment of creation - created_by: - type: string - description: The user profile ID of the user who created the comment or `assistant` if it was generated by the LLM - - RuleMigrationComments: - type: array - description: The comments for the migration including a summary from the LLM in markdown. - items: - description: The comments for the migration - $ref: '#/components/schemas/RuleMigrationComment' - UpdateRuleMigrationRule: type: object description: The rule migration data object for rule update operation @@ -314,7 +289,7 @@ components: $ref: '#/components/schemas/ElasticRulePartial' comments: description: The comments for the migration including a summary from the LLM in markdown. - $ref: '#/components/schemas/RuleMigrationComments' + $ref: './migration.schema.yaml#/components/schemas/MigrationComments' RuleMigrationRetryFilter: type: string diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/resource_identifier.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/resource_identifier.ts new file mode 100644 index 0000000000000..9b6f51cf8b15a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/resource_identifier.ts @@ -0,0 +1,78 @@ +/* + * 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. + */ +// TODO: move resource related types to migration.gen.ts +import type { + RuleMigrationResourceData, + RuleMigrationResourceBase, +} from '../model/rule_migration.gen'; +import type { VendorResourceIdentifier } from './types'; +import { splResourceIdentifier } from './splunk'; +import type { ItemDocument, SiemMigrationVendor } from '../types'; + +export const identifiers: Record = { + splunk: splResourceIdentifier, +}; + +// Type for a class that extends the ResourceIdentifier abstract class +export type ResourceIdentifierClass = new ( + item: I +) => ResourceIdentifier; + +export abstract class ResourceIdentifier { + protected identifier: VendorResourceIdentifier; + + constructor(protected readonly item: I) { + this.identifier = identifiers[this.getVendor()]; + } + + protected abstract getVendor(): SiemMigrationVendor; + public abstract fromOriginal(item?: I): RuleMigrationResourceBase[]; + + public fromOriginals(item: I[]): RuleMigrationResourceBase[] { + const lookups = new Set(); + const macros = new Set(); + item.forEach((rule) => { + const resources = this.fromOriginal(rule); + resources.forEach((resource) => { + if (resource.type === 'macro') { + macros.add(resource.name); + } else if (resource.type === 'lookup') { + lookups.add(resource.name); + } + }); + }); + return [ + ...Array.from(macros).map((name) => ({ type: 'macro', name })), + ...Array.from(lookups).map((name) => ({ type: 'lookup', name })), + ]; + } + + public fromResource(resource: RuleMigrationResourceData): RuleMigrationResourceBase[] { + if (resource.type === 'macro' && resource.content) { + return this.identifier(resource.content); + } + return []; + } + + public fromResources(resources: RuleMigrationResourceData[]): RuleMigrationResourceBase[] { + const lookups = new Set(); + const macros = new Set(); + resources.forEach((resource) => { + this.fromResource(resource).forEach((identifiedResource) => { + if (identifiedResource.type === 'macro') { + macros.add(identifiedResource.name); + } else if (identifiedResource.type === 'lookup') { + lookups.add(identifiedResource.name); + } + }); + }); + return [ + ...Array.from(macros).map((name) => ({ type: 'macro', name })), + ...Array.from(lookups).map((name) => ({ type: 'lookup', name })), + ]; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/__mocks__/esql_knowledge_base.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/index.ts similarity index 70% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/__mocks__/esql_knowledge_base.ts rename to x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/index.ts index 73f5e8a2930f5..9bb513df812c0 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/__mocks__/esql_knowledge_base.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -import { MockEsqlKnowledgeBase } from './mocks'; -export const EsqlKnowledgeBase = MockEsqlKnowledgeBase; +export * from './splunk_identifier'; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/splunk_identifier.test.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/splunk_identifier.test.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/splunk_identifier.test.ts rename to x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/splunk_identifier.test.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/splunk_identifier.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/splunk_identifier.ts similarity index 89% rename from x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/splunk_identifier.ts rename to x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/splunk_identifier.ts index 90856896193dc..3ed9210d2dac5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/splunk_identifier.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/splunk/splunk_identifier.ts @@ -11,13 +11,14 @@ * Please make sure to test all regular expressions them before using them. * At the time of writing, this tool can be used to test it: https://devina.io/redos-checker */ -import type { RuleMigrationResourceBase } from '../../../model/rule_migration.gen'; -import type { ResourceIdentifier } from '../types'; +// TODO: move resource related types to migration.gen.ts +import type { RuleMigrationResourceBase } from '../../model/rule_migration.gen'; +import type { VendorResourceIdentifier } from '../types'; const lookupRegex = /\b(?:lookup)\s+([\w-]+)\b/g; // Captures only the lookup name const macrosRegex = /`([\w-]+)(?:\(([^`]*?)\))?`/g; // Captures only the macro name and arguments -export const splResourceIdentifier: ResourceIdentifier = (input) => { +export const splResourceIdentifier: VendorResourceIdentifier = (input) => { // sanitize the query to avoid mismatching macro and lookup names inside comments or literal strings const sanitizedInput = sanitizeInput(input); diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/types.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/types.ts similarity index 67% rename from x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/types.ts rename to x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/types.ts index db4df43cc3df6..3e5c61e042e04 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/resources/types.ts @@ -4,16 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +// TODO: move resource related types to migration.gen.ts import type { - OriginalRule, RuleMigrationResourceBase, RuleMigrationResourceData, -} from '../../model/rule_migration.gen'; +} from '../model/rule_migration.gen'; -export type ResourceIdentifier = (input: string) => RuleMigrationResourceBase[]; +export type VendorResourceIdentifier = (input: string) => RuleMigrationResourceBase[]; export interface ResourceIdentifiers { - fromOriginalRule: (originalRule: OriginalRule) => RuleMigrationResourceBase[]; fromResource: (resource: RuleMigrationResourceData) => RuleMigrationResourceBase[]; } diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/index.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/index.ts index 4b6472eb0e55c..d636cecbed06f 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/index.ts @@ -4,74 +4,4 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import type { - OriginalRule, - OriginalRuleVendor, - RuleMigrationResourceData, - RuleMigrationResourceBase, -} from '../../model/rule_migration.gen'; -import type { ResourceIdentifiers } from './types'; -import { splResourceIdentifiers } from './splunk'; - -const ruleResourceIdentifiers: Record = { - splunk: splResourceIdentifiers, -}; - -export const getRuleResourceIdentifier = (vendor: OriginalRuleVendor): ResourceIdentifiers => { - return ruleResourceIdentifiers[vendor]; -}; - -export class ResourceIdentifier { - private identifiers: ResourceIdentifiers; - - constructor(vendor: OriginalRuleVendor) { - // The constructor may need query_language as an argument for other vendors - this.identifiers = ruleResourceIdentifiers[vendor]; - } - - public fromOriginalRule(originalRule: OriginalRule): RuleMigrationResourceBase[] { - return this.identifiers.fromOriginalRule(originalRule); - } - - public fromResource(resource: RuleMigrationResourceData): RuleMigrationResourceBase[] { - return this.identifiers.fromResource(resource); - } - - public fromOriginalRules(originalRules: OriginalRule[]): RuleMigrationResourceBase[] { - const lookups = new Set(); - const macros = new Set(); - originalRules.forEach((rule) => { - const resources = this.identifiers.fromOriginalRule(rule); - resources.forEach((resource) => { - if (resource.type === 'macro') { - macros.add(resource.name); - } else if (resource.type === 'lookup') { - lookups.add(resource.name); - } - }); - }); - return [ - ...Array.from(macros).map((name) => ({ type: 'macro', name })), - ...Array.from(lookups).map((name) => ({ type: 'lookup', name })), - ]; - } - - public fromResources(resources: RuleMigrationResourceData[]): RuleMigrationResourceBase[] { - const lookups = new Set(); - const macros = new Set(); - resources.forEach((resource) => { - this.identifiers.fromResource(resource).forEach((identifiedResource) => { - if (identifiedResource.type === 'macro') { - macros.add(identifiedResource.name); - } else if (identifiedResource.type === 'lookup') { - lookups.add(identifiedResource.name); - } - }); - }); - return [ - ...Array.from(macros).map((name) => ({ type: 'macro', name })), - ...Array.from(lookups).map((name) => ({ type: 'lookup', name })), - ]; - } -} +export * from './resource_identifier'; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/resource_identifier.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/resource_identifier.ts new file mode 100644 index 0000000000000..bf376fb58c301 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/resource_identifier.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ +// TODO: move resource related types to migration.gen.ts +import type { RuleMigrationResourceBase, RuleMigrationRule } from '../../model/rule_migration.gen'; +import { ResourceIdentifier } from '../../resources/resource_identifier'; +import type { SiemMigrationVendor } from '../../types'; + +export class RuleResourceIdentifier extends ResourceIdentifier { + protected getVendor(): SiemMigrationVendor { + return this.item.original_rule.vendor; + } + + public fromOriginal(rule?: RuleMigrationRule): RuleMigrationResourceBase[] { + const originalRule = rule?.original_rule ?? this.item.original_rule; + return this.identifier(originalRule.query); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/index.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/index.ts deleted file mode 100644 index a16c328da947a..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/resources/splunk/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ResourceIdentifiers } from '../types'; -import { splResourceIdentifier } from './splunk_identifier'; - -export const splResourceIdentifiers: ResourceIdentifiers = { - fromOriginalRule: (originalRule) => splResourceIdentifier(originalRule.query), - fromResource: (resource) => { - if (resource.type === 'macro' && resource.content) { - return splResourceIdentifier(resource.content); - } - return []; - }, -}; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/types.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/types.ts new file mode 100644 index 0000000000000..f3bcaac24bfc4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/rules/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SiemMigrationFilters } from '../types'; + +export interface RuleMigrationFilters extends SiemMigrationFilters { + searchTerm?: string; + installed?: boolean; + installable?: boolean; + prebuilt?: boolean; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/types.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/types.ts index 7b275975695c7..4ac9cbb2c6ddc 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/types.ts @@ -5,17 +5,28 @@ * 2.0. */ +import type { + DashboardMigration, + DashboardMigrationDashboard, + OriginalDashboardVendor, +} from './model/dashboard_migration.gen'; +import type { + OriginalRuleVendor, + RuleMigration, + RuleMigrationRule, +} from './model/rule_migration.gen'; import type { SiemMigrationStatus } from './constants'; -export interface RuleMigrationFilters { +export interface SiemMigrationFilters { status?: SiemMigrationStatus | SiemMigrationStatus[]; ids?: string[]; - installed?: boolean; - installable?: boolean; - prebuilt?: boolean; failed?: boolean; fullyTranslated?: boolean; partiallyTranslated?: boolean; untranslatable?: boolean; - searchTerm?: string; } + +export type SiemMigrationVendor = OriginalRuleVendor | OriginalDashboardVendor; + +export type MigrationDocument = RuleMigration | DashboardMigration; +export type ItemDocument = RuleMigrationRule | DashboardMigrationDashboard; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts index c4fcbb22f2c00..0e7a83ccdcae6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/api/index.ts @@ -8,7 +8,7 @@ import { replaceParams } from '@kbn/openapi-common/shared'; import type { UpdateRuleMigrationRule } from '../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationFilters } from '../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../common/siem_migrations/rules/types'; import type { LangSmithOptions } from '../../../../common/siem_migrations/model/common.gen'; import { KibanaServices } from '../../../common/lib/kibana'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx index fdfd55547aa11..1dc1313da5af7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/index.tsx @@ -18,7 +18,7 @@ import { } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; -import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/rules/types'; import { useIsOpenState } from '../../../../common/hooks/use_is_open_state'; import type { RelatedIntegration, RuleResponse } from '../../../../../common/api/detection_engine'; import { isMigrationPrebuiltRule } from '../../../../../common/siem_migrations/rules/utils'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/utils/filters.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/utils/filters.ts index 885b6085bf110..8e49eb5144f68 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/utils/filters.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/rules_table/utils/filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RuleMigrationFilters } from '../../../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../../../common/siem_migrations/rules/types'; import type { FilterOptions } from '../../../types'; import { AuthorFilter, StatusFilter } from '../../../types'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/status_badge/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/status_badge/index.tsx index 44361ccf672c5..eb8d0d0bf9599 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/status_badge/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/status_badge/index.tsx @@ -9,8 +9,10 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiIcon, EuiToolTip } from '@elastic/eui'; import { css } from '@emotion/css'; -import { MigrationStatusEnum } from '../../../../../common/siem_migrations/model/common.gen'; -import { RuleTranslationResult } from '../../../../../common/siem_migrations/constants'; +import { + RuleTranslationResult, + SiemMigrationStatus, +} from '../../../../../common/siem_migrations/constants'; import { convertTranslationResultIntoText, useResultVisColors, @@ -48,7 +50,7 @@ export const StatusBadge: React.FC = React.memo( } // Failed - if (migrationRule.status === MigrationStatusEnum.failed) { + if (migrationRule.status === SiemMigrationStatus.FAILED) { const tooltipMessage = migrationRule.comments?.length ? migrationRule.comments[0].message : i18n.RULE_STATUS_FAILED; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/logic/use_get_migration_rules.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/logic/use_get_migration_rules.ts index 117ddd83d3e70..97717aa4439e1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/logic/use_get_migration_rules.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/logic/use_get_migration_rules.ts @@ -8,7 +8,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { replaceParams } from '@kbn/openapi-common/shared'; import { useCallback } from 'react'; -import type { RuleMigrationFilters } from '../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../common/siem_migrations/rules/types'; import { SIEM_RULE_MIGRATION_RULES_PATH } from '../../../../common/siem_migrations/constants'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import * as i18n from './translations'; diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts b/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts index e2c234e01a84d..d472de34a8161 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts @@ -19,7 +19,7 @@ import type { InferenceServerStart } from '@kbn/inference-plugin/server'; import { getGenerateEsqlGraph as getGenerateEsqlAgent } from '../../server/assistant/tools/esql/graphs/generate_esql/generate_esql'; import { getRuleMigrationAgent } from '../../server/lib/siem_migrations/rules/task/agent'; import type { RuleMigrationsRetriever } from '../../server/lib/siem_migrations/rules/task/retrievers'; -import type { EsqlKnowledgeBase } from '../../server/lib/siem_migrations/rules/task/util/esql_knowledge_base'; +import type { EsqlKnowledgeBase } from '../../server/lib/siem_migrations/common/task/util/esql_knowledge_base'; import type { RuleMigrationTelemetryClient } from '../../server/lib/siem_migrations/rules/task/rule_migrations_telemetry_client'; import type { CreateLlmInstance } from '../../server/assistant/tools/esql/utils/common'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/retry.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/retry.ts index 3f2ce8dafd97e..d43be51bd0067 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/retry.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/api/util/retry.ts @@ -6,7 +6,7 @@ */ import type { RuleMigrationRetryFilter } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationFilters } from '../../../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../../../common/siem_migrations/rules/types'; const RETRY_FILTERS: Record = { failed: { failed: true }, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/mocks.ts new file mode 100644 index 0000000000000..17ff2627f79b3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/mocks.ts @@ -0,0 +1,84 @@ +/* + * 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 { SiemMigrationsDataClient } from '../siem_migrations_data_client'; +import type { SiemMigrationsDataMigrationClient } from '../siem_migrations_data_migration_client'; +import type { SiemMigrationsDataResourcesClient } from '../siem_migrations_data_resources_client'; +import type { SiemMigrationsDataItemClient } from '../siem_migrations_data_item_client'; + +// Rule migrations data items client +export const mockSiemMigrationsDataItemClient = { + create: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue({ data: [], total: 0 }), + searchBatches: jest.fn().mockReturnValue({ + next: jest.fn().mockResolvedValue([]), + all: jest.fn().mockResolvedValue([]), + }), + saveProcessing: jest.fn().mockResolvedValue(undefined), + saveCompleted: jest.fn().mockResolvedValue(undefined), + saveError: jest.fn().mockResolvedValue(undefined), + releaseProcessing: jest.fn().mockResolvedValue(undefined), + updateStatus: jest.fn().mockResolvedValue(undefined), + getStats: jest.fn().mockResolvedValue(undefined), + getAllStats: jest.fn().mockResolvedValue([]), +} as unknown as jest.Mocked; +export const MockSiemMigrationsDataItemClient = jest + .fn() + .mockImplementation(() => mockSiemMigrationsDataItemClient); + +// Rule migrations data resources client +export const mockSiemMigrationsDataResourcesClient = { + upsert: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(undefined), + searchBatches: jest.fn().mockReturnValue({ + next: jest.fn().mockResolvedValue([]), + all: jest.fn().mockResolvedValue([]), + }), +} as unknown as jest.Mocked; +export const MockSiemMigrationsDataResourcesClient = jest + .fn() + .mockImplementation(() => mockSiemMigrationsDataResourcesClient); + +export const mockSiemMigrationsDataMigrationsClient = { + create: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(undefined), + getAll: jest.fn().mockResolvedValue([]), + saveAsStarted: jest.fn().mockResolvedValue(undefined), + saveAsFinished: jest.fn().mockResolvedValue(undefined), + saveAsFailed: jest.fn().mockResolvedValue(undefined), + setIsStopped: jest.fn().mockResolvedValue(undefined), + updateLastExecution: jest.fn().mockResolvedValue(undefined), +} as unknown as jest.Mocked; + +export const mockDeleteMigration = jest.fn().mockResolvedValue(undefined); + +// Rule migrations data client +export const createSiemMigrationsDataClientMock = () => + ({ + items: mockSiemMigrationsDataItemClient, + resources: mockSiemMigrationsDataResourcesClient, + migrations: mockSiemMigrationsDataMigrationsClient, + deleteMigration: mockDeleteMigration, + } as unknown as jest.MockedObjectDeep); + +export const MockSiemMigrationsDataClient = jest + .fn() + .mockImplementation(() => createSiemMigrationsDataClientMock()); + +// Rule migrations data service +export const mockIndexName = 'mocked_siem_siem_migrations_index_name'; +export const mockInstall = jest.fn().mockResolvedValue(undefined); +export const mockCreateClient = jest.fn(() => createSiemMigrationsDataClientMock()); +export const mockSetup = jest.fn().mockResolvedValue(undefined); + +export const MockSiemMigrationsDataService = jest.fn().mockImplementation(() => ({ + createAdapter: jest.fn(), + install: mockInstall, + createClient: mockCreateClient, + createIndexNameProvider: jest.fn().mockResolvedValue(mockIndexName), + setup: mockSetup, +})); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_client.ts new file mode 100644 index 0000000000000..c8e0057f3cfe1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockSiemMigrationsDataClient } from './mocks'; +export const SiemMigrationsDataClient = MockSiemMigrationsDataClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_prebuilt_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_prebuilt_rules_client.ts new file mode 100644 index 0000000000000..1061adc52eced --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_prebuilt_rules_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { mockRuleMigrationsDataPrebuiltRulesClient } from './mocks'; +export const RuleMigrationsDataPrebuiltRulesClient = mockRuleMigrationsDataPrebuiltRulesClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_resources_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_resources_client.ts new file mode 100644 index 0000000000000..96fc5b47fb1cc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_resources_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsDataResourcesClient } from './mocks'; +export const RuleMigrationsDataResourcesClient = MockRuleMigrationsDataResourcesClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_rules_client.ts new file mode 100644 index 0000000000000..a7a6a29c17cbe --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_rules_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsDataRulesClient } from './mocks'; +export const RuleMigrationsDataRulesClient = MockRuleMigrationsDataRulesClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_service.ts new file mode 100644 index 0000000000000..e53eec629e3f1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/__mocks__/siem_migrations_data_service.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockSiemMigrationsDataService } from './mocks'; +export const SiemMigrationsDataService = MockSiemMigrationsDataService; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/mocks.ts new file mode 100644 index 0000000000000..a9f3daa65a1d5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/mocks.ts @@ -0,0 +1,125 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import { FakeLLM } from '@langchain/core/utils/testing'; +import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons'; +import type { SiemMigrationTelemetryClient } from '../siem_migrations_telemetry_client'; +import type { BaseLLMParams } from '@langchain/core/language_models/llms'; + +export const createSiemMigrationTelemetryClientMock = () => { + // Mock for the object returned by startSiemMigrationTask + const mockStartItemTranslationReturn = { + success: jest.fn(), + failure: jest.fn(), + }; + + // Mock for the function returned by startSiemMigrationTask + const mockStartItemTranslation = jest.fn().mockReturnValue(mockStartItemTranslationReturn); + + // Mock for startSiemMigrationTask return value + const mockStartSiemMigrationTaskReturn = { + startItemTranslation: mockStartItemTranslation, + success: jest.fn(), + failure: jest.fn(), + aborted: jest.fn(), + }; + + return { + startSiemMigrationTask: jest.fn().mockReturnValue(mockStartSiemMigrationTaskReturn), + } as jest.Mocked>; +}; + +// Factory function for the mock class +export const MockSiemMigrationTelemetryClient = jest + .fn() + .mockImplementation(() => createSiemMigrationTelemetryClientMock()); + +export const createSiemMigrationsTaskClientMock = () => ({ + start: jest.fn().mockResolvedValue({ started: true }), + stop: jest.fn().mockResolvedValue({ stopped: true }), + getStats: jest.fn().mockResolvedValue({ + status: 'done', + rules: { + total: 1, + finished: 1, + processing: 0, + pending: 0, + failed: 0, + }, + }), + getAllStats: jest.fn().mockResolvedValue([]), +}); + +export const MockSiemMigrationsTaskClient = jest + .fn() + .mockImplementation(() => createSiemMigrationsTaskClientMock()); + +// Siem migrations task service +export const mockStopAll = jest.fn(); +export const mockCreateClient = jest.fn(() => createSiemMigrationsTaskClientMock()); + +export const MockSiemMigrationsTaskService = jest.fn().mockImplementation(() => ({ + createClient: mockCreateClient, + stopAll: mockStopAll, +})); + +export interface NodeResponse { + nodeId: string; + response: string; +} + +interface SiemMigrationFakeLLMParams extends BaseLLMParams { + nodeResponses: NodeResponse[]; +} + +export class SiemMigrationFakeLLM extends FakeLLM { + private nodeResponses: NodeResponse[]; + private defaultResponse: string; + private callCount: Map; + private totalCount: number; + + constructor(fields: SiemMigrationFakeLLMParams) { + super({ + response: 'unexpected node call', + ...fields, + }); + this.nodeResponses = fields.nodeResponses; + this.defaultResponse = 'unexpected node call'; + this.callCount = new Map(); + this.totalCount = 0; + } + + _llmType(): string { + return 'fake'; + } + + async _call(prompt: string, _options: this['ParsedCallOptions']): Promise { + // Get the current runnable config metadata + const item = AsyncLocalStorageProviderSingleton.getRunnableConfig(); + for (const nodeResponse of this.nodeResponses) { + if (item.metadata.langgraph_node === nodeResponse.nodeId) { + const currentCount = this.callCount.get(nodeResponse.nodeId) || 0; + this.callCount.set(nodeResponse.nodeId, currentCount + 1); + this.totalCount += 1; + return nodeResponse.response; + } + } + return this.defaultResponse; + } + + getNodeCallCount(nodeId: string): number { + return this.callCount.get(nodeId) || 0; + } + + getTotalCallCount(): number { + return this.totalCount; + } + + resetCallCounts(): void { + this.callCount.clear(); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_client.ts new file mode 100644 index 0000000000000..b900fe857e099 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockSiemMigrationsTaskClient } from './mocks'; +export const SiemMigrationsTaskClient = MockSiemMigrationsTaskClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_service.ts new file mode 100644 index 0000000000000..6dd8ea2f6d62a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_task_service.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockSiemMigrationsTaskService } from './mocks'; +export const SiemMigrationsTaskService = MockSiemMigrationsTaskService; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_telemetry_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_telemetry_client.ts new file mode 100644 index 0000000000000..199e630d2f1de --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/__mocks__/siem_migrations_telemetry_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockSiemMigrationTelemetryClient } from './mocks'; +export const SiemMigrationTelemetryClient = MockSiemMigrationTelemetryClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/cim_ecs_map.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/cim_ecs_map.ts similarity index 99% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/cim_ecs_map.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/cim_ecs_map.ts index 3bafaf2fc6518..8514b6ab393d9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/cim_ecs_map.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/cim_ecs_map.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const SIEM_RULE_MIGRATION_CIM_ECS_MAP = ` +export const CIM_TO_ECS_MAP = ` datamodel,object,source_field,ecs_field,data_type Application_State,All_Application_State,dest,service.node.name,string Application_State,All_Application_State,process,process.title,string diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/convert_esql_schema_cim_to_ecs.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/convert_esql_schema_cim_to_ecs.ts new file mode 100644 index 0000000000000..8f20c3de2f77d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/convert_esql_schema_cim_to_ecs.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 type { Logger } from '@kbn/core/server'; +import type { MigrationComments } from '../../../../../../../../common/siem_migrations/model/migration.gen'; +import type { EsqlKnowledgeBase } from '../../../util/esql_knowledge_base'; +import { CIM_TO_ECS_MAP } from './cim_ecs_map'; +import { ESQL_CONVERT_CIM_TO_ECS_PROMPT } from './prompts'; +import { cleanMarkdown, generateAssistantComment } from '../../../util/comments'; +import type { NodeToolCreator } from '../types'; + +export interface GetConvertEsqlSchemaCisToEcsParams { + esqlKnowledgeBase: EsqlKnowledgeBase; + logger: Logger; +} +export interface ConvertEsqlSchemaCisToEcsInput { + title: string; + description: string; + query: string; + originalQuery: string; +} +export interface ConvertEsqlSchemaCisToEcsOutput { + query: string | undefined; + comments: MigrationComments; +} + +export const getConvertEsqlSchemaCisToEcs: NodeToolCreator< + GetConvertEsqlSchemaCisToEcsParams, + ConvertEsqlSchemaCisToEcsInput, + ConvertEsqlSchemaCisToEcsOutput +> = ({ esqlKnowledgeBase, logger }) => { + return async (input) => { + const esqlQuery = { + title: input.title, + description: input.description, + query: input.query, + }; + + const prompt = await ESQL_CONVERT_CIM_TO_ECS_PROMPT.format({ + field_mapping: CIM_TO_ECS_MAP, + spl_query: input.originalQuery, + esql_query: JSON.stringify(esqlQuery, null, 2), + }); + + const response = await esqlKnowledgeBase.translate(prompt); + + const updatedQuery = response.match(/```esql\n([\s\S]*?)\n```/)?.[1] ?? ''; + if (!updatedQuery) { + logger.warn('Failed to apply ECS mapping to the query'); + const summary = '## Field Mapping Summary\n\nFailed to apply ECS mapping to the query'; + return { + query: undefined, // No updated query if ECS mapping failed + comments: [generateAssistantComment(summary)], + }; + } + + const ecsSummary = response.match(/## Field Mapping Summary[\s\S]*$/)?.[0] ?? ''; + + // We set success to true to indicate that the ecs mapping has been applied. + // This is to ensure that the node only runs once + return { + comments: [generateAssistantComment(cleanMarkdown(ecsSummary))], + query: updatedQuery, + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/index.ts new file mode 100644 index 0000000000000..3d85289bf2b21 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/index.ts @@ -0,0 +1,7 @@ +/* + * 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 * from './convert_esql_schema_cim_to_ecs'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/prompts.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/prompts.ts similarity index 97% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/prompts.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/prompts.ts index 1e89cda884ca0..41a8f3954fde8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/prompts.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/convert_esql_schema_cim_to_ecs/prompts.ts @@ -7,7 +7,7 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; -export const ESQL_TRANSLATE_ECS_MAPPING_PROMPT = +export const ESQL_CONVERT_CIM_TO_ECS_PROMPT = ChatPromptTemplate.fromTemplate(`You are a helpful cybersecurity (SIEM) expert agent. Your task is to migrate "detection rules" from Splunk SPL to Elasticsearch ESQL. Your task is to look at the new ESQL query already generated from its initial Splunk SPL query and translate the Splunk CIM field names to the Elastic Common Schema (ECS) fields. Below is the relevant context used when deciding which Elastic Common Schema field to use when translating from Splunk CIM fields: @@ -17,10 +17,10 @@ Below is the relevant context used when deciding which Elastic Common Schema fie {field_mapping} -{splunk_query} +{spl_query} -{elastic_rule} +{esql_query} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/fix_esql_query_errors.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/fix_esql_query_errors.ts new file mode 100644 index 0000000000000..7e32a2ed71ddb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/fix_esql_query_errors.ts @@ -0,0 +1,49 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import type { NodeToolCreator } from '../types'; +import type { EsqlKnowledgeBase } from '../../../util/esql_knowledge_base'; +import { RESOLVE_ESQL_ERRORS_TEMPLATE } from './prompts'; + +export interface GetFixEsqlQueryErrorsParams { + esqlKnowledgeBase: EsqlKnowledgeBase; + logger: Logger; +} +export interface FixEsqlQueryErrorsInput { + invalidQuery?: string; + validationErrors?: string; +} +export interface FixEsqlQueryErrorsOutput { + query?: string; +} + +export const getFixEsqlQueryErrors: NodeToolCreator< + GetFixEsqlQueryErrorsParams, + FixEsqlQueryErrorsInput, + FixEsqlQueryErrorsOutput +> = ({ esqlKnowledgeBase, logger }) => { + return async (input) => { + if (!input.validationErrors) { + logger.debug('Trying to fix errors without validationErrors'); + return {}; + } + if (!input.invalidQuery) { + logger.debug('Trying to fix errors without invalidQuery'); + return {}; + } + + const prompt = await RESOLVE_ESQL_ERRORS_TEMPLATE.format({ + esql_errors: input.validationErrors, + esql_query: input.invalidQuery, + }); + const response = await esqlKnowledgeBase.translate(prompt); + + const query = response.match(/```esql\n([\s\S]*?)\n```/)?.[1]; + return { query }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/index.ts new file mode 100644 index 0000000000000..361b44bb22b99 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/index.ts @@ -0,0 +1,7 @@ +/* + * 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 * from './fix_esql_query_errors'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/fix_query_errors/prompts.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/prompts.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/fix_query_errors/prompts.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/fix_esql_query_errors/prompts.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/index.ts new file mode 100644 index 0000000000000..a0b2989ff407e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/index.ts @@ -0,0 +1,7 @@ +/* + * 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 * from './inline_spl_query'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts new file mode 100644 index 0000000000000..350a9c683d6b6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts @@ -0,0 +1,92 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { StringOutputParser } from '@langchain/core/output_parsers'; +import { isEmpty } from 'lodash/fp'; +import type { MigrationComments } from '../../../../../../../../common/siem_migrations/model/migration.gen'; +import type { ChatModel } from '../../../util/actions_client_chat'; +import { cleanMarkdown, generateAssistantComment } from '../../../util/comments'; +import type { MigrationResources } from '../../../retrievers/resource_retriever'; +import type { NodeToolCreator } from '../types'; +import { REPLACE_QUERY_RESOURCE_PROMPT, getResourcesContext } from './prompts'; + +export interface GetInlineSplQueryParams { + model: ChatModel; + logger: Logger; +} +export interface InlineSplQueryInput { + query: string; + resources: MigrationResources; +} +export interface InlineSplQueryOutput { + inlineQuery?: string | undefined; + isUnsupported?: boolean; + comments: MigrationComments; +} + +export const getInlineSplQuery: NodeToolCreator< + GetInlineSplQueryParams, + InlineSplQueryInput, + InlineSplQueryOutput +> = ({ model, logger }) => { + return async ({ query, resources }) => { + // Early check to avoid unnecessary LLM calls + let unsupportedComment = getUnsupportedComment(query); + if (unsupportedComment) { + return { + isUnsupported: true, + comments: [generateAssistantComment(unsupportedComment)], + }; + } + + if (isEmpty(resources)) { + // No resources identified in the query, no need to replace anything + const summary = '## Inlining Summary\n\nNo macro or lookup found in the query.'; + return { inlineQuery: query, comments: [generateAssistantComment(summary)] }; + } + + const replaceQueryParser = new StringOutputParser(); + const replaceQueryResourcePrompt = + REPLACE_QUERY_RESOURCE_PROMPT.pipe(model).pipe(replaceQueryParser); + const resourceContext = getResourcesContext(resources); + const response = await replaceQueryResourcePrompt.invoke({ + query, + macros: resourceContext.macros, + lookups: resourceContext.lookups, + }); + + const inlineQuery = response.match(/```spl\n([\s\S]*?)\n```/)?.[1].trim() ?? ''; + if (!inlineQuery) { + logger.warn('Failed to retrieve inline query'); + const summary = '## Inlining Summary\n\nFailed to retrieve inline query'; + return { comments: [generateAssistantComment(summary)] }; + } + + // Check after replacing in case the inlining made it untranslatable + unsupportedComment = getUnsupportedComment(inlineQuery); + if (unsupportedComment) { + return { + isUnsupported: true, + comments: [generateAssistantComment(unsupportedComment)], + }; + } + + const inliningSummary = response.match(/## Inlining Summary[\s\S]*$/)?.[0] ?? ''; + return { + inlineQuery, + comments: [generateAssistantComment(cleanMarkdown(inliningSummary))], + }; + }; +}; + +const getUnsupportedComment = (query: string): string | undefined => { + const unsupportedText = '## Translation Summary\nCan not create custom translation.\n'; + if (query.includes(' inputlookup')) { + return `${unsupportedText}Reason: \`inputlookup\` command is not supported.`; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/prompts.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/prompts.ts similarity index 96% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/prompts.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/prompts.ts index 290a2d4e0e05e..d2581edd9c2f9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/prompts.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/prompts.ts @@ -6,14 +6,14 @@ */ import { ChatPromptTemplate } from '@langchain/core/prompts'; -import type { RuleMigrationResources } from '../../../../../retrievers/rule_resource_retriever'; +import type { MigrationResources } from '../../../retrievers/resource_retriever'; interface ResourceContext { macros: string; lookups: string; } -export const getResourcesContext = (resources: RuleMigrationResources): ResourceContext => { +export const getResourcesContext = (resources: MigrationResources): ResourceContext => { const context: ResourceContext = { macros: '', lookups: '' }; // Process macros diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/index.ts new file mode 100644 index 0000000000000..2fd911b4b6a53 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/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 * from './translate_rule'; +export { TASK_DESCRIPTION } from './prompts'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/prompts.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/prompts.ts similarity index 58% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/prompts.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/prompts.ts index 23a6829e6235d..d5aea1c5cf833 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/prompts.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/prompts.ts @@ -7,15 +7,29 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; +export const TASK_DESCRIPTION = { + migrate_rule: `Your task is to migrate a "detection rule" SPL search from Splunk to an Elasticsearch ES|QL query.`, + migrate_dashboard: `Your task is to migrate a "dashboard" SPL search from Splunk to an Elasticsearch ES|QL query.`, +}; + export const ESQL_SYNTAX_TRANSLATION_PROMPT = - ChatPromptTemplate.fromTemplate(`You are a helpful cybersecurity (SIEM) expert agent. Your task is to migrate "detection rules" from Splunk SPL to Elasticsearch ES|QL. -Your goal is to translate the SPL query syntax into an equivalent Elastic Search Query Language (ES|QL) query without changing any of the field names except lookup lists and macros when relevant and focusing only on translating the syntax and structure. + ChatPromptTemplate.fromTemplate(`You are a helpful cybersecurity (SIEM) expert agent. {task_description} +Your goal is to translate the SPL query syntax into an equivalent Elastic Search Query Language (ES|QL) query without changing any of the field names, except for lookup lists when relevant, and focusing only on translating the syntax and structure. +Also you'll need to write a summary at the end in markdown language. Here are some context for you to reference for your task, read it carefully as you will get questions about it later: - -{splunk_rule} - + +{splunk_query} + + +If you encounter any placeholders for macros or lookups in the SPL query, leave them as-is in the ES|QL query output. They are markers that need to be preserved. +They are wrapped in brackets ("[]") and always start with "macro:" or "lookup:". Mention all placeholders you left in the final summary. +Examples of macros and lookups placeholders: +- [macro:someMacroName(3)] +- [macro:another_macro] +- [lookup:someLookup_name] + If in an SPL query you identify a lookup call, it should be translated the following way: \`\`\`spl @@ -30,11 +44,13 @@ However in the ES|QL query, some of the information is removed and should be use ... | LOOKUP JOIN 'index_name' ON 'field_to_match' \`\`\` We do not define OUTPUTNEW or which fields is returned, only the index name and the field to match. + +Mention all translated lookups in the final summary. -Go through each step and part of the splunk rule and query while following the below guide to produce the resulting ES|QL query: -- Analyze all the information about the related splunk rule and try to determine the intent of the rule, in order to translate into an equivalent ES|QL rule. +Go through each step and part of the splunk_query while following the below guide to produce the resulting ES|QL query: +- Analyze all the information about the related splunk query and try to determine the intent of the query, in order to translate into an equivalent ES|QL query. - Go through each part of the SPL query and determine the steps required to produce the same end results using ES|QL. Only focus on translating the structure without modifying any of the field names. - Do NOT map any of the fields to the Elastic Common Schema (ECS), this will happen in a later step. - Always remember to translate any lookup list using the lookup_syntax above @@ -42,9 +58,9 @@ Go through each step and part of the splunk rule and query while following the b - Analyze the SPL query and identify the key components. - Do NOT translate the field names of the SPL query. -- Always start the resulting ES|QL query by filtering using FROM and with these index pattern: {indexPatterns}. -- Always remember to translate any lookup list using the lookup_syntax above -- Always remember to replace macro call with the appropriate placeholder as defined in the macro info. +- Always start the resulting ES|QL query by filtering using FROM and with the following index pattern: {index_pattern} +- Always remember to leave placeholders defined in the placeholders_syntax context as they are, don't replace them. +- Always remember to translate any lookup (that are not inside a placeholder) using the lookup_syntax rules above. diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/translate_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/translate_rule.ts new file mode 100644 index 0000000000000..cc09c9f78e4fc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/translate_spl_to_esql/translate_rule.ts @@ -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 type { Logger } from '@kbn/core/server'; +import type { MigrationComments } from '../../../../../../../../common/siem_migrations/model/migration.gen'; +import { cleanMarkdown, generateAssistantComment } from '../../../util/comments'; +import type { EsqlKnowledgeBase } from '../../../util/esql_knowledge_base'; +import { ESQL_SYNTAX_TRANSLATION_PROMPT } from './prompts'; +import type { NodeToolCreator } from '../types'; + +export interface GetTranslateSplToEsqlParams { + esqlKnowledgeBase: EsqlKnowledgeBase; + logger: Logger; +} + +export interface TranslateSplToEsqlInput { + title: string; + taskDescription: string; + description: string; + inlineQuery: string; + indexPattern: string; +} +export interface TranslateSplToEsqlOutput { + esqlQuery?: string; + comments: MigrationComments; +} + +export const getTranslateSplToEsql: NodeToolCreator< + GetTranslateSplToEsqlParams, + TranslateSplToEsqlInput, + TranslateSplToEsqlOutput +> = ({ esqlKnowledgeBase, logger }) => { + return async (input) => { + const splunkQuery = { + title: input.title, + description: input.description, + inline_query: input.inlineQuery, + }; + + const prompt = await ESQL_SYNTAX_TRANSLATION_PROMPT.format({ + splunk_query: JSON.stringify(splunkQuery, null, 2), + index_pattern: input.indexPattern, + task_description: input.taskDescription, + }); + const response = await esqlKnowledgeBase.translate(prompt); + + const esqlQuery = response.match(/```esql\n([\s\S]*?)\n```/)?.[1].trim() ?? ''; + if (!esqlQuery) { + logger.warn('Failed to extract ESQL query from translation response'); + const comment = + '## Translation Summary\n\nFailed to extract ESQL query from translation response'; + return { + comments: [generateAssistantComment(comment)], + }; + } + + const translationSummary = response.match(/## Translation Summary[\s\S]*$/)?.[0] ?? ''; + + return { + esqlQuery, + comments: [generateAssistantComment(cleanMarkdown(translationSummary))], + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/types.ts new file mode 100644 index 0000000000000..aee3340345d70 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/types.ts @@ -0,0 +1,11 @@ +/* + * 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 type NodeTool = (input: I) => Promise; +export type NodeToolCreator

= ( + params: P +) => NodeTool; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/index.ts new file mode 100644 index 0000000000000..a8c0f55191e42 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getValidationNode } from './validation'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts new file mode 100644 index 0000000000000..89fbeaa6a9ecb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts @@ -0,0 +1,59 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { isEmpty } from 'lodash/fp'; +import { parseEsqlQuery } from '@kbn/securitysolution-utils'; +import type { NodeToolCreator } from '../types'; + +export interface GetValidateEsqlParams { + logger: Logger; +} + +export interface ValidateEsqlInput { + query: string; +} + +export interface ValidateEsqlOutput { + error?: string; +} + +export const getValidateEsql: NodeToolCreator< + GetValidateEsqlParams, + ValidateEsqlInput, + ValidateEsqlOutput +> = ({ logger }) => { + return async (input) => { + // We want to prevent infinite loops, so we increment the iterations counter for each validation run. + let error: string = ''; + try { + const sanitizedQuery = input.query ? removePlaceHolders(input.query) : ''; + if (!isEmpty(sanitizedQuery)) { + const { errors, isEsqlQueryAggregating, hasMetadataOperator } = + parseEsqlQuery(sanitizedQuery); + if (!isEmpty(errors)) { + error = JSON.stringify(errors); + } else if (!isEsqlQueryAggregating && !hasMetadataOperator) { + error = `Queries that do't use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* metadata _id, _version, _index.`; + } + } + if (error) { + logger.debug(`ESQL query validation failed: ${error}`); + } + } catch (err) { + error = err.message.toString(); + logger.info(`Error parsing ESQL query: ${error}`); + } + return { error }; + }; +}; + +function removePlaceHolders(query: string): string { + return query + .replaceAll(/\[(macro|lookup):.*?\]/g, '') // Removes any macro or lookup placeholders + .replaceAll(/\n(\s*?\|\s*?\n)*/g, '\n'); // Removes any empty lines with | (pipe) alone after removing the placeholders +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.test.ts new file mode 100644 index 0000000000000..7602ccfe03d64 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { ResourceRetriever } from './resource_retriever'; // Adjust path as needed +import type { ResourceIdentifierClass } from '../../../../../../common/siem_migrations/resources/resource_identifier'; +import { ResourceIdentifier } from '../../../../../../common/siem_migrations/resources/resource_identifier'; +import type { SiemMigrationsDataResourcesClient } from '../../data/siem_migrations_data_resources_client'; +import type { ItemDocument } from '../../../../../../common/siem_migrations/types'; + +jest.mock('../../data/siem_migrations_data_resources_client'); +jest.mock('../../../../../../common/siem_migrations/resources/resource_identifier'); + +const migrationItem = {} as unknown as ItemDocument; + +const MockResourceIdentifier = ResourceIdentifier as jest.MockedClass; + +class TestResourceRetriever extends ResourceRetriever { + protected ResourceIdentifierClass = MockResourceIdentifier; +} + +const defaultResourceIdentifier = () => + ({ + getVendor: jest.fn().mockReturnValue('splunk'), + fromOriginal: jest.fn().mockReturnValue([]), + fromResources: jest.fn().mockReturnValue([]), + } as unknown as jest.Mocked); + +describe('ResourceRetriever', () => { + let retriever: ResourceRetriever; + let mockDataClient: jest.Mocked; + let mockResourceIdentifier: jest.Mocked; + + beforeEach(() => { + mockDataClient = { + searchBatches: jest.fn().mockReturnValue({ next: jest.fn(() => []) }), + } as unknown as jest.Mocked; + + retriever = new TestResourceRetriever('mockMigrationId', mockDataClient); + + MockResourceIdentifier.mockImplementation(defaultResourceIdentifier); + mockResourceIdentifier = new MockResourceIdentifier( + migrationItem + ) as jest.Mocked; + }); + + it('throws an error if initialize is not called before getResources', async () => { + await expect(retriever.getResources(migrationItem)).rejects.toThrow( + 'initialize must be called before calling getResources' + ); + }); + + it('returns an empty object if no matching resources are found', async () => { + // Mock the resource identifier to return no resources + mockResourceIdentifier.fromOriginal.mockReturnValue([]); + await retriever.initialize(); // Pretend initialize has been called + + const result = await retriever.getResources(migrationItem); + expect(result).toEqual({}); + }); + + it('returns matching macro and lookup resources', async () => { + const mockExistingResources = { + macro: { macro1: { name: 'macro1', type: 'macro' } }, + lookup: { lookup1: { name: 'lookup1', type: 'lookup' } }, + }; + // Inject existing resources manually + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (retriever as any).existingResources = mockExistingResources; + + const mockResourcesIdentified = [ + { name: 'macro1', type: 'macro' as const }, + { name: 'lookup1', type: 'lookup' as const }, + ]; + MockResourceIdentifier.mockImplementation( + () => + ({ + ...defaultResourceIdentifier(), + fromOriginal: jest.fn().mockReturnValue(mockResourcesIdentified), + } as unknown as jest.Mocked) + ); + + const result = await retriever.getResources(migrationItem); + expect(result).toEqual({ + macro: [{ name: 'macro1', type: 'macro' }], + lookup: [{ name: 'lookup1', type: 'lookup' }], + }); + }); + + it('handles nested resources properly', async () => { + const mockExistingResources = { + macro: { + macro1: { name: 'macro1', type: 'macro' }, + macro2: { name: 'macro2', type: 'macro' }, + }, + lookup: { + lookup1: { name: 'lookup1', type: 'lookup' }, + lookup2: { name: 'lookup2', type: 'lookup' }, + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (retriever as any).existingResources = mockExistingResources; + + const mockResourcesIdentifiedFromRule = [ + { name: 'macro1', type: 'macro' as const }, + { name: 'lookup1', type: 'lookup' as const }, + ]; + + const mockNestedResources = [ + { name: 'macro2', type: 'macro' as const }, + { name: 'lookup2', type: 'lookup' as const }, + ]; + + MockResourceIdentifier.mockImplementation( + () => + ({ + ...defaultResourceIdentifier(), + fromOriginal: jest.fn().mockReturnValue(mockResourcesIdentifiedFromRule), + fromResources: jest.fn().mockReturnValue([]).mockReturnValueOnce(mockNestedResources), + } as unknown as jest.Mocked) + ); + + const result = await retriever.getResources(migrationItem); + expect(result).toEqual({ + macro: [ + { name: 'macro1', type: 'macro' }, + { name: 'macro2', type: 'macro' }, + ], + lookup: [ + { name: 'lookup1', type: 'lookup' }, + { name: 'lookup2', type: 'lookup' }, + ], + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.ts new file mode 100644 index 0000000000000..989f89e1467f3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/retrievers/resource_retriever.ts @@ -0,0 +1,113 @@ +/* + * 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 { ResourceIdentifierClass } from '../../../../../../common/siem_migrations/resources/resource_identifier'; +// TODO: move resource related types to migration.gen.ts +import type { + RuleMigrationResource as MigrationResource, + RuleMigrationResourceType as MigrationResourceType, +} from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { SiemMigrationsDataResourcesClient } from '../../data/siem_migrations_data_resources_client'; +import type { ItemDocument } from '../../types'; + +export interface MigrationDefinedResource extends MigrationResource { + content: string; // ensures content exists +} +export type MigrationResourcesData = Pick; +export type MigrationResources = Partial>; +interface ExistingResources { + macro: Record; + lookup: Record; +} + +export abstract class ResourceRetriever { + protected abstract ResourceIdentifierClass: ResourceIdentifierClass; + + private existingResources?: ExistingResources; + + constructor( + private readonly migrationId: string, + private readonly resourcesDataClient: SiemMigrationsDataResourcesClient + ) {} + + public async initialize(): Promise { + const batches = this.resourcesDataClient.searchBatches( + this.migrationId, + { filters: { hasContent: true } } // filters out missing (undefined) content resources, empty strings content will be included + ); + + const existingResources: ExistingResources = { macro: {}, lookup: {} }; + let resources; + do { + resources = await batches.next(); + resources.forEach((resource) => { + existingResources[resource.type][resource.name] = resource; + }); + } while (resources.length > 0); + + this.existingResources = existingResources; + } + + public async getResources(migrationItem: I): Promise { + const existingResources = this.existingResources; + if (!existingResources) { + throw new Error('initialize must be called before calling getResources'); + } + + const resourceIdentifier = new this.ResourceIdentifierClass(migrationItem); + const resourcesIdentifiedFromRule = resourceIdentifier.fromOriginal(); + + const macrosFound = new Map(); + const lookupsFound = new Map(); + resourcesIdentifiedFromRule.forEach((resource) => { + const existingResource = existingResources[resource.type][resource.name]; + if (existingResource) { + if (resource.type === 'macro') { + macrosFound.set(resource.name, existingResource); + } else if (resource.type === 'lookup') { + lookupsFound.set(resource.name, existingResource); + } + } + }); + + const resourcesFound = [...macrosFound.values(), ...lookupsFound.values()]; + if (!resourcesFound.length) { + return {}; + } + + let nestedResourcesFound = resourcesFound; + do { + const nestedResourcesIdentified = resourceIdentifier.fromResources(nestedResourcesFound); + + nestedResourcesFound = []; + nestedResourcesIdentified.forEach((resource) => { + const existingResource = existingResources[resource.type][resource.name]; + if (existingResource) { + nestedResourcesFound.push(existingResource); + if (resource.type === 'macro') { + macrosFound.set(resource.name, existingResource); + } else if (resource.type === 'lookup') { + lookupsFound.set(resource.name, existingResource); + } + } + }); + } while (nestedResourcesFound.length > 0); + + return { + ...(macrosFound.size > 0 ? { macro: this.formatOutput(macrosFound) } : {}), + ...(lookupsFound.size > 0 ? { lookup: this.formatOutput(lookupsFound) } : {}), + }; + } + + private formatOutput(resources: Map): MigrationResourcesData[] { + return Array.from(resources.values()).map(({ name, content, type }) => ({ + name, + content, + type, + })); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts index b85fb934e4d6d..f63e1f6d240ae 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.test.ts @@ -19,7 +19,7 @@ import type { StoredSiemMigration } from '../types'; import type { SiemMigrationTaskStartParams } from './types'; import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; import type { SiemMigrationDataStats } from '../data/siem_migrations_data_item_client'; -import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/rules/types'; import type { SiemMigrationsClientDependencies } from '../types'; jest.mock('./rule_migrations_task_runner', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts index 8f573ccbc960f..d1854be5c630c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts @@ -12,7 +12,7 @@ import { SiemMigrationStatus, SiemMigrationTaskStatus, } from '../../../../../common/siem_migrations/constants'; -import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/rules/types'; import type { SiemMigrationsDataClient } from '../data/siem_migrations_data_client'; import type { SiemMigrationTaskStats } from '../data/types'; import type { @@ -28,7 +28,7 @@ import type { SiemMigrationTaskStopResult, } from './types'; import type { SiemMigrationTaskRunner } from './siem_migrations_task_runner'; -import type { SiemMigrationTaskEvaluator } from './siem_migrations_task_evaluator'; +import type { SiemMigrationTaskEvaluatorClass } from './siem_migrations_task_evaluator'; export abstract class SiemMigrationsTaskClient< M extends MigrationDocument = StoredSiemMigration, @@ -36,7 +36,7 @@ export abstract class SiemMigrationsTaskClient< C extends object = {} > { protected abstract readonly TaskRunnerClass: typeof SiemMigrationTaskRunner; - protected abstract readonly EvaluatorClass?: SiemMigrationTaskEvaluator; + protected abstract readonly EvaluatorClass?: SiemMigrationTaskEvaluatorClass; constructor( protected migrationsRunning: Map>, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts index 16f6c5600f9d4..29449c7dc39a3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.test.ts @@ -8,7 +8,7 @@ import type { CustomEvaluator } from './siem_migrations_task_evaluator'; import { SiemMigrationTaskEvaluable } from './siem_migrations_task_evaluator'; import type { Run, Example } from 'langsmith/schemas'; -import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; +import { createSiemMigrationsDataClientMock } from '../data/__mocks__/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import type { AuthenticatedUser } from '@kbn/core/server'; import type { SiemMigrationsClientDependencies } from '../types'; @@ -31,16 +31,13 @@ jest.mock('langsmith', () => ({ // Create generic task evaluator class using the generic task runner class SiemMigrationTaskEvaluator extends SiemMigrationTaskEvaluable(SiemMigrationTaskRunner) { - protected evaluators: Record = { - custom_query_accuracy: () => { - return { score: 50, comment: 'this is a mock evaluation' }; - }, - }; + public async setup(_: string) {} + protected evaluators: Record = {}; } describe('SiemMigrationTaskEvaluator', () => { let taskEvaluator: SiemMigrationTaskEvaluator; - let mockRuleMigrationsDataClient: ReturnType; + let mockRuleMigrationsDataClient: ReturnType; let abortController: AbortController; const mockLogger = loggerMock.create(); @@ -57,7 +54,7 @@ describe('SiemMigrationTaskEvaluator', () => { const mockUser = {} as unknown as AuthenticatedUser; beforeAll(() => { - mockRuleMigrationsDataClient = createRuleMigrationsDataClientMock(); + mockRuleMigrationsDataClient = createSiemMigrationsDataClientMock(); abortController = new AbortController(); taskEvaluator = new SiemMigrationTaskEvaluator( @@ -79,7 +76,7 @@ describe('SiemMigrationTaskEvaluator', () => { // Helper to access private evaluator methods const setEvaluator = (name: string) => { // @ts-expect-error accessing protected property - evaluator = taskEvaluator.evaluators[name]; + evaluator = taskEvaluator.genericEvaluators[name]; }; describe('translation_result evaluator', () => { @@ -134,188 +131,5 @@ describe('SiemMigrationTaskEvaluator', () => { }); }); }); - - describe('custom_query_accuracy evaluator', () => { - beforeAll(() => { - setEvaluator('custom_query_accuracy'); - }); - - it('should return perfect score when queries are identical', () => { - const mockRun = { - outputs: { elastic_rule: { query: 'process.name:test AND user.name:admin' } }, - } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { query: 'process.name:test AND user.name:admin' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: 1, - comment: 'Distance: 0', - }); - }); - - it('should calculate similarity score when queries are different', () => { - const mockRun = { - outputs: { elastic_rule: { query: 'process.name:testing' } }, - } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { query: 'process.name:testing AND user.name:admin' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - // Expected distance would be the length of " AND user.name:admin" which is 20 characters - // Total length of expected query is 40 characters - // Similarity = 1 - (20/40) = 0.5 - expect(result.score).toEqual(0.5); - expect(result.comment).toEqual('Distance: 20'); - }); - - it('should calculate similarity score with a precision of 3 decimals', () => { - const mockRun = { - outputs: { elastic_rule: { query: 'process.name:test' } }, - } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { query: 'process.name:test AND user.name:admin' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - // Similarity = 1 - (20/37) = 0.45945945945945943 - expect(result.score).toEqual(0.459); - }); - - it('should calculate similarity score with a precision of 3 decimals rounded correctly', () => { - const mockRun = { - outputs: { elastic_rule: { query: 'process.name:tests' } }, - } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { query: 'process.name:tests AND user.name:admin' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - // Similarity = 1 - (20/38) = 0.4736842105263158 - expect(result.score).toEqual(0.474); - }); - - it('should ignore score when no custom query is expected', () => { - const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; - const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - comment: 'No custom translation expected', - }); - }); - - it('should handle case when no custom query is expected but one is received', () => { - const mockRun = { - outputs: { elastic_rule: { query: 'process.name:tests' } }, - } as unknown as Run; - const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: 0, - comment: 'No custom translation expected, but received', - }); - }); - - it('should handle case when no custom query is returned but one was expected', () => { - const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { query: 'process.name:test' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: 0, - comment: 'Custom translation expected, but not received', - }); - }); - }); - - describe('prebuilt_rule_match evaluator', () => { - beforeAll(() => { - setEvaluator('prebuilt_rule_match'); - }); - - it('should return success when prebuilt rule IDs match', () => { - const mockRun = { - outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, - } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: true, - comment: 'Correct match', - }); - }); - - it('should return failure when prebuilt rule IDs do not match', () => { - const mockRun = { - outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, - } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { prebuilt_rule_id: 'rule-456' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: false, - comment: 'Incorrect match, expected ID is "rule-456" but got "rule-123"', - }); - }); - - it('should handle case when no prebuilt rule is expected', () => { - const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; - const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - comment: 'No prebuilt rule expected', - }); - }); - - it('should handle case when no prebuilt rule is expected but one is received', () => { - const mockRun = { - outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, - } as unknown as Run; - const mockExample = { outputs: { elastic_rule: {} } } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: false, - comment: 'No prebuilt rule expected, but received', - }); - }); - - it('should handle case when no prebuilt rule is returned but one was expected', () => { - const mockRun = { outputs: { elastic_rule: {} } } as unknown as Run; - const mockExample = { - outputs: { elastic_rule: { prebuilt_rule_id: 'rule-123' } }, - } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: false, - comment: 'Prebuilt rule expected, but not received', - }); - }); - }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts index 0062d06959f5b..c025ef39305a1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_evaluator.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import assert from 'assert'; import type { EvaluationResult } from 'langsmith/evaluation'; import type { RunnableConfig } from '@langchain/core/runnables'; import type { Run, Example } from 'langsmith/schemas'; @@ -25,11 +25,13 @@ export type Evaluator = (args: { run: Run; example: Example }) => EvaluationResu type CustomEvaluatorResult = Omit; export type CustomEvaluator = (args: { run: Run; example: Example }) => CustomEvaluatorResult; -export type SiemMigrationTaskEvaluator< +export type SiemMigrationTaskEvaluatorClass< M extends MigrationDocument = MigrationDocument, I extends ItemDocument = ItemDocument, - C extends object = {} -> = ReturnType>; + P extends object = {}, + C extends object = {}, + O extends object = {} +> = ReturnType>; /** * Mixin to create a task evaluator based on a concrete implementation of a SiemMigrationTaskRunner. @@ -39,32 +41,12 @@ export type SiemMigrationTaskEvaluator< export function SiemMigrationTaskEvaluable< M extends MigrationDocument = MigrationDocument, I extends ItemDocument = ItemDocument, - C extends object = {} ->(TaskRunnerConcreteClass: typeof SiemMigrationTaskRunner) { + P extends object = {}, // The migration task input parameters schema + C extends object = {}, // The migration task config schema + O extends object = {} // The migration task output schema +>(TaskRunnerConcreteClass: typeof SiemMigrationTaskRunner) { return class extends TaskRunnerConcreteClass { protected evaluators!: Record; - private genericEvaluators: Record = { - translation_result: ({ run, example }) => { - const runResult = (run?.outputs as ItemDocument)?.translation_result; - const expectedResult = (example?.outputs as ItemDocument)?.translation_result; - - if (!expectedResult) { - return { comment: 'No translation result expected' }; - } - if (!runResult) { - return { score: false, comment: 'No translation result received' }; - } - - if (runResult === expectedResult) { - return { score: true, comment: 'Correct' }; - } - - return { - score: false, - comment: `Incorrect, expected "${expectedResult}" but got "${runResult}"`, - }; - }, - }; public async evaluate({ connectorId, langsmithOptions, invocationConfig }: EvaluateParams) { if (!isLangSmithEnabled()) { @@ -95,7 +77,10 @@ export function SiemMigrationTaskEvaluable< await this.setup(connectorId); // create the migration task after setup - const migrateItemTask = this.createMigrateItemTask(invocationConfig); + const migrateItemTask = (params: P) => { + assert(this.task, 'Task is not defined'); + return this.task(params, invocationConfig); + }; const evaluators = this.getEvaluators(); evaluate(migrateItemTask, { @@ -113,6 +98,29 @@ export function SiemMigrationTaskEvaluable< }); } + private genericEvaluators: Record = { + translation_result: ({ run, example }) => { + const runResult = (run?.outputs as ItemDocument)?.translation_result; + const expectedResult = (example?.outputs as ItemDocument)?.translation_result; + + if (!expectedResult) { + return { comment: 'No translation result expected' }; + } + if (!runResult) { + return { score: false, comment: 'No translation result received' }; + } + + if (runResult === expectedResult) { + return { score: true, comment: 'Correct' }; + } + + return { + score: false, + comment: `Incorrect, expected "${expectedResult}" but got "${runResult}"`, + }; + }, + }; + private getEvaluators(): Evaluator[] { return Object.entries({ ...this.genericEvaluators, ...this.evaluators }).map( ([key, evaluator]) => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts index 7cf4f182d1039..2b0fae7eaed01 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts @@ -5,44 +5,22 @@ * 2.0. */ -import { RuleMigrationTaskRunner } from './siem_migrations_task_runner'; +import { SiemMigrationTaskRunner } from './siem_migrations_task_runner'; import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import type { AuthenticatedUser } from '@kbn/core/server'; -import type { StoredRuleMigration } from '../types'; -import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; +import type { StoredSiemMigrationItem, SiemMigrationsClientDependencies } from '../types'; +import { createSiemMigrationsDataClientMock } from '../data/__mocks__/mocks'; import { loggerMock } from '@kbn/logging-mocks'; -import type { SiemMigrationsClientDependencies } from '../types'; - -jest.mock('./rule_migrations_telemetry_client'); - -const mockRetrieverInitialize = jest.fn().mockResolvedValue(undefined); -jest.mock('./retrievers', () => ({ - ...jest.requireActual('./retrievers'), - RuleMigrationsRetriever: jest.fn().mockImplementation(() => ({ - initialize: mockRetrieverInitialize, - resources: { - getResources: jest.fn(() => ({})), - }, - })), -})); - -const mockCreateModel = jest.fn(() => ({ model: 'test-model' })); -jest.mock('./util/actions_client_chat', () => ({ - ...jest.requireActual('./util/actions_client_chat'), - ActionsClientChat: jest.fn().mockImplementation(() => ({ createModel: mockCreateModel })), -})); - -const mockInvoke = jest.fn().mockResolvedValue({}); -jest.mock('./agent', () => ({ - ...jest.requireActual('./agent'), - getRuleMigrationAgent: () => ({ invoke: mockInvoke }), -})); +import { SiemMigrationTelemetryClient } from './__mocks__/siem_migrations_telemetry_client'; +import { TELEMETRY_SIEM_MIGRATION_ID } from './util/constants'; + +jest.mock('./siem_migrations_telemetry_client'); // Mock dependencies const mockLogger = loggerMock.create(); const mockDependencies: jest.Mocked = { - rulesClient: {}, + itemsClient: {}, savedObjectsClient: {}, inferenceClient: {}, actionsClient: {}, @@ -50,7 +28,7 @@ const mockDependencies: jest.Mocked = { } as unknown as SiemMigrationsClientDependencies; const mockUser = {} as unknown as AuthenticatedUser; -const ruleId = 'test-rule-id'; +const itemId = 'test-item-id'; jest.useFakeTimers(); jest.spyOn(global, 'setTimeout'); @@ -60,42 +38,66 @@ mockTimeout.mockImplementation((cb) => { cb(); }); -describe('RuleMigrationTaskRunner', () => { - let taskRunner: RuleMigrationTaskRunner; +const mockSetup = jest.fn().mockResolvedValue(undefined); +const mockInvoke = jest.fn().mockResolvedValue(undefined); +const mockTaskPrepare = jest.fn().mockResolvedValue(mockInvoke); +const mockInitialize = jest.fn().mockResolvedValue(undefined); + +class TestMigrationTaskRunner extends SiemMigrationTaskRunner { + protected TaskRunnerClass = SiemMigrationTaskRunner; + protected EvaluatorClass = undefined; + + public async setup(connectorId: string): Promise { + await mockSetup(); + this.task = { prepare: mockTaskPrepare }; + this.telemetry = new SiemMigrationTelemetryClient( + this.dependencies.telemetry, + this.logger, + this.migrationId, + TELEMETRY_SIEM_MIGRATION_ID + ); + } + + public async initialize(): Promise { + await mockInitialize(); + } +} + +describe('SiemMigrationTaskRunner', () => { + let taskRunner: SiemMigrationTaskRunner; let abortController: AbortController; - let mockRuleMigrationsDataClient: ReturnType; + let mockSiemMigrationsDataClient: ReturnType; beforeEach(() => { - mockRetrieverInitialize.mockResolvedValue(undefined); // Reset the mock + mockSetup.mockResolvedValue(undefined); // Reset the mock + mockInitialize.mockResolvedValue(undefined); // Reset the mock mockInvoke.mockResolvedValue({}); // Reset the mock - mockRuleMigrationsDataClient = createRuleMigrationsDataClientMock(); + mockSiemMigrationsDataClient = createSiemMigrationsDataClientMock(); jest.clearAllMocks(); abortController = new AbortController(); - taskRunner = new RuleMigrationTaskRunner( + taskRunner = new TestMigrationTaskRunner( 'test-migration-id', mockUser, abortController, - mockRuleMigrationsDataClient, + mockSiemMigrationsDataClient, mockLogger, mockDependencies ); }); describe('setup', () => { - it('should create the agent and tools', async () => { + it('should create the task and tools', async () => { await expect(taskRunner.setup('test-connector-id')).resolves.toBeUndefined(); // @ts-expect-error (checking private properties) - expect(taskRunner.agent).toBeDefined(); - // @ts-expect-error (checking private properties) - expect(taskRunner.retriever).toBeDefined(); + expect(taskRunner.task).toBeDefined(); // @ts-expect-error (checking private properties) expect(taskRunner.telemetry).toBeDefined(); }); it('should throw if an error occurs', async () => { const errorMessage = 'Test error'; - mockCreateModel.mockImplementationOnce(() => { + mockSetup.mockImplementationOnce(() => { throw new Error(errorMessage); }); @@ -110,22 +112,23 @@ describe('RuleMigrationTaskRunner', () => { }); it('should handle the migration successfully', async () => { - mockRuleMigrationsDataClient.rules.get.mockResolvedValue({ total: 0, data: [] }); - mockRuleMigrationsDataClient.rules.get.mockResolvedValueOnce({ + mockSiemMigrationsDataClient.items.get.mockResolvedValue({ total: 0, data: [] }); + mockSiemMigrationsDataClient.items.get.mockResolvedValueOnce({ total: 1, - data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + data: [{ id: itemId, status: SiemMigrationStatus.PENDING }] as StoredSiemMigrationItem[], }); await taskRunner.setup('test-connector-id'); await expect(taskRunner.run({})).resolves.toBeUndefined(); - expect(mockRuleMigrationsDataClient.rules.saveProcessing).toHaveBeenCalled(); + expect(mockSiemMigrationsDataClient.items.saveProcessing).toHaveBeenCalled(); expect(mockTimeout).toHaveBeenCalledTimes(1); // random execution sleep expect(mockTimeout).toHaveBeenNthCalledWith(1, expect.any(Function), expect.any(Number)); + expect(mockTaskPrepare).toHaveBeenCalledTimes(1); expect(mockInvoke).toHaveBeenCalledTimes(1); - expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalled(); - expect(mockRuleMigrationsDataClient.rules.get).toHaveBeenCalledTimes(2); // One with data, one without + expect(mockSiemMigrationsDataClient.items.saveCompleted).toHaveBeenCalled(); + expect(mockSiemMigrationsDataClient.items.get).toHaveBeenCalledTimes(2); // One with data, one without expect(mockLogger.info).toHaveBeenCalledWith('Migration completed successfully'); }); @@ -134,9 +137,9 @@ describe('RuleMigrationTaskRunner', () => { describe('during initialization', () => { it('should handle abort error correctly', async () => { - runPromise = taskRunner.run({}); - abortController.abort(); // Trigger the abort signal + abortController.abort(); + runPromise = taskRunner.run({}); await expect(runPromise).resolves.toBeUndefined(); // Ensure the function handles abort gracefully expect(mockLogger.info).toHaveBeenCalledWith( @@ -145,7 +148,7 @@ describe('RuleMigrationTaskRunner', () => { }); it('should handle other errors correctly', async () => { - mockRetrieverInitialize.mockRejectedValueOnce(new Error(errorMessage)); + mockInitialize.mockRejectedValueOnce(new Error(errorMessage)); runPromise = taskRunner.run({}); await expect(runPromise).rejects.toEqual( @@ -156,12 +159,14 @@ describe('RuleMigrationTaskRunner', () => { describe('during migration', () => { beforeEach(() => { - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + mockSiemMigrationsDataClient.items.get.mockRestore(); + mockSiemMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce({ total: 1, - data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + data: [ + { id: itemId, status: SiemMigrationStatus.PENDING }, + ] as StoredSiemMigrationItem[], }); }); @@ -175,7 +180,7 @@ describe('RuleMigrationTaskRunner', () => { await expect(runPromise).resolves.toBeUndefined(); // Ensure the function handles abort gracefully expect(mockLogger.info).toHaveBeenCalledWith('Abort signal received, stopping migration'); - expect(mockRuleMigrationsDataClient.rules.releaseProcessing).toHaveBeenCalled(); + expect(mockSiemMigrationsDataClient.items.releaseProcessing).toHaveBeenCalled(); }); it('should handle other errors correctly', async () => { @@ -185,48 +190,49 @@ describe('RuleMigrationTaskRunner', () => { await expect(runPromise).resolves.toBeUndefined(); expect(mockLogger.error).toHaveBeenCalledWith( - `Error translating rule \"${ruleId}\" with error: ${errorMessage}` + `Error translating document \"${itemId}\" with error: ${errorMessage}` ); - expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalled(); + expect(mockSiemMigrationsDataClient.items.saveError).toHaveBeenCalled(); }); describe('during rate limit errors', () => { - const rule2Id = 'test-rule-id-2'; + const item2Id = 'test-item-id-2'; const error = new Error('429. You did way too many requests to this random LLM API bud'); beforeEach(async () => { - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + mockSiemMigrationsDataClient.items.get.mockRestore(); + mockSiemMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce({ total: 2, data: [ - { id: ruleId, status: SiemMigrationStatus.PENDING }, - { id: rule2Id, status: SiemMigrationStatus.PENDING }, - ] as StoredRuleMigration[], + { id: itemId, status: SiemMigrationStatus.PENDING }, + { id: item2Id, status: SiemMigrationStatus.PENDING }, + ] as StoredSiemMigrationItem[], }); }); it('should retry with exponential backoff', async () => { mockInvoke .mockResolvedValue({}) // Successful calls from here on - .mockRejectedValueOnce(error) // First failed call for rule 1 - .mockRejectedValueOnce(error) // First failed call for rule 2 - .mockRejectedValueOnce(error) // Second failed call for rule 1 - .mockRejectedValueOnce(error); // Third failed call for rule 1 + .mockRejectedValueOnce(error) // First failed call for item 1 + .mockRejectedValueOnce(error) // First failed call for item 2 + .mockRejectedValueOnce(error) // Second failed call for item 1 + .mockRejectedValueOnce(error); // Third failed call for item 1 await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + expect(mockTaskPrepare).toHaveBeenCalledTimes(2); // 2 items /** * Invoke calls: - * rule 1 -> failure -> start backoff retries - * rule 2 -> failure -> await for rule 1 backoff + * item 1 -> failure -> start backoff retries + * item 2 -> failure -> await for item 1 backoff * then: - * rule 1 retry 1 -> failure - * rule 1 retry 2 -> failure - * rule 1 retry 3 -> success + * item 1 retry 1 -> failure + * item 1 retry 2 -> failure + * item 1 retry 3 -> success * then: - * rule 2 -> success + * item 2 -> success */ expect(mockInvoke).toHaveBeenCalledTimes(6); expect(mockTimeout).toHaveBeenCalledTimes(6); // 2 execution sleeps + 3 backoff sleeps + 1 execution sleep @@ -250,10 +256,11 @@ describe('RuleMigrationTaskRunner', () => { ); expect(mockLogger.debug).toHaveBeenCalledWith( - `Awaiting backoff task for rule "${rule2Id}"` + `Awaiting backoff task for document "${item2Id}"` ); + expect(mockTaskPrepare).toHaveBeenCalledTimes(2); // 2 items expect(mockInvoke).toHaveBeenCalledTimes(6); // 3 retries + 3 executions - expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalledTimes(2); // 2 rules + expect(mockSiemMigrationsDataClient.items.saveCompleted).toHaveBeenCalledTimes(2); // 2 items }); it('should fail when reached maxRetries', async () => { @@ -261,59 +268,62 @@ describe('RuleMigrationTaskRunner', () => { await expect(taskRunner.run({})).resolves.toBeUndefined(); // success + expect(mockTaskPrepare).toHaveBeenCalledTimes(2); // 2 items // maxRetries = 8 expect(mockInvoke).toHaveBeenCalledTimes(10); // 8 retries + 2 executions expect(mockTimeout).toHaveBeenCalledTimes(10); // 2 execution sleeps + 8 backoff sleeps - expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalledTimes(2); // 2 rules + expect(mockSiemMigrationsDataClient.items.saveError).toHaveBeenCalledTimes(2); // 2 items }); it('should fail when reached max recovery attempts', async () => { - const rule3Id = 'test-rule-id-3'; - const rule4Id = 'test-rule-id-4'; - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + const item3Id = 'test-item-id-3'; + const item4Id = 'test-item-id-4'; + mockSiemMigrationsDataClient.items.get.mockRestore(); + mockSiemMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce({ total: 4, data: [ - { id: ruleId, status: SiemMigrationStatus.PENDING }, - { id: rule2Id, status: SiemMigrationStatus.PENDING }, - { id: rule3Id, status: SiemMigrationStatus.PENDING }, - { id: rule4Id, status: SiemMigrationStatus.PENDING }, - ] as StoredRuleMigration[], + { id: itemId, status: SiemMigrationStatus.PENDING }, + { id: item2Id, status: SiemMigrationStatus.PENDING }, + { id: item3Id, status: SiemMigrationStatus.PENDING }, + { id: item4Id, status: SiemMigrationStatus.PENDING }, + ] as StoredSiemMigrationItem[], }); // max recovery attempts = 3 mockInvoke .mockResolvedValue({}) // should never reach this - .mockRejectedValueOnce(error) // 1st failed call for rule 1 - .mockRejectedValueOnce(error) // 1st failed call for rule 2 - .mockRejectedValueOnce(error) // 1st failed call for rule 3 - .mockRejectedValueOnce(error) // 1st failed call for rule 4 - .mockResolvedValueOnce({}) // Successful call for the rule 1 backoff - .mockRejectedValueOnce(error) // 2nd failed call for the rule 2 recover - .mockRejectedValueOnce(error) // 2nd failed call for the rule 3 recover - .mockRejectedValueOnce(error) // 2nd failed call for the rule 4 recover - .mockResolvedValueOnce({}) // Successful call for the rule 2 backoff - .mockRejectedValueOnce(error) // 3rd failed call for the rule 3 recover - .mockRejectedValueOnce(error) // 3rd failed call for the rule 4 recover - .mockResolvedValueOnce({}) // Successful call for the rule 3 backoff - .mockRejectedValueOnce(error); // 4th failed call for the rule 4 recover (max attempts failure) + .mockRejectedValueOnce(error) // 1st failed call for item 1 + .mockRejectedValueOnce(error) // 1st failed call for item 2 + .mockRejectedValueOnce(error) // 1st failed call for item 3 + .mockRejectedValueOnce(error) // 1st failed call for item 4 + .mockResolvedValueOnce({}) // Successful call for the item 1 backoff + .mockRejectedValueOnce(error) // 2nd failed call for the item 2 recover + .mockRejectedValueOnce(error) // 2nd failed call for the item 3 recover + .mockRejectedValueOnce(error) // 2nd failed call for the item 4 recover + .mockResolvedValueOnce({}) // Successful call for the item 2 backoff + .mockRejectedValueOnce(error) // 3rd failed call for the item 3 recover + .mockRejectedValueOnce(error) // 3rd failed call for the item 4 recover + .mockResolvedValueOnce({}) // Successful call for the item 3 backoff + .mockRejectedValueOnce(error); // 4th failed call for the item 4 recover (max attempts failure) await expect(taskRunner.run({})).resolves.toBeUndefined(); // success - expect(mockRuleMigrationsDataClient.rules.saveCompleted).toHaveBeenCalledTimes(3); // rules 1, 2 and 3 - expect(mockRuleMigrationsDataClient.rules.saveError).toHaveBeenCalledTimes(1); // rule 4 + expect(mockSiemMigrationsDataClient.items.saveCompleted).toHaveBeenCalledTimes(3); // items 1, 2 and 3 + expect(mockSiemMigrationsDataClient.items.saveError).toHaveBeenCalledTimes(1); // item 4 }); it('should increase the executor sleep time when rate limited', async () => { const getResponse = { total: 1, - data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + data: [ + { id: itemId, status: SiemMigrationStatus.PENDING }, + ] as StoredSiemMigrationItem[], }; - mockRuleMigrationsDataClient.rules.get.mockRestore(); - mockRuleMigrationsDataClient.rules.get + mockSiemMigrationsDataClient.items.get.mockRestore(); + mockSiemMigrationsDataClient.items.get .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce(getResponse) .mockResolvedValueOnce({ total: 0, data: [] }) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts index 228793745af92..69cab860fc54f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.ts @@ -12,7 +12,7 @@ import type { RunnableConfig } from '@langchain/core/runnables'; import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import { initPromisePool } from '../../../../utils/promise_pool'; import type { SiemMigrationsDataClient } from '../data/siem_migrations_data_client'; -import type { MigrationState, MigrationTask, MigrationTaskInvoke } from './types'; +import type { Invocation, Invoke, MigrationTask } from './types'; import { generateAssistantComment } from './util/comments'; import type { ItemDocument, @@ -59,10 +59,12 @@ const EXECUTOR_RECOVER_MAX_ATTEMPTS = 3 as const; export class SiemMigrationTaskRunner< M extends MigrationDocument = MigrationDocument, // The migration document type (rule migrations and dashboard migrations very similar but have differences) I extends ItemDocument = ItemDocument, // The rule or dashboard document type - C extends object = {} // The migration task config to be passed to the agent, includes parameters for the agent invocation + P extends object = {}, // The migration task input parameters schema + C extends object = {}, // The migration task config schema + O extends object = {} // The migration task output schema > { protected telemetry?: SiemMigrationTelemetryClient; - protected task?: MigrationTask; + protected task?: MigrationTask; declare actionsClientChat: ActionsClientChat; private abort: ReturnType; private executorSleepMultiplier: number = EXECUTOR_SLEEP.initialValueSeconds; @@ -80,9 +82,22 @@ export class SiemMigrationTaskRunner< this.abort = abortSignalToPromise(this.abortController.signal); } - /** Retrieves the connector and creates the migration agent */ - public async setup(_connectorId: string): Promise {} + /** Receives the connectorId and creates the `this.task` and `this.telemetry` attributes */ + public async setup(connectorId: string): Promise { + throw new Error('setup method must be implemented in the subclass'); + } + + /** Prepares the migration item for the task execution */ + protected async prepareTaskInput(item: Stored): Promise

{ + throw new Error('prepareTaskInput method must be implemented in the subclass'); + } + + /** Processes the output of the migration task and returns the item to save */ + protected processTaskOutput(item: Stored, output: O): Stored { + throw new Error('processTaskOutput method must be implemented in the subclass'); + } + /** Optional initialization logic */ protected async initialize() {} public async run(invocationConfig: RunnableConfig): Promise { @@ -119,7 +134,7 @@ export class SiemMigrationTaskRunner< this.logger.debug(`Start processing batch of ${migrationItems.length} items`); - const { errors } = await initPromisePool({ + const { errors } = await initPromisePool, void, Error>({ concurrency: TASK_CONCURRENCY, abortSignal: this.abortController.signal, items: migrationItems, @@ -128,10 +143,10 @@ export class SiemMigrationTaskRunner< try { await this.saveItemProcessing(migrationItem); - const migrationResult = await migrateItemTask(migrationItem); + const migratedItem = await migrateItemTask(migrationItem); - await this.saveItemCompleted(migrationItem, migrationResult); - itemTranslationTelemetry.success(migrationResult); + await this.saveItemCompleted(migratedItem); + itemTranslationTelemetry.success(migratedItem); } catch (error) { if (this.abortController.signal.aborted) { throw new AbortError(); @@ -166,6 +181,19 @@ export class SiemMigrationTaskRunner< } } + /** Creates the task invoke function, the input is prepared and the output is processed as a migrationItem */ + private createTaskInvoke = async ( + migrationItem: I, + config: RunnableConfig + ): Promise> => { + const input = await this.prepareTaskInput(migrationItem); + return async () => { + assert(this.task, 'Migration task is not defined'); + const output = await this.task(input, config); + return this.processTaskOutput(migrationItem, output); + }; + }; + protected createMigrateItemTask(invocationConfig?: RunnableConfig) { const config: RunnableConfig = { timeout: AGENT_INVOKE_TIMEOUT_MIN * 60 * 1000, // milliseconds timeout @@ -174,9 +202,7 @@ export class SiemMigrationTaskRunner< }; // Invokes the item translation with exponential backoff, should be called only when the rate limit has been hit - const invokeWithBackoff = async ( - invoke: MigrationTaskInvoke - ): Promise> => { + const invokeWithBackoff = async (invoke: Invoke): Invocation => { this.logger.debug('Rate limit backoff started'); let retriesLeft: number = RETRY_CONFIG.maxRetries; while (true) { @@ -204,12 +230,11 @@ export class SiemMigrationTaskRunner< } }; - let backoffPromise: Promise> | undefined; + let backoffPromise: Invocation | undefined; // Migrates one item, this function will be called concurrently by the promise pool. // Handles rate limit errors and ensures only one task is executing the backoff retries at a time, the rest of translation will await. - const migrateItem = async (migrationItem: I): Promise> => { - assert(this.task, 'task is not initialized'); - const invoke = await this.task.prepare(migrationItem, config); + const migrateItem = async (migrationItem: I): Invocation => { + const invoke = await this.createTaskInvoke(migrationItem, config); let recoverAttemptsLeft: number = EXECUTOR_RECOVER_MAX_ATTEMPTS; while (true) { @@ -283,9 +308,9 @@ export class SiemMigrationTaskRunner< return this.data.items.saveProcessing(migrationItem.id); } - protected async saveItemCompleted(migrationItem: Stored, migrationResult: MigrationState) { + protected async saveItemCompleted(migrationItem: Stored) { this.logger.debug(`Translation of document "${migrationItem.id}" succeeded`); - return this.data.items.saveCompleted(migrationResult as Stored); + return this.data.items.saveCompleted(migrationItem); } protected async saveItemFailed(migrationItem: Stored, error: Error) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts index 7d3c7ce8a8740..440999c9e994d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/types.ts @@ -42,13 +42,13 @@ export interface SiemMigrationTaskEvaluateParams { abortController: AbortController; } -export type MigrationState = Partial>; -export type MigrationTaskInvoke = () => Promise< - MigrationState ->; -export interface MigrationTask { - prepare: (item: Stored, config: RunnableConfig) => Promise>; -} +export type Invocation = Promise>; +export type Invoke = () => Invocation; + +export type MigrationTask

= ( + params: P, + config?: RunnableConfig +) => Promise; export interface RuleMigrationAgentRunOptions { skipPrebuiltRulesMatching: boolean; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts index 291e8c9bcf094..65fe046d48c03 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/comments.ts @@ -6,14 +6,14 @@ */ import { SIEM_MIGRATIONS_ASSISTANT_USER } from '../../../../../../common/siem_migrations/constants'; -import type { RuleMigrationComment } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { MigrationComment } from '../../../../../../common/siem_migrations/model/migration.gen'; export const cleanMarkdown = (markdown: string): string => { // Use languages known by the code block plugin return markdown.replaceAll('```esql', '```sql').replaceAll('```spl', '```splunk-spl'); }; -export const generateAssistantComment = (message: string): RuleMigrationComment => { +export const generateAssistantComment = (message: string): MigrationComment => { return { message, created_at: new Date().toISOString(), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/constants.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/constants.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/esql_knowledge_base.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/esql_knowledge_base.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/esql_knowledge_base.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts new file mode 100644 index 0000000000000..8e285db2fa440 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { nullifyMissingProperties } from './nullify_missing_properties'; + +interface TestObject { + title?: string; + name?: string; + boolean?: boolean; + missing?: string | undefined; + emptyText?: string; + counter?: number; +} + +describe('nullifyMissingProperties', () => { + it('should return an object with nullified empty values', () => { + const source: TestObject = { + title: 'Some Title', + boolean: true, + missing: 'defined', + emptyText: 'defined', + counter: 1, + }; + const target: TestObject = { + name: 'Some Name', + boolean: false, + emptyText: '', + counter: 0, + missing: undefined, + }; + + const result = nullifyMissingProperties({ + source, + target, + }); + + expect(result).toMatchObject({ + title: null, + name: 'Some Name', + boolean: false, + emptyText: '', + counter: 0, + missing: null, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.ts new file mode 100644 index 0000000000000..926338fce50bf --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/nullify_missing_properties.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. + */ + +interface NullifyMissingProperties { + source?: T; + target: T; +} +export const nullifyMissingProperties = ( + params: NullifyMissingProperties +): T => { + const { source: stored, target: output } = params; + if (!stored) { + return output; + } + const result: T = { ...stored, ...output }; + (Object.keys(stored) as Array).forEach((key) => { + if (output[key] == null) { + result[key] = null as T[keyof T]; + } + }); + return result; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts index 9a8258cbf6f88..7b0d74680f1bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/types.ts @@ -44,6 +44,7 @@ export type SiemMigrationsIndexNameProvider = () => Promise; export type Stored = T & { id: string }; // TODO: replace these with the schemas for the common properties of the migrations and items +// TODO: move these to the security_solution/common/siem_migrations/types.ts export type MigrationDocument = RuleMigration | DashboardMigration; export type ItemDocument = RuleMigrationRule | DashboardMigrationDashboard; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/__mocks__/mocks.ts index cc1d94268894e..4b9eb50c85636 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/__mocks__/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/__mocks__/mocks.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { DashboardMigrationsDataDashboardsClient } from '../dashboard_migrations_dashboards_client'; +import type { DashboardMigrationsDataDashboardsClient } from '../dashboard_migrations_data_dashboards_client'; import type { DashboardMigrationsDataMigrationClient } from '../dashboard_migrations_migration_client'; export const mockDashboardMigrationDataMigrationClient = { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts index 2198ee69e4ec4..d2a68fd0a4c4a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts @@ -9,7 +9,7 @@ import type { Logger } from '@kbn/logging'; import type { AuthenticatedUser, IScopedClusterClient } from '@kbn/core/server'; import type { DashboardMigration } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; import { SiemMigrationsDataMigrationClient } from '../../common/data/siem_migrations_data_migration_client'; -import { DashboardMigrationsDataDashboardsClient } from './dashboard_migrations_dashboards_client'; +import { DashboardMigrationsDataDashboardsClient } from './dashboard_migrations_data_dashboards_client'; import type { DashboardMigrationIndexNameProviders } from '../types'; import type { SiemMigrationsClientDependencies } from '../../common/types'; import { SiemMigrationsDataClient } from '../../common/data/siem_migrations_data_client'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.test.ts similarity index 99% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.test.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.test.ts index cfb69a6127779..b0bf66d0c7207 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.test.ts @@ -7,7 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; -import { DashboardMigrationsDataDashboardsClient } from './dashboard_migrations_dashboards_client'; +import { DashboardMigrationsDataDashboardsClient } from './dashboard_migrations_data_dashboards_client'; import type { AuthenticatedUser, IScopedClusterClient } from '@kbn/core/server'; import type { SiemMigrationsClientDependencies } from '../../common/types'; import type { SplunkOriginalDashboardExport } from '../../../../../common/siem_migrations/model/vendor/dashboards/splunk.gen'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_dashboards_client.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/siem_dashboard_migration_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/siem_dashboard_migration_service.ts index 9fb101233a767..18c411b1bd0bb 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/siem_dashboard_migration_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/siem_dashboard_migration_service.ts @@ -11,6 +11,8 @@ import type { Subject } from 'rxjs'; import type { DashboardMigrationsDataClient } from './data/dashboard_migrations_data_client'; import { DashboardMigrationsDataService } from './data/dashboard_migrations_data_service'; import type { SiemMigrationsCreateClientParams } from '../common/types'; +import type { DashboardMigrationsTaskClient } from './task/dashboard_migrations_task_client'; +import { DashboardMigrationsTaskService } from './task/dashboard_migrations_task_service'; export interface SiemDashboardsMigrationsSetupParams { esClusterClient: IClusterClient; @@ -20,16 +22,19 @@ export interface SiemDashboardsMigrationsSetupParams { export interface SiemDashboardMigrationsClient { data: DashboardMigrationsDataClient; + task: DashboardMigrationsTaskClient; } export class SiemDashboardMigrationsService { private dataService: DashboardMigrationsDataService; private esClusterClient?: IClusterClient; + private taskService: DashboardMigrationsTaskService; private logger: Logger; constructor(logger: LoggerFactory, kibanaVersion: string, elserInferenceId?: string) { this.logger = logger.get('siemDashboardMigrations'); this.dataService = new DashboardMigrationsDataService(this.logger, kibanaVersion); + this.taskService = new DashboardMigrationsTaskService(this.logger); } setup({ esClusterClient, ...params }: SiemDashboardsMigrationsSetupParams) { @@ -58,8 +63,12 @@ export class SiemDashboardMigrationsService { dependencies, }); - return { data: dataClient }; + const taskClient = this.taskService.createClient({ currentUser, dataClient, dependencies }); + + return { data: dataClient, task: taskClient }; } - stop() {} + stop() { + this.taskService.stopAll(); + } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts new file mode 100644 index 0000000000000..2f4da50727e3f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { FakeLLM } from '@langchain/core/utils/testing'; +import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons'; +import type { RuleMigrationTelemetryClient } from '../dashboard_migrations_telemetry_client'; +import type { BaseLLMParams } from '@langchain/core/language_models/llms'; + +export const createSiemMigrationTelemetryClientMock = () => { + // Mock for the object returned by startSiemMigrationTask + const mockStartRuleTranslationReturn = { + success: jest.fn(), + failure: jest.fn(), + }; + + // Mock for the function returned by startSiemMigrationTask + const mockStartRuleTranslation = jest.fn().mockReturnValue(mockStartRuleTranslationReturn); + + // Mock for startSiemMigrationTask return value + const mockStartSiemMigrationTaskReturn = { + startRuleTranslation: mockStartRuleTranslation, + success: jest.fn(), + failure: jest.fn(), + aborted: jest.fn(), + }; + + return { + reportIntegrationsMatch: jest.fn(), + reportPrebuiltRulesMatch: jest.fn(), + startSiemMigrationTask: jest.fn().mockReturnValue(mockStartSiemMigrationTaskReturn), + } as jest.Mocked>; +}; + +// Factory function for the mock class +export const MockSiemMigrationTelemetryClient = jest + .fn() + .mockImplementation(() => createSiemMigrationTelemetryClientMock()); + +export const createRuleMigrationsTaskClientMock = () => ({ + start: jest.fn().mockResolvedValue({ started: true }), + stop: jest.fn().mockResolvedValue({ stopped: true }), + getStats: jest.fn().mockResolvedValue({ + status: 'done', + rules: { + total: 1, + finished: 1, + processing: 0, + pending: 0, + failed: 0, + }, + }), + getAllStats: jest.fn().mockResolvedValue([]), +}); + +export const MockRuleMigrationsTaskClient = jest + .fn() + .mockImplementation(() => createRuleMigrationsTaskClientMock()); + +// Rule migrations task service +export const mockStopAll = jest.fn(); +export const mockCreateClient = jest.fn(() => createRuleMigrationsTaskClientMock()); + +export const MockRuleMigrationsTaskService = jest.fn().mockImplementation(() => ({ + createClient: mockCreateClient, + stopAll: mockStopAll, +})); + +export interface NodeResponse { + nodeId: string; + response: string; +} + +interface SiemMigrationFakeLLMParams extends BaseLLMParams { + nodeResponses: NodeResponse[]; +} + +export class SiemMigrationFakeLLM extends FakeLLM { + private nodeResponses: NodeResponse[]; + private defaultResponse: string; + private callCount: Map; + private totalCount: number; + + constructor(fields: SiemMigrationFakeLLMParams) { + super({ + response: 'unexpected node call', + ...fields, + }); + this.nodeResponses = fields.nodeResponses; + this.defaultResponse = 'unexpected node call'; + this.callCount = new Map(); + this.totalCount = 0; + } + + _llmType(): string { + return 'fake'; + } + + async _call(prompt: string, _options: this['ParsedCallOptions']): Promise { + // Get the current runnable config metadata + const item = AsyncLocalStorageProviderSingleton.getRunnableConfig(); + for (const nodeResponse of this.nodeResponses) { + if (item.metadata.langgraph_node === nodeResponse.nodeId) { + const currentCount = this.callCount.get(nodeResponse.nodeId) || 0; + this.callCount.set(nodeResponse.nodeId, currentCount + 1); + this.totalCount += 1; + return nodeResponse.response; + } + } + return this.defaultResponse; + } + + getNodeCallCount(nodeId: string): number { + return this.callCount.get(nodeId) || 0; + } + + getTotalCallCount(): number { + return this.totalCount; + } + + resetCallCounts(): void { + this.callCount.clear(); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_client.ts new file mode 100644 index 0000000000000..b4eac7ccf2462 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsTaskClient } from './mocks'; +export const RuleMigrationsTaskClient = MockRuleMigrationsTaskClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_service.ts new file mode 100644 index 0000000000000..04da946c083c5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_service.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsTaskService } from './mocks'; +export const RuleMigrationsTaskService = MockRuleMigrationsTaskService; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_telemetry_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_telemetry_client.ts new file mode 100644 index 0000000000000..199e630d2f1de --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_telemetry_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockSiemMigrationTelemetryClient } from './mocks'; +export const SiemMigrationTelemetryClient = MockSiemMigrationTelemetryClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts new file mode 100644 index 0000000000000..1f0cb4afcb734 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getDashboardMigrationAgent = (params: object) => ({ + // TODO: Implement the dashboard migration agent + invoke: (input: object, config?: object) => {}, +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts new file mode 100644 index 0000000000000..21718296efd3c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts @@ -0,0 +1,9 @@ +/* + * 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 type MigrateDashboardConfigSchema = object; +export type MigrateDashboardState = object; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.test.ts new file mode 100644 index 0000000000000..cd83a985a1aff --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.test.ts @@ -0,0 +1,515 @@ +/* + * 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 { AuthenticatedUser } from '@kbn/core/server'; +import type { DashboardMigrationsRunning } from './dashboard_migrations_task_client'; +import { DashboardMigrationsTaskClient } from './dashboard_migrations_task_client'; +import { + SiemMigrationStatus, + SiemMigrationTaskStatus, +} from '../../../../../common/siem_migrations/constants'; +import { DashboardMigrationTaskRunner } from './dashboard_migrations_task_runner'; +import type { MockedLogger } from '@kbn/logging-mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { StoredDashboardMigration } from '../types'; +import type { DashboardMigrationTaskStartParams } from './types'; +import { createDashboardMigrationsDataClientMock } from '../data/__mocks__/mocks'; +import type { DashboardMigrationFilters } from '../../../../../common/siem_migrations/dashboards/types'; +import type { SiemMigrationsClientDependencies } from '../../common/types'; +import type { SiemMigrationDataStats } from '../../common/data/types'; + +jest.mock('./dashboard_migrations_task_runner', () => { + return { + DashboardMigrationTaskRunner: jest.fn().mockImplementation(() => { + return { + setup: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue(undefined), + abortController: { abort: jest.fn() }, + }; + }), + }; +}); + +const currentUser = {} as AuthenticatedUser; +const dependencies = {} as SiemMigrationsClientDependencies; +const migrationId = 'migration1'; + +describe('DashboardMigrationsTaskClient', () => { + let migrationsRunning: DashboardMigrationsRunning; + let logger: MockedLogger; + let data: ReturnType; + const params: DashboardMigrationTaskStartParams = { + migrationId, + connectorId: 'connector1', + invocationConfig: {}, + }; + + beforeEach(() => { + migrationsRunning = new Map(); + logger = loggerMock.create(); + + data = createDashboardMigrationsDataClientMock(); + // @ts-expect-error resetting private property for each test. + DashboardMigrationsTaskClient.migrationsLastError = new Map(); + jest.clearAllMocks(); + }); + + describe('start', () => { + it('should not start if migration is already running', async () => { + // Pre-populate with the migration id. + migrationsRunning.set(migrationId, {} as DashboardMigrationTaskRunner); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(result).toEqual({ exists: true, started: false }); + expect(data.items.updateStatus).not.toHaveBeenCalled(); + }); + + it('should not start if there are no dashboards to migrate (total = 0)', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 0, pending: 0, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(data.items.updateStatus).toHaveBeenCalledWith( + migrationId, + { status: SiemMigrationStatus.PROCESSING }, + SiemMigrationStatus.PENDING, + { refresh: true } + ); + expect(result).toEqual({ exists: false, started: false }); + }); + + it('should not start if there are no pending dashboards', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 0, completed: 10, failed: 0 }, + } as SiemMigrationDataStats); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(result).toEqual({ exists: true, started: false }); + }); + + it('should start migration successfully', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const mockedRunnerInstance = { + setup: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockResolvedValue(undefined), + abortController: { abort: jest.fn() }, + }; + // Use our custom mock for this test. + (DashboardMigrationTaskRunner as jest.Mock).mockImplementationOnce( + () => mockedRunnerInstance + ); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.start(params); + expect(result).toEqual({ exists: true, started: true }); + expect(logger.get).toHaveBeenCalledWith(migrationId); + expect(mockedRunnerInstance.setup).toHaveBeenCalledWith(params.connectorId); + expect(logger.get(migrationId).info).toHaveBeenCalledWith('Starting migration'); + expect(migrationsRunning.has(migrationId)).toBe(true); + + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + expect(migrationsRunning.has(migrationId)).toBe(false); + // @ts-expect-error check private property + expect(DashboardMigrationsTaskClient.migrationsLastError.has(migrationId)).toBe(false); + }); + + it('should throw error if a race condition occurs after setup', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const mockedRunnerInstance = { + setup: jest.fn().mockImplementationOnce(() => { + // Simulate a race condition by setting the migration as running during setup. + migrationsRunning.set(migrationId, {} as DashboardMigrationTaskRunner); + return Promise.resolve(); + }), + run: jest.fn().mockResolvedValue(undefined), + abortController: { abort: jest.fn() }, + }; + (DashboardMigrationTaskRunner as jest.Mock).mockImplementation(() => mockedRunnerInstance); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + await expect(client.start(params)).rejects.toThrow('Task already running for this migration'); + }); + + it('should mark migration as started by calling saveAsStarted', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + await client.start(params); + expect(data.migrations.saveAsStarted).toHaveBeenCalledWith({ + id: migrationId, + connectorId: params.connectorId, + }); + }); + + it('should mark migration as ended by calling saveAsEnded if run completes successfully', async () => { + migrationsRunning = new Map(); + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + await client.start(params); + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + expect(data.migrations.saveAsFinished).toHaveBeenCalledWith({ id: migrationId }); + }); + }); + + describe('updateToRetry', () => { + it('should not update if migration is currently running', async () => { + migrationsRunning.set(migrationId, {} as DashboardMigrationTaskRunner); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const filter: DashboardMigrationFilters = { fullyTranslated: true }; + const result = await client.updateToRetry(migrationId, filter); + expect(result).toEqual({ updated: false }); + expect(data.items.updateStatus).not.toHaveBeenCalled(); + }); + + it('should update to retry if migration is not running', async () => { + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const filter: DashboardMigrationFilters = { fullyTranslated: true }; + const result = await client.updateToRetry(migrationId, filter); + expect(filter.installed).toBe(false); + expect(data.items.updateStatus).toHaveBeenCalledWith( + migrationId, + { fullyTranslated: true, installed: false }, + SiemMigrationStatus.PENDING, + { refresh: true } + ); + expect(result).toEqual({ updated: true }); + }); + }); + + describe('getStats', () => { + it('should return RUNNING status if migration is running', async () => { + migrationsRunning.set(migrationId, {} as DashboardMigrationTaskRunner); // migration is running + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 5, completed: 3, failed: 2 }, + } as SiemMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredDashboardMigration); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.RUNNING); + }); + + it('should return READY status if pending equals total', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredDashboardMigration); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.READY); + }); + + it('should return FINISHED status if completed+failed equals total', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 0, completed: 5, failed: 5 }, + } as SiemMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredDashboardMigration); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.FINISHED); + }); + + it('should return STOPPED status for other cases', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 2, completed: 3, failed: 2 }, + } as SiemMigrationDataStats); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.status).toEqual(SiemMigrationTaskStatus.INTERRUPTED); + }); + + it('should include error if one exists', async () => { + const errorMessage = 'Test error'; + data.items.getStats.mockResolvedValue({ + id: 'migration-1', + items: { total: 10, pending: 2, completed: 3, failed: 2 }, + } as SiemMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: 'migration-1', + name: 'Test Migration', + created_at: new Date().toISOString(), + created_by: 'test-user', + last_execution: { + error: errorMessage, + }, + }); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + last_execution: { + error: 'Test error', + }, + } as unknown as StoredDashboardMigration); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const stats = await client.getStats(migrationId); + expect(stats.last_execution?.error).toEqual('Test error'); + }); + }); + + describe('getAllStats', () => { + it('should return combined stats for all migrations', async () => { + const statsArray = [ + { + id: 'm1', + items: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats, + { + id: 'm2', + items: { total: 10, pending: 2, completed: 3, failed: 2 }, + } as SiemMigrationDataStats, + ]; + const migrations = [{ id: 'm1' }, { id: 'm2' }] as unknown as StoredDashboardMigration[]; + data.items.getAllStats.mockResolvedValue(statsArray); + data.migrations.getAll.mockResolvedValue(migrations); + // Mark migration m1 as running. + migrationsRunning.set('m1', {} as DashboardMigrationTaskRunner); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const allStats = await client.getAllStats(); + const m1Stats = allStats.find((s) => s.id === 'm1'); + const m2Stats = allStats.find((s) => s.id === 'm2'); + expect(m1Stats?.status).toEqual(SiemMigrationTaskStatus.RUNNING); + expect(m2Stats?.status).toEqual(SiemMigrationTaskStatus.INTERRUPTED); + }); + }); + + describe('stop', () => { + it('should stop a running migration', async () => { + const abortMock = jest.fn(); + const migrationRunner = { + abortController: { abort: abortMock }, + } as unknown as DashboardMigrationTaskRunner; + migrationsRunning.set(migrationId, migrationRunner); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: true, stopped: true }); + expect(abortMock).toHaveBeenCalled(); + }); + + it('should return stopped even if migration is already stopped', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: true, stopped: true }); + }); + + it('should return exists false if migration is not running and total equals 0', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 0, pending: 0, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: false, stopped: true }); + }); + + it('should catch errors and return exists true, stopped false', async () => { + const error = new Error('Stop error'); + data.items.getStats.mockRejectedValue(error); + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + const result = await client.stop(migrationId); + expect(result).toEqual({ exists: true, stopped: false }); + expect(logger.error).toHaveBeenCalledWith( + `Error stopping migration ID:${migrationId}`, + error + ); + }); + + it('should mark migration task as stopped when manually stopping a running migration', async () => { + const abortMock = jest.fn(); + const migrationRunner = { + abortController: { abort: abortMock }, + } as unknown as DashboardMigrationTaskRunner; + migrationsRunning.set(migrationId, migrationRunner); + data.migrations.setIsStopped.mockResolvedValue(undefined); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + await client.stop(migrationId); + expect(data.migrations.setIsStopped).toHaveBeenCalledWith({ id: migrationId }); + }); + }); + describe('task error', () => { + it('should call saveAsFailed when there has been an error during the migration', async () => { + data.items.getStats.mockResolvedValue({ + items: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as SiemMigrationDataStats); + const error = new Error('Migration error'); + + const mockedRunnerInstance = { + setup: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockRejectedValue(error), + } as unknown as DashboardMigrationTaskRunner; + + (DashboardMigrationTaskRunner as jest.Mock).mockImplementation(() => mockedRunnerInstance); + + const client = new DashboardMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + const response = await client.start(params); + + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + + expect(response).toEqual({ exists: true, started: true }); + + expect(data.migrations.saveAsFailed).toHaveBeenCalledWith({ + id: migrationId, + error: error.message, + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts new file mode 100644 index 0000000000000..ac07a5a012b63 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { + DashboardMigration, + DashboardMigrationDashboard, +} from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import { DashboardMigrationTaskRunner } from './dashboard_migrations_task_runner'; +import { SiemMigrationsTaskClient } from '../../common/task/siem_migrations_task_client'; +import type { MigrateDashboardConfigSchema } from './agent/types'; +import { DashboardMigrationTaskEvaluator } from './dashboard_migrations_task_evaluator'; + +export type DashboardMigrationsRunning = Map; +export class DashboardMigrationsTaskClient extends SiemMigrationsTaskClient< + DashboardMigration, + DashboardMigrationDashboard, + MigrateDashboardConfigSchema +> { + protected readonly TaskRunnerClass = DashboardMigrationTaskRunner; + protected readonly EvaluatorClass = DashboardMigrationTaskEvaluator; + + // Dashboards specific last_execution config + protected getLastExecutionConfig( + invocationConfig: RunnableConfig + ): Record { + return { + skipPrebuiltDashboardsMatching: + invocationConfig.configurable?.skipPrebuiltDashboardsMatching ?? false, + }; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_evaluator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_evaluator.ts new file mode 100644 index 0000000000000..e9f5fac943eb8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_evaluator.ts @@ -0,0 +1,22 @@ +/* + * 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 { EvaluationResult } from 'langsmith/evaluation'; +import type { Run, Example } from 'langsmith/schemas'; +import { DashboardMigrationTaskRunner } from './dashboard_migrations_task_runner'; +import { SiemMigrationTaskEvaluable } from '../../common/task/siem_migrations_task_evaluator'; + +type CustomEvaluatorResult = Omit; +export type CustomEvaluator = (args: { run: Run; example: Example }) => CustomEvaluatorResult; + +export class DashboardMigrationTaskEvaluator extends SiemMigrationTaskEvaluable( + DashboardMigrationTaskRunner +) { + protected readonly evaluators: Record = { + // TODO: Implement custom evaluators for dashboard migrations + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts new file mode 100644 index 0000000000000..d47792d9f1804 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts @@ -0,0 +1,115 @@ +/* + * 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 { AuthenticatedUser, Logger } from '@kbn/core/server'; +import type { + ElasticDashboard, + DashboardMigration, + DashboardMigrationDashboard, +} from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { DashboardMigrationsDataClient } from '../data/dashboard_migrations_data_client'; +import type { MigrateDashboardConfigSchema, MigrateDashboardState } from './agent/types'; +import { getDashboardMigrationAgent } from './agent'; +import { DashboardMigrationsRetriever } from './retrievers'; +import type { StoredDashboardMigrationDashboard } from '../types'; +import { EsqlKnowledgeBase } from '../../common/task/util/esql_knowledge_base'; +import type { SiemMigrationsClientDependencies } from '../../common/types'; +import { SiemMigrationTaskRunner } from '../../common/task/siem_migrations_task_runner'; +import { DashboardMigrationTelemetryClient } from './dashboard_migrations_telemetry_client'; +import type { MigrationResources } from '../../common/task/retrievers/resource_retriever'; +import { nullifyMissingProperties } from '../../common/task/util/nullify_missing_properties'; + +export interface DashboardMigrationTaskInput + extends Pick { + resources: MigrationResources; +} +export type DashboardMigrationTaskOutput = MigrateDashboardState; + +export class DashboardMigrationTaskRunner extends SiemMigrationTaskRunner< + DashboardMigration, + DashboardMigrationDashboard, + DashboardMigrationTaskInput, + MigrateDashboardConfigSchema, + DashboardMigrationTaskOutput +> { + private retriever: DashboardMigrationsRetriever; + + constructor( + public readonly migrationId: string, + public readonly startedBy: AuthenticatedUser, + public readonly abortController: AbortController, + protected readonly data: DashboardMigrationsDataClient, + protected readonly logger: Logger, + protected readonly dependencies: SiemMigrationsClientDependencies + ) { + super(migrationId, startedBy, abortController, data, logger, dependencies); + this.retriever = new DashboardMigrationsRetriever(this.migrationId, { + data: this.data, + }); + } + + /** Retrieves the connector and creates the migration agent */ + public async setup(connectorId: string): Promise { + const { inferenceClient } = this.dependencies; + const model = await this.actionsClientChat.createModel({ + connectorId, + migrationId: this.migrationId, + abortController: this.abortController, + }); + + const telemetryClient = new DashboardMigrationTelemetryClient( + this.dependencies.telemetry, + this.logger, + this.migrationId, + model.model + ); + + const esqlKnowledgeBase = new EsqlKnowledgeBase( + connectorId, + this.migrationId, + inferenceClient, + this.logger + ); + + const agent = getDashboardMigrationAgent({ + esqlKnowledgeBase, + model, + dashboardMigrationsRetriever: this.retriever, + logger: this.logger, + telemetryClient, + }); + + this.telemetry = telemetryClient; + this.task = (input, config) => agent.invoke(input, config); + } + + protected async prepareTaskInput( + migrationDashboard: StoredDashboardMigrationDashboard + ): Promise { + const resources = await this.retriever.resources.getResources(migrationDashboard); + return { + id: migrationDashboard.id, + original_dashboard: migrationDashboard.original_dashboard, + resources, + }; + } + + protected processTaskOutput( + migrationDashboard: StoredDashboardMigrationDashboard, + migrationOutput: DashboardMigrationTaskOutput + ): StoredDashboardMigrationDashboard { + return { + ...migrationDashboard, + elastic_dashboard: nullifyMissingProperties({ + source: migrationDashboard.elastic_dashboard, + target: migrationOutput.elastic_dashboard as ElasticDashboard, + }), + translation_result: migrationOutput.translation_result, + // TODO: comments: migrationOutput.comments, + }; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_service.ts new file mode 100644 index 0000000000000..c5b485251c980 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_service.ts @@ -0,0 +1,43 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import type { DashboardMigrationTaskCreateClientParams } from './types'; +import { + DashboardMigrationsTaskClient, + type DashboardMigrationsRunning, +} from './dashboard_migrations_task_client'; + +export class DashboardMigrationsTaskService { + private migrationsRunning: DashboardMigrationsRunning; + + constructor(private logger: Logger) { + this.migrationsRunning = new Map(); + } + + public createClient({ + currentUser, + dataClient, + dependencies, + }: DashboardMigrationTaskCreateClientParams): DashboardMigrationsTaskClient { + return new DashboardMigrationsTaskClient( + this.migrationsRunning, + this.logger, + dataClient, + currentUser, + dependencies + ); + } + + /** Stops all running migrations */ + stopAll() { + this.migrationsRunning.forEach((migrationRunning) => { + migrationRunning.abortController.abort('Server shutdown'); + }); + this.migrationsRunning.clear(); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_telemetry_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_telemetry_client.ts new file mode 100644 index 0000000000000..72a9cab9491dd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_telemetry_client.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AnalyticsServiceSetup, Logger, EventTypeOpts } from '@kbn/core/server'; +import type { DashboardMigrationDashboard } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import { + SIEM_MIGRATIONS_MIGRATION_ABORTED, + SIEM_MIGRATIONS_MIGRATION_FAILURE, + SIEM_MIGRATIONS_MIGRATION_SUCCESS, + SIEM_MIGRATIONS_RULE_TRANSLATION_FAILURE, + SIEM_MIGRATIONS_RULE_TRANSLATION_SUCCESS, +} from '../../../telemetry/event_based/events'; +import { siemMigrationEventNames } from '../../../telemetry/event_based/event_meta'; +import { SiemMigrationsEventTypes } from '../../../telemetry/event_based/types'; +import type { MigrateDashboardState } from './agent/types'; +import type { SiemMigrationTelemetryClient } from '../../common/task/siem_migrations_telemetry_client'; + +export class DashboardMigrationTelemetryClient + implements SiemMigrationTelemetryClient +{ + constructor( + private readonly telemetry: AnalyticsServiceSetup, + private readonly logger: Logger, + private readonly migrationId: string, + private readonly modelName: string = '' + ) {} + + private reportEvent(eventTypeOpts: EventTypeOpts, data: T): void { + try { + this.telemetry.reportEvent(eventTypeOpts.eventType, data); + } catch (e) { + this.logger.error(`Error reporting event ${eventTypeOpts.eventType}: ${e.message}`); + } + } + + public startSiemMigrationTask() { + const startTime = Date.now(); + const stats = { completed: 0, failed: 0 }; + + return { + startItemTranslation: () => { + const dashboardStartTime = Date.now(); + return { + success: (migrationResult: MigrateDashboardState) => { + stats.completed++; + this.reportEvent(SIEM_MIGRATIONS_RULE_TRANSLATION_SUCCESS, { + migrationId: this.migrationId, + translationResult: migrationResult.translation_result || '', + duration: Date.now() - dashboardStartTime, + model: this.modelName, + prebuiltMatch: migrationResult.elastic_dashboard?.prebuilt_dashboard_id + ? true + : false, + eventName: siemMigrationEventNames[SiemMigrationsEventTypes.TranslationSuccess], + }); + }, + failure: (error: Error) => { + stats.failed++; + this.reportEvent(SIEM_MIGRATIONS_RULE_TRANSLATION_FAILURE, { + migrationId: this.migrationId, + error: error.message, + model: this.modelName, + eventName: siemMigrationEventNames[SiemMigrationsEventTypes.TranslationFailure], + }); + }, + }; + }, + success: () => { + const duration = Date.now() - startTime; + this.reportEvent(SIEM_MIGRATIONS_MIGRATION_SUCCESS, { + migrationId: this.migrationId, + model: this.modelName || '', + completed: stats.completed, + failed: stats.failed, + total: stats.completed + stats.failed, + duration, + eventName: siemMigrationEventNames[SiemMigrationsEventTypes.MigrationSuccess], + }); + }, + failure: (error: Error) => { + const duration = Date.now() - startTime; + this.reportEvent(SIEM_MIGRATIONS_MIGRATION_FAILURE, { + migrationId: this.migrationId, + model: this.modelName || '', + completed: stats.completed, + failed: stats.failed, + total: stats.completed + stats.failed, + duration, + error: error.message, + eventName: siemMigrationEventNames[SiemMigrationsEventTypes.MigrationFailure], + }); + }, + aborted: (error: Error) => { + const duration = Date.now() - startTime; + this.reportEvent(SIEM_MIGRATIONS_MIGRATION_ABORTED, { + migrationId: this.migrationId, + model: this.modelName || '', + completed: stats.completed, + failed: stats.failed, + total: stats.completed + stats.failed, + duration, + reason: error.message, + eventName: siemMigrationEventNames[SiemMigrationsEventTypes.MigrationAborted], + }); + }, + }; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts new file mode 100644 index 0000000000000..4b4b4f74ef648 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { RuleMigrationsRetriever } from '..'; + +export const createRuleMigrationsRetrieverMock = () => { + const mockResources = { + initialize: jest.fn().mockResolvedValue(undefined), + getResources: jest.fn().mockResolvedValue({}), + }; + + const mockIntegrations = { + populateIndex: jest.fn().mockResolvedValue(undefined), + search: jest.fn().mockResolvedValue([]), + }; + + const mockPrebuiltRules = { + populateIndex: jest.fn().mockResolvedValue(undefined), + search: jest.fn().mockResolvedValue([]), + }; + + const mockRetriever = { + resources: mockResources, + integrations: mockIntegrations, + prebuiltRules: mockPrebuiltRules, + initialize: jest.fn().mockResolvedValue(undefined), + }; + + return mockRetriever as jest.Mocked>; +}; + +export const MockRuleMigrationsRetriever = jest + .fn() + .mockImplementation(() => createRuleMigrationsRetrieverMock()); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/rule_migrations_retriever.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/rule_migrations_retriever.ts new file mode 100644 index 0000000000000..d7977fd3495e6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/rule_migrations_retriever.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsRetriever } from './mocks'; +export const RuleMigrationsRetriever = MockRuleMigrationsRetriever; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts new file mode 100644 index 0000000000000..3df4358903153 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DashboardMigrationsDataClient } from '../../data/dashboard_migrations_data_client'; +import { DashboardResourceRetriever } from './dashboard_resource_retriever'; + +export interface DashboardMigrationsRetrieverClients { + data: DashboardMigrationsDataClient; +} + +/** + * DashboardMigrationsRetriever is a class that is responsible for retrieving all the necessary data during the dashboard migration process. + * It is composed of multiple retrievers that are responsible for retrieving specific types of data. + * Such as dashboard integrations, prebuilt dashboards, and dashboard resources. + */ +export class DashboardMigrationsRetriever { + public readonly resources: DashboardResourceRetriever; + + constructor(migrationId: string, clients: DashboardMigrationsRetrieverClients) { + this.resources = new DashboardResourceRetriever(migrationId, clients.data.resources); + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_resource_retriever.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_resource_retriever.ts new file mode 100644 index 0000000000000..050f16ef22ac9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_resource_retriever.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DashboardResourceIdentifier } from '../../../../../../common/siem_migrations/dashboards/resources'; +import type { DashboardMigrationDashboard } from '../../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import { ResourceRetriever } from '../../../common/task/retrievers/resource_retriever'; + +export class DashboardResourceRetriever extends ResourceRetriever { + protected ResourceIdentifierClass = DashboardResourceIdentifier; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/index.ts new file mode 100644 index 0000000000000..34a8293ce40e6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/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 { DashboardMigrationsRetriever } from './dashboard_migrations_retriever'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/types.ts new file mode 100644 index 0000000000000..b3837eac23495 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AuthenticatedUser } from '@kbn/core/server'; +import type { LangSmithEvaluationOptions } from '../../../../../common/siem_migrations/model/common.gen'; +import type { DashboardMigrationsDataClient } from '../data/dashboard_migrations_data_client'; +import type { StoredDashboardMigrationDashboard } from '../types'; +import type { getDashboardMigrationAgent } from './agent'; +import type { DashboardMigrationTelemetryClient } from './dashboard_migrations_telemetry_client'; +import type { ChatModel } from '../../common/task/util/actions_client_chat'; +import type { DashboardMigrationsRetriever } from './retrievers'; +import type { MigrateDashboardGraphConfig } from './agent/types'; +import type { SiemMigrationsClientDependencies } from '../../common/types'; +import type { MigrationResources } from '../../common/task/retrievers/resource_retriever'; + +export type MigrationAgent = ReturnType; + +export interface DashboardMigrationInput + extends Pick { + resources: MigrationResources; +} + +export interface DashboardMigrationTaskCreateClientParams { + currentUser: AuthenticatedUser; + dataClient: DashboardMigrationsDataClient; + dependencies: SiemMigrationsClientDependencies; +} + +export interface DashboardMigrationTaskStartParams { + migrationId: string; + connectorId: string; + invocationConfig: MigrateDashboardGraphConfig; +} + +export interface DashboardMigrationTaskRunParams extends DashboardMigrationTaskStartParams { + model: ChatModel; + abortController: AbortController; +} + +export interface DashboardMigrationTaskCreateAgentParams { + connectorId: string; + retriever: DashboardMigrationsRetriever; + telemetryClient: DashboardMigrationTelemetryClient; + model: ChatModel; +} + +export interface DashboardMigrationTaskStartResult { + started: boolean; + exists: boolean; +} + +export interface DashboardMigrationTaskStopResult { + stopped: boolean; + exists: boolean; +} + +export interface DashboardMigrationTaskEvaluateParams { + evaluationId: string; + connectorId: string; + langsmithOptions: LangSmithEvaluationOptions; + invocationConfig: MigrateDashboardGraphConfig; + abortController: AbortController; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts index 9f446fcf66af8..0fdc074e9e906 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/types.ts @@ -6,6 +6,11 @@ */ import type { IndexPatternAdapter } from '@kbn/index-adapter'; +import type { + DashboardMigration, + DashboardMigrationDashboard, +} from '../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { Stored } from '../types'; export interface DashboardMigrationAdapters { migrations: IndexPatternAdapter; @@ -20,3 +25,6 @@ export type DashboardMigrationIndexNameProviders = Record< DashboardMigrationAdapterId, DashboardMigrationIndexNameProvider >; + +export type StoredDashboardMigration = Stored; +export type StoredDashboardMigrationDashboard = Stored; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts index 37879cf6318f2..6dfc2e0d81aa5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/evaluation/evaluate.ts @@ -16,7 +16,7 @@ import { createTracersCallbacks } from '../../../common/api/util/tracing'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; import { authz } from '../../../common/api/util/authz'; import { withLicense } from '../../../common/api/util/with_license'; -import type { MigrateRuleGraphConfig } from '../../task/agent/types'; +import type { MigrateRuleConfig } from '../../task/agent/types'; const REQUEST_TIMEOUT = 10 * 60 * 1000; // 10 minutes @@ -62,7 +62,7 @@ export const registerSiemRuleMigrationsEvaluateRoute = ( const securitySolutionContext = await context.securitySolution; const ruleMigrationsClient = securitySolutionContext.siemMigrations.getRulesClient(); - const invocationConfig: MigrateRuleGraphConfig = { + const invocationConfig: MigrateRuleConfig = { callbacks: createTracersCallbacks(langsmithOptions, logger), configurable: { skipPrebuiltRulesMatching, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts index 9e96bde0ec686..f350e909b1341 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/util/installation.ts @@ -15,7 +15,7 @@ import { performTimelinesInstallation } from '../../../../detection_engine/prebu import { createPrebuiltRules } from '../../../../detection_engine/prebuilt_rules/logic/rule_objects/create_prebuilt_rules'; import type { IDetectionRulesClient } from '../../../../detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface'; import type { RuleResponse } from '../../../../../../common/api/detection_engine'; -import type { StoredRuleMigration } from '../../types'; +import type { StoredRuleMigrationRule } from '../../types'; import { getPrebuiltRules, getUniquePrebuiltRuleIds } from './prebuilt_rules'; import { convertMigrationCustomRuleToSecurityRulePayload, @@ -26,7 +26,7 @@ import { getVendorTag } from './tags'; const MAX_CUSTOM_RULES_TO_CREATE_IN_PARALLEL = 50; const installPrebuiltRules = async ( - rulesToInstall: StoredRuleMigration[], + rulesToInstall: StoredRuleMigrationRule[], enabled: boolean, securitySolutionContext: SecuritySolutionApiRequestHandlerContext, rulesClient: RulesClient, @@ -88,7 +88,7 @@ const installPrebuiltRules = async ( }; export const installCustomRules = async ( - rulesToInstall: StoredRuleMigration[], + rulesToInstall: StoredRuleMigrationRule[], enabled: boolean, detectionRulesClient: IDetectionRulesClient ): Promise<{ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts index 5508e98756fb0..18a53ebfbb4c4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts @@ -13,7 +13,7 @@ import type { QueryDslQueryContainer, } from '@elastic/elasticsearch/lib/api/types'; import type { estypes } from '@elastic/elasticsearch'; -import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/rules/types'; import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import { type RuleMigrationTaskStats, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.test.ts index 38df357d295c7..308cf85eda3cf 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.test.ts @@ -6,7 +6,7 @@ */ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; -import type { RuleMigrationAdapters, StoredSiemMigration } from '../types'; +import type { RuleMigrationAdapters, StoredRuleMigration } from '../types'; import { RuleMigrationSpaceIndexMigrator } from './rule_migrations_per_space_index_migrator'; import type { SearchResponseBody } from 'elasticsearch-8.x/lib/api/types'; @@ -33,7 +33,7 @@ const mockMigrationsIndexResult = { hits: { hits: [], }, -} as unknown as SearchResponseBody; +} as unknown as SearchResponseBody; const getMockedESSearchFunction = ( rulesIndexAggResult: typeof mockRuleIndexAggregationsResult = mockRuleIndexAggregationsResult, @@ -114,7 +114,7 @@ describe('RuleMigrationSpaceIndexMigrator', () => { }, ], }, - } as unknown as SearchResponseBody; + } as unknown as SearchResponseBody; esClientMock.search.mockImplementation( getMockedESSearchFunction( mockRuleIndexAggregationsResult, @@ -164,7 +164,7 @@ describe('RuleMigrationSpaceIndexMigrator', () => { }, ], }, - } as unknown as SearchResponseBody; + } as unknown as SearchResponseBody; esClientMock.search.mockImplementation( getMockedESSearchFunction( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts index b03def0e2eece..efa372bc288b9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts @@ -12,7 +12,7 @@ import type { AggregationsStringTermsAggregate, AggregationsStringTermsBucket, } from '@elastic/elasticsearch/lib/api/types'; -import type { RuleMigrationAdapters, StoredSiemMigration } from '../types'; +import type { RuleMigrationAdapters, StoredRuleMigration } from '../types'; import { MAX_ES_SEARCH_SIZE } from '../constants'; export class RuleMigrationSpaceIndexMigrator { @@ -101,7 +101,7 @@ export class RuleMigrationSpaceIndexMigrator { /** * Creates migration documents in the migrations index. */ - private async createMigrationDocs(docs: Array>) { + private async createMigrationDocs(docs: Array>) { const _index = this.ruleMigrationIndexAdapters.migrations.getIndexName(this.spaceId); const operations = docs.flatMap(({ id: _id, ...doc }) => [ { create: { _id, _index } }, @@ -113,7 +113,7 @@ export class RuleMigrationSpaceIndexMigrator { /** * Updates migration documents in the migrations index. */ - private async updateMigrationDocs(docs: Array>) { + private async updateMigrationDocs(docs: Array>) { const _index = this.ruleMigrationIndexAdapters.migrations.getIndexName(this.spaceId); const operations = docs.flatMap(({ id: _id, ...doc }) => [ { update: { _id, _index } }, @@ -159,7 +159,7 @@ export class RuleMigrationSpaceIndexMigrator { */ private async getMigrationIdsFromMigrationsIndex(): Promise { const index = this.ruleMigrationIndexAdapters.migrations.getIndexName(this.spaceId); - const result = await this.esClient.search({ + const result = await this.esClient.search({ index, size: MAX_ES_SEARCH_SIZE, query: { match_all: {} }, @@ -178,7 +178,7 @@ export class RuleMigrationSpaceIndexMigrator { private async getMigrationIdsWithoutName(): Promise { const index = this.ruleMigrationIndexAdapters.migrations.getIndexName(this.spaceId); - const result = await this.esClient.search({ + const result = await this.esClient.search({ index, query: { bool: { must_not: { exists: { field: 'name' } } } }, size: MAX_ES_SEARCH_SIZE, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.test.ts index 53f79b5207e87..3b2b002905ef9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.test.ts @@ -9,7 +9,7 @@ import type { ActionsClientChatOpenAI } from '@kbn/langchain/server/language_mod import { loggerMock } from '@kbn/logging-mocks'; import type { NodeResponse } from '../__mocks__/mocks'; import { SiemMigrationFakeLLM, MockSiemMigrationTelemetryClient } from '../__mocks__/mocks'; -import { MockEsqlKnowledgeBase } from '../util/__mocks__/mocks'; +import { MockEsqlKnowledgeBase } from '../../../common/task/util/__mocks__/mocks'; import { MockRuleMigrationsRetriever } from '../retrievers/__mocks__/mocks'; import { getRuleMigrationAgent } from './graph'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts index fce206bea969e..3f981e3f48cd5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts @@ -11,9 +11,9 @@ import { getMatchPrebuiltRuleNode } from './nodes/match_prebuilt_rule'; import { migrateRuleConfigSchema, migrateRuleState } from './state'; import { getTranslateRuleGraph } from './sub_graphs/translate_rule'; import type { - MigrateRuleGraphConfig, + MigrateRuleConfig, MigrateRuleGraphParams, - MigrateRuleGraphState, + MigrateRuleState, } from './types'; export function getRuleMigrationAgent({ @@ -62,8 +62,8 @@ export function getRuleMigrationAgent({ } const skipPrebuiltRuleConditional = ( - _state: MigrateRuleGraphState, - config: MigrateRuleGraphConfig + _state: MigrateRuleState, + config: MigrateRuleConfig ) => { if (config.configurable?.skipPrebuiltRulesMatching) { return 'translationSubGraph'; @@ -71,7 +71,7 @@ const skipPrebuiltRuleConditional = ( return 'matchPrebuiltRule'; }; -const matchedPrebuiltRuleConditional = (state: MigrateRuleGraphState) => { +const matchedPrebuiltRuleConditional = (state: MigrateRuleState) => { if (state.elastic_rule?.prebuilt_rule_id) { return END; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/create_semantic_query/create_semantic_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/create_semantic_query/create_semantic_query.ts index 446b96234711a..0af5f46f79ab4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/create_semantic_query/create_semantic_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/create_semantic_query/create_semantic_query.ts @@ -6,7 +6,7 @@ */ import { JsonOutputParser } from '@langchain/core/output_parsers'; -import type { ChatModel } from '../../../util/actions_client_chat'; +import type { ChatModel } from '../../../../../common/task/util/actions_client_chat'; import type { GraphNode } from '../../types'; import { CREATE_SEMANTIC_QUERY_PROMPT } from './prompts'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts index e0b9542046c20..c0ee9cdec3e4d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/match_prebuilt_rule/match_prebuilt_rule.ts @@ -7,10 +7,10 @@ import type { Logger } from '@kbn/core/server'; import { JsonOutputParser } from '@langchain/core/output_parsers'; +import type { ChatModel } from '../../../../../common/task/util/actions_client_chat'; import { RuleTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; import type { RuleMigrationsRetriever } from '../../../retrievers'; import type { RuleMigrationTelemetryClient } from '../../../rule_migrations_telemetry_client'; -import type { ChatModel } from '../../../util/actions_client_chat'; import { cleanMarkdown, generateAssistantComment } from '../../../../../common/task/util/comments'; import type { GraphNode } from '../../types'; import { MATCH_PREBUILT_RULE_PROMPT } from './prompts'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts index 0216adb44c5dc..41667bcff373c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts @@ -13,11 +13,11 @@ import type { OriginalRule, RuleMigrationRule, } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationResources } from '../retrievers/rule_resource_retriever'; +import type { MigrationResources } from '../../../common/task/retrievers/resource_retriever'; export const migrateRuleState = Annotation.Root({ original_rule: Annotation(), - resources: Annotation(), + resources: Annotation(), elastic_rule: Annotation({ reducer: (state, action) => ({ ...state, ...action }), }), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/cim_ecs_map.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/cim_ecs_map.ts deleted file mode 100644 index 3bafaf2fc6518..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/cim_ecs_map.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const SIEM_RULE_MIGRATION_CIM_ECS_MAP = ` -datamodel,object,source_field,ecs_field,data_type -Application_State,All_Application_State,dest,service.node.name,string -Application_State,All_Application_State,process,process.title,string -Application_State,All_Application_State,user,user.name,string -Application_State,Ports,dest_port,destination.port,number -Application_State,Ports,transport,network.transport,string -Application_State,Ports,transport_dest_port,destination.port,string -Application_State,Services,service,service.name,string -Application_State,Services,service_id,service.id,string -Application_State,Services,status,service.state,string -Authentication,Authentication,action,event.action,string -Authentication,Authentication,app,process.name,string -Authentication,Authentication,dest,host.name,string -Authentication,Authentication,duration,event.duration,number -Authentication,Authentication,signature,event.code,string -Authentication,Authentication,signature_id,event.reason,string -Authentication,Authentication,src,source.address,string -Authentication,Authentication,src_nt_domain,source.domain,string -Authentication,Authentication,user,user.name,string -Certificates,All_Certificates,dest_port,destination.port,number -Certificates,All_Certificates,duration,event.duration,number -Certificates,All_Certificates,src,source.address,string -Certificates,All_Certificates,src_port,source.port,number -Certificates,All_Certificates,transport,network.protocol,string -Certificates,SSL,ssl_end_time,tls.server.not_after,time -Certificates,SSL,ssl_hash,tls.server.hash,string -Certificates,SSL,ssl_issuer_common_name,tls.server.issuer,string -Certificates,SSL,ssl_issuer_locality,x509.issuer.locality,string -Certificates,SSL,ssl_issuer_organization,x509.issuer.organization,string -Certificates,SSL,ssl_issuer_state,x509.issuer.state_or_province,string -Certificates,SSL,ssl_issuer_unit,x509.issuer.organizational_unit,string -Certificates,SSL,ssl_publickey_algorithm,x509.public_key_algorithm,string -Certificates,SSL,ssl_serial,x509.serial_number,string -Certificates,SSL,ssl_signature_algorithm,x509.signature_algorithm,string -Certificates,SSL,ssl_start_time,x509.not_before,time -Certificates,SSL,ssl_subject,x509.subject.distinguished_name,string -Certificates,SSL,ssl_subject_common_name,x509.subject.common_name,string -Certificates,SSL,ssl_subject_locality,x509.subject.locality,string -Certificates,SSL,ssl_subject_organization,x509.subject.organization,string -Certificates,SSL,ssl_subject_state,x509.subject.state_or_province,string -Certificates,SSL,ssl_subject_unit,x509.subject.organizational_unit,string -Certificates,SSL,ssl_version,tls.version,string -Change,All_Changes,action,event.action,string -Change,Account_Management,dest_nt_domain,destination.domain,string -Change,Account_Management,src_nt_domain,source.domain,string -Change,Account_Management,src_user,source.user,string -Intrusion_Detection,IDS_Attacks,action,event.action,string -Intrusion_Detection,IDS_Attacks,dest,destination.address,string -Intrusion_Detection,IDS_Attacks,dest_port,destination.port,number -Intrusion_Detection,IDS_Attacks,dvc,observer.hostname,string -Intrusion_Detection,IDS_Attacks,severity,event.severity,string -Intrusion_Detection,IDS_Attacks,src,source.ip,string -Intrusion_Detection,IDS_Attacks,user,source.user,string -JVM,OS,os,host.os.name,string -JVM,OS,os_architecture,host.architecture,string -JVM,OS,os_version,host.os.version,string -Malware,Malware_Attacks,action,event.action,string -Malware,Malware_Attacks,date,event.created,string -Malware,Malware_Attacks,dest,host.hostname,string -Malware,Malware_Attacks,file_hash,file.hash.*,string -Malware,Malware_Attacks,file_name,file.name,string -Malware,Malware_Attacks,file_path,file.path,string -Malware,Malware_Attacks,Sender,source.user.email,string -Malware,Malware_Attacks,src,source.ip,string -Malware,Malware_Attacks,user,related.user,string -Malware,Malware_Attacks,url,rule.reference,string -Network_Resolution,DNS,answer,dns.answers,string -Network_Resolution,DNS,dest,destination.address,string -Network_Resolution,DNS,dest_port,destination.port,number -Network_Resolution,DNS,duration,event.duration,number -Network_Resolution,DNS,message_type,dns.type,string -Network_Resolution,DNS,name,dns.question.name,string -Network_Resolution,DNS,query,dns.question.name,string -Network_Resolution,DNS,query_type,dns.op_code,string -Network_Resolution,DNS,record_type,dns.question.type,string -Network_Resolution,DNS,reply_code,dns.response_code,string -Network_Resolution,DNS,reply_code_id,dns.id,number -Network_Resolution,DNS,response_time,event.duration,number -Network_Resolution,DNS,src,source.address,string -Network_Resolution,DNS,src_port,source.port,number -Network_Resolution,DNS,transaction_id,dns.id,number -Network_Resolution,DNS,transport,network.transport,string -Network_Resolution,DNS,ttl,dns.answers.ttl,number -Network_Sessions,All_Sessions,action,event.action,string -Network_Sessions,All_Sessions,dest_ip,destination.ip,string -Network_Sessions,All_Sessions,dest_mac,destination.mac,string -Network_Sessions,All_Sessions,duration,event.duration,number -Network_Sessions,All_Sessions,src_dns,source.registered_domain,string -Network_Sessions,All_Sessions,src_ip,source.ip,string -Network_Sessions,All_Sessions,src_mac,source.mac,string -Network_Sessions,All_Sessions,user,user.name,string -Network_Traffic,All_Traffic,action,event.action,string -Network_Traffic,All_Traffic,app,network.protocol,string -Network_Traffic,All_Traffic,bytes,network.bytes,number -Network_Traffic,All_Traffic,dest,destination.ip,string -Network_Traffic,All_Traffic,dest_ip,destination.ip,string -Network_Traffic,All_Traffic,dest_mac,destination.mac,string -Network_Traffic,All_Traffic,dest_port,destination.port,number -Network_Traffic,All_Traffic,dest_translated_ip,destination.nat.ip,string -Network_Traffic,All_Traffic,dest_translated_port,destination.nat.port,number -Network_Traffic,All_Traffic,direction,network.direction,string -Network_Traffic,All_Traffic,duration,event.duration,number -Network_Traffic,All_Traffic,dvc,observer.name,string -Network_Traffic,All_Traffic,dvc_ip,observer.ip,string -Network_Traffic,All_Traffic,dvc_mac,observer.mac,string -Network_Traffic,All_Traffic,dvc_zone,observer.egress.zone,string -Network_Traffic,All_Traffic,packets,network.packets,number -Network_Traffic,All_Traffic,packets_in,source.packets,number -Network_Traffic,All_Traffic,packets_out,destination.packets,number -Network_Traffic,All_Traffic,protocol,network.protocol,string -Network_Traffic,All_Traffic,rule,rule.name,string -Network_Traffic,All_Traffic,src,source.address,string -Network_Traffic,All_Traffic,src_ip,source.ip,string -Network_Traffic,All_Traffic,src_mac,source.mac,string -Network_Traffic,All_Traffic,src_port,source.port,number -Network_Traffic,All_Traffic,src_translated_ip,source.nat.ip,string -Network_Traffic,All_Traffic,src_translated_port,source.nat.port,number -Network_Traffic,All_Traffic,transport,network.transport,string -Network_Traffic,All_Traffic,vlan,vlan.name,string -Vulnerabilities,Vulnerabilities,category,vulnerability.category,string -Vulnerabilities,Vulnerabilities,cve,vulnerability.id,string -Vulnerabilities,Vulnerabilities,cvss,vulnerability.score.base,number -Vulnerabilities,Vulnerabilities,dest,host.name,string -Vulnerabilities,Vulnerabilities,dvc,vulnerability.scanner.vendor,string -Vulnerabilities,Vulnerabilities,severity,vulnerability.severity,string -Vulnerabilities,Vulnerabilities,url,vulnerability.reference,string -Vulnerabilities,Vulnerabilities,user,related.user,string -Vulnerabilities,Vulnerabilities,vendor_product,vulnerability.scanner.vendor,string -Endpoint,Ports,creation_time,@timestamp,timestamp -Endpoint,Ports,dest_port,destination.port,number -Endpoint,Ports,process_id,process.pid,string -Endpoint,Ports,transport,network.transport,string -Endpoint,Ports,transport_dest_port,destination.port,string -Endpoint,Processes,action,event.action,string -Endpoint,Processes,os,os.full,string -Endpoint,Processes,parent_process_exec,process.parent.name,string -Endpoint,Processes,parent_process_id,process.ppid,number -Endpoint,Processes,parent_process_guid,process.parent.entity_id,string -Endpoint,Processes,parent_process_path,process.parent.executable,string -Endpoint,Processes,process_current_directory,process.parent.working_directory, -Endpoint,Processes,process_exec,process.name,string -Endpoint,Processes,process_hash,process.hash.*,string -Endpoint,Processes,process_guid,process.entity_id,string -Endpoint,Processes,process_id,process.pid,number -Endpoint,Processes,process_path,process.executable,string -Endpoint,Processes,user_id,related.user,string -Endpoint,Services,description,service.name,string -Endpoint,Services,process_id,service.id,string -Endpoint,Services,service_dll,dll.name,string -Endpoint,Services,service_dll_path,dll.path,string -Endpoint,Services,service_dll_hash,dll.hash.*,string -Endpoint,Services,service_dll_signature_exists,dll.code_signature.exists,boolean -Endpoint,Services,service_dll_signature_verified,dll.code_signature.valid,boolean -Endpoint,Services,service_exec,service.name,string -Endpoint,Services,service_hash,hash.*,string -Endpoint,Filesystem,file_access_time,file.accessed,timestamp -Endpoint,Filesystem,file_create_time,file.created,timestamp -Endpoint,Filesystem,file_modify_time,file.mtime,timestamp -Endpoint,Filesystem,process_id,process.pid,string -Endpoint,Registry,process_id,process.id,string -Web,Web,action,event.action,string -Web,Web,app,observer.product,string -Web,Web,bytes_in,http.request.bytes,number -Web,Web,bytes_out,http.response.bytes,number -Web,Web,dest,destination.ip,string -Web,Web,duration,event.duration,number -Web,Web,http_method,http.request.method,string -Web,Web,http_referrer,http.request.referrer,string -Web,Web,http_user_agent,user_agent.name,string -Web,Web,status,http.response.status_code,string -Web,Web,url,url.full,string -Web,Web,user,url.username,string -Web,Web,vendor_product,observer.product,string`; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts index 10d8dcb359c68..0b70bc67ea9f5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/ecs_mapping/ecs_mapping.ts @@ -5,58 +5,27 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; -import type { EsqlKnowledgeBase } from '../../../../../util/esql_knowledge_base'; +import { + getConvertEsqlSchemaCisToEcs, + type GetConvertEsqlSchemaCisToEcsParams, +} from '../../../../../../../common/task/agent/tools/convert_esql_schema_cim_to_ecs'; import type { GraphNode } from '../../types'; -import { SIEM_RULE_MIGRATION_CIM_ECS_MAP } from './cim_ecs_map'; -import { ESQL_TRANSLATE_ECS_MAPPING_PROMPT } from './prompts'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; -interface GetEcsMappingNodeParams { - esqlKnowledgeBase: EsqlKnowledgeBase; - logger: Logger; -} - -export const getEcsMappingNode = ({ - esqlKnowledgeBase, - logger, -}: GetEcsMappingNodeParams): GraphNode => { +export const getEcsMappingNode = (params: GetConvertEsqlSchemaCisToEcsParams): GraphNode => { + const convertEsqlSchemaCimToEcs = getConvertEsqlSchemaCisToEcs(params); return async (state) => { - const elasticRule = { - title: state.elastic_rule.title, - description: state.elastic_rule.description, - query: state.elastic_rule.query, - }; - - const prompt = await ESQL_TRANSLATE_ECS_MAPPING_PROMPT.format({ - field_mapping: SIEM_RULE_MIGRATION_CIM_ECS_MAP, - splunk_query: state.inline_query, - elastic_rule: JSON.stringify(elasticRule, null, 2), + const { query, comments } = await convertEsqlSchemaCimToEcs({ + title: state.elastic_rule.title ?? '', + description: state.elastic_rule.description ?? '', + query: state.elastic_rule.query ?? '', + originalQuery: state.inline_query, }); - const response = await esqlKnowledgeBase.translate(prompt); - - const updatedQuery = response.match(/```esql\n([\s\S]*?)\n```/)?.[1] ?? ''; - if (!updatedQuery) { - logger.warn('Failed to apply ECS mapping to the query'); - const summary = '## Field Mapping Summary\n\nFailed to apply ECS mapping to the query'; - return { - includes_ecs_mapping: true, - comments: [generateAssistantComment(summary)], - }; - } - - const ecsSummary = response.match(/## Field Mapping Summary[\s\S]*$/)?.[0] ?? ''; - - // We set includes_ecs_mapping to true to indicate that the ecs mapping has been applied. - // This is to ensure that the node only runs once + // Set includes_ecs_mapping to indicate that this node has been executed to ensure it only runs once return { - comments: [generateAssistantComment(cleanMarkdown(ecsSummary))], includes_ecs_mapping: true, - elastic_rule: { - ...state.elastic_rule, - query: updatedQuery, - }, + comments, + ...(query && { elastic_rule: { ...state.elastic_rule, query } }), }; }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/fix_query_errors/fix_query_errors.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/fix_query_errors/fix_query_errors.ts index dc010840f9f6e..7a79ae48f44cc 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/fix_query_errors/fix_query_errors.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/fix_query_errors/fix_query_errors.ts @@ -5,30 +5,22 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; -import type { EsqlKnowledgeBase } from '../../../../../util/esql_knowledge_base'; +import { + getFixEsqlQueryErrors, + type GetFixEsqlQueryErrorsParams, +} from '../../../../../../../common/task/agent/tools/fix_esql_query_errors'; import type { GraphNode } from '../../types'; -import { RESOLVE_ESQL_ERRORS_TEMPLATE } from './prompts'; -interface GetFixQueryErrorsNodeParams { - esqlKnowledgeBase: EsqlKnowledgeBase; - logger: Logger; -} - -export const getFixQueryErrorsNode = ({ - esqlKnowledgeBase, - logger, -}: GetFixQueryErrorsNodeParams): GraphNode => { +export const getFixQueryErrorsNode = (params: GetFixEsqlQueryErrorsParams): GraphNode => { + const fixEsqlQueryErrors = getFixEsqlQueryErrors(params); return async (state) => { - const rule = state.elastic_rule; - const prompt = await RESOLVE_ESQL_ERRORS_TEMPLATE.format({ - esql_errors: state.validation_errors.esql_errors, - esql_query: rule.query, + const { query } = await fixEsqlQueryErrors({ + invalidQuery: state.elastic_rule.query, + validationErrors: state.validation_errors.esql_errors, }); - const response = await esqlKnowledgeBase.translate(prompt); - - const esqlQuery = response.match(/```esql\n([\s\S]*?)\n```/)?.[1] ?? ''; - rule.query = esqlQuery; - return { elastic_rule: rule }; + if (!query) { + return {}; + } + return { elastic_rule: { ...state.elastic_rule, query } }; }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts index 92b49b39aee90..1f75ee8744246 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts @@ -4,76 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import type { Logger } from '@kbn/core/server'; -import { StringOutputParser } from '@langchain/core/output_parsers'; -import { isEmpty } from 'lodash/fp'; -import type { ChatModel } from '../../../../../util/actions_client_chat'; +import { + getInlineSplQuery, + type GetInlineSplQueryParams, +} from '../../../../../../../common/task/agent/tools/inline_spl_query'; import type { GraphNode } from '../../../../types'; -import { REPLACE_QUERY_RESOURCE_PROMPT, getResourcesContext } from './prompts'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; - -interface GetInlineQueryNodeParams { - model: ChatModel; - logger: Logger; -} -export const getInlineQueryNode = ({ model, logger }: GetInlineQueryNodeParams): GraphNode => { +export const getInlineQueryNode = (params: GetInlineSplQueryParams): GraphNode => { + const inlineSplQuery = getInlineSplQuery(params); return async (state) => { - const query = state.original_rule.query; - // Check before to avoid unnecessary LLM calls - let unsupportedComment = getUnsupportedComment(query); - if (unsupportedComment) { - return { - inline_query: undefined, // No inline query if unsupported to jump to the end of the graph - comments: [generateAssistantComment(unsupportedComment)], - }; - } - - if (isEmpty(state.resources)) { - // No resources identified in the query, no need to replace - return { inline_query: query }; - } - - const replaceQueryParser = new StringOutputParser(); - const replaceQueryResourcePrompt = - REPLACE_QUERY_RESOURCE_PROMPT.pipe(model).pipe(replaceQueryParser); - const resourceContext = getResourcesContext(state.resources); - const response = await replaceQueryResourcePrompt.invoke({ + const { inlineQuery, isUnsupported, comments } = await inlineSplQuery({ query: state.original_rule.query, - macros: resourceContext.macros, - lookups: resourceContext.lookups, + resources: state.resources, }); - const inlineQuery = response.match(/```spl\n([\s\S]*?)\n```/)?.[1].trim() ?? ''; - if (!inlineQuery) { - logger.warn('Failed to retrieve inline query'); - const summary = '## Inlining Summary\n\nFailed to retrieve inline query'; - return { - inline_query: query, - comments: [generateAssistantComment(summary)], - }; + if (isUnsupported) { + // Graph conditional edge detects undefined inline_query as unsupported query + return { inline_query: undefined, comments }; } - - // Check after replacing in case the replacements made it untranslatable - unsupportedComment = getUnsupportedComment(inlineQuery); - if (unsupportedComment) { - return { - inline_query: undefined, // No inline query if unsupported to jump to the end of the graph - comments: [generateAssistantComment(unsupportedComment)], - }; - } - - const inliningSummary = response.match(/## Inlining Summary[\s\S]*$/)?.[0] ?? ''; return { - inline_query: inlineQuery, - comments: [generateAssistantComment(cleanMarkdown(inliningSummary))], + inline_query: inlineQuery ?? state.original_rule.query, + comments, }; }; }; - -const getUnsupportedComment = (query: string): string | undefined => { - const unsupportedText = '## Translation Summary\nCan not create custom translation.\n'; - if (query.includes(' inputlookup')) { - return `${unsupportedText}Reason: \`inputlookup\` command is not supported.`; - } -}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts index de12e7e82fe46..4701ad5268683 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translate_rule/translate_rule.ts @@ -5,56 +5,37 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; -import { cleanMarkdown, generateAssistantComment } from '../../../../../../../common/task/util/comments'; -import type { EsqlKnowledgeBase } from '../../../../../util/esql_knowledge_base'; +import { + getTranslateSplToEsql, + TASK_DESCRIPTION, + type GetTranslateSplToEsqlParams, +} from '../../../../../../../common/task/agent/tools/translate_spl_to_esql'; import type { GraphNode } from '../../types'; -import { ESQL_SYNTAX_TRANSLATION_PROMPT } from './prompts'; import { getElasticRiskScoreFromOriginalRule, getElasticSeverityFromOriginalRule, } from './severity'; -interface GetTranslateRuleNodeParams { - esqlKnowledgeBase: EsqlKnowledgeBase; - logger: Logger; -} - -export const getTranslateRuleNode = ({ - esqlKnowledgeBase, - logger, -}: GetTranslateRuleNodeParams): GraphNode => { +export const getTranslateRuleNode = (params: GetTranslateSplToEsqlParams): GraphNode => { + const translateSplToEsql = getTranslateSplToEsql(params); return async (state) => { const indexPatterns = state.integration?.data_streams?.map((dataStream) => dataStream.index_pattern).join(',') || 'logs-*'; - const splunkRule = { + const { esqlQuery, comments } = await translateSplToEsql({ title: state.original_rule.title, + taskDescription: TASK_DESCRIPTION.migrate_rule, description: state.original_rule.description, - inline_query: state.inline_query, - }; - - const prompt = await ESQL_SYNTAX_TRANSLATION_PROMPT.format({ - splunk_rule: JSON.stringify(splunkRule, null, 2), - indexPatterns, + inlineQuery: state.inline_query, + indexPattern: indexPatterns, }); - const response = await esqlKnowledgeBase.translate(prompt); - const esqlQuery = response.match(/```esql\n([\s\S]*?)\n```/)?.[1].trim() ?? ''; if (!esqlQuery) { - logger.warn('Failed to extract ESQL query from translation response'); - const comment = - '## Translation Summary\n\nFailed to extract ESQL query from translation response'; - return { - comments: [generateAssistantComment(comment)], - }; + return { comments }; } - const translationSummary = response.match(/## Translation Summary[\s\S]*$/)?.[0] ?? ''; - return { - comments: [generateAssistantComment(cleanMarkdown(translationSummary))], elastic_rule: { query: esqlQuery, query_language: 'esql', @@ -62,6 +43,7 @@ export const getTranslateRuleNode = ({ severity: getElasticSeverityFromOriginalRule(state.original_rule), ...(state.integration?.id && { integration_ids: [state.integration.id] }), }, + comments, }; }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translation_result/translation_result.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translation_result/translation_result.ts index e17a17ca8c6df..2ee45c9f9219e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translation_result/translation_result.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/translation_result/translation_result.ts @@ -13,7 +13,7 @@ import { RuleTranslationResult } from '../../../../../../../../../../common/siem import type { GraphNode } from '../../types'; export const getTranslationResultNode = (): GraphNode => { - return async (state, config) => { + return async (state) => { // Set defaults const elasticRule = { title: state.original_rule.title, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts index d16dcd95ada67..ac413bb48d89d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/validation/validation.ts @@ -5,50 +5,27 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; -import { isEmpty } from 'lodash/fp'; -import { parseEsqlQuery } from '@kbn/securitysolution-utils'; +import { + getValidateEsql, + type GetValidateEsqlParams, +} from '../../../../../../../common/task/agent/tools/validate_esql/validation'; import type { GraphNode } from '../../types'; -interface GetValidationNodeParams { - logger: Logger; -} - /** * This node runs all validation steps, and will redirect to the END of the graph if no errors are found. * Any new validation steps should be added here. */ -export const getValidationNode = ({ logger }: GetValidationNodeParams): GraphNode => { +export const getValidationNode = (params: GetValidateEsqlParams): GraphNode => { + const validateEsql = getValidateEsql(params); return async (state) => { - const query = state.elastic_rule.query; - - // We want to prevent infinite loops, so we increment the iterations counter for each validation run. - const currentIteration = state.validation_errors.iterations + 1; - let esqlErrors: string = ''; - try { - const sanitizedQuery = query ? removePlaceHolders(query) : ''; - if (!isEmpty(sanitizedQuery)) { - const { errors, isEsqlQueryAggregating, hasMetadataOperator } = - parseEsqlQuery(sanitizedQuery); - if (!isEmpty(errors)) { - esqlErrors = JSON.stringify(errors); - } else if (!isEsqlQueryAggregating && !hasMetadataOperator) { - esqlErrors = `Queries that do't use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* metadata _id, _version, _index.`; - } - } - if (esqlErrors) { - logger.debug(`ESQL query validation failed: ${esqlErrors}`); - } - } catch (error) { - esqlErrors = error.message; - logger.info(`Error parsing ESQL query: ${error.message}`); + const iterations = state.validation_errors.iterations + 1; + if (!state.elastic_rule.query) { + params.logger.warn('Missing query in validation node'); + return { iterations }; } - return { validation_errors: { iterations: currentIteration, esql_errors: esqlErrors } }; + + const { error } = await validateEsql({ query: state.elastic_rule.query }); + + return { validation_errors: { iterations, esql_errors: error } }; }; }; - -function removePlaceHolders(query: string): string { - return query - .replaceAll(/\[(macro|lookup):.*?\]/g, '') // Removes any macro or lookup placeholders - .replaceAll(/\n(\s*?\|\s*?\n)*/g, '\n'); // Removes any empty lines with | (pipe) alone after removing the placeholders -} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/state.ts index 456d7bde6cf4e..1916ffddfd801 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/state.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/state.ts @@ -12,13 +12,13 @@ import type { OriginalRule, RuleMigrationRule, } from '../../../../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationResources } from '../../../retrievers/rule_resource_retriever'; +import type { MigrationResources } from '../../../../../common/task/retrievers/resource_retriever'; import type { RuleMigrationIntegration } from '../../../../types'; import type { TranslateRuleValidationErrors } from './types'; export const translateRuleState = Annotation.Root({ original_rule: Annotation(), - resources: Annotation(), + resources: Annotation(), integration: Annotation({ reducer: (current, value) => value ?? current, default: () => ({} as RuleMigrationIntegration), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts index 80585f35d737e..6086823be491d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/types.ts @@ -7,10 +7,10 @@ import type { Logger } from '@kbn/core/server'; import type { RunnableConfig } from '@langchain/core/runnables'; -import type { EsqlKnowledgeBase } from '../../../util/esql_knowledge_base'; +import type { ChatModel } from '../../../../../common/task/util/actions_client_chat'; +import type { EsqlKnowledgeBase } from '../../../../../common/task/util/esql_knowledge_base'; import type { RuleMigrationsRetriever } from '../../../retrievers'; import type { RuleMigrationTelemetryClient } from '../../../rule_migrations_telemetry_client'; -import type { ChatModel } from '../../../util/actions_client_chat'; import type { translateRuleState } from './state'; import type { migrateRuleConfigSchema } from '../../state'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts index 77f0e63ce8d88..e1cdc34fe7849 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts @@ -7,21 +7,18 @@ import type { Logger } from '@kbn/core/server'; import type { RunnableConfig } from '@langchain/core/runnables'; -import type { RuleMigrationRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; import type { RuleMigrationsRetriever } from '../retrievers'; -import type { EsqlKnowledgeBase } from '../util/esql_knowledge_base'; +import type { EsqlKnowledgeBase } from '../../../common/task/util/esql_knowledge_base'; import type { ChatModel } from '../../../common/task/util/actions_client_chat'; -import type { MigrationState } from '../../../common/task/types'; import type { migrateRuleConfigSchema, migrateRuleState } from './state'; import type { RuleMigrationTelemetryClient } from '../rule_migrations_telemetry_client'; -export type MigrateRuleGraphState = typeof migrateRuleState.State; -export type MigrateRuleState = MigrationState; +export type MigrateRuleState = typeof migrateRuleState.State; export type MigrateRuleConfigSchema = (typeof migrateRuleConfigSchema)['State']; -export type MigrateRuleGraphConfig = RunnableConfig; +export type MigrateRuleConfig = RunnableConfig; export type GraphNode = ( state: MigrateRuleState, - config: MigrateRuleGraphConfig + config: MigrateRuleConfig ) => Promise>; export interface RuleMigrationAgentRunOptions { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_migrations_retriever.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_migrations_retriever.ts index 8608f1961fd52..fcfce28b10d0f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_migrations_retriever.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_migrations_retriever.ts @@ -33,7 +33,7 @@ export class RuleMigrationsRetriever { public readonly prebuiltRules: PrebuiltRulesRetriever; constructor(migrationId: string, clients: RuleMigrationsRetrieverClients) { - this.resources = new RuleResourceRetriever(migrationId, clients.data); + this.resources = new RuleResourceRetriever(migrationId, clients.data.resources); this.integrations = new IntegrationRetriever(clients); this.prebuiltRules = new PrebuiltRulesRetriever(clients); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_resource_retriever.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_resource_retriever.ts index 03c978812085b..c2538c55aa8b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_resource_retriever.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/retrievers/rule_resource_retriever.ts @@ -5,114 +5,10 @@ * 2.0. */ -import { ResourceIdentifier } from '../../../../../../common/siem_migrations/rules/resources'; -import type { - RuleMigrationResource, - RuleMigrationResourceType, - RuleMigrationRule, -} from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationsDataClient } from '../../data/rule_migrations_data_client'; +import { RuleResourceIdentifier } from '../../../../../../common/siem_migrations/rules/resources'; +import type { RuleMigrationRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { ResourceRetriever } from '../../../common/task/retrievers/resource_retriever'; -export interface RuleMigrationDefinedResource extends RuleMigrationResource { - content: string; // ensures content exists -} -export type RuleMigrationResourcesData = Pick< - RuleMigrationDefinedResource, - 'name' | 'content' | 'type' ->; -export type RuleMigrationResources = Partial< - Record ->; -interface ExistingResources { - macro: Record; - lookup: Record; -} - -export class RuleResourceRetriever { - private existingResources?: ExistingResources; - - constructor( - private readonly migrationId: string, - private readonly dataClient: RuleMigrationsDataClient - ) {} - - public async initialize(): Promise { - const batches = this.dataClient.resources.searchBatches( - this.migrationId, - { filters: { hasContent: true } } // filters out missing (undefined) content resources, empty strings content will be included - ); - - const existingRuleResources: ExistingResources = { macro: {}, lookup: {} }; - let resources; - do { - resources = await batches.next(); - resources.forEach((resource) => { - existingRuleResources[resource.type][resource.name] = resource; - }); - } while (resources.length > 0); - - this.existingResources = existingRuleResources; - } - - public async getResources(migrationRule: RuleMigrationRule): Promise { - const originalRule = migrationRule.original_rule; - const existingResources = this.existingResources; - if (!existingResources) { - throw new Error('initialize must be called before calling getResources'); - } - - const resourceIdentifier = new ResourceIdentifier(originalRule.vendor); - const resourcesIdentifiedFromRule = resourceIdentifier.fromOriginalRule(originalRule); - - const macrosFound = new Map(); - const lookupsFound = new Map(); - resourcesIdentifiedFromRule.forEach((resource) => { - const existingResource = existingResources[resource.type][resource.name]; - if (existingResource) { - if (resource.type === 'macro') { - macrosFound.set(resource.name, existingResource); - } else if (resource.type === 'lookup') { - lookupsFound.set(resource.name, existingResource); - } - } - }); - - const resourcesFound = [...macrosFound.values(), ...lookupsFound.values()]; - if (!resourcesFound.length) { - return {}; - } - - let nestedResourcesFound = resourcesFound; - do { - const nestedResourcesIdentified = resourceIdentifier.fromResources(nestedResourcesFound); - - nestedResourcesFound = []; - nestedResourcesIdentified.forEach((resource) => { - const existingResource = existingResources[resource.type][resource.name]; - if (existingResource) { - nestedResourcesFound.push(existingResource); - if (resource.type === 'macro') { - macrosFound.set(resource.name, existingResource); - } else if (resource.type === 'lookup') { - lookupsFound.set(resource.name, existingResource); - } - } - }); - } while (nestedResourcesFound.length > 0); - - return { - ...(macrosFound.size > 0 ? { macro: this.formatOutput(macrosFound) } : {}), - ...(lookupsFound.size > 0 ? { lookup: this.formatOutput(lookupsFound) } : {}), - }; - } - - private formatOutput( - resources: Map - ): RuleMigrationResourcesData[] { - return Array.from(resources.values()).map(({ name, content, type }) => ({ - name, - content, - type, - })); - } +export class RuleResourceRetriever extends ResourceRetriever { + protected ResourceIdentifierClass = RuleResourceIdentifier; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts index b88f44b4d6da2..89fae9a5bb5cc 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts @@ -15,11 +15,11 @@ import { import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; import type { MockedLogger } from '@kbn/logging-mocks'; import { loggerMock } from '@kbn/logging-mocks'; -import type { StoredSiemMigration } from '../types'; +import type { StoredRuleMigration } from '../types'; import type { RuleMigrationTaskStartParams } from './types'; import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; import type { RuleMigrationDataStats } from '../data/rule_migrations_data_rules_client'; -import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; +import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/rules/types'; import type { SiemMigrationsClientDependencies } from '../../common/types'; jest.mock('./rule_migrations_task_runner', () => { @@ -255,7 +255,7 @@ describe('RuleMigrationsTaskClient', () => { data.migrations.get.mockResolvedValue({ id: migrationId, - } as unknown as StoredSiemMigration); + } as unknown as StoredRuleMigration); const client = new RuleMigrationsTaskClient( migrationsRunning, @@ -274,7 +274,7 @@ describe('RuleMigrationsTaskClient', () => { } as RuleMigrationDataStats); data.migrations.get.mockResolvedValue({ id: migrationId, - } as unknown as StoredSiemMigration); + } as unknown as StoredRuleMigration); const client = new RuleMigrationsTaskClient( migrationsRunning, @@ -294,7 +294,7 @@ describe('RuleMigrationsTaskClient', () => { data.migrations.get.mockResolvedValue({ id: migrationId, - } as unknown as StoredSiemMigration); + } as unknown as StoredRuleMigration); const client = new RuleMigrationsTaskClient( migrationsRunning, logger, @@ -343,7 +343,7 @@ describe('RuleMigrationsTaskClient', () => { last_execution: { error: 'Test error', }, - } as unknown as StoredSiemMigration); + } as unknown as StoredRuleMigration); const client = new RuleMigrationsTaskClient( migrationsRunning, @@ -369,7 +369,7 @@ describe('RuleMigrationsTaskClient', () => { rules: { total: 10, pending: 2, completed: 3, failed: 2 }, } as RuleMigrationDataStats, ]; - const migrations = [{ id: 'm1' }, { id: 'm2' }] as unknown as StoredSiemMigration[]; + const migrations = [{ id: 'm1' }, { id: 'm2' }] as unknown as StoredRuleMigration[]; data.items.getAllStats.mockResolvedValue(statsArray); data.migrations.getAll.mockResolvedValue(migrations); // Mark migration m1 as running. diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.test.ts index 010804bc92d26..faa50fb1c39c2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.test.ts @@ -5,13 +5,13 @@ * 2.0. */ -import type { CustomEvaluator } from './rule_migrations_task_evaluator'; import { RuleMigrationTaskEvaluator } from './rule_migrations_task_evaluator'; import type { Run, Example } from 'langsmith/schemas'; import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import type { AuthenticatedUser } from '@kbn/core/server'; import type { SiemMigrationsClientDependencies } from '../../common/types'; +import type { CustomEvaluator } from '../../common/task/siem_migrations_task_evaluator'; // Mock dependencies jest.mock('langsmith/evaluation', () => ({ @@ -66,65 +66,13 @@ describe('RuleMigrationTaskEvaluator', () => { describe('evaluators', () => { let evaluator: CustomEvaluator; + // Helper to access private evaluator methods const setEvaluator = (name: string) => { // @ts-expect-error (accessing private property) evaluator = taskEvaluator.evaluators[name]; }; - describe('translation_result evaluator', () => { - beforeAll(() => { - setEvaluator('translation_result'); - }); - - it('should return true score when translation results match', () => { - const mockRun = { outputs: { translation_result: 'full' } } as unknown as Run; - const mockExample = { outputs: { translation_result: 'full' } } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: true, - comment: 'Correct', - }); - }); - - it('should return false score when translation results do not match', () => { - const mockRun = { outputs: { translation_result: 'full' } } as unknown as Run; - const mockExample = { outputs: { translation_result: 'partial' } } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: false, - comment: 'Incorrect, expected "partial" but got "full"', - }); - }); - - it('should ignore score when expected result is missing', () => { - const mockRun = { outputs: { translation_result: 'full' } } as unknown as Run; - const mockExample = { outputs: {} } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - comment: 'No translation result expected', - }); - }); - - it('should return false score when run result is missing', () => { - const mockRun = { outputs: {} } as unknown as Run; - const mockExample = { outputs: { translation_result: 'full' } } as unknown as Example; - - const result = evaluator({ run: mockRun, example: mockExample }); - - expect(result).toEqual({ - score: false, - comment: 'No translation result received', - }); - }); - }); - describe('custom_query_accuracy evaluator', () => { beforeAll(() => { setEvaluator('custom_query_accuracy'); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts index 16783dd3ebe02..62f5bfd47bc1d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_evaluator.ts @@ -5,25 +5,15 @@ * 2.0. */ -import type { EvaluationResult } from 'langsmith/evaluation'; -import type { Run, Example } from 'langsmith/schemas'; import { distance } from 'fastest-levenshtein'; -import type { - RuleMigration, - RuleMigrationRule, -} from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; -import type { MigrateRuleConfigSchema, MigrateRuleState } from './agent/types'; +import type { MigrateRuleState } from './agent/types'; +import type { CustomEvaluator } from '../../common/task/siem_migrations_task_evaluator'; import { SiemMigrationTaskEvaluable } from '../../common/task/siem_migrations_task_evaluator'; -type CustomEvaluatorResult = Omit; -export type CustomEvaluator = (args: { run: Run; example: Example }) => CustomEvaluatorResult; - -export class RuleMigrationTaskEvaluator extends SiemMigrationTaskEvaluable< - RuleMigration, - RuleMigrationRule, - MigrateRuleConfigSchema ->(RuleMigrationTaskRunner) { +export class RuleMigrationTaskEvaluator extends SiemMigrationTaskEvaluable( + RuleMigrationTaskRunner +) { protected readonly evaluators: Record = { custom_query_accuracy: ({ run, example }) => { const runQuery = (run?.outputs as MigrateRuleState)?.elastic_rule?.query; @@ -54,7 +44,8 @@ export class RuleMigrationTaskEvaluator extends SiemMigrationTaskEvaluable< }, prebuilt_rule_match: ({ run, example }) => { - const runPrebuiltRuleId = (run?.outputs as MigrateRuleState)?.elastic_rule?.prebuilt_rule_id; + const runPrebuiltRuleId = (run?.outputs as MigrateRuleState)?.elastic_rule + ?.prebuilt_rule_id; const expectedPrebuiltRuleId = (example?.outputs as MigrateRuleState)?.elastic_rule ?.prebuilt_rule_id; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts index de37b14c9f91f..ee499c58ac68a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.test.ts @@ -8,7 +8,7 @@ import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import type { AuthenticatedUser } from '@kbn/core/server'; -import type { StoredRuleMigration } from '../types'; +import type { StoredRuleMigrationRule } from '../types'; import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import type { SiemMigrationsClientDependencies } from '../../common/types'; @@ -113,7 +113,7 @@ describe('RuleMigrationTaskRunner', () => { mockRuleMigrationsDataClient.items.get.mockResolvedValue({ total: 0, data: [] }); mockRuleMigrationsDataClient.items.get.mockResolvedValueOnce({ total: 1, - data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigrationRule[], }); await taskRunner.setup('test-connector-id'); @@ -161,7 +161,7 @@ describe('RuleMigrationTaskRunner', () => { .mockResolvedValue({ total: 0, data: [] }) .mockResolvedValueOnce({ total: 1, - data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigrationRule[], }); }); @@ -203,7 +203,7 @@ describe('RuleMigrationTaskRunner', () => { data: [ { id: ruleId, status: SiemMigrationStatus.PENDING }, { id: rule2Id, status: SiemMigrationStatus.PENDING }, - ] as StoredRuleMigration[], + ] as StoredRuleMigrationRule[], }); }); @@ -281,7 +281,7 @@ describe('RuleMigrationTaskRunner', () => { { id: rule2Id, status: SiemMigrationStatus.PENDING }, { id: rule3Id, status: SiemMigrationStatus.PENDING }, { id: rule4Id, status: SiemMigrationStatus.PENDING }, - ] as StoredRuleMigration[], + ] as StoredRuleMigrationRule[], }); // max recovery attempts = 3 @@ -310,7 +310,7 @@ describe('RuleMigrationTaskRunner', () => { it('should increase the executor sleep time when rate limited', async () => { const getResponse = { total: 1, - data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigration[], + data: [{ id: ruleId, status: SiemMigrationStatus.PENDING }] as StoredRuleMigrationRule[], }; mockRuleMigrationsDataClient.items.get.mockRestore(); mockRuleMigrationsDataClient.items.get diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts index 5096032fdcee8..6d33c63cc8d45 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts @@ -7,29 +7,35 @@ import type { AuthenticatedUser, Logger } from '@kbn/core/server'; import type { + ElasticRule, RuleMigration, RuleMigrationRule, - ElasticRule, } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; -import type { MigrateRuleConfigSchema, MigrateRuleGraphConfig } from './agent/types'; +import type { MigrateRuleConfigSchema, MigrateRuleState } from './agent/types'; import { getRuleMigrationAgent } from './agent'; import { RuleMigrationsRetriever } from './retrievers'; -import type { RuleMigrationInput } from './types'; -import type { StoredRuleMigration } from '../types'; -import { EsqlKnowledgeBase } from './util/esql_knowledge_base'; -import { nullifyElasticRule } from './util/nullify_missing_properties'; +import type { StoredRuleMigrationRule } from '../types'; +import { EsqlKnowledgeBase } from '../../common/task/util/esql_knowledge_base'; +import { nullifyMissingProperties } from '../../common/task/util/nullify_missing_properties'; import type { SiemMigrationsClientDependencies } from '../../common/types'; import { SiemMigrationTaskRunner } from '../../common/task/siem_migrations_task_runner'; import { RuleMigrationTelemetryClient } from './rule_migrations_telemetry_client'; -import type { MigrationState, MigrationTask, MigrationTaskInvoke } from '../../common/task/types'; +import type { MigrationResources } from '../../common/task/retrievers/resource_retriever'; + +export interface RuleMigrationTaskInput + extends Pick { + resources: MigrationResources; +} +export type RuleMigrationTaskOutput = MigrateRuleState; export class RuleMigrationTaskRunner extends SiemMigrationTaskRunner< RuleMigration, RuleMigrationRule, - MigrateRuleConfigSchema + RuleMigrationTaskInput, + MigrateRuleConfigSchema, + RuleMigrationTaskOutput > { - protected declare task?: MigrationTask; private retriever: RuleMigrationsRetriever; constructor( @@ -80,22 +86,7 @@ export class RuleMigrationTaskRunner extends SiemMigrationTaskRunner< }); this.telemetry = telemetryClient; - this.task = { - prepare: async ( - migrationRule: StoredRuleMigration, - config: MigrateRuleGraphConfig - ): Promise> => { - const resources = await this.retriever.resources.getResources(migrationRule); - const input: RuleMigrationInput = { - id: migrationRule.id, - original_rule: migrationRule.original_rule, - resources, - }; - return async () => { - return agent.invoke(input, config) as Promise>; - }; - }, - }; + this.task = (input, config) => agent.invoke(input, config); } /** Initializes the retriever populating ELSER indices. It may take a few minutes */ @@ -103,22 +94,25 @@ export class RuleMigrationTaskRunner extends SiemMigrationTaskRunner< await this.retriever.initialize(); } - /** Overload to nullify elastic rule specific properties */ - protected async saveItemCompleted( - ruleMigration: StoredRuleMigration, - migrationResult: Partial - ) { - this.logger.debug(`Translation of rule "${ruleMigration.id}" succeeded`); - const nullifiedElasticRule = nullifyElasticRule( - migrationResult.elastic_rule as ElasticRule, - this.logger.error - ); - const ruleMigrationTranslated = { - ...ruleMigration, - elastic_rule: nullifiedElasticRule as ElasticRule, - translation_result: migrationResult.translation_result, - comments: migrationResult.comments, + protected async prepareTaskInput( + migrationRule: StoredRuleMigrationRule + ): Promise { + const resources = await this.retriever.resources.getResources(migrationRule); + return { id: migrationRule.id, original_rule: migrationRule.original_rule, resources }; + } + + protected processTaskOutput( + migrationRule: StoredRuleMigrationRule, + migrationOutput: RuleMigrationTaskOutput + ): StoredRuleMigrationRule { + return { + ...migrationRule, + elastic_rule: nullifyMissingProperties({ + source: migrationRule.elastic_rule, + target: migrationOutput.elastic_rule as ElasticRule, + }), + translation_result: migrationOutput.translation_result, + comments: migrationOutput.comments, }; - return super.saveItemCompleted(ruleMigration, ruleMigrationTranslated); } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts index cad7c25b41ada..c000cbee577a9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts @@ -8,19 +8,19 @@ import type { AuthenticatedUser } from '@kbn/core/server'; import type { LangSmithEvaluationOptions } from '../../../../../common/siem_migrations/model/common.gen'; import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; -import type { StoredRuleMigration } from '../types'; +import type { StoredRuleMigrationRule } from '../types'; import type { getRuleMigrationAgent } from './agent'; import type { RuleMigrationTelemetryClient } from './rule_migrations_telemetry_client'; import type { ChatModel } from '../../common/task/util/actions_client_chat'; -import type { RuleMigrationResources } from './retrievers/rule_resource_retriever'; +import type { MigrationResources } from '../../common/task/retrievers/resource_retriever'; import type { RuleMigrationsRetriever } from './retrievers'; -import type { MigrateRuleGraphConfig } from './agent/types'; +import type { MigrateRuleConfig } from './agent/types'; import type { SiemMigrationsClientDependencies } from '../../common/types'; export type MigrationAgent = ReturnType; -export interface RuleMigrationInput extends Pick { - resources: RuleMigrationResources; +export interface RuleMigrationInput extends Pick { + resources: MigrationResources; } export interface RuleMigrationTaskCreateClientParams { @@ -32,7 +32,7 @@ export interface RuleMigrationTaskCreateClientParams { export interface RuleMigrationTaskStartParams { migrationId: string; connectorId: string; - invocationConfig: MigrateRuleGraphConfig; + invocationConfig: MigrateRuleConfig; } export interface RuleMigrationTaskRunParams extends RuleMigrationTaskStartParams { @@ -61,6 +61,6 @@ export interface RuleMigrationTaskEvaluateParams { evaluationId: string; connectorId: string; langsmithOptions: LangSmithEvaluationOptions; - invocationConfig: MigrateRuleGraphConfig; + invocationConfig: MigrateRuleConfig; abortController: AbortController; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/__mocks__/mocks.ts deleted file mode 100644 index 978ff356fa29b..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/__mocks__/mocks.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EsqlKnowledgeBase } from '../esql_knowledge_base'; -import type { PublicMethodsOf } from '@kbn/utility-types'; - -export const createEsqlKnowledgeBaseMock = () => { - return { - translate: jest.fn().mockResolvedValue(''), - } as jest.Mocked>; -}; - -// Factory function for the mock class -export const MockEsqlKnowledgeBase = jest - .fn() - .mockImplementation(() => createEsqlKnowledgeBaseMock()); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.test.ts deleted file mode 100644 index e73195fe9751f..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { z } from '@kbn/zod'; -import type { ElasticRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import { nullifyElasticRule, nullifyMissingPropertiesInObject } from './nullify_missing_properties'; - -describe('nullify missing values in object', () => { - describe('nullifyMissingPropertiesInObject', () => { - const someZodObject = z.object({ - foo: z.string(), - bar: z.number().optional(), - baz: z.object({ - qux: z.boolean().optional(), - }), - }); - - const val: z.infer = { - foo: 'test', - baz: { - qux: true, - }, - }; - it('should correctly nullify missing values in zod object at first level', () => { - const result = nullifyMissingPropertiesInObject(someZodObject, val); - expect(result).toMatchObject({ - foo: 'test', - bar: null, - baz: { - qux: true, - }, - }); - }); - - it('should throw if object does not conform to the schema', () => { - const invalidVal = { - foo: 'test', - // Missing 'baz' property - }; - - expect(() => - nullifyMissingPropertiesInObject(someZodObject, invalidVal as z.infer) - ).toThrow(); - }); - }); - - describe('nullifyElasticRule', () => { - it('should return an object with nullified empty values', () => { - const elasticRule: ElasticRule = { - title: 'Some Title', - }; - - const result = nullifyElasticRule(elasticRule); - - expect(result).toMatchObject({ - title: 'Some Title', - description: null, - severity: null, - risk_score: null, - query: null, - query_language: null, - prebuilt_rule_id: null, - integration_ids: null, - id: null, - }); - }); - - it('should return original object and call error callback in case of error', () => { - const elasticRule = { - hero: 'Some Title', - } as unknown as ElasticRule; - - const errorMock = jest.fn(); - - const result = nullifyElasticRule(elasticRule, errorMock); - - expect(result).toMatchObject(elasticRule); - expect(errorMock).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.ts deleted file mode 100644 index 0942fc67983ad..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/util/nullify_missing_properties.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { z } from '@kbn/zod'; -import type { ElasticRule as ElasticRuleType } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import { ElasticRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; - -type Nullable = { [K in keyof T]: T[K] | null }; - -/** - * This function takes a Zod schema and an object, and returns a new object - * where any missing values of `only first-level keys` in the object are set to null, according to the schema. - * - * Raises an error if the object does not conform to the schema. - * - * This is specially beneficial for `unsetting` fields in Elasticsearch documents. - */ -export const nullifyMissingPropertiesInObject = ( - zodType: T, - obj: z.infer -): Nullable> => { - const schemaWithNullValues = zodType.transform((value: z.infer) => { - const result: Nullable> = { ...value }; - Object.keys(zodType.shape).forEach((key) => { - if (!(key in value)) { - result[key as keyof z.infer] = null; - } - }); - return result; - }); - - return schemaWithNullValues.parse(obj); -}; - -/** - * This function takes an ElasticRule object and returns a new object - * where any missing values are set to null, according to the ElasticRule schema. - * - * If an error occurs during the transformation, it calls the onError callback - * with the error and returns the original object. - */ -export const nullifyElasticRule = (obj: ElasticRuleType, onError?: (error: Error) => void) => { - try { - return nullifyMissingPropertiesInObject(ElasticRule, obj); - } catch (error) { - onError?.(error); - return obj; - } -}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts index 9410b4d35de71..fd19b3a3d1101 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts @@ -17,9 +17,8 @@ import type { RuleVersions } from './data/rule_migrations_data_prebuilt_rules_cl import type { Stored } from '../types'; import type { SiemMigrationsIndexNameProvider } from '../common/types'; -export type StoredSiemMigration = Stored; - -export type StoredRuleMigration = Stored; +export type StoredRuleMigration = Stored; +export type StoredRuleMigrationRule = Stored; export type StoredRuleMigrationResource = Stored; export interface RuleMigrationIntegration { From 4c76a48da3a2962501c7eeb9f2a966206519e415 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Tue, 12 Aug 2025 14:07:48 +0200 Subject: [PATCH 6/8] dashboards agent graph initial implementation --- .../common/siem_migrations/constants.ts | 6 + .../siem_migrations/dashboards/constants.ts | 11 +- .../api/dashboards/dashboard_migration.gen.ts | 62 +++++- .../dashboard_migration.schema.yaml | 80 ++++++++ .../model/dashboard_migration.gen.ts | 28 ++- .../model/dashboard_migration.schema.yaml | 22 ++- .../inline_spl_query/inline_spl_query.ts | 2 +- .../task/siem_migrations_task_client.ts | 10 +- .../__mocks__/original_dashboard_example.xml | 53 +++++ .../siem_migrations/dashboards/api/index.ts | 6 + .../siem_migrations/dashboards/api/start.ts | 27 ++- .../siem_migrations/dashboards/api/stats.ts | 1 - .../siem_migrations/dashboards/api/stop.ts | 65 +++++++ .../data/dashboard_migrations_data_service.ts | 1 + .../splunk_xml_dashboard_parser.test.ts | 79 ++++++++ .../splunk/splunk_xml_dashboard_parser.ts | 83 ++++++++ .../dashboard_migrations_task_client.ts | 9 + .../dashboard_migrations_task_service.ts | 9 + ... dashboard_migrations_telemetry_client.ts} | 0 .../dashboards/task/__mocks__/mocks.ts | 28 +-- .../dashboards/task/agent/graph.test.ts | 73 +++++++ .../dashboards/task/agent/graph.ts | 52 +++++ .../dashboards/task/agent/index.ts | 5 +- .../aggregate_dashboard.ts | 46 +++++ .../agent/nodes/aggregate_dashboard/index.ts | 7 + .../templates/area.viz.json | 129 +++++++++++++ .../templates/area_stacked.viz.json | 130 +++++++++++++ .../templates/bar_horizontal.viz.json | 123 ++++++++++++ .../templates/bar_horizontal_stacked.viz.json | 130 +++++++++++++ .../templates/bar_vertical.viz.json | 129 +++++++++++++ .../templates/bar_vertical_stacked.viz.json | 130 +++++++++++++ .../templates/dashboard.json | 23 +++ .../templates/donut.viz.json | 108 +++++++++++ .../templates/gauge.viz.json | 181 ++++++++++++++++++ .../templates/heatmap.viz.json | 92 +++++++++ .../templates/line.viz.json | 129 +++++++++++++ .../templates/markdown.viz.json | 38 ++++ .../templates/metric.viz.json | 69 +++++++ .../templates/pie.viz.json | 104 ++++++++++ .../templates/table.viz.json | 76 ++++++++ .../templates/treemap.viz.json | 108 +++++++++++ .../nodes/parse_original_dashboard/index.ts} | 3 +- .../parse_original_dashboard.ts | 52 +++++ .../task/agent/nodes/translate_panel/index.ts | 7 + .../nodes/translate_panel/translate_panel.ts | 52 +++++ .../dashboards/task/agent/state.ts | 43 +++++ .../agent/sub_graphs/translate_panel/graph.ts | 84 ++++++++ .../sub_graphs/translate_panel/index.ts} | 3 +- .../nodes/ecs_mapping/ecs_mapping.ts | 31 +++ .../nodes/ecs_mapping/index.ts | 7 + .../fix_query_errors/fix_query_errors.ts | 26 +++ .../nodes/fix_query_errors/index.ts | 7 + .../nodes/inline_query/index.ts | 7 + .../nodes/inline_query/inline_query.ts | 29 +++ .../nodes/select_index_pattern/index.ts | 7 + .../select_index_pattern.ts | 25 +++ .../nodes/translate_query/index.ts | 7 + .../nodes/translate_query/translate_query.ts | 41 ++++ .../nodes/translation_result/index.ts | 7 + .../translation_result/translation_result.ts | 43 +++++ .../translate_panel/nodes/validation/index.ts | 7 + .../nodes/validation/validation.ts | 31 +++ .../agent/sub_graphs/translate_panel/state.ts | 42 ++++ .../agent/sub_graphs/translate_panel/types.ts | 37 ++++ .../dashboards/task/agent/types.ts | 88 ++++++++- .../task/dashboard_migrations_task_client.ts | 8 +- .../task/dashboard_migrations_task_runner.ts | 6 +- .../task/retrievers/__mocks__/mocks.ts | 14 +- .../dashboard_migrations_retriever.ts | 4 + .../rules/task/__mocks__/mocks.ts | 2 +- .../siem_migrations/rules/task/agent/state.ts | 1 + .../nodes/inline_query/inline_query.ts | 2 +- .../rules/task/rule_migrations_task_client.ts | 8 +- .../rules/task/rule_migrations_task_runner.ts | 6 +- 74 files changed, 3135 insertions(+), 66 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/__mocks__/original_dashboard_example.xml create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stop.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_client.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_service.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/{rule_migrations_telemetry_client.ts => dashboard_migrations_telemetry_client.ts} (100%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area_stacked.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal_stacked.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical_stacked.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/dashboard.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/donut.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/gauge.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/heatmap.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/line.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/markdown.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/metric.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/pie.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/table.viz.json create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/treemap.viz.json rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/{__mocks__/rule_migrations_task_client.ts => agent/nodes/parse_original_dashboard/index.ts} (66%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/{__mocks__/rule_migrations_task_service.ts => agent/sub_graphs/translate_panel/index.ts} (66%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts index a072c876f0f97..04103f333e94d 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts @@ -63,11 +63,17 @@ export enum SiemMigrationRetryFilter { NOT_FULLY_TRANSLATED = 'not_fully_translated', } +// TODO: Refactor all uses of `RuleTranslationResult` -> `MigrationTranslationResult` export enum RuleTranslationResult { FULL = 'full', PARTIAL = 'partial', UNTRANSLATABLE = 'untranslatable', } +export enum MigrationTranslationResult { + FULL = 'full', + PARTIAL = 'partial', + UNTRANSLATABLE = 'untranslatable', +} export const DEFAULT_TRANSLATION_FIELDS = { from: 'now-360s', diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts index fbc3c9bd3fbba..64cecf7f329f4 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts @@ -12,8 +12,15 @@ export const SIEM_DASHBOARD_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/dashboard export const SIEM_DASHBOARD_MIGRATION_PATH = `${SIEM_DASHBOARD_MIGRATIONS_PATH}/{migration_id}` as const; +export const SIEM_DASHBOARD_MIGRATION_DASHBOARDS_PATH = + `${SIEM_DASHBOARD_MIGRATION_PATH}/dashboards` as const; + +export const SIEM_DASHBOARD_MIGRATION_START_PATH = + `${SIEM_DASHBOARD_MIGRATION_PATH}/start` as const; +export const SIEM_DASHBOARD_MIGRATION_STOP_PATH = `${SIEM_DASHBOARD_MIGRATION_PATH}/stop` as const; + export const SIEM_DASHBOARD_MIGRATION_STATS_PATH = `${SIEM_DASHBOARD_MIGRATION_PATH}/stats` as const; -export const SIEM_DASHBOARD_MIGRATION_DASHBOARDS_PATH = - `${SIEM_DASHBOARD_MIGRATION_PATH}/dashboards` as const; +export const SIEM_DASHBOARD_MIGRATION_TRANSLATION_STATS_PATH = + `${SIEM_DASHBOARD_MIGRATION_PATH}/translation_stats` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.gen.ts index 5bb821fab0580..e51d25f311425 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.gen.ts @@ -17,8 +17,14 @@ import { z } from '@kbn/zod'; import { NonEmptyString } from '../../../../api/model/primitives.gen'; -import { DashboardMigration, DashboardMigrationTaskStats } from '../../dashboard_migration.gen'; +import { + DashboardMigration, + DashboardMigrationTaskExecutionSettings, + DashboardMigrationRetryFilter, + DashboardMigrationTaskStats, +} from '../../dashboard_migration.gen'; import { SplunkOriginalDashboardExport } from '../../vendor/dashboards/splunk.gen'; +import { LangSmithOptions } from '../../common.gen'; export type CreateDashboardMigrationRequestBody = z.infer< typeof CreateDashboardMigrationRequestBody @@ -82,3 +88,57 @@ export type GetDashboardMigrationStatsRequestParamsInput = z.input< export type GetDashboardMigrationStatsResponse = z.infer; export const GetDashboardMigrationStatsResponse = DashboardMigrationTaskStats; + +export type StartDashboardsMigrationRequestParams = z.infer< + typeof StartDashboardsMigrationRequestParams +>; +export const StartDashboardsMigrationRequestParams = z.object({ + migration_id: NonEmptyString, +}); +export type StartDashboardsMigrationRequestParamsInput = z.input< + typeof StartDashboardsMigrationRequestParams +>; + +export type StartDashboardsMigrationRequestBody = z.infer< + typeof StartDashboardsMigrationRequestBody +>; +export const StartDashboardsMigrationRequestBody = z.object({ + /** + * Settings applicable to current dashboard migration task execution. + */ + settings: DashboardMigrationTaskExecutionSettings, + langsmith_options: LangSmithOptions.optional(), + /** + * The optional indicator to retry the dashboard translation based on this filter criteria. + */ + retry: DashboardMigrationRetryFilter.optional(), +}); +export type StartDashboardsMigrationRequestBodyInput = z.input< + typeof StartDashboardsMigrationRequestBody +>; + +export type StartDashboardsMigrationResponse = z.infer; +export const StartDashboardsMigrationResponse = z.object({ + /** + * Indicates the migration has been started. `false` means the migration does not need to be started. + */ + started: z.boolean(), +}); + +export type StopDashboardsMigrationRequestParams = z.infer< + typeof StopDashboardsMigrationRequestParams +>; +export const StopDashboardsMigrationRequestParams = z.object({ + migration_id: NonEmptyString, +}); +export type StopDashboardsMigrationRequestParamsInput = z.input< + typeof StopDashboardsMigrationRequestParams +>; + +export type StopDashboardsMigrationResponse = z.infer; +export const StopDashboardsMigrationResponse = z.object({ + /** + * Indicates the migration has been stopped. + */ + stopped: z.boolean(), +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.schema.yaml index cf1adfb04b938..998aa0ae1951a 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/api/dashboards/dashboard_migration.schema.yaml @@ -94,6 +94,86 @@ paths: 200: description: Indicates dashboards have been added to the migration successfully. + /internal/siem_migrations/dashboards/{migration_id}/start: + post: + summary: Starts a dashboard migration + operationId: StartDashboardsMigration + x-codegen-enabled: true + x-internal: true + description: Starts a SIEM dashboards migration using the migration id provided + tags: + - SIEM Dashboard Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + description: The migration id to start + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - settings + properties: + settings: + $ref: '../../dashboard_migration.schema.yaml#/components/schemas/DashboardMigrationTaskExecutionSettings' + description: Settings applicable to current dashboard migration task execution. + langsmith_options: + $ref: '../../common.schema.yaml#/components/schemas/LangSmithOptions' + retry: + $ref: '../../dashboard_migration.schema.yaml#/components/schemas/DashboardMigrationRetryFilter' + description: The optional indicator to retry the dashboard translation based on this filter criteria. + responses: + 200: + description: Indicates the migration start request has been processed successfully. + content: + application/json: + schema: + type: object + required: + - started + properties: + started: + type: boolean + description: Indicates the migration has been started. `false` means the migration does not need to be started. + 204: + description: Indicates the migration id was not found. + + /internal/siem_migrations/dashboards/{migration_id}/stop: + post: + summary: Stops an existing dashboard migration + operationId: StopDashboardsMigration + x-codegen-enabled: true + x-internal: true + description: Stops a running SIEM dashboards migration using the migration id provided + tags: + - SIEM Dashboard Migrations + parameters: + - name: migration_id + in: path + required: true + schema: + description: The migration id to stop + $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + responses: + 200: + description: Indicates migration task stop has been processed successfully. + content: + application/json: + schema: + type: object + required: + - stopped + properties: + stopped: + type: boolean + description: Indicates the migration has been stopped. + 204: + description: Indicates the migration id was not found running. /internal/siem_migrations/dashboards/{migration_id}/stats: get: diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts index 7864bd16d12c5..247fc1022a279 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.gen.ts @@ -21,6 +21,7 @@ import { MigrationLastExecution, MigrationTranslationResult, MigrationStatus, + MigrationComments, MigrationTaskStats, } from './migration.gen'; import { SplunkOriginalDashboardProperties } from './vendor/dashboards/splunk.gen'; @@ -88,7 +89,7 @@ export const OriginalDashboard = z.object({ /** * The data of the dashboard in the specified format */ - data: z.object({}), + data: z.string(), /** * The last updated timestamp of the dashboard */ @@ -159,6 +160,10 @@ export const DashboardMigrationDashboardData = z.object({ * The status of the dashboard migration process. */ status: MigrationStatus.default('pending'), + /** + * The comments for the migration including a summary from the LLM in markdown. + */ + comments: MigrationComments.optional(), /** * The moment of the last update */ @@ -187,3 +192,24 @@ export const DashboardMigrationDashboard = z */ export type DashboardMigrationTaskStats = z.infer; export const DashboardMigrationTaskStats = MigrationTaskStats; + +/** + * The dashboard migration task execution settings. + */ +export type DashboardMigrationTaskExecutionSettings = z.infer< + typeof DashboardMigrationTaskExecutionSettings +>; +export const DashboardMigrationTaskExecutionSettings = z.object({ + /** + * The connector ID used in the last execution. + */ + connector_id: z.string(), +}); + +/** + * Indicates the filter to retry the migrations dashboards translation + */ +export type DashboardMigrationRetryFilter = z.infer; +export const DashboardMigrationRetryFilter = z.enum(['failed', 'not_fully_translated']); +export type DashboardMigrationRetryFilterEnum = typeof DashboardMigrationRetryFilter.enum; +export const DashboardMigrationRetryFilterEnum = DashboardMigrationRetryFilter.enum; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml index 3b119a9aafe38..f77363cbcfaa9 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/dashboard_migration.schema.yaml @@ -89,6 +89,9 @@ components: description: The status of the dashboard migration process. $ref: './migration.schema.yaml#/components/schemas/MigrationStatus' default: pending + comments: + description: The comments for the migration including a summary from the LLM in markdown. + $ref: './migration.schema.yaml#/components/schemas/MigrationComments' updated_at: type: string description: The moment of the last update @@ -119,7 +122,7 @@ components: type: string description: The description of the dashboard data: - type: object + type: string description: The data of the dashboard in the specified format last_updated: type: string @@ -153,3 +156,20 @@ components: DashboardMigrationTaskStats: description: The dashboard migration task stats object. $ref: './migration.schema.yaml#/components/schemas/MigrationTaskStats' + + DashboardMigrationTaskExecutionSettings: + type: object + description: The dashboard migration task execution settings. + required: + - connector_id + properties: + connector_id: + type: string + description: The connector ID used in the last execution. + + DashboardMigrationRetryFilter: + type: string + description: Indicates the filter to retry the migrations dashboards translation + enum: # should match SiemMigrationRetryFilter enum at ../constants.ts + - failed + - not_fully_translated diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts index 350a9c683d6b6..0f4e9d6998a2c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/inline_spl_query/inline_spl_query.ts @@ -85,7 +85,7 @@ export const getInlineSplQuery: NodeToolCreator< }; const getUnsupportedComment = (query: string): string | undefined => { - const unsupportedText = '## Translation Summary\nCan not create custom translation.\n'; + const unsupportedText = '## Translation Summary\nCan not create query translation.\n'; if (query.includes(' inputlookup')) { return `${unsupportedText}Reason: \`inputlookup\` command is not supported.`; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts index d1854be5c630c..bfdad05d01bb7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_client.ts @@ -33,13 +33,15 @@ import type { SiemMigrationTaskEvaluatorClass } from './siem_migrations_task_eva export abstract class SiemMigrationsTaskClient< M extends MigrationDocument = StoredSiemMigration, I extends ItemDocument = ItemDocument, - C extends object = {} + P extends object = {}, // The migration task input parameters schema + C extends object = {}, // The migration task config schema + O extends object = {} // The migration task output schema > { - protected abstract readonly TaskRunnerClass: typeof SiemMigrationTaskRunner; - protected abstract readonly EvaluatorClass?: SiemMigrationTaskEvaluatorClass; + protected abstract readonly TaskRunnerClass: typeof SiemMigrationTaskRunner; + protected abstract readonly EvaluatorClass?: SiemMigrationTaskEvaluatorClass; constructor( - protected migrationsRunning: Map>, + protected migrationsRunning: Map>, private logger: Logger, private data: SiemMigrationsDataClient, private currentUser: AuthenticatedUser, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/__mocks__/original_dashboard_example.xml b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/__mocks__/original_dashboard_example.xml new file mode 100644 index 0000000000000..c802f65601318 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/__mocks__/original_dashboard_example.xml @@ -0,0 +1,53 @@ + + + + + Number of dashboards by user | Search app only + + + | rest /servicesNS/-/-/data/ui/views + | search eai:acl.app = "search" ```eai:acl.owner!="nobody"``` + | stats count by eai:acl.owner + -24h@h + now + + + + + + + + Number of custom dashboards in Search app + + + | rest /servicesNS/-/-/data/ui/views + | search eai:acl.app = "search" eai:acl.owner!="nobody" + | stats count + -24h@h + now + + + + + + + + + Number of dashboards by app + + + | rest /servicesNS/-/-/data/ui/views + ```| search eai:acl.app = "search" ``` + | stats count by eai:acl.app | sort - count + -24h@h + now + + + + + + + + + + \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts index 6cd9b01a1918b..43f6794fda4ac 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts @@ -10,6 +10,8 @@ import { registerSiemDashboardMigrationsCreateRoute } from './create'; import { registerSiemDashboardMigrationsCreateDashboardsRoute } from './dashboards/create'; import { registerSiemDashboardMigrationsStatsRoute } from './stats'; import { registerSiemDashboardMigrationsGetRoute } from './get'; +import { registerSiemDashboardMigrationsStartRoute } from './start'; +import { registerSiemDashboardMigrationsStopRoute } from './stop'; export const registerSiemDashboardMigrationsRoutes = ( router: SecuritySolutionPluginRouter, @@ -22,6 +24,10 @@ export const registerSiemDashboardMigrationsRoutes = ( // ===== Stats ======== registerSiemDashboardMigrationsStatsRoute(router, logger); + // ===== Task ======== + registerSiemDashboardMigrationsStartRoute(router, logger); + registerSiemDashboardMigrationsStopRoute(router, logger); + // ===== Dashboards ====== registerSiemDashboardMigrationsCreateDashboardsRoute(router, logger); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts index 2ea39ea739629..dc710412e62dd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/start.ts @@ -7,12 +7,12 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import { SIEM_RULE_MIGRATION_START_PATH } from '../../../../../common/siem_migrations/constants'; +import { SIEM_DASHBOARD_MIGRATION_START_PATH } from '../../../../../common/siem_migrations/dashboards/constants'; import { - StartRuleMigrationRequestBody, - StartRuleMigrationRequestParams, - type StartRuleMigrationResponse, -} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; + StartDashboardsMigrationRequestBody, + StartDashboardsMigrationRequestParams, + type StartDashboardsMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/dashboards/dashboard_migration.gen'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; import { authz } from '../../common/api/util/authz'; @@ -21,13 +21,13 @@ import { withLicense } from '../../common/api/util/with_license'; import { createTracersCallbacks } from '../../common/api/util/tracing'; import { withExistingDashboardMigration } from './util/with_existing_dashboard_migration'; -export const registerSiemRuleMigrationsStartRoute = ( +export const registerSiemDashboardMigrationsStartRoute = ( router: SecuritySolutionPluginRouter, logger: Logger ) => { router.versioned .post({ - path: SIEM_RULE_MIGRATION_START_PATH, + path: SIEM_DASHBOARD_MIGRATION_START_PATH, access: 'internal', security: { authz }, }) @@ -36,21 +36,18 @@ export const registerSiemRuleMigrationsStartRoute = ( version: '1', validate: { request: { - params: buildRouteValidationWithZod(StartRuleMigrationRequestParams), - body: buildRouteValidationWithZod(StartRuleMigrationRequestBody), + params: buildRouteValidationWithZod(StartDashboardsMigrationRequestParams), + body: buildRouteValidationWithZod(StartDashboardsMigrationRequestBody), }, }, }, withLicense( withExistingDashboardMigration( - async (context, req, res): Promise> => { + async (context, req, res): Promise> => { const migrationId = req.params.migration_id; const { langsmith_options: langsmithOptions, - settings: { - connector_id: connectorId, - skip_prebuilt_rules_matching: skipPrebuiltRulesMatching = false, - }, + settings: { connector_id: connectorId }, retry, } = req.body; @@ -81,7 +78,7 @@ export const registerSiemRuleMigrationsStartRoute = ( const { exists, started } = await dashboardMigrationsClient.task.start({ migrationId, connectorId, - invocationConfig: { callbacks, configurable: { skipPrebuiltRulesMatching } }, + invocationConfig: { callbacks }, }); if (!exists) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts index fb7e01e4ac0ab..e29554d91509d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stats.ts @@ -13,7 +13,6 @@ import { SIEM_DASHBOARD_MIGRATION_STATS_PATH } from '../../../../../common/siem_ import type { SecuritySolutionPluginRouter } from '../../../../types'; import { withLicense } from '../../common/api/util/with_license'; import { authz } from '../../common/api/util/authz'; -import { MIGRATION_ID_NOT_FOUND } from '../../common/translations'; import { withExistingDashboardMigration } from './util/with_existing_dashboard_migration'; export const registerSiemDashboardMigrationsStatsRoute = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stop.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stop.ts new file mode 100644 index 0000000000000..8502fbd0ccab0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/stop.ts @@ -0,0 +1,65 @@ +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { SIEM_DASHBOARD_MIGRATION_STOP_PATH } from '../../../../../common/siem_migrations/dashboards/constants'; +import { + StopDashboardsMigrationRequestParams, + type StopDashboardsMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/dashboards/dashboard_migration.gen'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { SiemMigrationAuditLogger } from '../../common/api/util/audit'; +import { authz } from '../../common/api/util/authz'; +import { withLicense } from '../../common/api/util/with_license'; +import { withExistingDashboardMigration } from './util/with_existing_dashboard_migration'; + +export const registerSiemDashboardMigrationsStopRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .post({ + path: SIEM_DASHBOARD_MIGRATION_STOP_PATH, + access: 'internal', + security: { authz }, + }) + .addVersion( + { + version: '1', + validate: { + request: { params: buildRouteValidationWithZod(StopDashboardsMigrationRequestParams) }, + }, + }, + withLicense( + withExistingDashboardMigration( + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + const siemMigrationAuditLogger = new SiemMigrationAuditLogger(context.securitySolution); + try { + const ctx = await context.resolve(['securitySolution']); + const dashboardMigrationsClient = + ctx.securitySolution.siemMigrations.getDashboardsClient(); + + const { exists, stopped } = await dashboardMigrationsClient.task.stop(migrationId); + + if (!exists) { + return res.notFound(); + } + await siemMigrationAuditLogger.logStop({ migrationId }); + + return res.ok({ body: { stopped } }); + } catch (error) { + logger.error(error); + await siemMigrationAuditLogger.logStop({ migrationId, error }); + return res.badRequest({ body: error.message }); + } + } + ) + ) + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts index 163260dc883f4..6cb649e9d4d42 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_service.ts @@ -73,6 +73,7 @@ export class DashboardMigrationsDataService extends SiemMigrationsBaseDataServic await Promise.all([ this.adapters.dashboards.install({ ...params, logger: this.logger }), this.adapters.migrations.install({ ...params, logger: this.logger }), + this.adapters.resources.install({ ...params, logger: this.logger }), ]); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.ts new file mode 100644 index 0000000000000..5274abdfe1b91 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.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 path from 'path'; +import fs from 'fs/promises'; +import { SplunkXmlDashboardParser } from './splunk_xml_dashboard_parser'; + +describe('SplunkXmlDashboardParser', () => { + let exampleXml: string; + + beforeAll(async () => { + const examplePath = path.join(__dirname, '../../../__mocks__/original_dashboard_example.xml'); + exampleXml = await fs.readFile(examplePath, 'utf8'); + }); + + describe('constructor', () => { + test('should create an instance successfully', () => { + const parser = new SplunkXmlDashboardParser(exampleXml); + expect(parser).toBeInstanceOf(SplunkXmlDashboardParser); + }); + }); + + describe('toObject', () => { + test('should parse XML to object correctly', async () => { + const parser = new SplunkXmlDashboardParser(exampleXml); + const result = await parser.toObject(); + + expect(result).toEqual(expect.any(Object)); + expect(result.dashboard?.label).toEqual(['Dashboard example']); + }); + + test('should apply parser options correctly', async () => { + const parser = new SplunkXmlDashboardParser(exampleXml); + const result = await parser.toObject({ explicitArray: false }); + + expect(result).toEqual(expect.any(Object)); + expect(result.dashboard?.label).toEqual('Dashboard example'); + }); + }); + + describe('getQueries', () => { + test('should extract all queries from the dashboard', async () => { + const parser = new SplunkXmlDashboardParser(exampleXml); + const queries = await parser.getQueries(); + + // There should be 3 queries (one from each panel in the example) + expect(queries).toHaveLength(3); + + // Verify the content of the first query + expect(queries[0]).toContain( + `| rest /servicesNS/-/-/data/ui/views + | search eai:acl.app = "search" \`\`\`eai:acl.owner!="nobody"\`\`\` + | stats count by eai:acl.owner` + ); + + // Verify the content of the second query + expect(queries[1]).toContain(`| rest /servicesNS/-/-/data/ui/views + | search eai:acl.app = "search" eai:acl.owner!="nobody" + | stats count`); + + // Verify the content of the third query + expect(queries[2]).toContain(`| rest /servicesNS/-/-/data/ui/views + \`\`\`| search eai:acl.app = "search" \`\`\` + | stats count by eai:acl.app | sort - count`); + }); + + test('should return an empty array if no queries are found', async () => { + const emptyXml = ''; + const parser = new SplunkXmlDashboardParser(emptyXml); + const queries = await parser.getQueries(); + + expect(queries).toEqual([]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts new file mode 100644 index 0000000000000..fa79b9cdf1fec --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts @@ -0,0 +1,83 @@ +/* + * 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 xml2js, { type ParserOptions } from 'xml2js'; + +export interface Search { + query?: string[]; + earliest?: string[]; + latest?: string[]; +} + +export interface ChartPanel { + search?: Search[]; + option?: Array<{ + $: { name: string }; + _: string; + }>; +} + +export interface SinglePanel { + search?: Search[]; + option?: Array<{ + $: { name: string }; + _: string; + }>; +} + +export interface Panel { + title?: string[]; + description?: string[]; + chart?: ChartPanel[]; + single?: SinglePanel[]; +} + +export interface Row { + panel?: Panel[]; +} + +export interface Dashboard { + $?: { + version: string; + theme: string; + }; + label?: string[]; + row?: Row[]; +} + +export interface DashboardObject { + dashboard?: Dashboard; +} + +export class SplunkXmlDashboardParser { + constructor(private readonly xml: string) {} + + async toObject(options?: ParserOptions): Promise { + return xml2js.parseStringPromise(this.xml, options); + } + + async getQueries(): Promise { + const obj = await this.toObject(); + const queries: string[] = []; + + // Extract queries from all panels in all rows + obj?.dashboard?.row?.forEach((row: Row) => { + row.panel?.forEach((panel: Panel) => { + // Handle chart panel queries + if (panel.chart?.[0]?.search?.[0]?.query?.[0]) { + queries.push(panel.chart[0].search[0].query[0]); + } + // Handle single panel queries + if (panel.single?.[0]?.search?.[0]?.query?.[0]) { + queries.push(panel.single[0].search[0].query[0]); + } + }); + }); + + return queries; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_client.ts new file mode 100644 index 0000000000000..869d1ca037a07 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockDashboardMigrationsTaskClient } from './mocks'; +export const DashboardMigrationsTaskClient = MockDashboardMigrationsTaskClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_service.ts new file mode 100644 index 0000000000000..9c9e71b048043 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_task_service.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockDashboardMigrationsTaskService } from './mocks'; +export const DashboardMigrationsTaskService = MockDashboardMigrationsTaskService; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_telemetry_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_telemetry_client.ts similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_telemetry_client.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/dashboard_migrations_telemetry_client.ts diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts index 2f4da50727e3f..fd59bf8c97354 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/mocks.ts @@ -7,22 +7,24 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { FakeLLM } from '@langchain/core/utils/testing'; import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons'; -import type { RuleMigrationTelemetryClient } from '../dashboard_migrations_telemetry_client'; +import type { DashboardMigrationTelemetryClient } from '../dashboard_migrations_telemetry_client'; import type { BaseLLMParams } from '@langchain/core/language_models/llms'; export const createSiemMigrationTelemetryClientMock = () => { // Mock for the object returned by startSiemMigrationTask - const mockStartRuleTranslationReturn = { + const mockStartDashboardTranslationReturn = { success: jest.fn(), failure: jest.fn(), }; // Mock for the function returned by startSiemMigrationTask - const mockStartRuleTranslation = jest.fn().mockReturnValue(mockStartRuleTranslationReturn); + const mockStartDashboardTranslation = jest + .fn() + .mockReturnValue(mockStartDashboardTranslationReturn); // Mock for startSiemMigrationTask return value const mockStartSiemMigrationTaskReturn = { - startRuleTranslation: mockStartRuleTranslation, + startDashboardTranslation: mockStartDashboardTranslation, success: jest.fn(), failure: jest.fn(), aborted: jest.fn(), @@ -30,9 +32,9 @@ export const createSiemMigrationTelemetryClientMock = () => { return { reportIntegrationsMatch: jest.fn(), - reportPrebuiltRulesMatch: jest.fn(), + reportPrebuiltDashboardsMatch: jest.fn(), startSiemMigrationTask: jest.fn().mockReturnValue(mockStartSiemMigrationTaskReturn), - } as jest.Mocked>; + } as jest.Mocked>; }; // Factory function for the mock class @@ -40,12 +42,12 @@ export const MockSiemMigrationTelemetryClient = jest .fn() .mockImplementation(() => createSiemMigrationTelemetryClientMock()); -export const createRuleMigrationsTaskClientMock = () => ({ +export const createDashboardMigrationsTaskClientMock = () => ({ start: jest.fn().mockResolvedValue({ started: true }), stop: jest.fn().mockResolvedValue({ stopped: true }), getStats: jest.fn().mockResolvedValue({ status: 'done', - rules: { + items: { total: 1, finished: 1, processing: 0, @@ -56,15 +58,15 @@ export const createRuleMigrationsTaskClientMock = () => ({ getAllStats: jest.fn().mockResolvedValue([]), }); -export const MockRuleMigrationsTaskClient = jest +export const MockDashboardMigrationsTaskClient = jest .fn() - .mockImplementation(() => createRuleMigrationsTaskClientMock()); + .mockImplementation(() => createDashboardMigrationsTaskClientMock()); -// Rule migrations task service +// Dashboard migrations task service export const mockStopAll = jest.fn(); -export const mockCreateClient = jest.fn(() => createRuleMigrationsTaskClientMock()); +export const mockCreateClient = jest.fn(() => createDashboardMigrationsTaskClientMock()); -export const MockRuleMigrationsTaskService = jest.fn().mockImplementation(() => ({ +export const MockDashboardMigrationsTaskService = jest.fn().mockImplementation(() => ({ createClient: mockCreateClient, stopAll: mockStopAll, })); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.test.ts new file mode 100644 index 0000000000000..9f39c5a54917d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.test.ts @@ -0,0 +1,73 @@ +/* + * 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 fs from 'fs'; +import type { ActionsClientChatOpenAI } from '@kbn/langchain/server/language_models'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { NodeResponse } from '../__mocks__/mocks'; +import { SiemMigrationFakeLLM, MockSiemMigrationTelemetryClient } from '../__mocks__/mocks'; +import { MockEsqlKnowledgeBase } from '../../../common/task/util/__mocks__/mocks'; +import { MockDashboardMigrationsRetriever } from '../retrievers/__mocks__/mocks'; +import { getDashboardMigrationAgent } from './graph'; +import type { OriginalDashboard } from '../../../../../../common/siem_migrations/model/dashboard_migration.gen'; + +const mockOriginalDashboardData = fs.readFileSync( + `${__dirname}/../../__mocks__/original_dashboard_example.xml` +); + +const mockOriginalDashboard: OriginalDashboard = { + id: 'b12c89bc-9d06-11eb-a592-acde48001122', + vendor: 'splunk' as const, + title: 'Office Document Executing Macro Code', + description: + 'The following analytic identifies office documents executing macro code. It leverages Sysmon EventCode 7 to detect when processes like WINWORD.EXE or EXCEL.EXE load specific DLLs associated with macros (e.g., VBE7.DLL). This activity is significant because macros are a common attack vector for delivering malicious payloads, such as malware. If confirmed malicious, this could lead to unauthorized code execution, data exfiltration, or further compromise of the system. Disabling macros by default is recommended to mitigate this risk.', + data: mockOriginalDashboardData, + format: 'xml', +}; + +const logger = loggerMock.create(); +let fakeLLM: SiemMigrationFakeLLM; +let mockRetriever = new MockDashboardMigrationsRetriever(); +let mockEsqlKnowledgeBase = new MockEsqlKnowledgeBase(); +let mockTelemetryClient = new MockSiemMigrationTelemetryClient(); + +const setupAgent = (responses: NodeResponse[]) => { + fakeLLM = new SiemMigrationFakeLLM({ nodeResponses: responses }); + const model = fakeLLM as unknown as ActionsClientChatOpenAI; + const graph = getDashboardMigrationAgent({ + model, + esqlKnowledgeBase: mockEsqlKnowledgeBase, + dashboardMigrationsRetriever: mockRetriever, + logger, + telemetryClient: mockTelemetryClient, + }); + return graph; +}; + +describe('getDashboardMigrationAgent', () => { + beforeEach(() => { + mockRetriever = new MockDashboardMigrationsRetriever(); + mockTelemetryClient = new MockSiemMigrationTelemetryClient(); + mockEsqlKnowledgeBase = new MockEsqlKnowledgeBase(); + jest.clearAllMocks(); + }); + + it('should compile graph', () => { + setupAgent([{ nodeId: '', response: '' }]); + }); + + it('should run graph', async () => { + const agent = setupAgent([{ nodeId: '', response: '' }]); + const result = await agent.invoke({ + id: 'testId', + original_dashboard: mockOriginalDashboard, + resources: {}, + }); + + expect(result).toEqual(expect.any(Object)); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts new file mode 100644 index 0000000000000..9075fdfe8017b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.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 { END, START, StateGraph } from '@langchain/langgraph'; +import { getParseOriginalDashboardNode } from './nodes/parse_original_dashboard'; +import { migrateDashboardConfigSchema, migrateDashboardState } from './state'; +import type { MigrateDashboardGraphParams } from './types'; +import { + fanOutPanelTranslations, + getTranslatePanelNode, +} from './nodes/translate_panel/translate_panel'; +import { getAggregateDashboardNode } from './nodes/aggregate_dashboard'; + +export function getDashboardMigrationAgent({ + model, + esqlKnowledgeBase, + dashboardMigrationsRetriever, + logger, + telemetryClient, +}: MigrateDashboardGraphParams) { + const parseOriginalDashboardNode = getParseOriginalDashboardNode(); + const translatePanelNode = getTranslatePanelNode({ + model, + esqlKnowledgeBase, + dashboardMigrationsRetriever, + telemetryClient, + logger, + }); + const aggregateDashboardNode = getAggregateDashboardNode(); + + const siemMigrationAgentGraph = new StateGraph( + migrateDashboardState, + migrateDashboardConfigSchema + ) + // Nodes + .addNode('parseOriginalDashboard', parseOriginalDashboardNode) + .addNode('translatePanel', translatePanelNode) + .addNode('aggregateDashboard', aggregateDashboardNode) + // Edges + .addEdge(START, 'parseOriginalDashboard') + .addConditionalEdges('parseOriginalDashboard', fanOutPanelTranslations, ['translatePanel']) + .addEdge('translatePanel', 'aggregateDashboard') + .addEdge('aggregateDashboard', END); + + const graph = siemMigrationAgentGraph.compile(); + graph.name = 'Dashboard Migration Graph'; // Customizes the name displayed in LangSmith + return graph; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts index 1f0cb4afcb734..f3c73aa6e453c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/index.ts @@ -5,7 +5,4 @@ * 2.0. */ -export const getDashboardMigrationAgent = (params: object) => ({ - // TODO: Implement the dashboard migration agent - invoke: (input: object, config?: object) => {}, -}); +export { getDashboardMigrationAgent } from './graph'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts new file mode 100644 index 0000000000000..32d38c7eb762d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts @@ -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 { MigrationTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; +import type { GraphNode } from '../../types'; + +export const getAggregateDashboardNode = (): GraphNode => { + return async (state) => { + // dashboard data is the SO data ready to be installed + // TODO: use the templates (viz_type) to generate the correct dashboardData, this is still dummy data + const dashboardData = state.translated_panels + .sort((a, b) => a.index - b.index) + .map(({ panel }) => ({ + title: panel.title, + description: panel.description, + query: panel.query, + // id + // position + // viz_type + })); + + // TODO: Consider adding individual translation results for each panel, and aggregate them here + let translationResult; + if (state.translated_panels.length > 0) { + if (state.translated_panels.length > 0) { + translationResult = MigrationTranslationResult.PARTIAL; + } else { + translationResult = MigrationTranslationResult.FULL; + } + } else { + translationResult = MigrationTranslationResult.UNTRANSLATABLE; + } + + return { + elastic_dashboard: { + title: state.original_dashboard.title, + data: JSON.stringify(dashboardData), + }, + translation_result: translationResult, + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/index.ts new file mode 100644 index 0000000000000..4859c811ad430 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getAggregateDashboardNode } from './aggregate_dashboard'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area.viz.json new file mode 100644 index 0000000000000..b390775b84cfd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area.viz.json @@ -0,0 +1,129 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "dea780b5-eacc-4166-9158-994d596956ab" + }, + "panelIndex": "dea780b5-eacc-4166-9158-994d596956ab", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-3d7f0133-8948-4059-885f-1ecc1feb4a50" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "legend": { + "isVisible":true, + "position": "right" + }, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "tickLabelsVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "labelsOrientation": { + "x": 0, + "yLeft": 0, + "yRight": 0 + }, + "gridlinesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "preferredSeriesType": "area", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "seriesType": "area", + "xAccessor": "day", + "accessors": [ + "count()" + ], + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsXY", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area_stacked.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area_stacked.viz.json new file mode 100644 index 0000000000000..a5866cf0e0a28 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area_stacked.viz.json @@ -0,0 +1,130 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "81d5799f-6666-41b1-afcf-9226c99c6829" + }, + "panelIndex": "81d5799f-6666-41b1-afcf-9226c99c6829", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-58de5f95-1ea0-46cc-9593-273de8f4935b" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "legend": { + "isVisible":true, + "position": "right" + }, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "tickLabelsVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "labelsOrientation": { + "x": 0, + "yLeft": 0, + "yRight": 0 + }, + "gridlinesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "preferredSeriesType": "area_stacked", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "seriesType": "area_stacked", + "xAccessor": "day", + "accessors": [ + "count()", + "names" + ], + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsXY", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal.viz.json new file mode 100644 index 0000000000000..199b647f8c6b1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal.viz.json @@ -0,0 +1,123 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "46688264-e84d-40e9-8dc3-422d451bd364" + }, + "panelIndex": "46688264-e84d-40e9-8dc3-422d451bd364", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "legend": { + "isVisible":true, + "position": "right" + }, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "tickLabelsVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "labelsOrientation": { + "x": 0, + "yLeft": 0, + "yRight": 0 + }, + "gridlinesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "preferredSeriesType": "bar_stacked", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "seriesType": "bar_horizontal", + "xAccessor": "agent.name", + "accessors": [ + "count()" + ], + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsXY", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal_stacked.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal_stacked.viz.json new file mode 100644 index 0000000000000..d920e4641ef5c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal_stacked.viz.json @@ -0,0 +1,130 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "e0e7cfe4-bff9-464a-a753-759dad5e865e" + }, + "panelIndex": "e0e7cfe4-bff9-464a-a753-759dad5e865e", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-67d41b7c-dad3-4209-b4a6-c9ece0aa9d69" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "legend": { + "isVisible":true, + "position": "right" + }, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "tickLabelsVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "labelsOrientation": { + "x": 0, + "yLeft": 0, + "yRight": 0 + }, + "gridlinesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "preferredSeriesType": "bar_horizontal_stacked", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "seriesType": "bar_horizontal_stacked", + "xAccessor": "agent.name", + "splitAccessor": "agent.name", + "accessors": [ + "count()" + ], + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsXY", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical.viz.json new file mode 100644 index 0000000000000..59a6f0559bbf2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical.viz.json @@ -0,0 +1,129 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "da3460eb-dcbe-4da9-b675-320cd9cc17e4" + }, + "panelIndex": "da3460eb-dcbe-4da9-b675-320cd9cc17e4", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-7fb5abc5-850c-4ed2-bf42-815ec57524fe" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "legend": { + "isVisible":true, + "position": "right" + }, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "tickLabelsVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "labelsOrientation": { + "x": 0, + "yLeft": 0, + "yRight": 0 + }, + "gridlinesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "preferredSeriesType": "bar", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "seriesType": "bar", + "xAccessor": "agent.name", + "accessors": [ + "count()" + ], + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsXY", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical_stacked.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical_stacked.viz.json new file mode 100644 index 0000000000000..60fdf2f2a43ba --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical_stacked.viz.json @@ -0,0 +1,130 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "f16cf895-b9fc-4bd5-a065-04bc010a798c" + }, + "panelIndex": "f16cf895-b9fc-4bd5-a065-04bc010a798c", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-52a01fd3-4fd6-47dc-b748-104390a59003" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "legend": { + "isVisible":true, + "position": "right" + }, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "tickLabelsVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "labelsOrientation": { + "x": 0, + "yLeft": 0, + "yRight": 0 + }, + "gridlinesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "preferredSeriesType": "bar_stacked", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "seriesType": "bar_stacked", + "xAccessor": "agent.name", + "splitAccessor": "agent.name", + "accessors": [ + "count()" + ], + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsXY", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/dashboard.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/dashboard.json new file mode 100644 index 0000000000000..7cfaf18340ada --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/dashboard.json @@ -0,0 +1,23 @@ +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "", + "timeRestore": false, + "title": "", + "version": 2 + }, + "coreMigrationVersion": "8.8.0", + "created_at": "2024-11-22T22:38:58.430Z", + "created_by": "u_3965676980_cloud", + "id": "cb50f38e-2cdd-42cf-bd51-16063303dda1", + "managed": false, + "type": "dashboard", + "typeMigrationVersion": "10.2.0", + "updated_at": "2024-11-23T00:49:12.254Z", + "updated_by": "u_3965676980_cloud", + "version": "WzEzODMsNF0=" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/donut.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/donut.viz.json new file mode 100644 index 0000000000000..3edd74dbc3ee7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/donut.viz.json @@ -0,0 +1,108 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "c927fb91-0322-4e90-9714-65b0e3e6a073" + }, + "panelIndex": "c927fb91-0322-4e90-9714-65b0e3e6a073", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-69a4b241-c0ac-4834-8c2b-618d3faf53f1" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "shape": "donut", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "primaryGroups": [ + "agent.name" + ], + "metrics": [ + "count()" + ], + "numberDisplay": "percent", + "categoryDisplay": "default", + "legendDisplay": "default", + "nestedLegend":false, + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsPie", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/gauge.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/gauge.viz.json new file mode 100644 index 0000000000000..83995cc5f39d9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/gauge.viz.json @@ -0,0 +1,181 @@ +{ + "type": "lens", + "title": "Requests successful", + "embeddableConfig": + { + "attributes": + { + "title": "Table ", + "references": + [], + "state": + { + "datasourceStates": + { + "textBased": + { + "layers": + { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": + { + "index": "047b9ce1c481e9105458e4238be7cbb304abc176b09c3b4d196d84686c42b5d0", + "query": + { + "esql": "FROM logs-nginx\\n| WHERE event.dataset == 'nginx.access' AND event.outcome == 'success'\\n| STATS a = COUNT(*)" + }, + "columns": + [ + { + "columnId": "a", + "fieldName": "a", + "meta": + { + "type": "number" + }, + "inMetricDimension": true + } + ], + "timeField": "@timestamp" + }, + "3b1b0102-bb45-40f5-9ef2-419d2eaaa56c": + { + "index": "047b9ce1c481e9105458e4238be7cbb304abc176b09c3b4d196d84686c42b5d0", + "query": + { + "esql": "FROM logs-nginx\\n| WHERE event.dataset == 'nginx.access' AND event.outcome == 'success'\\n| STATS a = COUNT(*)" + }, + "columns": + [ + { + "columnId": "aa9a832b-d4bf-4652-b2eb-7cbebb4bbb13", + "fieldName": "a", + "meta": + { + "type": "number", + "esType": "long" + } + } + ] + } + }, + "indexPatternRefs": + [ + { + "id": "047b9ce1c481e9105458e4238be7cbb304abc176b09c3b4d196d84686c42b5d0", + "title": "logs*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": + [], + "query": + { + "esql": "FROM logs-nginx\\n| WHERE event.dataset == 'nginx.access' AND event.outcome == 'success'\\n| STATS a = COUNT(*)" + }, + "visualization": + { + "shape": "semiCircle", + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "layerType": "data", + "ticksPosition": "bands", + "labelMajorMode": "auto", + "metricAccessor": "aa9a832b-d4bf-4652-b2eb-7cbebb4bbb13", + "palette": + { + "name": "custom", + "type": "palette", + "params": + { + "steps": 4, + "name": "custom", + "reverse": false, + "rangeType": "percent", + "rangeMin": null, + "rangeMax": null, + "progression": "fixed", + "stops": + [ + { + "color": "#d7060680", + "stop": 25 + }, + { + "color": "#f8e72e80", + "stop": 50 + }, + { + "color": "#baf50a80", + "stop": 75 + }, + { + "color": "#02bc0c80", + "stop": 100 + } + ], + "colorStops": + [ + { + "color": "#d7060680", + "stop": null + }, + { + "color": "#f8e72e80", + "stop": 25 + }, + { + "color": "#baf50a80", + "stop": 50 + }, + { + "color": "#02bc0c80", + "stop": 75 + } + ], + "continuity": "all", + "maxSteps": 5 + } + }, + "colorMode": "palette" + }, + "adHocDataViews": + { + "047b9ce1c481e9105458e4238be7cbb304abc176b09c3b4d196d84686c42b5d0": + { + "id": "047b9ce1c481e9105458e4238be7cbb304abc176b09c3b4d196d84686c42b5d0", + "title": "logs*", + "timeFieldName": "@timestamp", + "sourceFilters": + [], + "type": "esql", + "fieldFormats": + {}, + "runtimeFieldMap": + {}, + "allowNoIndex": false, + "name": "logs*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsGauge", + "type": "lens" + }, + "disabledActions": + [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": + {} + }, + "panelIndex": "2757c30b-a233-4cce-9db0-f56313513e9f", + "gridData": + { + "x": 0, + "y": 48, + "w": 24, + "h": 16, + "i": "2757c30b-a233-4cce-9db0-f56313513e9f" + } + } \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/heatmap.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/heatmap.viz.json new file mode 100644 index 0000000000000..59e876a414001 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/heatmap.viz.json @@ -0,0 +1,92 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "07fdc318-57e3-4e3a-916e-1b949c047e15" + }, + "panelIndex": "07fdc318-57e3-4e3a-916e-1b949c047e15", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-413744a0-350a-42a5-bff3-2b6d0ebc808c" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "shape": "heatmap", + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "layerType": "data", + "legend": { + "isVisible":true, + "position": "right", + "type": "heatmap_legend" + }, + "gridConfig": { + "type": "heatmap_grid", + "isCellLabelVisible":false, + "isYAxisLabelVisible":true, + "isXAxisLabelVisible":true, + "isYAxisTitleVisible":false, + "isXAxisTitleVisible":false + }, + "valueAccessor": "count()", + "xAccessor": "agent.name", + "yAccessor": "agent.id" + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsHeatmap", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/line.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/line.viz.json new file mode 100644 index 0000000000000..794e2dd4fa344 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/line.viz.json @@ -0,0 +1,129 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "de95d316-320e-42e6-a71a-24dfdb4d9a10" + }, + "panelIndex": "de95d316-320e-42e6-a71a-24dfdb4d9a10", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-db7c0a39-c039-4f8a-9029-e418c52ce62b" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "legend": { + "isVisible":true, + "position": "right" + }, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "tickLabelsVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "labelsOrientation": { + "x": 0, + "yLeft": 0, + "yRight": 0 + }, + "gridlinesVisibilitySettings": { + "x":true, + "yLeft":true, + "yRight":true + }, + "preferredSeriesType": "line", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "seriesType": "line", + "xAccessor": "day", + "accessors": [ + "count()" + ], + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsXY", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/markdown.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/markdown.viz.json new file mode 100644 index 0000000000000..fd6ba07840328 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/markdown.viz.json @@ -0,0 +1,38 @@ +{ + "type": "visualization", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "0735694d-1138-4e11-a315b20" + }, + "panelIndex": "0735694d-1138-4e11-a315-29c0dfdd3b20", + "embeddableConfig": { + "savedVis": { + "id": "", + "title": "", + "description": "", + "type": "markdown", + "params": { + "fontSize": 12, + "openLinksInNewTab": false, + "markdown": "Sample text" + }, + "uiState": {}, + "data": { + "aggs": [], + "searchSource": { + "query": { + "query": "", + "language": "kuery" + }, + "filter": [] + } + } + }, + "description": "", + "enhancements": {} + }, + "title": "Markdown" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/metric.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/metric.viz.json new file mode 100644 index 0000000000000..ed0c543743d35 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/metric.viz.json @@ -0,0 +1,69 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "bfc47c01-6421-428f-85fc-40b7d125901b" + }, + "panelIndex": "bfc47c01-6421-428f-85fc-40b7d125901b", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "layerType": "data", + "metricAccessor": "count()" + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsMetric", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + } +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/pie.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/pie.viz.json new file mode 100644 index 0000000000000..b02f1a0c19a0b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/pie.viz.json @@ -0,0 +1,104 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "1f6c3f5b-0869-4a6f-ad27-62b77ccf43f5" + }, + "panelIndex": "1f6c3f5b-0869-4a6f-ad27-62b77ccf43f5", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-69a4b241-c0ac-4834-8c2b-618d3faf53f1" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "shape": "pie", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "primaryGroups": [], + "metrics": [], + "numberDisplay": "percent", + "categoryDisplay": "default", + "legendDisplay": "default", + "nestedLegend":false, + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsPie", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/table.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/table.viz.json new file mode 100644 index 0000000000000..685a2afc03ac4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/table.viz.json @@ -0,0 +1,76 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "6e0b498e-a8f5-4b5e-9307-9f6cc505e21c" + }, + "panelIndex": "6e0b498e-a8f5-4b5e-9307-9f6cc505e21c", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-3a5310ab-2832-41db-bdbe-1b6939dd5651" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "layerType": "data", + "columns": [] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex":false, + "name": "logs-*", + "allowHidden":false + } + } + }, + "visualizationType": "lnsDatatable", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/treemap.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/treemap.viz.json new file mode 100644 index 0000000000000..26e1eb58e9c7c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/treemap.viz.json @@ -0,0 +1,108 @@ +{ + "type": "lens", + "gridData": { + "x": 0, + "y": 0, + "w": 12, + "h": 6, + "i": "2bad2a0b-9581-4a29-8cb0-c4e713bab912" + }, + "panelIndex": "2bad2a0b-9581-4a29-8cb0-c4e713bab912", + "embeddableConfig": { + "attributes": { + "title": "", + "references": [ + { + "type": "index-pattern", + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "name": "textBasedLanguages-datasource-layer-69d8f699-0603-479a-b4f6-712008393869" + } + ], + "state": { + "datasourceStates": { + "textBased": { + "layers": { + "3a5310ab-2832-41db-bdbe-1b6939dd5651": { + "index": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "query": { + "esql": "" + }, + "columns": [], + "timeField": "@timestamp" + } + }, + "indexPatternRefs": [ + { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeField": "@timestamp" + } + ] + } + }, + "filters": [], + "query": { + "esql": "" + }, + "visualization": { + "shape": "treemap", + "layers": [ + { + "layerId": "3a5310ab-2832-41db-bdbe-1b6939dd5651", + "primaryGroups": [ + "agent.name" + ], + "metrics": [ + "count()" + ], + "numberDisplay": "percent", + "categoryDisplay": "default", + "legendDisplay": "default", + "nestedLegend":false, + "layerType": "data", + "colorMapping": { + "assignments": [], + "specialAssignments": [ + { + "rule": { + "type": "other" + }, + "color": { + "type": "loop" + }, + "touched":false + } + ], + "paletteId": "eui_amsterdam_color_blind", + "colorMode": { + "type": "categorical" + } + } + } + ] + }, + "adHocDataViews": { + "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da": { + "id": "d6c911aa50e2dafcebcedfa50190d5c03bbac0070c96d6b16feb18aa1f84b5da", + "title": "logs-*", + "timeFieldName": "@timestamp", + "sourceFilters": [], + "type": "esql", + "fieldFormats": {}, + "runtimeFieldMap": {}, + "allowNoIndex": false, + "name": "logs-*", + "allowHidden": false + } + } + }, + "visualizationType": "lnsPie", + "type": "lens" + }, + "disabledActions": [ + "OPEN_FLYOUT_ADD_DRILLDOWN" + ], + "enhancements": {} + }, + "title": "" +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/index.ts similarity index 66% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_client.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/index.ts index b4eac7ccf2462..2ed917e114c34 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -import { MockRuleMigrationsTaskClient } from './mocks'; -export const RuleMigrationsTaskClient = MockRuleMigrationsTaskClient; +export { getParseOriginalDashboardNode } from './parse_original_dashboard'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.ts new file mode 100644 index 0000000000000..1675be9a64f99 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.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 { v4 as uuidV4 } from 'uuid'; +import type { ChartPanel } from '../../../../lib/parsers/splunk/splunk_xml_dashboard_parser'; +import { SplunkXmlDashboardParser } from '../../../../lib/parsers/splunk/splunk_xml_dashboard_parser'; +import type { VizType, GraphNode, PanelPosition } from '../../types'; + +export const getParseOriginalDashboardNode = (): GraphNode => { + return async (state) => { + if (state.original_dashboard.vendor !== 'splunk') { + throw new Error('Unsupported dashboard vendor'); + } + + const parser = new SplunkXmlDashboardParser(state.original_dashboard.data); + const parsedDashboard = await parser.toObject(); + + const panels = + parsedDashboard.dashboard?.row?.flatMap( + (row) => + row.panel?.map((panel) => ({ + id: uuidV4(), + title: panel?.title?.[0] ?? '', + description: panel?.description?.[0] ?? '', + query: panel?.chart?.[0]?.search?.[0]?.query?.[0] ?? '', + viz_type: getVizType(panel?.chart?.[0]), + position: getPosition(panel?.chart?.[0]), + })) ?? [] + ) ?? []; + + return { + parsed_original_dashboard: { + title: state.original_dashboard.title, + panels, + }, + }; + }; +}; + +function getVizType(chart: ChartPanel | undefined): VizType { + // TODO: Implement logic to determine viz type + return 'pie'; +} + +function getPosition(chart: ChartPanel | undefined): PanelPosition { + // TODO: Implement logic to determine position + return { x: 0, y: 0, w: 0, h: 0 }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/index.ts new file mode 100644 index 0000000000000..5a2010ebafee1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getTranslatePanelNode } from './translate_panel'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts new file mode 100644 index 0000000000000..e53a543acae69 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.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 { Send } from '@langchain/langgraph'; +import type { MigrateDashboardState, ParsedOriginalPanel } from '../../types'; +import { getTranslatePanelGraph } from '../../sub_graphs/translate_panel'; +import type { TranslatePanelGraphParams } from '../../sub_graphs/translate_panel/types'; + +export interface TranslatePanelNodeParams { + panel: ParsedOriginalPanel; + index: number; +} + +export type TranslatePanelNode = ( + params: TranslatePanelNodeParams +) => Promise>; + +// This is a special node, it's goal is to use map-reduce to translate the dashboard panels in parallel. +// - fan-out: the array of parsed_original_panels is split into individual panels for processing via the translatePanelSubGraph +// - fan-in: the results of the individual panel translations are aggregated back into the overall dashboard state via state reducer. +// This is the recommended technique at the time of writing this code. LangGraph docs: https://langchain-ai.github.io/langgraphjs/how-tos/map-reduce/. +export const getTranslatePanelNode = (params: TranslatePanelGraphParams): TranslatePanelNode => { + const translatePanelSubGraph = getTranslatePanelGraph(params); + return async ({ panel, index }) => { + try { + const output = await translatePanelSubGraph.invoke({ original_panel: panel }); + return { + // Fan-in: translated panels are concatenated by the state reducer, so the results can be aggregated later + translated_panels: [{ index, panel: output.elastic_panel }], + }; + } catch (err) { + // Fan-in: failed panels are concatenated by the state reducer, so the results can be aggregated later + return { + failed_panel_translations: [{ index, error_message: err.toString(), details: err }], + }; + } + }; +}; + +// Fan-out: for each panel, Send translatePanel to be executed in parallel. +// This function needs to be called inside a `conditionalEdge` +export const fanOutPanelTranslations = (state: MigrateDashboardState) => { + const panels = state.parsed_original_dashboard.panels ?? []; + return panels.map((panel, i) => { + const params: TranslatePanelNodeParams = { panel, index: i }; + return new Send('translatePanel', params); + }); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts new file mode 100644 index 0000000000000..4265c1e62343e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts @@ -0,0 +1,43 @@ +/* + * 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 { Annotation } from '@langchain/langgraph'; +import { uniq } from 'lodash/fp'; +import type { MigrationTranslationResult } from '../../../../../../common/siem_migrations/constants'; +import type { + ElasticDashboard, + OriginalDashboard, + DashboardMigrationDashboard, +} from '../../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { MigrationResources } from '../../../common/task/retrievers/resource_retriever'; +import type { FailedPanelTranslations, ParsedOriginalDashboard, TranslatedPanels } from './types'; + +export const migrateDashboardState = Annotation.Root({ + id: Annotation(), + original_dashboard: Annotation(), + parsed_original_dashboard: Annotation(), + translated_panels: Annotation({ + reducer: (current, value) => current.concat(value), + }), + failed_panel_translations: Annotation({ + reducer: (current, value) => current.concat(value), + }), + elastic_dashboard: Annotation({ + reducer: (current, value) => ({ ...current, ...value }), + }), + resources: Annotation(), + translation_result: Annotation(), + comments: Annotation({ + // Translation subgraph causes the original main graph comments to be concatenated again, we need to deduplicate them. + reducer: (current, value) => uniq(value ? (current ?? []).concat(value) : current), + default: () => [], + }), +}); + +export const migrateDashboardConfigSchema = Annotation.Root({ + skipPrebuiltDashboardsMatching: Annotation(), +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts new file mode 100644 index 0000000000000..3ddf64c1ab108 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts @@ -0,0 +1,84 @@ +/* + * 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 { END, START, StateGraph } from '@langchain/langgraph'; +import { isEmpty } from 'lodash/fp'; +import { getEcsMappingNode } from './nodes/ecs_mapping'; +import { getFixQueryErrorsNode } from './nodes/fix_query_errors'; +import { getInlineQueryNode } from './nodes/inline_query'; +import { getTranslateQueryNode } from './nodes/translate_query'; +import { getTranslationResultNode } from './nodes/translation_result'; +import { getValidationNode } from './nodes/validation'; +import { translateDashboardPanelState } from './state'; +import type { TranslatePanelGraphParams, TranslateDashboardPanelState } from './types'; +import { migrateDashboardConfigSchema } from '../../state'; +import { getSelectIndexPatternNode } from './nodes/select_index_pattern/select_index_pattern'; + +// How many times we will try to self-heal when validation fails, to prevent infinite graph recursions +const MAX_VALIDATION_ITERATIONS = 3; + +export function getTranslatePanelGraph({ + model, + esqlKnowledgeBase, + logger, + telemetryClient, +}: TranslatePanelGraphParams) { + const translateQueryNode = getTranslateQueryNode({ + esqlKnowledgeBase, + logger, + }); + const inlineQueryNode = getInlineQueryNode({ model, logger }); + const validationNode = getValidationNode({ logger }); + const fixQueryErrorsNode = getFixQueryErrorsNode({ esqlKnowledgeBase, logger }); + const ecsMappingNode = getEcsMappingNode({ esqlKnowledgeBase, logger }); + const selectIndexPatternNode = getSelectIndexPatternNode({ model, telemetryClient, logger }); + const translationResultNode = getTranslationResultNode(); + + const translateDashboardPanelGraph = new StateGraph( + translateDashboardPanelState, + migrateDashboardConfigSchema + ) + // Nodes + .addNode('inlineQuery', inlineQueryNode) + .addNode('translateQuery', translateQueryNode) + .addNode('validation', validationNode) + .addNode('fixQueryErrors', fixQueryErrorsNode) + .addNode('ecsMapping', ecsMappingNode) + .addNode('selectIndexPattern', selectIndexPatternNode) + .addNode('translationResult', translationResultNode) + // Edges + .addEdge(START, 'inlineQuery') + .addEdge('inlineQuery', 'translateQuery') + .addEdge('translateQuery', 'validation') + .addEdge('fixQueryErrors', 'validation') + .addEdge('ecsMapping', 'validation') + .addConditionalEdges('validation', validationRouter, [ + 'fixQueryErrors', + 'ecsMapping', + 'selectIndexPattern', + ]) + .addEdge('selectIndexPattern', 'translationResult') + .addEdge('translationResult', END); + + const graph = translateDashboardPanelGraph.compile(); + graph.name = 'Translate Dashboard Panel Graph'; + return graph; +} + +const validationRouter = (state: TranslateDashboardPanelState) => { + if ( + state.validation_errors.iterations <= MAX_VALIDATION_ITERATIONS && + !isEmpty(state.validation_errors?.esql_errors) + ) { + return 'fixQueryErrors'; + } + if (!state.includes_ecs_mapping) { + return 'ecsMapping'; + } + + return 'selectIndexPattern'; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/index.ts similarity index 66% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_service.ts rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/index.ts index 04da946c083c5..ce761fe4a02b4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/__mocks__/rule_migrations_task_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -import { MockRuleMigrationsTaskService } from './mocks'; -export const RuleMigrationsTaskService = MockRuleMigrationsTaskService; +export { getTranslatePanelGraph } from './graph'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts new file mode 100644 index 0000000000000..b1e246ba5a74b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getConvertEsqlSchemaCisToEcs, + type GetConvertEsqlSchemaCisToEcsParams, +} from '../../../../../../../common/task/agent/tools/convert_esql_schema_cim_to_ecs'; +import type { GraphNode } from '../../types'; + +export const getEcsMappingNode = (params: GetConvertEsqlSchemaCisToEcsParams): GraphNode => { + const convertEsqlSchemaCimToEcs = getConvertEsqlSchemaCisToEcs(params); + return async (state) => { + const { query, comments } = await convertEsqlSchemaCimToEcs({ + title: state.elastic_panel.title ?? '', + description: state.elastic_panel.description ?? '', + query: state.elastic_panel.query ?? '', + originalQuery: state.inline_query, + }); + + // Set includes_ecs_mapping to indicate that this node has been executed to ensure it only runs once + return { + includes_ecs_mapping: true, + comments, + ...(query && { elastic_panel: { ...state.elastic_panel, query } }), + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/index.ts new file mode 100644 index 0000000000000..339e6d3dd8e7a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getEcsMappingNode } from './ecs_mapping'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts new file mode 100644 index 0000000000000..e724f73be4d12 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getFixEsqlQueryErrors, + type GetFixEsqlQueryErrorsParams, +} from '../../../../../../../common/task/agent/tools/fix_esql_query_errors'; +import type { GraphNode } from '../../types'; + +export const getFixQueryErrorsNode = (params: GetFixEsqlQueryErrorsParams): GraphNode => { + const fixEsqlQueryErrors = getFixEsqlQueryErrors(params); + return async (state) => { + const { query } = await fixEsqlQueryErrors({ + invalidQuery: state.elastic_panel.query, + validationErrors: state.validation_errors.esql_errors, + }); + if (!query) { + return {}; + } + return { elastic_panel: { ...state.elastic_panel, query } }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/index.ts new file mode 100644 index 0000000000000..a805331675389 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getFixQueryErrorsNode } from './fix_query_errors'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/index.ts new file mode 100644 index 0000000000000..c466306e99074 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getInlineQueryNode } from './inline_query'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts new file mode 100644 index 0000000000000..88fbe0f178bf3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts @@ -0,0 +1,29 @@ +/* + * 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 { + getInlineSplQuery, + type GetInlineSplQueryParams, +} from '../../../../../../../common/task/agent/tools/inline_spl_query'; +import type { GraphNode } from '../../types'; + +export const getInlineQueryNode = (params: GetInlineSplQueryParams): GraphNode => { + const inlineSplQuery = getInlineSplQuery(params); + return async (state) => { + const { inlineQuery, isUnsupported, comments } = await inlineSplQuery({ + query: state.original_panel.query, + resources: state.resources, + }); + if (isUnsupported) { + // Graph conditional edge detects undefined inline_query as unsupported query + return { inline_query: undefined, comments }; + } + return { + inline_query: inlineQuery ?? state.original_panel.query, + comments, + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/index.ts new file mode 100644 index 0000000000000..b8ad36a18805e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getSelectIndexPanelNode as getEcsMappingNode } from './select_index_pattern'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts new file mode 100644 index 0000000000000..7a713205b5586 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts @@ -0,0 +1,25 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import type { ChatModel } from '../../../../../../../common/task/util/actions_client_chat'; +import type { DashboardMigrationTelemetryClient } from '../../../../../dashboard_migrations_telemetry_client'; +import type { GraphNode } from '../../types'; + +interface GetSelectIndexPatternParams { + model: ChatModel; + telemetryClient: DashboardMigrationTelemetryClient; + logger: Logger; +} + +export const getSelectIndexPatternNode = (params: GetSelectIndexPatternParams): GraphNode => { + return async (_state) => { + // TODO: implement index pattern discovery + return { + index_pattern: '[index_pattern]', + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/index.ts new file mode 100644 index 0000000000000..7d247f755e9da --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getTranslateQueryNode } from './translate_query'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts new file mode 100644 index 0000000000000..3e2452cb0d8e9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts @@ -0,0 +1,41 @@ +/* + * 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 { + getTranslateSplToEsql, + TASK_DESCRIPTION, + type GetTranslateSplToEsqlParams, +} from '../../../../../../../common/task/agent/tools/translate_spl_to_esql'; +import type { GraphNode } from '../../types'; + +export const getTranslateQueryNode = (params: GetTranslateSplToEsqlParams): GraphNode => { + const translateSplToEsql = getTranslateSplToEsql(params); + return async (state) => { + const { title, description = '' } = state.original_panel; + const { esqlQuery, comments } = await translateSplToEsql({ + title, + description, + taskDescription: TASK_DESCRIPTION.migrate_dashboard, + inlineQuery: state.inline_query, + indexPattern: 'logs-*', // The index_pattern state is still undefined at this point + }); + + if (!esqlQuery) { + return { comments }; + } + + return { + elastic_panel: { + title, + description, + query: esqlQuery, + query_language: 'esql', + }, + comments, + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/index.ts new file mode 100644 index 0000000000000..6779a9c99ebc8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getTranslationResultNode } from './translation_result'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts new file mode 100644 index 0000000000000..777ff2402924a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts @@ -0,0 +1,43 @@ +/* + * 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 { RuleTranslationResult } from '../../../../../../../../../../common/siem_migrations/constants'; +import type { GraphNode } from '../../types'; + +export const getTranslationResultNode = (): GraphNode => { + return async (state) => { + // Set defaults + const elasticVisualization = { + title: state.original_panel.title, + description: state.original_panel.description || state.original_panel.title, + ...state.elastic_panel, + }; + + const query = elasticVisualization.query; + let translationResult; + + if (!query) { + translationResult = RuleTranslationResult.UNTRANSLATABLE; + } else { + if (query.startsWith('FROM logs-*')) { + elasticVisualization.query = query.replace('FROM logs-*', 'FROM [indexPattern]'); + translationResult = RuleTranslationResult.PARTIAL; + } else if (state.validation_errors?.esql_errors) { + translationResult = RuleTranslationResult.PARTIAL; + } else if (query.match(/\[(macro|lookup):.*?\]/)) { + translationResult = RuleTranslationResult.PARTIAL; + } else { + translationResult = RuleTranslationResult.FULL; + } + } + + return { + elastic_visualization: elasticVisualization, + translation_result: translationResult, + }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/index.ts new file mode 100644 index 0000000000000..a8c0f55191e42 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getValidationNode } from './validation'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts new file mode 100644 index 0000000000000..73f3f250840f5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getValidateEsql, + type GetValidateEsqlParams, +} from '../../../../../../../common/task/agent/tools/validate_esql/validation'; +import type { GraphNode } from '../../types'; + +/** + * This node runs all validation steps, and will redirect to the END of the graph if no errors are found. + * Any new validation steps should be added here. + */ +export const getValidationNode = (params: GetValidateEsqlParams): GraphNode => { + const validateEsql = getValidateEsql(params); + return async (state) => { + const iterations = state.validation_errors.iterations + 1; + if (!state.elastic_panel.query) { + params.logger.warn('Missing query in validation node'); + return { iterations }; + } + + const { error } = await validateEsql({ query: state.elastic_panel.query }); + + return { validation_errors: { iterations, esql_errors: error } }; + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts new file mode 100644 index 0000000000000..0c6d9df5397ec --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts @@ -0,0 +1,42 @@ +/* + * 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 { Annotation } from '@langchain/langgraph'; +import { RuleTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; +import type { DashboardMigrationDashboard } from '../../../../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { MigrationResources } from '../../../../../common/task/retrievers/resource_retriever'; +import type { ValidationErrors } from './types'; +import type { ParsedOriginalPanel, ElasticPanel } from '../../types'; + +export const translateDashboardPanelState = Annotation.Root({ + original_panel: Annotation(), + elastic_panel: Annotation({ + reducer: (current, value) => ({ ...current, ...value }), + }), + index_pattern: Annotation, + resources: Annotation(), + includes_ecs_mapping: Annotation({ + reducer: (current, value) => value ?? current, + default: () => false, + }), + inline_query: Annotation({ + reducer: (current, value) => value ?? current, + default: () => '', + }), + validation_errors: Annotation({ + reducer: (current, value) => value ?? current, + default: () => ({ iterations: 0 }), + }), + translation_result: Annotation({ + reducer: (current, value) => value ?? current, + default: () => RuleTranslationResult.UNTRANSLATABLE, + }), + comments: Annotation({ + reducer: (current, value) => (value ? (current ?? []).concat(value) : current), + default: () => [], + }), +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts new file mode 100644 index 0000000000000..5539290caf63c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts @@ -0,0 +1,37 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { ChatModel } from '../../../../../common/task/util/actions_client_chat'; +import type { EsqlKnowledgeBase } from '../../../../../common/task/util/esql_knowledge_base'; +import type { DashboardMigrationsRetriever } from '../../../retrievers'; +import type { DashboardMigrationTelemetryClient } from '../../../dashboard_migrations_telemetry_client'; +import type { translateDashboardPanelState } from './state'; +import type { migrateDashboardConfigSchema } from '../../state'; + +export type TranslateDashboardPanelState = typeof translateDashboardPanelState.State; +export type TranslateDashboardPanelGraphConfig = RunnableConfig< + (typeof migrateDashboardConfigSchema)['State'] +>; +export type GraphNode = ( + state: TranslateDashboardPanelState, + config: TranslateDashboardPanelGraphConfig +) => Promise>; + +export interface TranslatePanelGraphParams { + model: ChatModel; + esqlKnowledgeBase: EsqlKnowledgeBase; + dashboardMigrationsRetriever: DashboardMigrationsRetriever; + telemetryClient: DashboardMigrationTelemetryClient; + logger: Logger; +} + +export interface ValidationErrors { + iterations: number; + esql_errors?: string; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts index 21718296efd3c..98d9e6b0c9a2e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts @@ -5,5 +5,89 @@ * 2.0. */ -export type MigrateDashboardConfigSchema = object; -export type MigrateDashboardState = object; +import type { Logger } from '@kbn/core/server'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import type { DashboardMigrationsRetriever } from '../retrievers'; +import type { EsqlKnowledgeBase } from '../../../common/task/util/esql_knowledge_base'; +import type { ChatModel } from '../../../common/task/util/actions_client_chat'; +import type { migrateDashboardConfigSchema, migrateDashboardState } from './state'; +import type { DashboardMigrationTelemetryClient } from '../dashboard_migrations_telemetry_client'; + +export type MigrateDashboardState = typeof migrateDashboardState.State; +export type MigrateDashboardConfigSchema = (typeof migrateDashboardConfigSchema)['State']; +export type MigrateDashboardConfig = RunnableConfig; + +export type GraphNode = ( + state: MigrateDashboardState, + config: MigrateDashboardConfig +) => Promise>; + +export interface DashboardMigrationAgentRunOptions { + skipPrebuiltDashboardsMatching: boolean; +} + +export interface MigrateDashboardGraphParams { + esqlKnowledgeBase: EsqlKnowledgeBase; + model: ChatModel; + dashboardMigrationsRetriever: DashboardMigrationsRetriever; + logger: Logger; + telemetryClient: DashboardMigrationTelemetryClient; +} + +export interface ParsedOriginalPanel { + id: string; + title: string; + description?: string; + query: string; + viz_type: VizType; + position: PanelPosition; +} +export interface ElasticPanel { + title?: string; + description?: string; + query?: string; +} + +export interface ParsedOriginalDashboard { + title: string; + panels: Array; +} + +export type TranslatedPanels = Array<{ + index: number; + panel: ElasticPanel; +}>; + +export type FailedPanelTranslations = Array<{ + index: number; + error_message: string; + details: unknown; +}>; + +export type TranslatePanelNode = (params: { + panel: ParsedOriginalPanel; + index: number; +}) => Promise>; + +export type VizType = + | 'area_stacked' + | 'area' + | 'bar_horizontal_stacked' + | 'bar_horizontal' + | 'bar_vertical' + | 'donut' + | 'gauge' + | 'heatmap' + | 'line' + | 'markdown' + | 'metric' + | 'pie' + | 'table' + | 'treemap'; + +export interface PanelPosition { + x: number; + y: number; + w: number; + h: number; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts index ac07a5a012b63..34e8ddd407a92 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_client.ts @@ -10,6 +10,10 @@ import type { DashboardMigration, DashboardMigrationDashboard, } from '../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import type { + DashboardMigrationTaskInput, + DashboardMigrationTaskOutput, +} from './dashboard_migrations_task_runner'; import { DashboardMigrationTaskRunner } from './dashboard_migrations_task_runner'; import { SiemMigrationsTaskClient } from '../../common/task/siem_migrations_task_client'; import type { MigrateDashboardConfigSchema } from './agent/types'; @@ -19,7 +23,9 @@ export type DashboardMigrationsRunning = Map { protected readonly TaskRunnerClass = DashboardMigrationTaskRunner; protected readonly EvaluatorClass = DashboardMigrationTaskEvaluator; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts index d47792d9f1804..3833cd90100ba 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts @@ -87,6 +87,10 @@ export class DashboardMigrationTaskRunner extends SiemMigrationTaskRunner< this.task = (input, config) => agent.invoke(input, config); } + protected async initialize() { + await this.retriever.initialize(); + } + protected async prepareTaskInput( migrationDashboard: StoredDashboardMigrationDashboard ): Promise { @@ -109,7 +113,7 @@ export class DashboardMigrationTaskRunner extends SiemMigrationTaskRunner< target: migrationOutput.elastic_dashboard as ElasticDashboard, }), translation_result: migrationOutput.translation_result, - // TODO: comments: migrationOutput.comments, + comments: migrationOutput.comments, }; } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts index 4b4b4f74ef648..b4779b99a47ee 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/__mocks__/mocks.ts @@ -6,9 +6,9 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { RuleMigrationsRetriever } from '..'; +import type { DashboardMigrationsRetriever } from '..'; -export const createRuleMigrationsRetrieverMock = () => { +export const createDashboardMigrationsRetrieverMock = () => { const mockResources = { initialize: jest.fn().mockResolvedValue(undefined), getResources: jest.fn().mockResolvedValue({}), @@ -19,7 +19,7 @@ export const createRuleMigrationsRetrieverMock = () => { search: jest.fn().mockResolvedValue([]), }; - const mockPrebuiltRules = { + const mockPrebuiltDashboards = { populateIndex: jest.fn().mockResolvedValue(undefined), search: jest.fn().mockResolvedValue([]), }; @@ -27,13 +27,13 @@ export const createRuleMigrationsRetrieverMock = () => { const mockRetriever = { resources: mockResources, integrations: mockIntegrations, - prebuiltRules: mockPrebuiltRules, + prebuiltDashboards: mockPrebuiltDashboards, initialize: jest.fn().mockResolvedValue(undefined), }; - return mockRetriever as jest.Mocked>; + return mockRetriever as jest.Mocked>; }; -export const MockRuleMigrationsRetriever = jest +export const MockDashboardMigrationsRetriever = jest .fn() - .mockImplementation(() => createRuleMigrationsRetrieverMock()); + .mockImplementation(() => createDashboardMigrationsRetrieverMock()); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts index 3df4358903153..9ca4244e87f59 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/retrievers/dashboard_migrations_retriever.ts @@ -23,4 +23,8 @@ export class DashboardMigrationsRetriever { constructor(migrationId: string, clients: DashboardMigrationsRetrieverClients) { this.resources = new DashboardResourceRetriever(migrationId, clients.data.resources); } + + public async initialize() { + await this.resources.initialize(); + } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts index 219a4a72e3505..00a703a1863be 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts @@ -45,7 +45,7 @@ export const createRuleMigrationsTaskClientMock = () => ({ stop: jest.fn().mockResolvedValue({ stopped: true }), getStats: jest.fn().mockResolvedValue({ status: 'done', - rules: { + items: { total: 1, finished: 1, processing: 0, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts index 41667bcff373c..f60269f7f9641 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts @@ -16,6 +16,7 @@ import type { import type { MigrationResources } from '../../../common/task/retrievers/resource_retriever'; export const migrateRuleState = Annotation.Root({ + id: Annotation(), original_rule: Annotation(), resources: Annotation(), elastic_rule: Annotation({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts index 1f75ee8744246..83bb9769193de 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/sub_graphs/translate_rule/nodes/inline_query/inline_query.ts @@ -8,7 +8,7 @@ import { getInlineSplQuery, type GetInlineSplQueryParams, } from '../../../../../../../common/task/agent/tools/inline_spl_query'; -import type { GraphNode } from '../../../../types'; +import type { GraphNode } from '../../types'; export const getInlineQueryNode = (params: GetInlineSplQueryParams): GraphNode => { const inlineSplQuery = getInlineSplQuery(params); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts index 5a8dce3932a62..7d99708df8791 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts @@ -10,6 +10,10 @@ import type { RuleMigration, RuleMigrationRule, } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { + RuleMigrationTaskInput, + RuleMigrationTaskOutput, +} from './rule_migrations_task_runner'; import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; import { SiemMigrationsTaskClient } from '../../common/task/siem_migrations_task_client'; import type { MigrateRuleConfigSchema } from './agent/types'; @@ -19,7 +23,9 @@ export type RuleMigrationsRunning = Map; export class RuleMigrationsTaskClient extends SiemMigrationsTaskClient< RuleMigration, RuleMigrationRule, - MigrateRuleConfigSchema + RuleMigrationTaskInput, + MigrateRuleConfigSchema, + RuleMigrationTaskOutput > { protected readonly TaskRunnerClass = RuleMigrationTaskRunner; protected readonly EvaluatorClass = RuleMigrationTaskEvaluator; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts index 6d33c63cc8d45..1c738def48f5d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts @@ -21,12 +21,8 @@ import { nullifyMissingProperties } from '../../common/task/util/nullify_missing import type { SiemMigrationsClientDependencies } from '../../common/types'; import { SiemMigrationTaskRunner } from '../../common/task/siem_migrations_task_runner'; import { RuleMigrationTelemetryClient } from './rule_migrations_telemetry_client'; -import type { MigrationResources } from '../../common/task/retrievers/resource_retriever'; -export interface RuleMigrationTaskInput - extends Pick { - resources: MigrationResources; -} +export type RuleMigrationTaskInput = Pick; export type RuleMigrationTaskOutput = MigrateRuleState; export class RuleMigrationTaskRunner extends SiemMigrationTaskRunner< From 448ca98e03a919bdec68ea49bbfb40e9379708ad Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 14 Aug 2025 17:43:14 +0200 Subject: [PATCH 7/8] graph executing --- .../siem_migrations/dashboards/constants.ts | 5 + .../generate_esql/img/generate_esql_graph.png | Bin 103448 -> 104446 bytes .../docs/siem_migration/img/agent_graph.png | Bin 54381 -> 0 bytes .../img/dashboard_migration_agent_graph.png | Bin 0 -> 40965 bytes .../img/rule_migration_agent_graph.png | Bin 0 -> 51521 bytes .../scripts/langgraph/draw_graphs_script.ts | 34 +- .../data/siem_migrations_data_client.ts | 12 +- .../agent/tools/validate_esql/validation.ts | 5 +- .../task/siem_migrations_task_runner.test.ts | 17 +- .../common/task/util/actions_client_chat.ts | 59 ++- .../dashboards/api/evaluation/evaluate.ts | 83 ++++ .../siem_migrations/dashboards/api/index.ts | 9 + .../data/dashboard_migrations_data_client.ts | 8 +- ...board_migrations_data_dashboards_client.ts | 1 + .../splunk_xml_dashboard_parser.test.ts | 394 +++++++++++++++--- .../splunk/splunk_xml_dashboard_parser.ts | 337 ++++++++++++--- .../dashboards/lib/parsers/types.ts | 43 ++ .../dashboards/task/agent/graph.ts | 20 +- .../aggregate_dashboard.ts | 48 ++- .../{templates => }/dashboard.json | 0 .../parse_original_dashboard.ts | 29 +- .../nodes/translate_panel/translate_panel.ts | 28 +- .../dashboards/task/agent/state.ts | 2 + .../agent/sub_graphs/translate_panel/graph.ts | 42 +- .../nodes/ecs_mapping/ecs_mapping.ts | 8 +- .../fix_query_errors/fix_query_errors.ts | 4 +- .../nodes/inline_query/inline_query.ts | 5 +- .../nodes/select_index_pattern/prompts.ts | 29 ++ .../select_index_pattern.ts | 35 +- .../nodes/translate_query/translate_query.ts | 14 +- .../nodes/translation_result/process_panel.ts | 273 ++++++++++++ .../templates/area.viz.json | 0 .../templates/area_stacked.viz.json | 0 .../templates/bar_horizontal.viz.json | 0 .../templates/bar_horizontal_stacked.viz.json | 0 .../templates/bar_vertical.viz.json | 0 .../templates/bar_vertical_stacked.viz.json | 0 .../templates/donut.viz.json | 0 .../templates/gauge.viz.json | 0 .../templates/heatmap.viz.json | 0 .../templates/line.viz.json | 0 .../templates/markdown.viz.json | 0 .../templates/metric.viz.json | 0 .../templates/pie.viz.json | 0 .../templates/table.viz.json | 0 .../templates/treemap.viz.json | 0 .../translation_result/translation_result.ts | 51 ++- .../nodes/validation/validation.ts | 4 +- .../agent/sub_graphs/translate_panel/state.ts | 23 +- .../agent/sub_graphs/translate_panel/types.ts | 3 +- .../dashboards/task/agent/types.ts | 62 +-- .../task/dashboard_migrations_task_runner.ts | 9 +- .../server/lib/siem_migrations/routes.ts | 2 +- .../rules/data/rule_migrations_data_client.ts | 8 +- .../rules/task/rule_migrations_task_runner.ts | 4 +- .../siem_migrations_service.ts | 7 +- 56 files changed, 1377 insertions(+), 340 deletions(-) delete mode 100644 x-pack/solutions/security/plugins/security_solution/docs/siem_migration/img/agent_graph.png create mode 100644 x-pack/solutions/security/plugins/security_solution/docs/siem_migration/img/dashboard_migration_agent_graph.png create mode 100644 x-pack/solutions/security/plugins/security_solution/docs/siem_migration/img/rule_migration_agent_graph.png create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/evaluation/evaluate.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/types.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/{templates => }/dashboard.json (100%) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/process_panel.ts rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/area.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/area_stacked.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/bar_horizontal.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/bar_horizontal_stacked.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/bar_vertical.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/bar_vertical_stacked.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/donut.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/gauge.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/heatmap.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/line.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/markdown.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/metric.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/pie.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/table.viz.json (100%) rename x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/{nodes/aggregate_dashboard => sub_graphs/translate_panel/nodes/translation_result}/templates/treemap.viz.json (100%) diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts index 64cecf7f329f4..1bcdea3bc99d8 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/dashboards/constants.ts @@ -9,6 +9,11 @@ import { SIEM_MIGRATIONS_PATH } from '../constants'; export const SIEM_DASHBOARD_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/dashboards` as const; +export const SIEM_DASHBOARD_MIGRATION_EVALUATE_PATH = + `${SIEM_DASHBOARD_MIGRATIONS_PATH}/evaluate` as const; + +// Migration ID specific routes + export const SIEM_DASHBOARD_MIGRATION_PATH = `${SIEM_DASHBOARD_MIGRATIONS_PATH}/{migration_id}` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/docs/generate_esql/img/generate_esql_graph.png b/x-pack/solutions/security/plugins/security_solution/docs/generate_esql/img/generate_esql_graph.png index 140c640c783d7cacac355841fe47f0d9427b9432..ea1f41358d31542dfa42ccec08e9248e8b1aaa8a 100644 GIT binary patch literal 104446 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�V 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�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

_W4LO8p}R7*Pk%v+R#eqay6MY z^Xol>weGKK%a$R=joVn(-`Z>(FwbR>tPuG*jdpD?+xWD!-W9J9UA9D_RU`e^pACs^ zY@eT<)8{jFM^{;yT#v2Y7z@l;x{%M$n4S?F9}>9t!Q~Jajp6}1^_CZ124qHK7q815)ZZbA2fwf4>zP{nn$G| z7cT1aO{&X3=r5RBE3k~nKxJ#nG;LnX_Qdx3{Em)}gWDB<*~1rg&<;BF-e!g*GXci6 zVKGD(_*xAu@v$+?A*>+33(((`?tn5%2AJfUXa&r^f$H=hOSCD z&h#ku^XCUg>pHqDVN&?%1NA3S--~sZ{iX9_;SalR$q(V_ zli5GKkq&D~lgYx+WyH_uM04mY%^bUpqu83ho~oguFuR5qKf886o4uSp8r!e>0;^j# z`}aFv6$3$AQYULR=Gw?<=A0H-NjTEoH5r1QWToC zB(WuCzy8O?|Ljr1ENkVr*!FhY4(zU}+xgjkq&2ZIW?AGfHe8Vacy+J*yakuOD)H)V zr9lMUWl5yZt$qIZZ*P5HC7=Jzz2?BeDPrB?0yp$(Y_=oj#{Ltj&-3{_d_lb>e1MO* zcINKbYQaPn%vM9Ljdq=L-DX>`3>V-0_wnO<6OrAW|8EW2`QY)xhu6=g6G$qo*>rTd zEXJ16>8$+xE{3j>_X)mzqHr>uZfT4zI9y04=4J?KCBaJ21*TE6=G^YabUMviJiA!jZg!Q= zFBWGH6^rjYo&C!!E4%9N;o;%_A~YjCoNj`7;sK=X*3o5@BjKDVp{u0@+4HckEH$f#A$GXu;EE^k)|2+)lNS%wj zRC3Tl(4OW%qkCY1G(KS@@Rp_~W6S6=;b%>A@IMTBz2!~z5}VbUJOTh~1&onOyn68I zT|~>d2dsThtTuBlhp$1YR4sswbdgh!{)MB{*;&2(`lyD(#5A*EvMg8-SS8cVV%bJs zeK%Y0T8Zmh8!JSYWy=90NkTSErX~XK#sAp5nvlkpEL_)!Az0?x53MIzL~yN8CScRd z8{R-~-sVEli*N@KvS=FO%7xHqmN6T9o0W*0tDE#L3^YQRo%ABBhnrDDHX&eq(3>Cw zjf*+wR7Kkv8GUWX2vkyU6 zB%MZdff`xKx)W=?zTUa1(WOiVZqvJk(UIu;`y&!`O77I0yD%0{CRgU)V*(=EeYcUw zpB;ZMI+k(You1O{e8=^>_=oU#2X5lK5qAU`{f&jlM(?Y9R*ZSFvo86A_A?T?kjjO54>4Rpbe&?@x7&&%b(rkt5=cei+i)!XcN;w9bC}q z+05E}udjDwE)AwG`93-_kzQDcy7$NGlNhUhvoY~L0=nxn`~|$-rRevLk9pOR^wAM1 zAh>VeBa85GS61AWndlNCe`9Xcn6F6oUdxEpH^}NS^7RmObzMcA{nuhnh@ZIA_mqr%S6PF+7=12=g|LorzJDAP5VB*~sAh}a;aZ`k@ z$Q_xtJ2F9(WAq-B39=F{iDXAc+!sfeQLGw^JmhTW$I1UhB#?Lo0d*df7F|OoW7#z5 zuG5cwLl;I=JL^;|AuPIvOp=SByVm-qyTBZpco(;paTqP5`@(&3**A0{rnLCXqRXP| zN1}`3T}V&( z8s;S*bUve%kfCeQo$G773$xfh_P6wHL}KV9n@z8cPMn_hFWzRHEcfP)SCOUZ_!kR+cmf+b-#MHIypHR14&K4 z0d#%+I%*}?%6=B^?JOoU%$pr*OvEZ9pvzI9JUXi6n%)*RgJFMzi#~c zE1)ZBCDgLF7s+Na;4@<7a55h6@oX;{C#;p5z~$$`iuU-Hj@;wPZ)H0sp)^8 zUS7Ta#Y(8<_0=y|SFiu|&nqLnAMfh&^7rX|2SG%^2>4e&qzd~$BtUsF{7GW}xA~VN zqj4)Cmt-Wn2R>tTYz%c*uO%8CdHH#IKLyZ=F?~u9_Jvc5V3u$kBzqatMw4+V9V?-hL?Y!mve<$N#cvY59#49X zFCCNoVRYuF&%{Lz>z)PgVoUtYe!^d|t3Tut6XUVB7nP?P&Jlnx$DyupG4_p6gWj&* za+t%c41>Il#rW3WukBHfWpx!4P78kbD@psQk}L`e(-vl3qU31*OQZe6pT zUXM39xk}>PR!%1h8NO#U7n`XxhTpN9ec1ll2l#}H>;?fD66Wn;PvK=) z>7~SC-U&Er{x?y~9*=i3(l=&UFyZGILw8c6%lcbGA!~w`te6%_q#xL7NMS$zS3F1OApXZJPI@yJ4R?Q(o zRzhSLm1mBj=B#y6*BTG&cVA_09X>2Q>__IF%mQnbg zz;-yyFSO~6LiiMzpv$Qis${Dz@d}+NR=>Dt(cB3-j9>ugP)K!N)=E`dNwC$W zG5`P&!%0LzR0Nx%ix<|F90U`{&6(yDpw#DU;?r9BQ@auZzN|{MTuA|(B-lt!q@PQV zoLgmeTECf$J|l(N-b#rz>Nr2SgRP4aEPgrQ=mIo>ZyU7RUX_`$Rny^%Lalas`)*tW z`S~~#fGnQ_VQv+U%N3fRVBoLb!)<8rr2daQs4jdexzV_m?GJ+PlC)dUM=hXOP`~)J z-J%>2r&kABg02e!NUHHOAo*MycIEljn;Z!1f*-Vjp4UoS=NEYmI&8=$2j3f$68%R( zrw56Ap0~@W!tfLmm6QU^V;7Rh^#?~KRiHrDZLt$4pUS+x!|x)Nl4$S zuD%l&3LsTW+b3J4iZsyD9hL*_;1BlIV~vC-^o2`QSUz(;lv%Fb@@yPz08C4+RXpEm zx0Ik$Qn;Qaefoe1XUNELJXSwcteo$HPBTbz?6IOyu`R85jK%wJvqmrQuFvjiXEA7% zi|55sZRnhLUx0(sDa*}vQ&&+)I!^&(Vm_`R-#DCB9*-`p;3V?y$!_;PCIMNE>?}`mwY{&%h zNQr?C`@lMf6_d~+Us6(S*Z`EP4S^P8lY}Mi8RhPC9vRZ04j<-5_Wx7OyN<(goTvzA__mHtJd`kf# z3*NFa>_Am>e(|^_Dmh_OMUSWU2rc06h4VtY1O(n=QmZOXNnUiI1RuUNESP7??SWJ^KSN1|)9bn)f9(8=ehY}l|bvv*7g(~p{qoF8H5I|4`wL(VnqUh*9d7T|co=dzPtN-3X4TuC z+-i4sgP1TFGl^G475mT3egaR`yLI3h)&VGG=)2MhX?U&LDmPAc+Vv)5t42v#xqTBs zLxH!0aH`a0?f63GYPiKt*M?p1+(gv*!{<`}AGJj%<&fXgU21-JPck5@S>J9PH_8VU zPoFzy)z%&2cf%31UlYCwn!MI%uv)1`rALT7oxK$NnXS3*eKBA&KA>a*fK@nY9Cq@7 z$)Rz>zHC1CPK3b<^=@t^K+MY(4^^|BH>Lp$rAI6F`4BI}3I&;UAtv($W@#&>@(!B9 ziswo84gP!#C0c0R=~i+~4f)iEm9xM^zZApdQLAy_=P2?jpBJUqRqy~Xp>Kw2mWsts zjrKtmVMKeAr82!cJkeEO(!%N1P1GCBl`hQf>UpKNd85W>iS4yzX41zM-+g>yih_x;>kyh%ylQLW$M4tIvje zN$nlhcFTqHt-|_l4PJA&u2?cn6-;!3o9y)s0kiTwk&9-jCC(aoQ45&pW;xIraI?{q zRyx%*`Lq?myR&kPdb=jD6B+iqTb|x$vGKP*zr{0{`S>{hlQK?6QbeyH85(%5Y{5#z79{ajla)qqKv~gLdQiOK}|oq>gQO+-n`j5l6h)WS!$WtA8-| zlh{6KyZ^9vJ+E!!S^Vpxunxgc3DkqHlZ!Au?LVN)uv%+C0kNy2q3i6yu#`DWN;93r z&}ktX(U5O9-$D;grb}VrOGsjd$zJxb5ON6{x)2&7g^-b*h0KWfFeaqzdtXU*(xz!U z5ZKoG_?Ki$IFUYn@AKaGeV=5ZLU5=F8CJ61_FESHspCGdWaPhPja0C&qBwza+K%%QeM-r^`}!98L`aqb6;|?!GFrERE;ZUNr|gvWRi~KW5NXjn1jOyNA2zD2XhCp zjw)sSIj-MR#9E{&Cg53zN6Sj1(=?mnI;3Lyfdii*w}4m4q+>Ct`5HS&-x7#3FH054 zQ5@_Js}+%T($%-~z{bduRC3CTi;I6+1cH%4@6RiGz02txr6&F^;!0f^=M^rWbf%{l z8ZRH#M6$+8`gLTuZjGxXGrRHb$at}$^|=Fbbi8g(tjkG_cpN5(sY!};=>+hB;fa?j z`2t>aQkEpOI$Cj6_k*CSb$rSf(7l1ZYC40K{kgaON+8k`tw4o86o0gAn)3RSB`{cf z&*zp)m;-cPk9{&+e+-41MTtk<97PO@uh+=!y=C(Jge1iPmQ_kCnJ(#IanZ4%?J?C# z-mQQ0BHRc3{Gq^|!nJRJWTUcKzHy_dU3~=sv(J})E~_@N^!?t>LSyN0)5PS0D4IbI z#vRfi7R=1ob;~XhbBl&yaR#H5FbK0`V7i_>CrW$DBJ1(AT~nMAOTJt;^%*8yTh;yP zMR&d%Mc%JN6n6K!Yd^WFl>7H$@m=(S{~&SL;NO?$9!$57r#ia_V(OE}rf8c=f2k4P>Prb+_tnC9fp`Y4Ro7n_s^3Svb0VxQ;)Kv>IMyQqo$> zL=BFdub6>hpAb^#NnGdeq~j!H6dG!2Yv0X%NZF8oPYf1Vj~U}4 zE9q=@mgL>32~taCyrY3`K3+I*iezyuH_1nh*n8**ukxU8jkXFuV|3^KgPJ zwH{g%hfPu{mcec9wAyo4F(-}YD`CjEZg}YF66g31aJVtnU$qlY%9+l2IQez{rUVjk zsomO^DqtbYrP|?1=eV`dczL+#q@GU6Mg%_zsb-2(iN>Nv4yPU}C!O`2W$v_gwvROH zrds_^XalpjFh~heu7nNL0@EclS8Tj&TmhL1jjpIvJ!VVB8pSWG7KL%yW%2g zF%*fVDW-@lQ54B??O6+qOKau_cQ)hQ z6xj@=dQ6db%Pz(_8aw^mwt{BhAX&upu^)f!vS5ng#8Ohe>{uDM zK6+IjPuIfQ38mlVA6^SYy0_EbYwZ3-txkrV(pXO+yZC#si}BN*2pm%(Cg_zc*#>_! z>sB^*8^^~h$5MefnnDK2Qc*D^rj1(G+ds2<|C03p>~0JxSu!wnVPe5*5S*AiUcuZcEpDXsZ~^8`@g89oKr>|Nvg`) zVs^xb36_R+T?OJwiux#IM&j4b`NY^HA*1g0eE&3{%s)N#{az695Z*XztOjwADMCnH z*U#+^wkr%=S<8_TOR7k6X1j*Wb~y#fA>^NVU{^O{MxPw%!;Vu}obt#1>HRVPj0V0x zA8f$_?fJ7?XZKb&3?pV2W+g6A6WA3^R;8RmMu1*YNwcyeK1_%c0tC;zx*ypWsBtbm zit@eJqdd6#yKn>S7Tq}O0mEJed}C&n+UIGh54%PAqte#PWpZXiKem5}K)Kkn!TpCas3A+k7&WT^* zWb7I$lpF~YVX~x^-EQQ07r6l*u^$X_-}i6b^1)usoJNeE`oUelyP-#CKDAMIT^y7g z*siWy#e~RsDQU`#>t#BE{7o;-P?E zFIWryI!Wow8P%!KU0-~+5%^CdHpq1uIIbOG#aH4P!~3pUB_-`01<)BjP2Ck2rb&|Paif2@1v`&5*4B}$DY0c0ZAy!{vG z8D$L%pr!114;PB!oEJ5S87se^7wTMO-Wg-a60Y}aB`AFb%B!~pF7nji4CGeO#RS-C zkBcEAgQEE2SRWx|STa)d>T_2n1v0K>>>9#+moQ%Ca$Zv0`s1jdr;0cg?vSQD!8AR1 z#sEYX4Ri);oH9ybA;4Dy^Y;n(K;e6jX3=z+8M}rOi$x;krNzZkzM6Nx>oPkY9yG+D z5Bfs^K_y|6Y=%nT#%gC==eBT~!3OPb2V3&QIfBWXT~pPZDU*@$j9o*>uCid)4XK*X zt2g&UTO?Eseg;nOBKA3ocTBG`T+$h+cUT98o%(NVE9%4G(`fALyr!9?kg;nRaYL}{ z#vdd#54Vjj)|8^{io(|sK?KPe<{s<_H5S&+Lu?osEQ~ka8f?px+-ln7Q%J_H3`E|KbmoESN$An9k`QsD9WTb2Eb+!U#5zkvoVc?M)C=y`vqPGp z59v2uaBDUfvoLsCbow4Os^?~$akOtX#+LK-j9nSdq5j$YBcPXOi^6#QgMnUE^q*jM z`1g7>pB>Q*15{jhtV?wp=^@n92ZEwov~A|u-L1M>`d@ozjM^{^h2d&^XfcH#@S?d} z5%dU|vvzn}J+*@_P&#Dk+F~$1fDVug^Z?mr() z$p6DzR4~EPinl|*d`*8R<}Rs>!<}HW8@AsSS=a?8e7cn1Rd@aFXMbZ{%D&Vq`*?4k zYR{~%-2+Y({1eXI-?1x!$BxRo)cV)p)KeJ5xA z<~>)MNfXjd!AVOZX9UJoa}vxOAyF-6qxC*&u*6GeaAKEAf%BxbG0Y@HXT~_g(&9wH zj9p66IfUS>qCdM(yTFlMlr{u$?Q*&9K`bzDmpY_0 zN6oCbc8#+&6!ZeKcIAlqXi8BO_tO1wPjM7c4$kKn*}H=+?*%i400000NkvXXu0mjf D;7gJ< diff --git a/x-pack/solutions/security/plugins/security_solution/docs/siem_migration/img/dashboard_migration_agent_graph.png b/x-pack/solutions/security/plugins/security_solution/docs/siem_migration/img/dashboard_migration_agent_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..f26bcfdab8a1a7c1e002cc421bf7b3a307225dcb GIT binary patch literal 40965 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 R{#PcZnJ52Yn=9Yw{|n5HPu&0j literal 0 HcmV?d00001 diff --git a/x-pack/solutions/security/plugins/security_solution/docs/siem_migration/img/rule_migration_agent_graph.png b/x-pack/solutions/security/plugins/security_solution/docs/siem_migration/img/rule_migration_agent_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..90978ed5772886719f25361ea302bd750a372c95 GIT binary patch literal 51521 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 literal 0 HcmV?d00001 diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts b/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts index d472de34a8161..5eff27f947967 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/langgraph/draw_graphs_script.ts @@ -14,8 +14,12 @@ import { ToolingLog } from '@kbn/tooling-log'; import { FakeLLM } from '@langchain/core/utils/testing'; import fs from 'fs/promises'; import path from 'path'; -import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; +import type { ElasticsearchClient, IScopedClusterClient, KibanaRequest } from '@kbn/core/server'; import type { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { DashboardMigrationsRetriever } from '../../server/lib/siem_migrations/dashboards/task/retrievers'; +import { getDashboardMigrationAgent } from '../../server/lib/siem_migrations/dashboards/task/agent'; +import type { DashboardMigrationTelemetryClient } from '../../server/lib/siem_migrations/dashboards/task/dashboard_migrations_telemetry_client'; +import type { ChatModel } from '../../server/lib/siem_migrations/common/task/util/actions_client_chat'; import { getGenerateEsqlGraph as getGenerateEsqlAgent } from '../../server/assistant/tools/esql/graphs/generate_esql/generate_esql'; import { getRuleMigrationAgent } from '../../server/lib/siem_migrations/rules/task/agent'; import type { RuleMigrationsRetriever } from '../../server/lib/siem_migrations/rules/task/retrievers'; @@ -33,13 +37,14 @@ const mockLlm = new FakeLLM({ const esqlKnowledgeBase = {} as EsqlKnowledgeBase; const ruleMigrationsRetriever = {} as RuleMigrationsRetriever; +const dashboardMigrationsRetriever = {} as DashboardMigrationsRetriever; const createLlmInstance = () => { return mockLlm; }; -async function getSiemMigrationGraph(logger: Logger): Promise { - const model = createLlmInstance(); +async function getSiemRuleMigrationGraph(logger: Logger): Promise { + const model = createLlmInstance() as ChatModel; const telemetryClient = {} as RuleMigrationTelemetryClient; const graph = getRuleMigrationAgent({ model, @@ -51,6 +56,21 @@ async function getSiemMigrationGraph(logger: Logger): Promise { return graph.getGraphAsync({ xray: true }); } +async function getSiemDashboardMigrationGraph(logger: Logger): Promise { + const model = { bindTools: () => null } as unknown as ChatModel; + const telemetryClient = {} as DashboardMigrationTelemetryClient; + const esScopedClient = {} as IScopedClusterClient; + const graph = getDashboardMigrationAgent({ + model, + esScopedClient, + esqlKnowledgeBase, + dashboardMigrationsRetriever, + logger, + telemetryClient, + }); + return graph.getGraphAsync({ xray: true }); +} + async function getGenerateEsqlGraph(logger: Logger): Promise { const graph = await getGenerateEsqlAgent({ esClient: {} as unknown as ElasticsearchClient, @@ -90,7 +110,11 @@ export const draw = async () => { outputFilename: '../../docs/generate_esql/img/generate_esql_graph.png', }); await drawGraph({ - getGraphAsync: getSiemMigrationGraph, - outputFilename: '../../docs/siem_migration/img/agent_graph.png', + getGraphAsync: getSiemRuleMigrationGraph, + outputFilename: '../../docs/siem_migration/img/rule_migration_agent_graph.png', + }); + await drawGraph({ + getGraphAsync: getSiemDashboardMigrationGraph, + outputFilename: '../../docs/siem_migration/img/dashboard_migration_agent_graph.png', }); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts index 8f7749a365a28..d08b099b0f67d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/data/siem_migrations_data_client.ts @@ -15,13 +15,21 @@ export abstract class SiemMigrationsDataClient< M extends MigrationDocument = MigrationDocument, I extends ItemDocument = ItemDocument > { - protected abstract logger: Logger; - protected abstract esClient: IScopedClusterClient['asInternalUser']; + // Data clients use the ES client `asInternalUser` by default. + // We may want to use `asCurrentUser` instead in the future if the APIs are made public. + protected readonly esClient: IScopedClusterClient['asInternalUser']; public abstract readonly migrations: SiemMigrationsDataMigrationClient; public abstract readonly items: SiemMigrationsDataItemClient; public abstract readonly resources: SiemMigrationsDataResourcesClient; + constructor( + public readonly esScopedClient: IScopedClusterClient, + protected readonly logger: Logger + ) { + this.esClient = esScopedClient.asInternalUser; + } + /** Deletes a migration and all its associated items and resources. */ public async deleteMigration(migrationId: string) { const [ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts index 89fbeaa6a9ecb..ea40e07de0135 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/agent/tools/validate_esql/validation.ts @@ -31,7 +31,7 @@ export const getValidateEsql: NodeToolCreator< // We want to prevent infinite loops, so we increment the iterations counter for each validation run. let error: string = ''; try { - const sanitizedQuery = input.query ? removePlaceHolders(input.query) : ''; + const sanitizedQuery = input.query ? sanitizeQuery(input.query) : ''; if (!isEmpty(sanitizedQuery)) { const { errors, isEsqlQueryAggregating, hasMetadataOperator } = parseEsqlQuery(sanitizedQuery); @@ -52,8 +52,9 @@ export const getValidateEsql: NodeToolCreator< }; }; -function removePlaceHolders(query: string): string { +function sanitizeQuery(query: string): string { return query + .replace('FROM [indexPattern]', 'FROM *') // Replace the index pattern placeholder with a wildcard .replaceAll(/\[(macro|lookup):.*?\]/g, '') // Removes any macro or lookup placeholders .replaceAll(/\n(\s*?\|\s*?\n)*/g, '\n'); // Removes any empty lines with | (pipe) alone after removing the placeholders } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts index 2b0fae7eaed01..2bd54f41ea5ac 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/siem_migrations_task_runner.test.ts @@ -40,7 +40,8 @@ mockTimeout.mockImplementation((cb) => { const mockSetup = jest.fn().mockResolvedValue(undefined); const mockInvoke = jest.fn().mockResolvedValue(undefined); -const mockTaskPrepare = jest.fn().mockResolvedValue(mockInvoke); +const mockPrepareTaskInput = jest.fn().mockResolvedValue({}); +const mockProcessTaskOutput = jest.fn().mockResolvedValue({}); const mockInitialize = jest.fn().mockResolvedValue(undefined); class TestMigrationTaskRunner extends SiemMigrationTaskRunner { @@ -49,7 +50,7 @@ class TestMigrationTaskRunner extends SiemMigrationTaskRunner { public async setup(connectorId: string): Promise { await mockSetup(); - this.task = { prepare: mockTaskPrepare }; + this.task = mockInvoke; this.telemetry = new SiemMigrationTelemetryClient( this.dependencies.telemetry, this.logger, @@ -58,6 +59,9 @@ class TestMigrationTaskRunner extends SiemMigrationTaskRunner { ); } + prepareTaskInput = mockPrepareTaskInput; + processTaskOutput = mockProcessTaskOutput; + public async initialize(): Promise { await mockInitialize(); } @@ -125,8 +129,9 @@ describe('SiemMigrationTaskRunner', () => { expect(mockTimeout).toHaveBeenCalledTimes(1); // random execution sleep expect(mockTimeout).toHaveBeenNthCalledWith(1, expect.any(Function), expect.any(Number)); - expect(mockTaskPrepare).toHaveBeenCalledTimes(1); + expect(mockPrepareTaskInput).toHaveBeenCalledTimes(1); expect(mockInvoke).toHaveBeenCalledTimes(1); + expect(mockProcessTaskOutput).toHaveBeenCalledTimes(1); expect(mockSiemMigrationsDataClient.items.saveCompleted).toHaveBeenCalled(); expect(mockSiemMigrationsDataClient.items.get).toHaveBeenCalledTimes(2); // One with data, one without expect(mockLogger.info).toHaveBeenCalledWith('Migration completed successfully'); @@ -222,7 +227,7 @@ describe('SiemMigrationTaskRunner', () => { await expect(taskRunner.run({})).resolves.toBeUndefined(); // success - expect(mockTaskPrepare).toHaveBeenCalledTimes(2); // 2 items + expect(mockPrepareTaskInput).toHaveBeenCalledTimes(2); // 2 items /** * Invoke calls: * item 1 -> failure -> start backoff retries @@ -258,7 +263,7 @@ describe('SiemMigrationTaskRunner', () => { expect(mockLogger.debug).toHaveBeenCalledWith( `Awaiting backoff task for document "${item2Id}"` ); - expect(mockTaskPrepare).toHaveBeenCalledTimes(2); // 2 items + expect(mockPrepareTaskInput).toHaveBeenCalledTimes(2); // 2 items expect(mockInvoke).toHaveBeenCalledTimes(6); // 3 retries + 3 executions expect(mockSiemMigrationsDataClient.items.saveCompleted).toHaveBeenCalledTimes(2); // 2 items }); @@ -268,7 +273,7 @@ describe('SiemMigrationTaskRunner', () => { await expect(taskRunner.run({})).resolves.toBeUndefined(); // success - expect(mockTaskPrepare).toHaveBeenCalledTimes(2); // 2 items + expect(mockPrepareTaskInput).toHaveBeenCalledTimes(2); // 2 items // maxRetries = 8 expect(mockInvoke).toHaveBeenCalledTimes(10); // 8 retries + 2 executions expect(mockTimeout).toHaveBeenCalledTimes(10); // 2 execution sleeps + 8 backoff sleeps diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts index 4cea0b06655d6..fb21f343484f5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/common/task/util/actions_client_chat.ts @@ -7,28 +7,32 @@ import type { ActionsClient } from '@kbn/actions-plugin/server'; import type { Logger } from '@kbn/core/server'; -import type { ActionsClientSimpleChatModel } from '@kbn/langchain/server'; import { - ActionsClientBedrockChatModel, + ActionsClientChatBedrockConverse, ActionsClientChatOpenAI, ActionsClientChatVertexAI, } from '@kbn/langchain/server'; import type { CustomChatModelInput as ActionsClientBedrockChatModelParams } from '@kbn/langchain/server/language_models/bedrock_chat'; import type { ActionsClientChatOpenAIParams } from '@kbn/langchain/server/language_models/chat_openai'; -import type { CustomChatModelInput as ActionsClientChatVertexAIParams } from '@kbn/langchain/server/language_models/gemini_chat'; +import type { + CustomChatModelInput as ActionsClientChatVertexAIParams, + ActionsClientGeminiChatModel, +} from '@kbn/langchain/server/language_models/gemini_chat'; import type { CustomChatModelInput as ActionsClientSimpleChatModelParams } from '@kbn/langchain/server/language_models/simple_chat_model'; +import { InferenceChatModel } from '@kbn/inference-langchain'; import { TELEMETRY_SIEM_MIGRATION_ID } from './constants'; export type ChatModel = - | ActionsClientSimpleChatModel + | ActionsClientChatBedrockConverse | ActionsClientChatOpenAI - | ActionsClientBedrockChatModel - | ActionsClientChatVertexAI; + | ActionsClientGeminiChatModel + | ActionsClientChatVertexAI + | InferenceChatModel; export type ActionsClientChatModelClass = - | typeof ActionsClientSimpleChatModel + | typeof ActionsClientChatBedrockConverse | typeof ActionsClientChatOpenAI - | typeof ActionsClientBedrockChatModel + | typeof ActionsClientGeminiChatModel | typeof ActionsClientChatVertexAI; export type ChatModelParams = Partial & @@ -36,11 +40,17 @@ export type ChatModelParams = Partial & Partial & Partial; -const llmTypeDictionary: Record = { - [`.gen-ai`]: `openai`, - [`.bedrock`]: `bedrock`, - [`.gemini`]: `gemini`, - [`.inference`]: `inference`, +const llmTypeDictionary = { + '.gen-ai': 'openai', + '.bedrock': 'bedrock', + '.gemini': 'gemini', + '.inference': 'inference', +} as const; +type SupportedActionTypeId = keyof typeof llmTypeDictionary; +type LlmType = (typeof llmTypeDictionary)[SupportedActionTypeId]; + +const isSupportedActionTypeId = (actionTypeId: string): actionTypeId is SupportedActionTypeId => { + return actionTypeId in llmTypeDictionary; }; interface CreateModelParams { @@ -61,8 +71,16 @@ export class ActionsClientChat { if (!connector) { throw new Error(`Connector not found: ${connectorId}`); } + if (!isSupportedActionTypeId(connector.actionTypeId)) { + throw new Error(`Connector type not supported: ${connector.actionTypeId}`); + } const llmType = this.getLLMType(connector.actionTypeId); + if (llmType === 'inference') { + // TODO: instantiate from inferenceService + throw new Error('Inference model creation not implemented yet'); + } + const ChatModelClass = this.getLLMClass(llmType); const model = new ChatModelClass({ @@ -81,21 +99,28 @@ export class ActionsClientChat { return model; } - private getLLMType(actionTypeId: string): string | undefined { + public getModelName(model: ChatModel): string { + if (model instanceof InferenceChatModel) { + const modelName = model.identifyingParams().model_name; + return `inference${modelName ? ` (${modelName})` : ''}`; + } + return model.model; + } + + private getLLMType(actionTypeId: SupportedActionTypeId): LlmType { if (llmTypeDictionary[actionTypeId]) { return llmTypeDictionary[actionTypeId]; } throw new Error(`Unknown LLM type for action type ID: ${actionTypeId}`); } - private getLLMClass(llmType?: string): ActionsClientChatModelClass { + private getLLMClass(llmType: Omit): ActionsClientChatModelClass { switch (llmType) { case 'bedrock': - return ActionsClientBedrockChatModel; + return ActionsClientChatBedrockConverse; case 'gemini': return ActionsClientChatVertexAI; case 'openai': - case 'inference': default: return ActionsClientChatOpenAI; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/evaluation/evaluate.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/evaluation/evaluate.ts new file mode 100644 index 0000000000000..c9a623bb61609 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/evaluation/evaluate.ts @@ -0,0 +1,83 @@ +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { v4 as uuidV4 } from 'uuid'; +import { z } from '@kbn/zod'; +import { DashboardMigrationTaskExecutionSettings } from '../../../../../../common/siem_migrations/model/dashboard_migration.gen'; +import { LangSmithEvaluationOptions } from '../../../../../../common/siem_migrations/model/common.gen'; +import { SIEM_DASHBOARD_MIGRATION_EVALUATE_PATH } from '../../../../../../common/siem_migrations/dashboards/constants'; +import { createTracersCallbacks } from '../../../common/api/util/tracing'; +import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import { authz } from '../../../common/api/util/authz'; +import { withLicense } from '../../../common/api/util/with_license'; +import type { MigrateDashboardConfig } from '../../task/agent/types'; + +const REQUEST_TIMEOUT = 10 * 60 * 1000; // 10 minutes + +const requestBodyValidation = buildRouteValidationWithZod( + z.object({ + settings: DashboardMigrationTaskExecutionSettings, + langsmith_options: LangSmithEvaluationOptions, + }) +); + +interface EvaluateDashboardMigrationResponse { + evaluationId: string; +} + +export const registerSiemDashboardMigrationsEvaluateRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .post({ + path: SIEM_DASHBOARD_MIGRATION_EVALUATE_PATH, + access: 'internal', + security: { authz }, + options: { timeout: { idleSocket: REQUEST_TIMEOUT } }, + }) + .addVersion( + { version: '1', validate: { request: { body: requestBodyValidation } } }, + withLicense( + async (context, req, res): Promise> => { + const { + settings: { connector_id: connectorId }, + langsmith_options: langsmithOptions, + } = req.body; + + try { + const evaluationId = uuidV4(); + const abortController = new AbortController(); + req.events.aborted$.subscribe(() => abortController.abort()); + + const securitySolutionContext = await context.securitySolution; + const dashboardMigrationsClient = + securitySolutionContext.siemMigrations.getDashboardsClient(); + + const invocationConfig: MigrateDashboardConfig = { + callbacks: createTracersCallbacks(langsmithOptions, logger), + }; + + await dashboardMigrationsClient.task.evaluate({ + evaluationId, + connectorId, + langsmithOptions, + abortController, + invocationConfig, + }); + + return res.ok({ body: { evaluationId } }); + } catch (err) { + logger.error(err); + return res.customError({ body: err.message, statusCode: err.statusCode ?? 500 }); + } + } + ) + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts index 43f6794fda4ac..afae1edf7de32 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/api/index.ts @@ -5,6 +5,7 @@ * 2.0. */ import type { Logger } from '@kbn/logging'; +import type { ConfigType } from '../../../../config'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { registerSiemDashboardMigrationsCreateRoute } from './create'; import { registerSiemDashboardMigrationsCreateDashboardsRoute } from './dashboards/create'; @@ -12,9 +13,11 @@ import { registerSiemDashboardMigrationsStatsRoute } from './stats'; import { registerSiemDashboardMigrationsGetRoute } from './get'; import { registerSiemDashboardMigrationsStartRoute } from './start'; import { registerSiemDashboardMigrationsStopRoute } from './stop'; +import { registerSiemDashboardMigrationsEvaluateRoute } from './evaluation/evaluate'; export const registerSiemDashboardMigrationsRoutes = ( router: SecuritySolutionPluginRouter, + config: ConfigType, logger: Logger ) => { // ===== Dashboard Migrations ====== @@ -30,4 +33,10 @@ export const registerSiemDashboardMigrationsRoutes = ( // ===== Dashboards ====== registerSiemDashboardMigrationsCreateDashboardsRoute(router, logger); + + if (config.experimentalFeatures.assistantModelEvaluation) { + // Use the same experimental feature flag as the assistant model evaluation. + // This route is not intended to be used by the end user, but rather for internal purposes. + registerSiemDashboardMigrationsEvaluateRoute(router, logger); + } }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts index d2a68fd0a4c4a..6c69f3aaa9dcb 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_client.ts @@ -16,9 +16,6 @@ import { SiemMigrationsDataClient } from '../../common/data/siem_migrations_data import { SiemMigrationsDataResourcesClient } from '../../common/data/siem_migrations_data_resources_client'; export class DashboardMigrationsDataClient extends SiemMigrationsDataClient { - protected logger: Logger; - protected esClient: IScopedClusterClient['asInternalUser']; - public readonly migrations: SiemMigrationsDataMigrationClient; public readonly items: DashboardMigrationsDataDashboardsClient; public readonly resources: SiemMigrationsDataResourcesClient; @@ -31,10 +28,7 @@ export class DashboardMigrationsDataClient extends SiemMigrationsDataClient { spaceId: string, dependencies: SiemMigrationsClientDependencies ) { - super(); - - this.logger = logger; - this.esClient = esScopedClient.asInternalUser; + super(esScopedClient, logger); this.migrations = new SiemMigrationsDataMigrationClient( indexNameProviders.migrations, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.ts index f2f4dcbe1aee9..5537d1f1e9d7e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/data/dashboard_migrations_data_dashboards_client.ts @@ -14,6 +14,7 @@ export class DashboardMigrationsDataDashboardsClient extends SiemMigrationsDataI protected type = 'dashboard' as const; protected getSortOptions(sort: SiemMigrationItemSort = {}): estypes.Sort { + // TODO: implement sorting logic similar to getSortOptions in the rules client return []; } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.ts index 5274abdfe1b91..762e9ed957190 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.test.ts @@ -5,75 +5,375 @@ * 2.0. */ -import path from 'path'; -import fs from 'fs/promises'; import { SplunkXmlDashboardParser } from './splunk_xml_dashboard_parser'; +import type { VizType } from '../types'; describe('SplunkXmlDashboardParser', () => { - let exampleXml: string; + const createBasicXml = (content: string) => ` + + + + ${content} + + + `; - beforeAll(async () => { - const examplePath = path.join(__dirname, '../../../__mocks__/original_dashboard_example.xml'); - exampleXml = await fs.readFile(examplePath, 'utf8'); - }); + const createPanelXml = ( + title: string, + query: string, + chartType?: string, + stackMode?: string, + overlayMode?: string, + hasMetric?: boolean + ) => { + const chartElement = chartType ? `` : ''; + const vizElement = chartType ? `` : ''; + const chartOption = chartType ? `` : ''; + const stackOption = stackMode + ? `` + : ''; + const overlayOption = overlayMode + ? `` + : ''; + const metricElement = hasMetric ? 'some metric content' : ''; + + return ` + + ${title} + + ${query} + + ${chartElement} + ${vizElement} + ${chartOption} + ${stackOption} + ${overlayOption} + ${metricElement} + + `; + }; - describe('constructor', () => { - test('should create an instance successfully', () => { - const parser = new SplunkXmlDashboardParser(exampleXml); - expect(parser).toBeInstanceOf(SplunkXmlDashboardParser); + describe('extractPanels', () => { + it('should extract basic panel information', async () => { + const xml = createBasicXml(createPanelXml('Test Panel', 'index=main | stats count by host')); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + + expect(panels).toHaveLength(1); + expect(panels[0]).toMatchObject({ + title: 'Test Panel', + query: 'index=main | stats count by host', + viz_type: 'table', // default + position: { x: 0, y: 0, w: 48, h: 16 }, + }); + expect(panels[0].id).toBeDefined(); }); - }); - describe('toObject', () => { - test('should parse XML to object correctly', async () => { - const parser = new SplunkXmlDashboardParser(exampleXml); - const result = await parser.toObject(); + it('should handle multiple panels in a row', async () => { + const xml = createBasicXml( + createPanelXml('Panel 1', 'index=main | stats count') + + createPanelXml('Panel 2', 'index=app | stats sum(bytes)') + ); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + + expect(panels).toHaveLength(2); + expect(panels[0].title).toBe('Panel 1'); + expect(panels[1].title).toBe('Panel 2'); + + // Check position calculation + expect(panels[0].position).toEqual({ x: 0, y: 0, w: 24, h: 16 }); + expect(panels[1].position).toEqual({ x: 24, y: 0, w: 24, h: 16 }); + }); + + it('should generate fallback titles for panels without titles', async () => { + const xml = createBasicXml(` + + + index=main | stats count + + + `); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + + expect(panels).toHaveLength(1); + expect(panels[0].title).toBe('Untitled Panel 0'); + }); + + it('should skip panels without queries', async () => { + const xml = createBasicXml(` + + Panel without query + + `); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + + expect(panels).toHaveLength(0); + }); + + describe('chart type mapping', () => { + const testChartType = async ( + chartType: string, + expectedVizType: VizType, + stackMode?: string, + overlayMode?: string, + hasMetric?: boolean + ) => { + const xml = createBasicXml( + createPanelXml('Test', 'index=main', chartType, stackMode, overlayMode, hasMetric) + ); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + return panels[0]?.viz_type; + }; + + it('should map bar chart types correctly', async () => { + expect(await testChartType('bar', 'bar_horizontal')).toBe('bar_horizontal'); + expect(await testChartType('column', 'bar_vertical')).toBe('bar_vertical'); + }); + + it('should map stacked chart types correctly', async () => { + expect(await testChartType('bar', 'bar_horizontal_stacked', 'stacked')).toBe( + 'bar_horizontal_stacked' + ); + expect(await testChartType('column', 'bar_vertical_stacked', 'stacked')).toBe( + 'bar_vertical_stacked' + ); + expect(await testChartType('area', 'area_stacked', 'stacked')).toBe('area_stacked'); + }); + + it('should map special chart types correctly', async () => { + expect(await testChartType('pie', 'pie')).toBe('pie'); + expect(await testChartType('line', 'line')).toBe('line'); + expect(await testChartType('area', 'area')).toBe('area'); + expect(await testChartType('donut', 'donut')).toBe('donut'); + expect(await testChartType('radialGauge', 'gauge')).toBe('gauge'); + expect(await testChartType('treemap', 'treemap')).toBe('treemap'); + }); + + it('should map heatmap overlay mode correctly', async () => { + expect(await testChartType('table', 'heatmap', undefined, 'heatmap')).toBe('heatmap'); + }); + + it('should map metric type correctly', async () => { + expect(await testChartType('table', 'metric', undefined, undefined, true)).toBe('metric'); + }); - expect(result).toEqual(expect.any(Object)); - expect(result.dashboard?.label).toEqual(['Dashboard example']); + it('should default to table for unknown types', async () => { + expect(await testChartType('unknown', 'table')).toBe('table'); + }); }); - test('should apply parser options correctly', async () => { - const parser = new SplunkXmlDashboardParser(exampleXml); - const result = await parser.toObject({ explicitArray: false }); + it('should handle multiple rows', async () => { + const xml = ` + + + + ${createPanelXml('Row 1 Panel 1', 'index=main')} + + + ${createPanelXml('Row 2 Panel 1', 'index=app')} + ${createPanelXml('Row 2 Panel 2', 'index=web')} + + + `; + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); - expect(result).toEqual(expect.any(Object)); - expect(result.dashboard?.label).toEqual('Dashboard example'); + expect(panels).toHaveLength(3); + + // Check row positioning + expect(panels[0].position.y).toBe(0); // Row 1 + expect(panels[1].position.y).toBe(16); // Row 2 + expect(panels[2].position.y).toBe(16); // Row 2 + + // Check column positioning in row 2 + expect(panels[1].position.x).toBe(0); + expect(panels[2].position.x).toBe(24); + }); + + it('should handle deeply nested XML structures', async () => { + const xml = ` + + +

+
+ + ${createPanelXml('Nested Panel', 'index=main | stats count')} + +
+ + + `; + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + + expect(panels).toHaveLength(1); + expect(panels[0].title).toBe('Nested Panel'); }); }); - describe('getQueries', () => { - test('should extract all queries from the dashboard', async () => { - const parser = new SplunkXmlDashboardParser(exampleXml); - const queries = await parser.getQueries(); + describe('extractQueries', () => { + it('should extract queries from panels', async () => { + const xml = createBasicXml( + createPanelXml('Panel 1', 'index=main | stats count by host') + + createPanelXml('Panel 2', 'index=app | stats sum(bytes)') + ); + const parser = new SplunkXmlDashboardParser(xml); + const queries = await parser.extractQueries(); - // There should be 3 queries (one from each panel in the example) - expect(queries).toHaveLength(3); + expect(queries).toHaveLength(2); + expect(queries).toContain('index=main | stats count by host'); + expect(queries).toContain('index=app | stats sum(bytes)'); + }); - // Verify the content of the first query - expect(queries[0]).toContain( - `| rest /servicesNS/-/-/data/ui/views - | search eai:acl.app = "search" \`\`\`eai:acl.owner!="nobody"\`\`\` - | stats count by eai:acl.owner` + it('should deduplicate identical queries', async () => { + const xml = createBasicXml( + createPanelXml('Panel 1', 'index=main | stats count') + + createPanelXml('Panel 2', 'index=main | stats count') ); + const parser = new SplunkXmlDashboardParser(xml); + const queries = await parser.extractQueries(); + + expect(queries).toHaveLength(1); + expect(queries[0]).toBe('index=main | stats count'); + }); + + it('should handle panels without queries', async () => { + const xml = ` + + + + Panel without query + + ${createPanelXml('Panel with query', 'index=main')} + + + `; + const parser = new SplunkXmlDashboardParser(xml); + const queries = await parser.extractQueries(); + + expect(queries).toHaveLength(1); + expect(queries[0]).toBe('index=main'); + }); + + it('should extract queries from deeply nested panels', async () => { + const xml = ` + +
+
+ + + + index=nested | stats count + + + +
+
+
+ `; + const parser = new SplunkXmlDashboardParser(xml); + const queries = await parser.extractQueries(); + + expect(queries).toHaveLength(1); + expect(queries[0]).toBe('index=nested | stats count'); + }); + }); + + describe('edge cases', () => { + it('should handle empty XML', async () => { + const parser = new SplunkXmlDashboardParser(''); + const panels = await parser.extractPanels(); + const queries = await parser.extractQueries(); + + expect(panels).toHaveLength(0); + expect(queries).toHaveLength(0); + }); + + it('should handle XML without dashboard element', async () => { + const parser = new SplunkXmlDashboardParser(''); + const panels = await parser.extractPanels(); + const queries = await parser.extractQueries(); + + expect(panels).toHaveLength(0); + expect(queries).toHaveLength(0); + }); + + it('should handle malformed XML gracefully', async () => { + const parser = new SplunkXmlDashboardParser('not valid xml'); + + await expect(parser.extractPanels()).rejects.toThrow(); + await expect(parser.extractQueries()).rejects.toThrow(); + }); + + it('should trim whitespace from queries and titles', async () => { + const xml = createBasicXml(` + + Whitespace Title + + index=main | stats count + + + `); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + + expect(panels[0].title).toBe('Whitespace Title'); + expect(panels[0].query).toBe('index=main | stats count'); + }); + + it('should handle empty titles and queries', async () => { + const xml = createBasicXml(` + + + + index=main + + + `); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); + + expect(panels[0].title).toBe('Untitled Panel 0'); + }); + }); - // Verify the content of the second query - expect(queries[1]).toContain(`| rest /servicesNS/-/-/data/ui/views - | search eai:acl.app = "search" eai:acl.owner!="nobody" - | stats count`); + describe('chart type detection priority', () => { + it('should prioritize chart option over chart element', async () => { + const xml = createBasicXml(` + + Priority Test + + index=main + + + + + `); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); - // Verify the content of the third query - expect(queries[2]).toContain(`| rest /servicesNS/-/-/data/ui/views - \`\`\`| search eai:acl.app = "search" \`\`\` - | stats count by eai:acl.app | sort - count`); + expect(panels[0].viz_type).toBe('pie'); }); - test('should return an empty array if no queries are found', async () => { - const emptyXml = ''; - const parser = new SplunkXmlDashboardParser(emptyXml); - const queries = await parser.getQueries(); + it('should prioritize chart element over viz element', async () => { + const xml = createBasicXml(` + + Priority Test + + index=main + + + + + `); + const parser = new SplunkXmlDashboardParser(xml); + const panels = await parser.extractPanels(); - expect(queries).toEqual([]); + expect(panels[0].viz_type).toBe('bar_horizontal'); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts index fa79b9cdf1fec..2cbd17431abab 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/splunk/splunk_xml_dashboard_parser.ts @@ -5,79 +5,304 @@ * 2.0. */ -import xml2js, { type ParserOptions } from 'xml2js'; +import { v4 as uuidV4 } from 'uuid'; +import xml2js from 'xml2js'; +import type { ParsedPanel, PanelPosition, VizType } from '../types'; -export interface Search { - query?: string[]; - earliest?: string[]; - latest?: string[]; +interface XmlElement { + $?: { [key: string]: string }; // XML attributes + _?: string; // Text content } - -export interface ChartPanel { - search?: Search[]; - option?: Array<{ - $: { name: string }; - _: string; - }>; +/** + * Represents a parsed XML object from a Splunk dashboard + */ +interface SplunkXmlElement extends XmlElement { + [key: string]: SplunkXmlElement[] | SplunkXmlElement | string | undefined; } -export interface SinglePanel { - search?: Search[]; - option?: Array<{ - $: { name: string }; - _: string; - }>; -} +export class SplunkXmlDashboardParser { + constructor(private readonly xml: string) {} -export interface Panel { - title?: string[]; - description?: string[]; - chart?: ChartPanel[]; - single?: SinglePanel[]; -} + private async parse(): Promise { + return xml2js.parseStringPromise(this.xml, { + explicitArray: true, + }) as Promise; + } -export interface Row { - panel?: Panel[]; -} + public async extractPanels(): Promise { + const root = await this.parse(); + const panels: ParsedPanel[] = []; -export interface Dashboard { - $?: { - version: string; - theme: string; - }; - label?: string[]; - row?: Row[]; -} + if (!root) return panels; -export interface DashboardObject { - dashboard?: Dashboard; -} + const allRows = this.findAllDeep(root, 'row'); -export class SplunkXmlDashboardParser { - constructor(private readonly xml: string) {} + allRows.forEach((row, rowIndex) => { + const allPanels = this.findAllDeep(row, 'panel'); + const panelCount = allPanels.length; - async toObject(options?: ParserOptions): Promise { - return xml2js.parseStringPromise(this.xml, options); - } + allPanels.forEach((panel, panelIndex) => { + if (!panel) return; - async getQueries(): Promise { - const obj = await this.toObject(); - const queries: string[] = []; + // Use deep search to find query element (equivalent to .//query in Python) + const queryElement = this.findDeep(panel, 'query') as string[] | undefined; + if (!Array.isArray(queryElement) || !queryElement[0]) return; + + const query = queryElement[0].toString().trim(); - // Extract queries from all panels in all rows - obj?.dashboard?.row?.forEach((row: Row) => { - row.panel?.forEach((panel: Panel) => { - // Handle chart panel queries - if (panel.chart?.[0]?.search?.[0]?.query?.[0]) { - queries.push(panel.chart[0].search[0].query[0]); + // Extract panel title using deep search (equivalent to .//title in Python) + let title = ''; + const titleElement = this.findDeep(panel, 'title') as string[] | undefined; + if (Array.isArray(titleElement) && titleElement.length > 0) { + title = titleElement[0].toString().trim(); } - // Handle single panel queries - if (panel.single?.[0]?.search?.[0]?.query?.[0]) { - queries.push(panel.single[0].search[0].query[0]); + // If still no title, provide a fallback with panel index + if (!title || title === '') { + title = `Untitled Panel ${panelIndex}`; } + + // Get visualization type using deep search for all chart elements + const vizType = this.getPanelChartType(panel); + const height = 16; // Default height + const position = this.calculatePositions(rowIndex, panelIndex, panelCount, height); + + panels.push({ + id: uuidV4(), + title, + query, + viz_type: vizType, + position, + }); }); }); + return panels; + } + + public async extractQueries(): Promise { + const root = await this.parse(); + const queries: string[] = []; + + const allPanels = this.findAllDeep(root, 'panel'); + + allPanels.forEach((panel) => { + const queryElement = this.findDeep(panel, 'query') as string[] | undefined; + if (Array.isArray(queryElement) && queryElement[0]) { + const query = queryElement[0].toString().trim(); + if (query && !queries.includes(query)) { + queries.push(query); + } + } + }); + return queries; } + + // Unified chart type mapping with deep search (equivalent to Python logic) + private getPanelChartType(panel: SplunkXmlElement): VizType { + // Deep search for visualization elements + const metricXml = this.findDeep(panel, 'single'); + const vizXml = this.findDeep(panel, 'viz') as SplunkXmlElement[] | undefined; + const chartXml = this.findDeep(panel, 'chart') as SplunkXmlElement[] | undefined; + + // Deep search for chart options with attribute filtering + const chartOption = this.findDeep(panel, 'option', 'name', 'charting.chart') as + | SplunkXmlElement + | undefined; + const stackMode = this.findDeep(panel, 'option', 'name', 'charting.chart.stackMode') as + | SplunkXmlElement + | undefined; + const overlayMode = this.findDeep(panel, 'option', 'name', 'dataOverlayMode') as + | SplunkXmlElement + | undefined; + + // Extract chart type (combining extractChartType logic) + let chartType = 'table'; + + // Override with viz type if present (matching Python logic) + if (Array.isArray(vizXml) && vizXml[0]?.$ && vizXml[0].$.type) { + chartType = vizXml[0].$.type; + } + + // Override with chart type if present (matching Python logic) + if (Array.isArray(chartXml) && chartXml[0]?.$ && chartXml[0].$.type) { + chartType = chartXml[0].$.type; + } + + // Override with chart option if present (matching Python logic) + if (chartOption && chartOption._) { + chartType = chartOption._; + } + + // Convert to VizType (combining getChartType logic) + return this.mapToVizType(chartType, stackMode, overlayMode, metricXml); + } + + private mapToVizType( + chartType: string, + stackMode: SplunkXmlElement | undefined, + overlayMode: SplunkXmlElement | undefined, + metricXml: unknown + ): VizType { + const isStacked = stackMode?._ && stackMode._.includes('stacked'); + + // Direct mapping to VizType enum values (matching Python logic) + if (chartType === 'bar') { + return isStacked ? 'bar_horizontal_stacked' : 'bar_horizontal'; + } + + if (chartType === 'column') { + return isStacked ? 'bar_vertical_stacked' : 'bar_vertical'; + } + + if (chartType === 'area') { + return isStacked ? 'area_stacked' : 'area'; + } + + // Special case transformations (matching Python logic) + if (chartType === 'table' && overlayMode?._ === 'heatmap') { + return 'heatmap'; + } + + if (chartType === 'radialGauge') { + return 'gauge'; + } + + if (chartType === 'table' && metricXml) { + return 'metric'; + } + + // Direct enum mapping for other types + const typeMap: Record = { + table: 'table', + heatmap: 'heatmap', + gauge: 'gauge', + metric: 'metric', + pie: 'pie', + donut: 'donut', + line: 'line', + treemap: 'treemap', + markdown: 'markdown', + }; + + return typeMap[chartType] || 'table'; + } + + // Unified deep search method (equivalent to Python's .// XPath expressions) + private findDeep( + source: SplunkXmlElement, + elementName: string, + attrName?: string, + attrValue?: string + ): SplunkXmlElement[] | SplunkXmlElement | string | undefined { + if (typeof source !== 'object' || source === null) { + return undefined; + } + // Check if the element exists at this level + if (elementName in source) { + const element = source[elementName]; + + // If no attribute filtering is needed, return the element + if (!attrName || !attrValue) { + return element; + } + + // If attribute filtering is needed, check if it's an array of elements + if (Array.isArray(element)) { + for (const item of element as SplunkXmlElement[]) { + if (item.$ && item.$[attrName] === attrValue) { + return item; + } + } + } + } + + // Search recursively in all properties + for (const key of Object.keys(source)) { + const value = source[key]; + + // Handle array values + if (Array.isArray(value)) { + for (const item of value) { + const result = this.findDeep(item, elementName, attrName, attrValue); + if (result !== undefined) { + return result; + } + } + } + // Handle object values + else if (typeof value === 'object' && value !== null) { + const result = this.findDeep(value, elementName, attrName, attrValue); + if (result !== undefined) { + return result; + } + } + } + + return undefined; + } + + private findAllDeep(source: SplunkXmlElement, elementName: string): SplunkXmlElement[] { + const results: SplunkXmlElement[] = []; + + if (typeof source !== 'object' || source === null) { + return results; + } + + // Check if the element exists at this level + if (elementName in source) { + const element = source[elementName]; + + // If it's an array, add all elements to results + if (Array.isArray(element)) { + results.push(...element); + // Don't search deeper in these found elements, continue with siblings + } else if (element) { + // Single element, add it to results + results.push(element as SplunkXmlElement); + // Don't search deeper in this found element, continue with siblings + } + } + + // Search recursively in all properties (but skip children of found elements) + for (const key of Object.keys(source)) { + if (key === elementName) { + // Skip the element we already processed above + } else { + const value = source[key]; + + // Handle array values + if (Array.isArray(value)) { + for (const item of value) { + const childResults = this.findAllDeep(item, elementName); + results.push(...childResults); + } + } + // Handle object values + else if (typeof value === 'object' && value !== null) { + const childResults = this.findAllDeep(value, elementName); + results.push(...childResults); + } + } + } + + return results; + } + + // Calculate panel positions + private calculatePositions( + rowIndex: number, + panelIndex: number, + totalPanelsInRow: number, + height = 16 + ): PanelPosition { + const panelWidth = Math.floor(48 / totalPanelsInRow); + + return { + x: panelIndex * panelWidth, + y: rowIndex * height, + w: panelWidth, + h: height, + }; + } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/types.ts new file mode 100644 index 0000000000000..d81370e4c2d4f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/lib/parsers/types.ts @@ -0,0 +1,43 @@ +/* + * 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 ParsedPanel { + /** The generated panel uuid */ + id: string; + /** The panel title extracted */ + title: string; + /** The extracted query */ + query: string; + /** The visualization type */ + viz_type: VizType; + /** The computed position */ + position: PanelPosition; +} + +export type VizType = + | 'area_stacked' + | 'area' + | 'bar_horizontal_stacked' + | 'bar_horizontal' + | 'bar_vertical_stacked' + | 'bar_vertical' + | 'donut' + | 'gauge' + | 'heatmap' + | 'line' + | 'markdown' + | 'metric' + | 'pie' + | 'table' + | 'treemap'; + +export interface PanelPosition { + x: number; + y: number; + w: number; + h: number; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts index 9075fdfe8017b..873f802946df4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts @@ -14,22 +14,12 @@ import { getTranslatePanelNode, } from './nodes/translate_panel/translate_panel'; import { getAggregateDashboardNode } from './nodes/aggregate_dashboard'; +import { getTranslatePanelGraph } from './sub_graphs/translate_panel'; -export function getDashboardMigrationAgent({ - model, - esqlKnowledgeBase, - dashboardMigrationsRetriever, - logger, - telemetryClient, -}: MigrateDashboardGraphParams) { +export function getDashboardMigrationAgent(params: MigrateDashboardGraphParams) { const parseOriginalDashboardNode = getParseOriginalDashboardNode(); - const translatePanelNode = getTranslatePanelNode({ - model, - esqlKnowledgeBase, - dashboardMigrationsRetriever, - telemetryClient, - logger, - }); + const translatePanelNode = getTranslatePanelNode(params); + const translatePanelSubGraph = getTranslatePanelGraph(params); // only for graph drawing const aggregateDashboardNode = getAggregateDashboardNode(); const siemMigrationAgentGraph = new StateGraph( @@ -38,7 +28,7 @@ export function getDashboardMigrationAgent({ ) // Nodes .addNode('parseOriginalDashboard', parseOriginalDashboardNode) - .addNode('translatePanel', translatePanelNode) + .addNode('translatePanel', translatePanelNode, { subgraphs: [translatePanelSubGraph] }) .addNode('aggregateDashboard', aggregateDashboardNode) // Edges .addEdge(START, 'parseOriginalDashboard') diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts index 32d38c7eb762d..ab92becd6b22b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/aggregate_dashboard.ts @@ -5,25 +5,45 @@ * 2.0. */ +import path from 'path'; +import fs from 'fs'; import { MigrationTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; import type { GraphNode } from '../../types'; +interface DashboardData { + attributes: { + title: string; + panelsJSON: string; + }; +} + export const getAggregateDashboardNode = (): GraphNode => { return async (state) => { - // dashboard data is the SO data ready to be installed - // TODO: use the templates (viz_type) to generate the correct dashboardData, this is still dummy data - const dashboardData = state.translated_panels - .sort((a, b) => a.index - b.index) - .map(({ panel }) => ({ - title: panel.title, - description: panel.description, - query: panel.query, - // id - // position - // viz_type - })); - - // TODO: Consider adding individual translation results for each panel, and aggregate them here + let dashboardData: DashboardData; + try { + const templatePath = path.join(__dirname, `./dashboard.json`); + const template = fs.readFileSync(templatePath, 'utf-8'); + + if (!template) { + throw new Error(`Dashboard template not found`); + } + dashboardData = JSON.parse(template); + } catch (error) { + // TODO: log the error + return { + // TODO: add comment: "panel chart type not supported" + translation_result: MigrationTranslationResult.UNTRANSLATABLE, + }; + } + + const panels = state.translated_panels.sort((a, b) => a.index - b.index); + + dashboardData.attributes.title = state.original_dashboard.title; + dashboardData.attributes.panelsJSON = JSON.stringify(panels.map(({ data }) => data)); + + // TODO: Use individual translation results for each panel: + // panels.map((panel) => panel.translation_result) + // and aggregate the top level translation_result here let translationResult; if (state.translated_panels.length > 0) { if (state.translated_panels.length > 0) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/dashboard.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/dashboard.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/dashboard.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/dashboard.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.ts index 1675be9a64f99..074056f1be320 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/parse_original_dashboard/parse_original_dashboard.ts @@ -5,10 +5,8 @@ * 2.0. */ -import { v4 as uuidV4 } from 'uuid'; -import type { ChartPanel } from '../../../../lib/parsers/splunk/splunk_xml_dashboard_parser'; import { SplunkXmlDashboardParser } from '../../../../lib/parsers/splunk/splunk_xml_dashboard_parser'; -import type { VizType, GraphNode, PanelPosition } from '../../types'; +import type { GraphNode } from '../../types'; export const getParseOriginalDashboardNode = (): GraphNode => { return async (state) => { @@ -17,20 +15,7 @@ export const getParseOriginalDashboardNode = (): GraphNode => { } const parser = new SplunkXmlDashboardParser(state.original_dashboard.data); - const parsedDashboard = await parser.toObject(); - - const panels = - parsedDashboard.dashboard?.row?.flatMap( - (row) => - row.panel?.map((panel) => ({ - id: uuidV4(), - title: panel?.title?.[0] ?? '', - description: panel?.description?.[0] ?? '', - query: panel?.chart?.[0]?.search?.[0]?.query?.[0] ?? '', - viz_type: getVizType(panel?.chart?.[0]), - position: getPosition(panel?.chart?.[0]), - })) ?? [] - ) ?? []; + const panels = await parser.extractPanels(); return { parsed_original_dashboard: { @@ -40,13 +25,3 @@ export const getParseOriginalDashboardNode = (): GraphNode => { }; }; }; - -function getVizType(chart: ChartPanel | undefined): VizType { - // TODO: Implement logic to determine viz type - return 'pie'; -} - -function getPosition(chart: ChartPanel | undefined): PanelPosition { - // TODO: Implement logic to determine position - return { x: 0, y: 0, w: 0, h: 0 }; -} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts index e53a543acae69..58c198fd94a8b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts @@ -6,15 +6,10 @@ */ import { Send } from '@langchain/langgraph'; -import type { MigrateDashboardState, ParsedOriginalPanel } from '../../types'; +import type { MigrateDashboardState, TranslatePanelNodeParams } from '../../types'; import { getTranslatePanelGraph } from '../../sub_graphs/translate_panel'; import type { TranslatePanelGraphParams } from '../../sub_graphs/translate_panel/types'; -export interface TranslatePanelNodeParams { - panel: ParsedOriginalPanel; - index: number; -} - export type TranslatePanelNode = ( params: TranslatePanelNodeParams ) => Promise>; @@ -27,15 +22,30 @@ export const getTranslatePanelNode = (params: TranslatePanelGraphParams): Transl const translatePanelSubGraph = getTranslatePanelGraph(params); return async ({ panel, index }) => { try { - const output = await translatePanelSubGraph.invoke({ original_panel: panel }); + if (!panel.query) { + throw new Error('Panel query is missing'); + } + const output = await translatePanelSubGraph.invoke({ parsed_panel: panel }); return { // Fan-in: translated panels are concatenated by the state reducer, so the results can be aggregated later - translated_panels: [{ index, panel: output.elastic_panel }], + translated_panels: [ + { + index, + data: output.elastic_panel, + translation_result: output.translation_result, + }, + ], }; } catch (err) { // Fan-in: failed panels are concatenated by the state reducer, so the results can be aggregated later return { - failed_panel_translations: [{ index, error_message: err.toString(), details: err }], + failed_panel_translations: [ + { + index, + error_message: err.toString(), + details: err, + }, + ], }; } }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts index 4265c1e62343e..6d43a47c3bed7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/state.ts @@ -22,9 +22,11 @@ export const migrateDashboardState = Annotation.Root({ parsed_original_dashboard: Annotation(), translated_panels: Annotation({ reducer: (current, value) => current.concat(value), + default: () => [], }), failed_panel_translations: Annotation({ reducer: (current, value) => current.concat(value), + default: () => [], }), elastic_dashboard: Annotation({ reducer: (current, value) => ({ ...current, ...value }), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts index 3ddf64c1ab108..ee59b5241916a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts @@ -21,21 +21,13 @@ import { getSelectIndexPatternNode } from './nodes/select_index_pattern/select_i // How many times we will try to self-heal when validation fails, to prevent infinite graph recursions const MAX_VALIDATION_ITERATIONS = 3; -export function getTranslatePanelGraph({ - model, - esqlKnowledgeBase, - logger, - telemetryClient, -}: TranslatePanelGraphParams) { - const translateQueryNode = getTranslateQueryNode({ - esqlKnowledgeBase, - logger, - }); - const inlineQueryNode = getInlineQueryNode({ model, logger }); - const validationNode = getValidationNode({ logger }); - const fixQueryErrorsNode = getFixQueryErrorsNode({ esqlKnowledgeBase, logger }); - const ecsMappingNode = getEcsMappingNode({ esqlKnowledgeBase, logger }); - const selectIndexPatternNode = getSelectIndexPatternNode({ model, telemetryClient, logger }); +export function getTranslatePanelGraph(params: TranslatePanelGraphParams) { + const translateQueryNode = getTranslateQueryNode(params); + const inlineQueryNode = getInlineQueryNode(params); + const validationNode = getValidationNode(params); + const fixQueryErrorsNode = getFixQueryErrorsNode(params); + const ecsMappingNode = getEcsMappingNode(params); + const selectIndexPatternNode = getSelectIndexPatternNode(params); const translationResultNode = getTranslationResultNode(); const translateDashboardPanelGraph = new StateGraph( @@ -44,24 +36,32 @@ export function getTranslatePanelGraph({ ) // Nodes .addNode('inlineQuery', inlineQueryNode) + // TODO: .addNode('createDescription', createDescriptionNode) -> ask the LLM to create a description of the panel + .addNode('selectIndexPattern', selectIndexPatternNode) + + // Consider this block by the entire Assistant nlToEsql graph .addNode('translateQuery', translateQueryNode) .addNode('validation', validationNode) .addNode('fixQueryErrors', fixQueryErrorsNode) - .addNode('ecsMapping', ecsMappingNode) - .addNode('selectIndexPattern', selectIndexPatternNode) + .addNode('ecsMapping', ecsMappingNode) // Not sure about this one, maybe we should keep it anyway, tests need to be done + // Consider this block by the entire Assistant nlToEsql graph + .addNode('translationResult', translationResultNode) + // Edges .addEdge(START, 'inlineQuery') - .addEdge('inlineQuery', 'translateQuery') + // .addEdge('inlineQuery', 'createDescription') // createDescription would go after inlineQuery + .addEdge('inlineQuery', 'selectIndexPattern') + // .addEdge('createDescription', 'selectIndexPattern') // And before selectIndexPattern, the description is sent to the selectIndexPattern graph + .addEdge('selectIndexPattern', 'translateQuery') .addEdge('translateQuery', 'validation') .addEdge('fixQueryErrors', 'validation') .addEdge('ecsMapping', 'validation') .addConditionalEdges('validation', validationRouter, [ 'fixQueryErrors', 'ecsMapping', - 'selectIndexPattern', + 'translationResult', ]) - .addEdge('selectIndexPattern', 'translationResult') .addEdge('translationResult', END); const graph = translateDashboardPanelGraph.compile(); @@ -80,5 +80,5 @@ const validationRouter = (state: TranslateDashboardPanelState) => { return 'ecsMapping'; } - return 'selectIndexPattern'; + return 'translationResult'; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts index b1e246ba5a74b..2552aff75a021 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts @@ -15,9 +15,9 @@ export const getEcsMappingNode = (params: GetConvertEsqlSchemaCisToEcsParams): G const convertEsqlSchemaCimToEcs = getConvertEsqlSchemaCisToEcs(params); return async (state) => { const { query, comments } = await convertEsqlSchemaCimToEcs({ - title: state.elastic_panel.title ?? '', - description: state.elastic_panel.description ?? '', - query: state.elastic_panel.query ?? '', + title: state.parsed_panel.title ?? '', + description: state.description ?? '', + query: state.esql_query ?? '', originalQuery: state.inline_query, }); @@ -25,7 +25,7 @@ export const getEcsMappingNode = (params: GetConvertEsqlSchemaCisToEcsParams): G return { includes_ecs_mapping: true, comments, - ...(query && { elastic_panel: { ...state.elastic_panel, query } }), + ...(query && { esql_query: query }), }; }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts index e724f73be4d12..fae3ff5ccb5cb 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/fix_query_errors/fix_query_errors.ts @@ -15,12 +15,12 @@ export const getFixQueryErrorsNode = (params: GetFixEsqlQueryErrorsParams): Grap const fixEsqlQueryErrors = getFixEsqlQueryErrors(params); return async (state) => { const { query } = await fixEsqlQueryErrors({ - invalidQuery: state.elastic_panel.query, + invalidQuery: state.esql_query, validationErrors: state.validation_errors.esql_errors, }); if (!query) { return {}; } - return { elastic_panel: { ...state.elastic_panel, query } }; + return { esql_query: query }; }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts index 88fbe0f178bf3..3aea2dfc6d013 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/inline_query/inline_query.ts @@ -13,8 +13,9 @@ import type { GraphNode } from '../../types'; export const getInlineQueryNode = (params: GetInlineSplQueryParams): GraphNode => { const inlineSplQuery = getInlineSplQuery(params); return async (state) => { + // NOTE: "inputlookup" is not currently supported, to make it supported we need to parametrize the unsupported check logic here, and the Splunk lookups identifier. const { inlineQuery, isUnsupported, comments } = await inlineSplQuery({ - query: state.original_panel.query, + query: state.parsed_panel.query, resources: state.resources, }); if (isUnsupported) { @@ -22,7 +23,7 @@ export const getInlineQueryNode = (params: GetInlineSplQueryParams): GraphNode = return { inline_query: undefined, comments }; } return { - inline_query: inlineQuery ?? state.original_panel.query, + inline_query: inlineQuery ?? state.parsed_panel.query, comments, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts new file mode 100644 index 0000000000000..e3dda9015941f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts @@ -0,0 +1,29 @@ +/* + * 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 { ChatPromptTemplate } from '@langchain/core/prompts'; + +export const SELECT_INDEX_PATTERN_PROMPT = ChatPromptTemplate.fromTemplate( + `You are a helpful assistant that helps translating queries from Splunk to Elastic. +Your task is to find the best and more specific index pattern in the current environment for the Splunk query in the context. +Respond only with the index pattern string, it will be used later in the translated Elastic query. + +Read the context carefully and select the most appropriate index pattern from the list of available index patterns. + + + +{title} + + +{description} + + +{query} + + +` +); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts index 7a713205b5586..5a8415cc0c1f9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts @@ -4,22 +4,49 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import type { IScopedClusterClient, Logger } from '@kbn/core/server'; +import { getSelectIndexPatternGraph } from '../../../../../../../../../assistant/tools/esql/graphs/select_index_pattern/select_index_pattern'; import type { ChatModel } from '../../../../../../../common/task/util/actions_client_chat'; import type { DashboardMigrationTelemetryClient } from '../../../../../dashboard_migrations_telemetry_client'; import type { GraphNode } from '../../types'; +import { SELECT_INDEX_PATTERN_PROMPT } from './prompts'; interface GetSelectIndexPatternParams { model: ChatModel; + esScopedClient: IScopedClusterClient; telemetryClient: DashboardMigrationTelemetryClient; logger: Logger; } export const getSelectIndexPatternNode = (params: GetSelectIndexPatternParams): GraphNode => { - return async (_state) => { - // TODO: implement index pattern discovery + const selectIndexPatternGraphPromise = getSelectIndexPatternGraph({ + // Using the `asInternalUser` so we can access all indices to find the best index pattern + // we can change it to `asCurrentUser`, but we would be restricted to the indices the user (who started the migration task) has access to. + esClient: params.esScopedClient.asInternalUser, + createLlmInstance: async () => params.model, + }); + + return async (state, config) => { + const selectIndexPatternGraph = await selectIndexPatternGraphPromise; // This will only be awaited the first time the node is executed + + if (!state.esql_query) { + return { index_pattern: '[indexPattern]' }; + } + const question = await SELECT_INDEX_PATTERN_PROMPT.format({ + query: state.inline_query, + title: state.parsed_panel.title, + description: state.description ?? '', + }); + const { selectedIndexPattern } = await selectIndexPatternGraph.invoke( + { input: { question } }, + config + ); + + if (!selectedIndexPattern) { + return { index_pattern: '[indexPattern]' }; + } return { - index_pattern: '[index_pattern]', + index_pattern: selectedIndexPattern, }; }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts index 3e2452cb0d8e9..18d7b5091c3ee 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts @@ -15,13 +15,12 @@ import type { GraphNode } from '../../types'; export const getTranslateQueryNode = (params: GetTranslateSplToEsqlParams): GraphNode => { const translateSplToEsql = getTranslateSplToEsql(params); return async (state) => { - const { title, description = '' } = state.original_panel; const { esqlQuery, comments } = await translateSplToEsql({ - title, - description, + title: state.parsed_panel.title, + description: state.description, taskDescription: TASK_DESCRIPTION.migrate_dashboard, inlineQuery: state.inline_query, - indexPattern: 'logs-*', // The index_pattern state is still undefined at this point + indexPattern: state.index_pattern || '[indexPattern]', }); if (!esqlQuery) { @@ -29,12 +28,7 @@ export const getTranslateQueryNode = (params: GetTranslateSplToEsqlParams): Grap } return { - elastic_panel: { - title, - description, - query: esqlQuery, - query_language: 'esql', - }, + esql_query: esqlQuery, comments, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/process_panel.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/process_panel.ts new file mode 100644 index 0000000000000..0e87d17a03b2e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/process_panel.ts @@ -0,0 +1,273 @@ +/* + * 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 { getQueryColumnsFromESQLQuery } from '@kbn/esql-utils'; +import type { ParsedPanel } from '../../../../../../lib/parsers/types'; + +interface ColumnInfo { + columnId: string; + fieldName: string; + meta: { + type: string; + }; + inMetricDimension?: boolean; +} + +interface PanelJSON { + title?: string; + gridData?: { + x: number; + y: number; + w: number; + h: number; + i: string; + }; + panelIndex?: string; + embeddableConfig?: { + attributes?: { + state?: { + visualization?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any + datasourceStates?: { + textBased?: { + layers?: { + [key: string]: { + query?: { esql: string }; + columns?: ColumnInfo[]; + }; + }; + }; + }; + query?: { esql: string }; + }; + }; + }; + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// Process the panel and return the modified panelJSON +export const processPanel = (panel: object, query: string, parsedPanel: ParsedPanel): object => { + // Apply changes to panel - equivalent to Python convertToKibanaDashboard logic for single panel + const panelJSON = { ...panel } as PanelJSON; + + const { columnList, columns } = parseColumns(query); + const vizType = parsedPanel.viz_type; + + // Set panel basic properties + panelJSON.title = parsedPanel.title; + + // Set position from parsed_panel.position + if (parsedPanel.position) { + panelJSON.gridData = { + x: parsedPanel.position.x, + y: parsedPanel.position.y, + w: parsedPanel.position.w, + h: parsedPanel.position.h, + i: parsedPanel.id, + }; + panelJSON.panelIndex = parsedPanel.id; + } + + // Configure visualization-specific properties + configureChartSpecificProperties(panelJSON, vizType, columns); + + if (vizType === 'treemap') { + configureTreemapProperties(panelJSON, columns); + } + + configureStackedProperties(panelJSON, vizType, columns); + configureDatasourceProperties(panelJSON, query, columnList); + + // Handle metric visualization + if (vizType === 'metric') { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization) { + panelJSON.embeddableConfig.attributes.state.visualization.metricAccessor = columns[0]; + } + } + + // Handle gauge visualization + if (vizType === 'gauge') { + configureGaugeProperties(panelJSON, query, columns); + } + + return panelJSON; +}; + +// Parse columns from ESQL query and build column array for panel JSON +function parseColumns(query: string): { columnList: ColumnInfo[]; columns: string[] } { + const columnNames = getQueryColumnsFromESQLQuery(query); + const columnList: ColumnInfo[] = []; + const columns: string[] = []; + + let metricIndex = 0; + + columnNames.forEach((columnName) => { + // For now, assume numeric columns are metrics (first columns) and string columns are dimensions + // This is a simplification - in real implementation you'd need type information + const isNumeric = metricIndex === 0; // First column is typically the metric + + if (isNumeric) { + columnList.splice(metricIndex, 0, { + columnId: columnName, + fieldName: columnName, + meta: { type: 'number' }, + inMetricDimension: true, + }); + columns.splice(metricIndex, 0, columnName); + metricIndex++; + } else { + columnList.push({ + columnId: columnName, + fieldName: columnName, + meta: { type: 'string' }, + }); + columns.push(columnName); + } + }); + + // Ensure at least one column has inMetricDimension if no numeric columns + if (metricIndex === 0 && columnList.length > 0) { + columnList[0].inMetricDimension = true; + } + + return { columnList, columns }; +} + +// Configure chart-specific properties +function configureChartSpecificProperties( + panelJSON: PanelJSON, + vizType: string, + columns: string[] +): void { + const chartTypes = [ + 'bar', + 'bar_vertical', + 'bar_horizontal', + 'bar_vertical_stacked', + 'bar_horizontal_stacked', + 'area', + 'area_stacked', + 'line', + 'heatmap', + ]; + + if (chartTypes.includes(vizType)) { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization?.layers?.[0]) { + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].xAccessor = + columns[columns.length - 1]; + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].accessors = [columns[0]]; + } + } + + if (vizType === 'pie') { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization?.layers?.[0]) { + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].primaryGroups = [ + columns[columns.length - 1], + ]; + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].metrics = [[columns[0]]]; + } + } + + if (vizType === 'table') { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization) { + panelJSON.embeddableConfig.attributes.state.visualization.columns = columns.map((column) => ({ + columnId: column, + })); + } + } + + if (vizType === 'heatmap') { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization) { + panelJSON.embeddableConfig.attributes.state.visualization.valueAccessor = columns[0]; + panelJSON.embeddableConfig.attributes.state.visualization.xAccessor = + columns[columns.length - 1]; + if (columns.length > 1) { + panelJSON.embeddableConfig.attributes.state.visualization.yAccessor = + columns[columns.length - 2]; + } + } + } +} + +// Configure treemap specific properties +function configureTreemapProperties(panelJSON: PanelJSON, columns: string[]): void { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization?.layers?.[0]) { + if (panelJSON.embeddableConfig.attributes.state.visualization.layers[0].metrics) { + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].metrics.push(columns[0]); + } + if (panelJSON.embeddableConfig.attributes.state.visualization.layers[0].primaryGroups) { + for (let i = 1; i < columns.length - 1; i++) { + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].primaryGroups.push( + columns[i] + ); + } + } + } +} + +// Configure stacked chart properties +function configureStackedProperties( + panelJSON: PanelJSON, + vizType: string, + columns: string[] +): void { + if ((vizType.includes('stacked') || vizType.includes('line')) && columns.length > 2) { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization?.layers?.[0]) { + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].splitAccessor = + columns[columns.length - 2]; + } + } + + if (vizType.includes('stacked') && columns.length === 2) { + if (panelJSON.embeddableConfig?.attributes?.state?.visualization?.layers?.[0]) { + panelJSON.embeddableConfig.attributes.state.visualization.layers[0].splitAccessor = + columns[columns.length - 1]; + } + } +} + +// Configure datasource properties +function configureDatasourceProperties( + panelJSON: PanelJSON, + query: string, + columnList: ColumnInfo[] +): void { + if (panelJSON.embeddableConfig?.attributes?.state?.datasourceStates?.textBased?.layers) { + const layerId = '3a5310ab-2832-41db-bdbe-1b6939dd5651'; + if (panelJSON.embeddableConfig.attributes.state.datasourceStates.textBased.layers[layerId]) { + panelJSON.embeddableConfig.attributes.state.datasourceStates.textBased.layers[layerId].query = + { esql: query }; + panelJSON.embeddableConfig.attributes.state.datasourceStates.textBased.layers[ + layerId + ].columns = columnList; + } + } + + if (panelJSON.embeddableConfig?.attributes?.state?.query) { + panelJSON.embeddableConfig.attributes.state.query.esql = query; + } +} + +// Configure gauge visualization +function configureGaugeProperties(panelJSON: PanelJSON, query: string, columns: string[]): void { + const gaugeLayerId = '3b1b0102-bb45-40f5-9ef2-419d2eaaa56c'; + if ( + panelJSON.embeddableConfig?.attributes?.state?.datasourceStates?.textBased?.layers?.[ + gaugeLayerId + ] + ) { + const gaugeLayer = + panelJSON.embeddableConfig.attributes.state.datasourceStates.textBased.layers[gaugeLayerId]; + if (gaugeLayer.columns?.[0]) { + gaugeLayer.columns[0].fieldName = columns[0]; + gaugeLayer.columns[0].columnId = columns[0]; + } + gaugeLayer.query = { esql: query }; + } + if (panelJSON.embeddableConfig?.attributes?.state?.visualization) { + panelJSON.embeddableConfig.attributes.state.visualization.metricAccessor = columns[0]; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/area.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/area.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area_stacked.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/area_stacked.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/area_stacked.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/area_stacked.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_horizontal.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_horizontal.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal_stacked.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_horizontal_stacked.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_horizontal_stacked.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_horizontal_stacked.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_vertical.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_vertical.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical_stacked.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_vertical_stacked.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/bar_vertical_stacked.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/bar_vertical_stacked.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/donut.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/donut.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/donut.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/donut.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/gauge.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/gauge.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/gauge.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/gauge.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/heatmap.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/heatmap.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/heatmap.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/heatmap.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/line.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/line.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/line.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/line.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/markdown.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/markdown.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/markdown.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/markdown.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/metric.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/metric.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/metric.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/metric.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/pie.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/pie.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/pie.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/pie.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/table.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/table.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/table.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/table.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/treemap.viz.json b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/treemap.viz.json similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/aggregate_dashboard/templates/treemap.viz.json rename to x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/templates/treemap.viz.json diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts index 777ff2402924a..f3ec1babca71c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts @@ -5,38 +5,57 @@ * 2.0. */ -import { RuleTranslationResult } from '../../../../../../../../../../common/siem_migrations/constants'; +import fs from 'fs'; +import path from 'path'; +import { MigrationTranslationResult } from '../../../../../../../../../../common/siem_migrations/constants'; import type { GraphNode } from '../../types'; +import { processPanel } from './process_panel'; export const getTranslationResultNode = (): GraphNode => { return async (state) => { - // Set defaults - const elasticVisualization = { - title: state.original_panel.title, - description: state.original_panel.description || state.original_panel.title, - ...state.elastic_panel, - }; + const vizType = state.parsed_panel?.viz_type; + let panel: object; + try { + if (!vizType) { + throw new Error('Panel visualization type could not be extracted'); + } + + const templatePath = path.join(__dirname, `./templates/${vizType}.viz.json`); + const template = fs.readFileSync(templatePath, 'utf-8'); + + if (!template) { + throw new Error(`Template not found for visualization type: ${vizType}`); + } + panel = JSON.parse(template); + } catch (error) { + // TODO: log the error + return { + // TODO: add comment: "panel chart type not supported" + translation_result: MigrationTranslationResult.UNTRANSLATABLE, + }; + } + const query = state.esql_query; - const query = elasticVisualization.query; let translationResult; if (!query) { - translationResult = RuleTranslationResult.UNTRANSLATABLE; + translationResult = MigrationTranslationResult.UNTRANSLATABLE; } else { - if (query.startsWith('FROM logs-*')) { - elasticVisualization.query = query.replace('FROM logs-*', 'FROM [indexPattern]'); - translationResult = RuleTranslationResult.PARTIAL; + if (query.startsWith('FROM [indexPattern]')) { + translationResult = MigrationTranslationResult.PARTIAL; } else if (state.validation_errors?.esql_errors) { - translationResult = RuleTranslationResult.PARTIAL; + translationResult = MigrationTranslationResult.PARTIAL; } else if (query.match(/\[(macro|lookup):.*?\]/)) { - translationResult = RuleTranslationResult.PARTIAL; + translationResult = MigrationTranslationResult.PARTIAL; } else { - translationResult = RuleTranslationResult.FULL; + translationResult = MigrationTranslationResult.FULL; } } + const panelJSON = processPanel(panel, query, state.parsed_panel); + return { - elastic_visualization: elasticVisualization, + elastic_panel: panelJSON, translation_result: translationResult, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts index 73f3f250840f5..46332bda73971 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/validation/validation.ts @@ -19,12 +19,12 @@ export const getValidationNode = (params: GetValidateEsqlParams): GraphNode => { const validateEsql = getValidateEsql(params); return async (state) => { const iterations = state.validation_errors.iterations + 1; - if (!state.elastic_panel.query) { + if (!state.esql_query) { params.logger.warn('Missing query in validation node'); return { iterations }; } - const { error } = await validateEsql({ query: state.elastic_panel.query }); + const { error } = await validateEsql({ query: state.esql_query }); return { validation_errors: { iterations, esql_errors: error } }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts index 0c6d9df5397ec..bdad7ef501c58 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts @@ -6,34 +6,31 @@ */ import { Annotation } from '@langchain/langgraph'; -import { RuleTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; +import { MigrationTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; import type { DashboardMigrationDashboard } from '../../../../../../../../common/siem_migrations/model/dashboard_migration.gen'; import type { MigrationResources } from '../../../../../common/task/retrievers/resource_retriever'; import type { ValidationErrors } from './types'; -import type { ParsedOriginalPanel, ElasticPanel } from '../../types'; +import type { ParsedPanel } from '../../../../lib/parsers/types'; export const translateDashboardPanelState = Annotation.Root({ - original_panel: Annotation(), - elastic_panel: Annotation({ - reducer: (current, value) => ({ ...current, ...value }), - }), - index_pattern: Annotation, + parsed_panel: Annotation(), + elastic_panel: Annotation(), // The visualization panel object + index_pattern: Annotation(), resources: Annotation(), includes_ecs_mapping: Annotation({ reducer: (current, value) => value ?? current, default: () => false, }), - inline_query: Annotation({ - reducer: (current, value) => value ?? current, - default: () => '', - }), + inline_query: Annotation(), + description: Annotation(), + esql_query: Annotation(), validation_errors: Annotation({ reducer: (current, value) => value ?? current, default: () => ({ iterations: 0 }), }), - translation_result: Annotation({ + translation_result: Annotation({ reducer: (current, value) => value ?? current, - default: () => RuleTranslationResult.UNTRANSLATABLE, + default: () => MigrationTranslationResult.UNTRANSLATABLE, }), comments: Annotation({ reducer: (current, value) => (value ? (current ?? []).concat(value) : current), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts index 5539290caf63c..0149c5d8ca2c6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import type { Logger, IScopedClusterClient } from '@kbn/core/server'; import type { RunnableConfig } from '@langchain/core/runnables'; import type { ChatModel } from '../../../../../common/task/util/actions_client_chat'; import type { EsqlKnowledgeBase } from '../../../../../common/task/util/esql_knowledge_base'; @@ -25,6 +25,7 @@ export type GraphNode = ( export interface TranslatePanelGraphParams { model: ChatModel; + esScopedClient: IScopedClusterClient; esqlKnowledgeBase: EsqlKnowledgeBase; dashboardMigrationsRetriever: DashboardMigrationsRetriever; telemetryClient: DashboardMigrationTelemetryClient; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts index 98d9e6b0c9a2e..b38a4ecb73e72 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/types.ts @@ -5,13 +5,15 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import type { Logger, IScopedClusterClient } from '@kbn/core/server'; import type { RunnableConfig } from '@langchain/core/runnables'; +import type { MigrationTranslationResult } from '../../../../../../common/siem_migrations/constants'; import type { DashboardMigrationsRetriever } from '../retrievers'; import type { EsqlKnowledgeBase } from '../../../common/task/util/esql_knowledge_base'; import type { ChatModel } from '../../../common/task/util/actions_client_chat'; import type { migrateDashboardConfigSchema, migrateDashboardState } from './state'; import type { DashboardMigrationTelemetryClient } from '../dashboard_migrations_telemetry_client'; +import type { ParsedPanel } from '../../lib/parsers/types'; export type MigrateDashboardState = typeof migrateDashboardState.State; export type MigrateDashboardConfigSchema = (typeof migrateDashboardConfigSchema)['State']; @@ -27,35 +29,29 @@ export interface DashboardMigrationAgentRunOptions { } export interface MigrateDashboardGraphParams { - esqlKnowledgeBase: EsqlKnowledgeBase; model: ChatModel; + esScopedClient: IScopedClusterClient; + esqlKnowledgeBase: EsqlKnowledgeBase; dashboardMigrationsRetriever: DashboardMigrationsRetriever; logger: Logger; telemetryClient: DashboardMigrationTelemetryClient; } -export interface ParsedOriginalPanel { - id: string; - title: string; - description?: string; - query: string; - viz_type: VizType; - position: PanelPosition; -} -export interface ElasticPanel { - title?: string; - description?: string; - query?: string; -} - export interface ParsedOriginalDashboard { title: string; - panels: Array; + panels: Array; } export type TranslatedPanels = Array<{ + /** + * The index in the panels array, to keep the same order as in the original dashboard. + * this is probably not necessary since we have already calculated the `position` of each panel, but maintained for consistency + */ index: number; - panel: ElasticPanel; + /* The visualization json */ + data: object; + /* The individual panel translation result */ + translation_result: MigrationTranslationResult; }>; export type FailedPanelTranslations = Array<{ @@ -64,30 +60,10 @@ export type FailedPanelTranslations = Array<{ details: unknown; }>; -export type TranslatePanelNode = (params: { - panel: ParsedOriginalPanel; +export interface TranslatePanelNodeParams { + panel: ParsedPanel; index: number; -}) => Promise>; - -export type VizType = - | 'area_stacked' - | 'area' - | 'bar_horizontal_stacked' - | 'bar_horizontal' - | 'bar_vertical' - | 'donut' - | 'gauge' - | 'heatmap' - | 'line' - | 'markdown' - | 'metric' - | 'pie' - | 'table' - | 'treemap'; - -export interface PanelPosition { - x: number; - y: number; - w: number; - h: number; } +export type TranslatePanelNode = ( + params: TranslatePanelNodeParams +) => Promise>; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts index 3833cd90100ba..6a8d6f6997f34 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/dashboard_migrations_task_runner.ts @@ -60,12 +60,16 @@ export class DashboardMigrationTaskRunner extends SiemMigrationTaskRunner< migrationId: this.migrationId, abortController: this.abortController, }); + const modelName = this.actionsClientChat.getModelName(model); + + // TODO: inference model name + // console.log('modelName', modelName); const telemetryClient = new DashboardMigrationTelemetryClient( this.dependencies.telemetry, this.logger, this.migrationId, - model.model + modelName ); const esqlKnowledgeBase = new EsqlKnowledgeBase( @@ -76,8 +80,9 @@ export class DashboardMigrationTaskRunner extends SiemMigrationTaskRunner< ); const agent = getDashboardMigrationAgent({ - esqlKnowledgeBase, model, + esScopedClient: this.data.esScopedClient, + esqlKnowledgeBase, dashboardMigrationsRetriever: this.retriever, logger: this.logger, telemetryClient, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts index 820f1daa7f1c4..33093a4f23b0a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/routes.ts @@ -20,7 +20,7 @@ export const registerSiemMigrationsRoutes = ( registerSiemRuleMigrationsRoutes(router, config, logger); if (config.experimentalFeatures.automaticDashboardsMigration) { - registerSiemDashboardMigrationsRoutes(router, logger); + registerSiemDashboardMigrationsRoutes(router, config, logger); } } }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts index d55501a0aa814..da71749b730aa 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts @@ -17,9 +17,6 @@ import { SiemMigrationsDataClient } from '../../common/data/siem_migrations_data import { SiemMigrationsDataResourcesClient } from '../../common/data/siem_migrations_data_resources_client'; export class RuleMigrationsDataClient extends SiemMigrationsDataClient { - protected logger: Logger; - protected esClient: IScopedClusterClient['asInternalUser']; - public readonly migrations: RuleMigrationsDataMigrationClient; public readonly items: RuleMigrationsDataRulesClient; public readonly resources: SiemMigrationsDataResourcesClient; @@ -35,7 +32,7 @@ export class RuleMigrationsDataClient extends SiemMigrationsDataClient { spaceId: string, dependencies: SiemMigrationsClientDependencies ) { - super(); + super(esScopedClient, logger); this.migrations = new RuleMigrationsDataMigrationClient( indexNameProviders.migrations, @@ -78,8 +75,5 @@ export class RuleMigrationsDataClient extends SiemMigrationsDataClient { logger, spaceId ); - - this.logger = logger; - this.esClient = esScopedClient.asInternalUser; } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts index 1c738def48f5d..2e40db266cc40 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts @@ -53,17 +53,19 @@ export class RuleMigrationTaskRunner extends SiemMigrationTaskRunner< /** Retrieves the connector and creates the migration agent */ public async setup(connectorId: string): Promise { const { inferenceClient } = this.dependencies; + const model = await this.actionsClientChat.createModel({ connectorId, migrationId: this.migrationId, abortController: this.abortController, }); + const modelName = this.actionsClientChat.getModelName(model); const telemetryClient = new RuleMigrationTelemetryClient( this.dependencies.telemetry, this.logger, this.migrationId, - model.model + modelName ); const esqlKnowledgeBase = new EsqlKnowledgeBase( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts index ff079b57bfa3d..87c02b27c80e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts @@ -31,7 +31,6 @@ export class SiemMigrationsService { kibanaVersion, config.siemRuleMigrations?.elserInferenceId ); - this.dashboardsService = new SiemDashboardMigrationsService( logger, kibanaVersion, @@ -42,10 +41,10 @@ export class SiemMigrationsService { setup(params: SiemMigrationsSetupParams) { if (!this.config.experimentalFeatures.siemMigrationsDisabled) { this.rulesService.setup({ ...params, pluginStop$: this.pluginStop$ }); - } - if (this.config.experimentalFeatures.automaticDashboardsMigration) { - this.dashboardsService.setup({ ...params, pluginStop$: this.pluginStop$ }); + if (this.config.experimentalFeatures.automaticDashboardsMigration) { + this.dashboardsService.setup({ ...params, pluginStop$: this.pluginStop$ }); + } } } From dd23d56282e194e24ba7f454c2f211f162c3bf49 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 14 Aug 2025 18:37:42 +0200 Subject: [PATCH 8/8] fix selectIndexPatterns --- .../dashboards/task/agent/graph.ts | 15 ++- .../nodes/translate_panel/translate_panel.ts | 92 ++++++++++--------- .../agent/sub_graphs/translate_panel/graph.ts | 11 +++ .../nodes/ecs_mapping/ecs_mapping.ts | 2 +- .../nodes/select_index_pattern/prompts.ts | 36 ++++---- .../select_index_pattern.ts | 2 +- .../nodes/translate_query/translate_query.ts | 5 +- .../translation_result/translation_result.ts | 34 +++---- .../agent/sub_graphs/translate_panel/state.ts | 10 +- .../siem_migrations/rules/task/agent/graph.ts | 11 +-- 10 files changed, 117 insertions(+), 101 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts index 873f802946df4..0387f1a52902c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/graph.ts @@ -9,17 +9,12 @@ import { END, START, StateGraph } from '@langchain/langgraph'; import { getParseOriginalDashboardNode } from './nodes/parse_original_dashboard'; import { migrateDashboardConfigSchema, migrateDashboardState } from './state'; import type { MigrateDashboardGraphParams } from './types'; -import { - fanOutPanelTranslations, - getTranslatePanelNode, -} from './nodes/translate_panel/translate_panel'; +import { getTranslatePanelNode } from './nodes/translate_panel/translate_panel'; import { getAggregateDashboardNode } from './nodes/aggregate_dashboard'; -import { getTranslatePanelGraph } from './sub_graphs/translate_panel'; export function getDashboardMigrationAgent(params: MigrateDashboardGraphParams) { const parseOriginalDashboardNode = getParseOriginalDashboardNode(); - const translatePanelNode = getTranslatePanelNode(params); - const translatePanelSubGraph = getTranslatePanelGraph(params); // only for graph drawing + const translatePanel = getTranslatePanelNode(params); const aggregateDashboardNode = getAggregateDashboardNode(); const siemMigrationAgentGraph = new StateGraph( @@ -28,11 +23,13 @@ export function getDashboardMigrationAgent(params: MigrateDashboardGraphParams) ) // Nodes .addNode('parseOriginalDashboard', parseOriginalDashboardNode) - .addNode('translatePanel', translatePanelNode, { subgraphs: [translatePanelSubGraph] }) + .addNode('translatePanel', translatePanel.node, { subgraphs: [translatePanel.subgraph] }) .addNode('aggregateDashboard', aggregateDashboardNode) // Edges .addEdge(START, 'parseOriginalDashboard') - .addConditionalEdges('parseOriginalDashboard', fanOutPanelTranslations, ['translatePanel']) + .addConditionalEdges('parseOriginalDashboard', translatePanel.conditionalEdge, [ + 'translatePanel', + ]) .addEdge('translatePanel', 'aggregateDashboard') .addEdge('aggregateDashboard', END); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts index 58c198fd94a8b..1f4100444a0e1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/nodes/translate_panel/translate_panel.ts @@ -10,53 +10,61 @@ import type { MigrateDashboardState, TranslatePanelNodeParams } from '../../type import { getTranslatePanelGraph } from '../../sub_graphs/translate_panel'; import type { TranslatePanelGraphParams } from '../../sub_graphs/translate_panel/types'; -export type TranslatePanelNode = ( +export type TranslatePanelNode = (( params: TranslatePanelNodeParams -) => Promise>; +) => Promise>) & { + subgraph?: ReturnType; +}; +export interface TranslatePanel { + node: TranslatePanelNode; + conditionalEdge: (state: MigrateDashboardState) => Send[]; + subgraph: ReturnType; +} // This is a special node, it's goal is to use map-reduce to translate the dashboard panels in parallel. -// - fan-out: the array of parsed_original_panels is split into individual panels for processing via the translatePanelSubGraph -// - fan-in: the results of the individual panel translations are aggregated back into the overall dashboard state via state reducer. // This is the recommended technique at the time of writing this code. LangGraph docs: https://langchain-ai.github.io/langgraphjs/how-tos/map-reduce/. -export const getTranslatePanelNode = (params: TranslatePanelGraphParams): TranslatePanelNode => { +export const getTranslatePanelNode = (params: TranslatePanelGraphParams): TranslatePanel => { const translatePanelSubGraph = getTranslatePanelGraph(params); - return async ({ panel, index }) => { - try { - if (!panel.query) { - throw new Error('Panel query is missing'); + return { + // Fan-in: the results of the individual panel translations are aggregated back into the overall dashboard state via state reducer. + node: async ({ panel, index }) => { + try { + if (!panel.query) { + throw new Error('Panel query is missing'); + } + const output = await translatePanelSubGraph.invoke({ parsed_panel: panel }); + return { + // Fan-in: translated panels are concatenated by the state reducer, so the results can be aggregated later + translated_panels: [ + { + index, + data: output.elastic_panel ?? {}, + translation_result: output.translation_result, + }, + ], + }; + } catch (err) { + // Fan-in: failed panels are concatenated by the state reducer, so the results can be aggregated later + return { + failed_panel_translations: [ + { + index, + error_message: err.toString(), + details: err, + }, + ], + }; } - const output = await translatePanelSubGraph.invoke({ parsed_panel: panel }); - return { - // Fan-in: translated panels are concatenated by the state reducer, so the results can be aggregated later - translated_panels: [ - { - index, - data: output.elastic_panel, - translation_result: output.translation_result, - }, - ], - }; - } catch (err) { - // Fan-in: failed panels are concatenated by the state reducer, so the results can be aggregated later - return { - failed_panel_translations: [ - { - index, - error_message: err.toString(), - details: err, - }, - ], - }; - } + }, + // Fan-out: for each panel, Send translatePanel to be executed in parallel. + // This function needs to be called inside a `conditionalEdge` + conditionalEdge: (state: MigrateDashboardState) => { + const panels = state.parsed_original_dashboard.panels ?? []; + return panels.map((panel, i) => { + const translatePanelParams: TranslatePanelNodeParams = { panel, index: i }; + return new Send('translatePanel', translatePanelParams); + }); + }, + subgraph: translatePanelSubGraph, // Only for the diagram generation }; }; - -// Fan-out: for each panel, Send translatePanel to be executed in parallel. -// This function needs to be called inside a `conditionalEdge` -export const fanOutPanelTranslations = (state: MigrateDashboardState) => { - const panels = state.parsed_original_dashboard.panels ?? []; - return panels.map((panel, i) => { - const params: TranslatePanelNodeParams = { panel, index: i }; - return new Send('translatePanel', params); - }); -}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts index ee59b5241916a..a44015c4a6cf9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/graph.ts @@ -50,6 +50,10 @@ export function getTranslatePanelGraph(params: TranslatePanelGraphParams) { // Edges .addEdge(START, 'inlineQuery') + .addConditionalEdges('inlineQuery', translatableRouter, [ + 'selectIndexPattern', + 'translationResult', + ]) // .addEdge('inlineQuery', 'createDescription') // createDescription would go after inlineQuery .addEdge('inlineQuery', 'selectIndexPattern') // .addEdge('createDescription', 'selectIndexPattern') // And before selectIndexPattern, the description is sent to the selectIndexPattern graph @@ -69,6 +73,13 @@ export function getTranslatePanelGraph(params: TranslatePanelGraphParams) { return graph; } +const translatableRouter = (state: TranslateDashboardPanelState) => { + if (!state.inline_query) { + return 'translationResult'; + } + return 'selectIndexPattern'; +}; + const validationRouter = (state: TranslateDashboardPanelState) => { if ( state.validation_errors.iterations <= MAX_VALIDATION_ITERATIONS && diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts index 2552aff75a021..1ae49801632db 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/ecs_mapping/ecs_mapping.ts @@ -18,7 +18,7 @@ export const getEcsMappingNode = (params: GetConvertEsqlSchemaCisToEcsParams): G title: state.parsed_panel.title ?? '', description: state.description ?? '', query: state.esql_query ?? '', - originalQuery: state.inline_query, + originalQuery: state.inline_query ?? '', }); // Set includes_ecs_mapping to indicate that this node has been executed to ensure it only runs once diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts index e3dda9015941f..fcdb2f9dad5c3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/prompts.ts @@ -8,22 +8,26 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; export const SELECT_INDEX_PATTERN_PROMPT = ChatPromptTemplate.fromTemplate( - `You are a helpful assistant that helps translating queries from Splunk to Elastic. -Your task is to find the best and more specific index pattern in the current environment for the Splunk query in the context. -Respond only with the index pattern string, it will be used later in the translated Elastic query. - -Read the context carefully and select the most appropriate index pattern from the list of available index patterns. - - - -{title} - - -{description} - - -{query} - + `You are a cybersecurity expert familiar with both Splunk and Elasticsearch. + +Your task is: + +- Analyze the provided Splunk query and its context. + +- Determine the most specific Elastic index pattern that would support this query in the current Elastic cluster. + +Instructions: + +- Respond only with the index pattern string. + +- Do not add explanations, comments, or extra formatting. + +- Prioritize specificity: choose the narrowest index pattern that captures the relevant data. + + + {title} + {description} + {query} ` ); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts index 5a8415cc0c1f9..cfcd962aa1241 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/select_index_pattern/select_index_pattern.ts @@ -29,7 +29,7 @@ export const getSelectIndexPatternNode = (params: GetSelectIndexPatternParams): return async (state, config) => { const selectIndexPatternGraph = await selectIndexPatternGraphPromise; // This will only be awaited the first time the node is executed - if (!state.esql_query) { + if (!state.inline_query) { return { index_pattern: '[indexPattern]' }; } const question = await SELECT_INDEX_PATTERN_PROMPT.format({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts index 18d7b5091c3ee..4a3b72a637345 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translate_query/translate_query.ts @@ -15,9 +15,12 @@ import type { GraphNode } from '../../types'; export const getTranslateQueryNode = (params: GetTranslateSplToEsqlParams): GraphNode => { const translateSplToEsql = getTranslateSplToEsql(params); return async (state) => { + if (!state.inline_query) { + return {}; + } const { esqlQuery, comments } = await translateSplToEsql({ title: state.parsed_panel.title, - description: state.description, + description: state.description ?? '', taskDescription: TASK_DESCRIPTION.migrate_dashboard, inlineQuery: state.inline_query, indexPattern: state.index_pattern || '[indexPattern]', diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts index f3ec1babca71c..35884e7a009cc 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/nodes/translation_result/translation_result.ts @@ -13,6 +13,23 @@ import { processPanel } from './process_panel'; export const getTranslationResultNode = (): GraphNode => { return async (state) => { + const query = state.esql_query; + if (!query) { + return { translation_result: MigrationTranslationResult.UNTRANSLATABLE }; + } + + let translationResult; + // TODO: use placeholder constant + if (query.startsWith('FROM [indexPattern]')) { + translationResult = MigrationTranslationResult.PARTIAL; + } else if (state.validation_errors?.esql_errors) { + translationResult = MigrationTranslationResult.PARTIAL; + } else if (query.match(/\[(macro|lookup):.*?\]/)) { + translationResult = MigrationTranslationResult.PARTIAL; + } else { + translationResult = MigrationTranslationResult.FULL; + } + const vizType = state.parsed_panel?.viz_type; let panel: object; try { @@ -34,23 +51,6 @@ export const getTranslationResultNode = (): GraphNode => { translation_result: MigrationTranslationResult.UNTRANSLATABLE, }; } - const query = state.esql_query; - - let translationResult; - - if (!query) { - translationResult = MigrationTranslationResult.UNTRANSLATABLE; - } else { - if (query.startsWith('FROM [indexPattern]')) { - translationResult = MigrationTranslationResult.PARTIAL; - } else if (state.validation_errors?.esql_errors) { - translationResult = MigrationTranslationResult.PARTIAL; - } else if (query.match(/\[(macro|lookup):.*?\]/)) { - translationResult = MigrationTranslationResult.PARTIAL; - } else { - translationResult = MigrationTranslationResult.FULL; - } - } const panelJSON = processPanel(panel, query, state.parsed_panel); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts index bdad7ef501c58..7ec982823b507 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/dashboards/task/agent/sub_graphs/translate_panel/state.ts @@ -14,16 +14,16 @@ import type { ParsedPanel } from '../../../../lib/parsers/types'; export const translateDashboardPanelState = Annotation.Root({ parsed_panel: Annotation(), - elastic_panel: Annotation(), // The visualization panel object - index_pattern: Annotation(), + elastic_panel: Annotation(), // The visualization panel object + index_pattern: Annotation(), resources: Annotation(), includes_ecs_mapping: Annotation({ reducer: (current, value) => value ?? current, default: () => false, }), - inline_query: Annotation(), - description: Annotation(), - esql_query: Annotation(), + inline_query: Annotation(), + description: Annotation(), + esql_query: Annotation(), validation_errors: Annotation({ reducer: (current, value) => value ?? current, default: () => ({ iterations: 0 }), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts index 3f981e3f48cd5..ddd8c49cea1c8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts @@ -10,11 +10,7 @@ import { getCreateSemanticQueryNode } from './nodes/create_semantic_query'; import { getMatchPrebuiltRuleNode } from './nodes/match_prebuilt_rule'; import { migrateRuleConfigSchema, migrateRuleState } from './state'; import { getTranslateRuleGraph } from './sub_graphs/translate_rule'; -import type { - MigrateRuleConfig, - MigrateRuleGraphParams, - MigrateRuleState, -} from './types'; +import type { MigrateRuleConfig, MigrateRuleGraphParams, MigrateRuleState } from './types'; export function getRuleMigrationAgent({ model, @@ -61,10 +57,7 @@ export function getRuleMigrationAgent({ return graph; } -const skipPrebuiltRuleConditional = ( - _state: MigrateRuleState, - config: MigrateRuleConfig -) => { +const skipPrebuiltRuleConditional = (_state: MigrateRuleState, config: MigrateRuleConfig) => { if (config.configurable?.skipPrebuiltRulesMatching) { return 'translationSubGraph'; }