From 681d40eee64316bea85d04077f918bd7121988b9 Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Tue, 24 Dec 2024 05:49:10 -0500 Subject: [PATCH] [Security Solution] [Attack discovery] Alerts filtering (#205070) ## [Security Solution] [Attack discovery] Alerts filtering ![00_alerts_filtering](https://github.com/user-attachments/assets/1a81413b-b8f4-4965-a006-25fb529668a6) This PR enhances _Attack discovery_ by providing users additional control over which alerts are included as context to the large language model (LLM). Using the new resizeable _Attack discovery settings flyout_, users may: - Filter alerts via a search bar and filters - Control the time window (previously fixed to `Last 24 hrs`) ### Before (feature flag disabled) Previously, users could only set the number of alerts sent as context to the LLM via a modal: ![01_before_full_page](https://github.com/user-attachments/assets/65eaf604-3bdf-41bd-a726-f03ba5d630d5) ### After (feature flag enabled) The new Attack discovery settings flyout replaces the modal: ![02_alert_summary_full_page](https://github.com/user-attachments/assets/613c292b-c6ec-4dc6-aea3-b2eddbacd614) It has two tabs, _Alert summary_ and _Alerts preview_. ### Alert summary The _Alert summary_ Lens embeddable counts the selected field name via an ES|QL query: ![03_alert_summary_cropped](https://github.com/user-attachments/assets/6f5de0e4-3da6-4937-a3cd-9a0f80df16b6) The Alert summary query is an aggregation. It does NOT display the details of individual alerts. ### Alerts preview The _Alerts preview_ Lens embeddable shows a preview of the actual alerts that will be sent as context via an ES|QL query: ![05_alerts_preview_cropped](https://github.com/user-attachments/assets/6db23931-3fe6-46d2-8b9a-6cc7a9d8720c) Users may resize the settings flyout to view all the fields in the Alerts preview. ### Feature flag Enable the `attackDiscoveryAlertFiltering` feature flag via the following setting in `kibana.dev.yml`: ```yaml xpack.securitySolution.enableExperimental: - 'attackDiscoveryAlertFiltering' ``` Enabling the feature flag: - Replaces the `Settings` modal with the `Attack discovery settings` flyout - Includes additional `start`, `end`, and `filters` parameters in requests to generate Attack discoveries - Enables new loading messages ### Details #### Loading messages The loading messages displayed when generating Attack discoveries were updated to render three types of date ranges: 1) The default date range (`Last 24 hours`), which displays the same message seen in previous versions: ![06_loading_default_date_range](https://github.com/user-attachments/assets/b376a87c-b4b8-42d8-bcbf-ddf79cc82800) 2) Relative date ranges: ![07_loading_relative_date_range](https://github.com/user-attachments/assets/d0b6bddd-7722-4181-a99c-7450d07a6624) 3) Absolute date ranges: ![08_loading_absolute_date_range](https://github.com/user-attachments/assets/a542a921-eeaa-4ced-9568-25e63a47d42d) #### Filtering preferences Alert filtering preferences are stored in local storage. This PR adds the following new local storage keys: ``` elasticAssistantDefault.attackDiscovery.default.end elasticAssistantDefault.attackDiscovery.default.filters elasticAssistantDefault.attackDiscovery.default.query elasticAssistantDefault.attackDiscovery.default.start ``` Users may use the `Reset` button in the Attack discovery settings flyout to restore the above to their defaults. #### Known limitations The following known limitations in this PR may be mitigated in follow-up PRs: #### Table cell hover actions are disabled Table cell actions, i.e. `Filter for` and `Filter out` are disabled in the `Alert summary` and `Alerts preview` tables. The actions are disabled because custom cell hover actions registered in `x-pack/solutions/security/plugins/security_solution/public/app/actions/register.ts` do NOT appear to receive field metadata (i.e. the name of the field being hovered over) when the action is triggered. This limitation also appears to apply to ad hoc ES|QL visualizations created via Lens in Kibana's _Dashboard_ app. ##### Default table sort indicators are hidden The `Alert summary` and `Alerts preview` tables are sorted descending by Count, and Risk score, respectively, via their ES|QL queries. The tables _should_ display default sort indicators, as illustrated by the screenshots below: ![09_alert_summary_with_sort_indicator](https://github.com/user-attachments/assets/c4e78144-f516-40f8-b6da-7c8c808841c4) ![10_alerts_preview_with_sort_indicator](https://github.com/user-attachments/assets/c0061134-4734-462f-8eb0-978b2b02fb1e) The default indicators are hidden in this PR as a workaround for an error that occurs in `EuiDataGrid` when switching tabs when the column sort indicators are enabled: ``` TypeError: Cannot read properties of undefined (reading 'split') ``` To re-enable the sort indicators, `DEFAULT_ALERT_SUMMARY_SORT` and `DEFAULT_ALERTS_PREVIEW_SORT` must respectively be passed as the `sorting` prop to the `PreviewTab` in `x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx`, as illustrated by the following code: ```typescript ``` ##### Selected date range not persisted The `start` and `end` date range selected when a user starts generation are not (yet) persisted in Elasticsearch. As a result, the loading message always displays the currently configured range, rather than the range selected at the start of generation. --- .../index.test.ts | 1 + .../index.ts | 41 +++- .../impl/capabilities/index.ts | 1 + .../post_attack_discovery_route.gen.ts | 3 + .../post_attack_discovery_route.schema.yaml | 7 + .../get_capabilities_route.gen.ts | 1 + .../get_capabilities_route.schema.yaml | 3 + .../kbn-elastic-assistant-common/index.ts | 7 + .../impl/assistant_context/constants.tsx | 4 + .../shared/kbn-elastic-assistant/index.ts | 8 + .../default_attack_discovery_graph/index.ts | 8 +- .../anonymized_alerts_retriever/index.ts | 17 +- .../helpers/get_anonymized_alerts/index.ts | 9 + .../nodes/retriever/index.ts | 24 +- .../state/index.ts | 24 +- .../default_attack_discovery_graph/types.ts | 3 + .../invoke_attack_discovery_graph/index.tsx | 9 + .../post/post_attack_discovery.ts | 6 + .../common/experimental_features.ts | 5 + .../pages/header/index.test.tsx | 10 +- .../attack_discovery/pages/header/index.tsx | 60 ++++- .../pages/header/settings_modal/index.tsx | 2 +- .../pages/header/translations.ts | 7 + .../public/attack_discovery/pages/helpers.ts | 7 + .../public/attack_discovery/pages/index.tsx | 137 ++++++++++- .../pages/loading_callout/index.tsx | 10 +- .../get_formatted_time/index.ts | 30 +++ .../get_loading_message/index.ts | 36 +++ .../loading_messages/index.tsx | 38 ++- .../loading_messages/translations.ts | 64 ++++++ .../pages/loading_callout/translations.ts | 32 +++ .../deserialize_filters/index.ts | 34 +++ .../local_storage/deserialize_query/index.ts | 24 ++ .../empty_states/no_alerts/translations.ts | 2 +- .../alert_selection_query/index.tsx | 198 ++++++++++++++++ .../alert_selection_range/index.tsx | 71 ++++++ .../get_esql_keep_statement/index.ts | 17 ++ .../get_alert_summary_esql_query/index.ts | 25 ++ .../index.ts | 88 +++++++ .../get_esql_keep_statement/index.ts | 22 ++ .../get_alerts_preview_esql_query/index.ts | 23 ++ .../index.ts | 120 ++++++++++ .../helpers/get_common_time_ranges/index.ts | 37 +++ .../get_common_time_ranges/translations.ts | 78 +++++++ .../helpers/get_first_column_name/index.ts | 9 + .../helpers/get_max_alerts/index.ts | 20 ++ .../helpers/get_tabs/index.tsx | 109 +++++++++ .../settings_flyout/alert_selection/index.tsx | 138 +++++++++++ .../alert_selection/preview_tab/index.tsx | 217 ++++++++++++++++++ .../alert_selection/translations.ts | 81 +++++++ .../settings_flyout/alert_selection/types.ts | 11 + .../alert_selection/use_data_view/index.ts | 51 ++++ .../settings_flyout/alerts_preview/index.tsx | 54 +++++ .../settings_flyout/footer/index.test.tsx | 42 ++++ .../pages/settings_flyout/footer/index.tsx | 62 +++++ .../pages/settings_flyout/index.tsx | 159 +++++++++++++ .../parse_filter_query/index.ts | 38 +++ .../pages/settings_flyout/translations.ts | 15 ++ .../pages/use_attack_discovery/index.test.tsx | 5 +- .../pages/use_attack_discovery/index.tsx | 92 +++++--- .../security_solution/server/plugin.ts | 1 + 61 files changed, 2380 insertions(+), 77 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_formatted_time/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_message/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/translations.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_filters/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_query/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/get_esql_keep_statement/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_lens_attributes/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/get_esql_keep_statement/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_lens_attributes/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/translations.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_first_column_name/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_max_alerts/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/preview_tab/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/translations.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/types.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/use_data_view/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alerts_preview/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/footer/index.test.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/footer/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/parse_filter_query/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/translations.ts diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts index 975896f381443..022663e11d97d 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts @@ -13,6 +13,7 @@ describe('getOpenAndAcknowledgedAlertsQuery', () => { const anonymizationFields = [ { id: 'field1', field: 'field1', allowed: true, anonymized: false }, { id: 'field2', field: 'field2', allowed: true, anonymized: false }, + { id: 'field3', field: 'field3', allowed: false, anonymized: false }, ]; const size = 10; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts index 6f6e196053ca6..c275b9e640c3f 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts @@ -7,6 +7,34 @@ import type { AnonymizationFieldResponse } from '../../schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +export const DEFAULT_END = 'now'; +export const DEFAULT_START = 'now-24h'; + +interface GetOpenAndAcknowledgedAlertsQuery { + allow_no_indices: boolean; + body: { + fields: Array<{ + field: string; + include_unmapped: boolean; + }>; + query: { + bool: { + filter: Array>; + }; + }; + runtime_mappings: Record; + size: number; + sort: Array<{ + [key: string]: { + order: string; + }; + }>; + _source: boolean; + }; + ignore_unavailable: boolean; + index: string[]; +} + /** * This query returns open and acknowledged (non-building block) alerts in the last 24 hours. * @@ -15,12 +43,18 @@ import type { AnonymizationFieldResponse } from '../../schemas/anonymization_fie export const getOpenAndAcknowledgedAlertsQuery = ({ alertsIndexPattern, anonymizationFields, + end, + filter, size, + start, }: { alertsIndexPattern: string; anonymizationFields: AnonymizationFieldResponse[]; + end?: string | null; + filter?: Record | null; size: number; -}) => ({ + start?: string | null; +}): GetOpenAndAcknowledgedAlertsQuery => ({ allow_no_indices: true, body: { fields: anonymizationFields @@ -53,11 +87,12 @@ export const getOpenAndAcknowledgedAlertsQuery = ({ minimum_should_match: 1, }, }, + ...(filter != null ? [filter] : []), { range: { '@timestamp': { - gte: 'now-24h', - lte: 'now', + gte: start != null ? start : DEFAULT_START, + lte: end != null ? end : DEFAULT_END, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/capabilities/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/capabilities/index.ts index 0e204b4b949ea..35f4b88ef7174 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/capabilities/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/capabilities/index.ts @@ -20,5 +20,6 @@ export type AssistantFeatureKey = keyof AssistantFeatures; */ export const defaultAssistantFeatures = Object.freeze({ assistantModelEvaluation: false, + attackDiscoveryAlertFiltering: false, defendInsights: false, }); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.gen.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.gen.ts index e080be1138eb3..a3293ee255dbe 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.gen.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.gen.ts @@ -28,11 +28,14 @@ export const AttackDiscoveryPostRequestBody = z.object({ * LLM API configuration. */ apiConfig: ApiConfig, + end: z.string().optional(), + filter: z.object({}).catchall(z.unknown()).optional(), langSmithProject: z.string().optional(), langSmithApiKey: z.string().optional(), model: z.string().optional(), replacements: Replacements.optional(), size: z.number(), + start: z.string().optional(), subAction: z.enum(['invokeAI', 'invokeStream']), }); export type AttackDiscoveryPostRequestBodyInput = z.input; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.schema.yaml b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.schema.yaml index e613fbf841a21..ba85d4e31615b 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.schema.yaml +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/post_attack_discovery_route.schema.yaml @@ -38,6 +38,11 @@ paths: apiConfig: $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig' description: LLM API configuration. + end: + type: string + filter: + type: object + additionalProperties: true langSmithProject: type: string langSmithApiKey: @@ -48,6 +53,8 @@ paths: $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements' size: type: number + start: + type: string subAction: type: string enum: diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts index 8777e8d728279..5fc79ab1ce132 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts @@ -19,5 +19,6 @@ import { z } from '@kbn/zod'; export type GetCapabilitiesResponse = z.infer; export const GetCapabilitiesResponse = z.object({ assistantModelEvaluation: z.boolean(), + attackDiscoveryAlertFiltering: z.boolean(), defendInsights: z.boolean(), }); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml index e9b6ca9697256..e1861f01a4d93 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml @@ -22,10 +22,13 @@ paths: properties: assistantModelEvaluation: type: boolean + attackDiscoveryAlertFiltering: + type: boolean defendInsights: type: boolean required: - assistantModelEvaluation + - attackDiscoveryAlertFiltering - defendInsights '400': description: Generic Error diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/index.ts index 41ed86dacd9db..19221ee4c27ac 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/index.ts @@ -41,3 +41,10 @@ export { getRawDataOrDefault } from './impl/alerts/helpers/get_raw_data_or_defau /** Return true if the provided size is out of range */ export { sizeIsOutOfRange } from './impl/alerts/helpers/size_is_out_of_range'; + +export { + /** The default (relative) end of the date range (i.e. `now`) */ + DEFAULT_END, + /** The default (relative) start of the date range (i.e. `now-24h`) */ + DEFAULT_START, +} from './impl/alerts/get_open_and_acknowledged_alerts_query'; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/constants.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/constants.tsx index 63c8adf37e5b8..c8b08f90f14bc 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/constants.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/constants.tsx @@ -10,10 +10,14 @@ import { KnowledgeBaseConfig } from '../assistant/types'; export const ATTACK_DISCOVERY_STORAGE_KEY = 'attackDiscovery'; export const DEFEND_INSIGHTS_STORAGE_KEY = 'defendInsights'; export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault'; +export const END_LOCAL_STORAGE_KEY = 'end'; export const LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY = 'lastConversationId'; +export const FILTERS_LOCAL_STORAGE_KEY = 'filters'; export const MAX_ALERTS_LOCAL_STORAGE_KEY = 'maxAlerts'; export const KNOWLEDGE_BASE_LOCAL_STORAGE_KEY = 'knowledgeBase'; +export const QUERY_LOCAL_STORAGE_KEY = 'query'; export const SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY = 'showSettingsTour'; +export const START_LOCAL_STORAGE_KEY = 'start'; export const STREAMING_LOCAL_STORAGE_KEY = 'streaming'; export const TRACE_OPTIONS_SESSION_STORAGE_KEY = 'traceOptions'; export const CONVERSATION_TABLE_SESSION_STORAGE_KEY = 'conversationTable'; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts index 97cd680fae3af..5b9beab5467d7 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts @@ -84,11 +84,19 @@ export { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, DEFAULT_LATEST_ALERTS, DEFEND_INSIGHTS_STORAGE_KEY, + /** The end of the date range of alerts, sent as context to the LLM */ + END_LOCAL_STORAGE_KEY, + /** Search bar filters that apply to the alerts sent as context to the LLM */ + FILTERS_LOCAL_STORAGE_KEY, KNOWLEDGE_BASE_LOCAL_STORAGE_KEY, /** The local storage key that specifies the maximum number of alerts to send as context */ MAX_ALERTS_LOCAL_STORAGE_KEY, + /** Search bar query that apply to the alerts sent as context to the LLM */ + QUERY_LOCAL_STORAGE_KEY, /** The local storage key that specifies whether the settings tour should be shown */ SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY, + /** The start of the date range of alerts, sent as context to the LLM */ + START_LOCAL_STORAGE_KEY, } from './impl/assistant_context/constants'; export { useLoadConnectors } from './impl/connectorland/use_load_connectors'; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts index 14794ab893623..5b8b12656f469 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts @@ -26,12 +26,15 @@ import type { GraphState } from './types'; export interface GetDefaultAttackDiscoveryGraphParams { alertsIndexPattern?: string; anonymizationFields: AnonymizationFieldResponse[]; + end?: string; esClient: ElasticsearchClient; + filter?: Record; llm: ActionsClientLlm; logger?: Logger; onNewReplacements?: (replacements: Replacements) => void; replacements?: Replacements; size: number; + start?: string; } export type DefaultAttackDiscoveryGraph = ReturnType; @@ -46,19 +49,22 @@ export type DefaultAttackDiscoveryGraph = ReturnType, 'generate' | 'refine' | 'retrieve_anonymized_alerts' | '__start__' > => { try { - const graphState = getDefaultGraphState(); + const graphState = getDefaultGraphState({ end, filter, start }); // get nodes: const retrieveAnonymizedAlertsNode = getRetrieveAnonymizedAlertsNode({ diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts index 3a8b7ed3a6b94..b7863f2e150fd 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts @@ -21,36 +21,48 @@ export class AnonymizedAlertsRetriever extends BaseRetriever { #alertsIndexPattern?: string; #anonymizationFields?: AnonymizationFieldResponse[]; + #end?: string | null; #esClient: ElasticsearchClient; + #filter?: Record | null; #onNewReplacements?: (newReplacements: Replacements) => void; #replacements?: Replacements; #size?: number; + #start?: string | null; constructor({ alertsIndexPattern, anonymizationFields, fields, + end, esClient, + filter, onNewReplacements, replacements, size, + start, }: { alertsIndexPattern?: string; anonymizationFields?: AnonymizationFieldResponse[]; - fields?: CustomRetrieverInput; + end?: string | null; esClient: ElasticsearchClient; + fields?: CustomRetrieverInput; + filter?: Record | null; onNewReplacements?: (newReplacements: Replacements) => void; replacements?: Replacements; size?: number; + start?: string | null; }) { super(fields); this.#alertsIndexPattern = alertsIndexPattern; this.#anonymizationFields = anonymizationFields; + this.#end = end; this.#esClient = esClient; + this.#filter = filter; this.#onNewReplacements = onNewReplacements; this.#replacements = replacements; this.#size = size; + this.#start = start; } async _getRelevantDocuments( @@ -60,10 +72,13 @@ export class AnonymizedAlertsRetriever extends BaseRetriever { const anonymizedAlerts = await getAnonymizedAlerts({ alertsIndexPattern: this.#alertsIndexPattern, anonymizationFields: this.#anonymizationFields, + end: this.#end, esClient: this.#esClient, + filter: this.#filter, onNewReplacements: this.#onNewReplacements, replacements: this.#replacements, size: this.#size, + start: this.#start, }); return anonymizedAlerts.map((alert) => ({ diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts index bc2a7f5bf9e71..5e4d88c5a0dce 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts @@ -21,17 +21,23 @@ import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/s export const getAnonymizedAlerts = async ({ alertsIndexPattern, anonymizationFields, + end, esClient, + filter, onNewReplacements, replacements, size, + start, }: { alertsIndexPattern?: string; anonymizationFields?: AnonymizationFieldResponse[]; + end?: string | null; esClient: ElasticsearchClient; + filter?: Record | null; onNewReplacements?: (replacements: Replacements) => void; replacements?: Replacements; size?: number; + start?: string | null; }): Promise => { if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) { return []; @@ -40,7 +46,10 @@ export const getAnonymizedAlerts = async ({ const query = getOpenAndAcknowledgedAlertsQuery({ alertsIndexPattern, anonymizationFields: anonymizationFields ?? [], + end, + filter, size, + start, }); const result = await esClient.search(query); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts index a5d31fa14770a..eedf725ab754f 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts @@ -36,17 +36,23 @@ export const getRetrieveAnonymizedAlertsNode = ({ onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements }; - const retriever = new AnonymizedAlertsRetriever({ - alertsIndexPattern, - anonymizationFields, - esClient, - onNewReplacements: localOnNewReplacements, - replacements, - size, - }); - const retrieveAnonymizedAlerts = async (state: GraphState): Promise => { logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS---'); + + const { end, filter, start } = state; + + const retriever = new AnonymizedAlertsRetriever({ + alertsIndexPattern, + anonymizationFields, + end, + esClient, + filter, + onNewReplacements: localOnNewReplacements, + replacements, + size, + start, + }); + const documents = await retriever .withConfig({ runName: 'runAnonymizedAlertsRetriever' }) .invoke(''); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts index 4229155cc2e25..8a31dbeb4f57f 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts @@ -18,7 +18,17 @@ import { getDefaultAttackDiscoveryPrompt } from '../nodes/helpers/get_default_at import { getDefaultRefinePrompt } from '../nodes/refine/helpers/get_default_refine_prompt'; import type { GraphState } from '../types'; -export const getDefaultGraphState = (): StateGraphArgs['channels'] => ({ +export interface Options { + end?: string; + filter?: Record | null; + start?: string; +} + +export const getDefaultGraphState = ({ + end, + filter, + start, +}: Options | undefined = {}): StateGraphArgs['channels'] => ({ attackDiscoveries: { value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, default: () => null, @@ -39,10 +49,18 @@ export const getDefaultGraphState = (): StateGraphArgs['channels'] = value: (x: string, y?: string) => y ?? x, default: () => '', }, + end: { + value: (x?: string | null, y?: string | null) => y ?? x, + default: () => end, + }, errors: { value: (x: string[], y?: string[]) => y ?? x, default: () => [], }, + filter: { + value: (x?: Record | null, y?: Record | null) => y ?? x, + default: () => filter, + }, generationAttempts: { value: (x: number, y?: number) => y ?? x, default: () => 0, @@ -79,6 +97,10 @@ export const getDefaultGraphState = (): StateGraphArgs['channels'] = value: (x: Replacements, y?: Replacements) => y ?? x, default: () => ({}), }, + start: { + value: (x?: string | null, y?: string | null) => y ?? x, + default: () => start, + }, unrefinedResults: { value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, default: () => null, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts index b4473a02b82ae..8df901f927ade 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts @@ -14,7 +14,9 @@ export interface GraphState { anonymizedAlerts: Document[]; combinedGenerations: string; combinedRefinements: string; + end?: string | null; errors: string[]; + filter?: Record | null; generationAttempts: number; generations: string[]; hallucinationFailures: number; @@ -24,5 +26,6 @@ export interface GraphState { refinements: string[]; refinePrompt: string; replacements: Replacements; + start?: string | null; unrefinedResults: AttackDiscovery[] | null; } diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx index 8a8c49f796500..4668d69d1d1f7 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx @@ -30,25 +30,31 @@ export const invokeAttackDiscoveryGraph = async ({ anonymizationFields, apiConfig, connectorTimeout, + end, esClient, + filter, langSmithProject, langSmithApiKey, latestReplacements, logger, onNewReplacements, size, + start, }: { actionsClient: PublicMethodsOf; alertsIndexPattern: string; anonymizationFields: AnonymizationFieldResponse[]; apiConfig: ApiConfig; connectorTimeout: number; + end?: string; esClient: ElasticsearchClient; + filter?: Record; langSmithProject?: string; langSmithApiKey?: string; latestReplacements: Replacements; logger: Logger; onNewReplacements: (newReplacements: Replacements) => void; + start?: string; size: number; }): Promise<{ anonymizedAlerts: Document[]; @@ -86,12 +92,15 @@ export const invokeAttackDiscoveryGraph = async ({ const graph = getDefaultAttackDiscoveryGraph({ alertsIndexPattern, anonymizationFields, + end, esClient, + filter, llm, logger, onNewReplacements, replacements: latestReplacements, size, + start, }); logger?.debug(() => 'invokeAttackDiscoveryGraph: invoking the Attack discovery graph'); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts index ff4764ab04834..717d4d5dbf72f 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts @@ -91,10 +91,13 @@ export const postAttackDiscoveryRoute = ( const { apiConfig, anonymizationFields, + end, + filter, langSmithApiKey, langSmithProject, replacements, size, + start, } = request.body; if ( @@ -133,13 +136,16 @@ export const postAttackDiscoveryRoute = ( anonymizationFields, apiConfig, connectorTimeout: CONNECTOR_TIMEOUT, + end, esClient, + filter, langSmithProject, langSmithApiKey, latestReplacements, logger, onNewReplacements, size, + start, }) .then(({ anonymizedAlerts, attackDiscoveries }) => updateAttackDiscoveries({ diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index 56f4657500c45..94ffd959b680f 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -114,6 +114,11 @@ export const allowedExperimentalValues = Object.freeze({ */ assistantModelEvaluation: false, + /** + * Enables filtering of Attack Discovery alerts in a flyout + */ + attackDiscoveryAlertFiltering: false, + /** * Enables the Managed User section inside the new user details flyout. */ diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx index 7b0688eadafef..52a0c52304531 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx @@ -9,13 +9,13 @@ import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { Header } from '.'; import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; import { TestProviders } from '../../../common/mock'; +import { Header } from '.'; jest.mock('../../../assistant/use_assistant_availability'); -describe('Header', () => { +describe('Actions', () => { beforeEach(() => { (useAssistantAvailability as jest.Mock).mockReturnValue({ hasAssistantPrivilege: true, @@ -36,6 +36,7 @@ describe('Header', () => { onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} + openFlyout={jest.fn()} setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> @@ -61,6 +62,7 @@ describe('Header', () => { onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} + openFlyout={jest.fn()} setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> @@ -86,6 +88,7 @@ describe('Header', () => { onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={onGenerate} + openFlyout={jest.fn()} setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> @@ -113,6 +116,7 @@ describe('Header', () => { onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + openFlyout={jest.fn()} setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> @@ -139,6 +143,7 @@ describe('Header', () => { onCancel={onCancel} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + openFlyout={jest.fn()} setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> @@ -165,6 +170,7 @@ describe('Header', () => { onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + openFlyout={jest.fn()} setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx index ff170805670a6..46019520401e5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/header/index.tsx @@ -6,9 +6,16 @@ */ import type { EuiButtonProps } from '@elastic/eui'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; import { css } from '@emotion/react'; -import { ConnectorSelectorInline } from '@kbn/elastic-assistant'; +import { ConnectorSelectorInline, useAssistantContext } from '@kbn/elastic-assistant'; import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -26,6 +33,7 @@ interface Props { onGenerate: () => void; onCancel: () => void; onConnectorIdSelected: (connectorId: string) => void; + openFlyout: () => void; setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch>; stats: AttackDiscoveryStats | null; } @@ -39,9 +47,14 @@ const HeaderComponent: React.FC = ({ onGenerate, onConnectorIdSelected, onCancel, + openFlyout, setLocalStorageAttackDiscoveryMaxAlerts, stats, }) => { + const { + assistantFeatures: { attackDiscoveryAlertFiltering }, + } = useAssistantContext(); + const { euiTheme } = useEuiTheme(); const disabled = connectorId == null; @@ -78,23 +91,20 @@ const HeaderComponent: React.FC = ({ - - - {connectorsAreConfigured && ( - + = ({ )} + + {attackDiscoveryAlertFiltering ? ( + + + + ) : ( + + )} + + index < 3; interface ErrorWithStringMessage { @@ -130,3 +132,8 @@ export const getSize = ({ return isNaN(size) || size <= 0 ? defaultMaxAlerts : size; }; + +export const getDefaultQuery = (): Query => ({ + language: 'kuery', + query: '', +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx index 26738eac2dffe..affb0d4588301 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -6,41 +6,102 @@ */ import { EuiEmptyPrompt, EuiLoadingLogo, EuiSpacer } from '@elastic/eui'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; +import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common'; import { css } from '@emotion/react'; import { ATTACK_DISCOVERY_STORAGE_KEY, DEFAULT_ASSISTANT_NAMESPACE, DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + END_LOCAL_STORAGE_KEY, + FILTERS_LOCAL_STORAGE_KEY, MAX_ALERTS_LOCAL_STORAGE_KEY, + QUERY_LOCAL_STORAGE_KEY, + START_LOCAL_STORAGE_KEY, useAssistantContext, useLoadConnectors, } from '@kbn/elastic-assistant'; import type { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; +import type { Filter, Query } from '@kbn/es-query'; import { uniq } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import { SecurityPageName } from '../../../common/constants'; import { HeaderPage } from '../../common/components/header_page'; +import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query'; import { useSpaceId } from '../../common/hooks/use_space_id'; +import { useKibana } from '../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../common/lib/kuery'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { Header } from './header'; -import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getSize, showLoading } from './helpers'; +import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getDefaultQuery, getSize, showLoading } from './helpers'; import { LoadingCallout } from './loading_callout'; +import { deserializeQuery } from './local_storage/deserialize_query'; +import { deserializeFilters } from './local_storage/deserialize_filters'; import { PageTitle } from './page_title'; import { Results } from './results'; +import { SettingsFlyout } from './settings_flyout'; +import { parseFilterQuery } from './settings_flyout/parse_filter_query'; +import { useSourcererDataView } from '../../sourcerer/containers'; import { useAttackDiscovery } from './use_attack_discovery'; +export const ID = 'attackDiscoveryQuery'; + const AttackDiscoveryPageComponent: React.FC = () => { + const { + services: { uiSettings }, + } = useKibana(); + const spaceId = useSpaceId() ?? 'default'; - const { http } = useAssistantContext(); + const { + assistantFeatures: { attackDiscoveryAlertFiltering }, + http, + } = useAssistantContext(); const { data: aiConnectors } = useLoadConnectors({ http, }); // for showing / hiding anonymized data: const [showAnonymized, setShowAnonymized] = useState(false); + + // showing / hiding the flyout: + const [showFlyout, setShowFlyout] = useState(false); + const openFlyout = useCallback(() => setShowFlyout(true), []); + + // time selection: + const [start, setStart] = useLocalStorage( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${START_LOCAL_STORAGE_KEY}`, + DEFAULT_START + ); + const [end, setEnd] = useLocalStorage( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${END_LOCAL_STORAGE_KEY}`, + DEFAULT_END + ); + + // search bar query: + const [query, setQuery] = useLocalStorage( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${QUERY_LOCAL_STORAGE_KEY}`, + getDefaultQuery(), + { + raw: false, + serializer: (value: Query) => JSON.stringify(value), + deserializer: deserializeQuery, + } + ); + + // search bar filters: + const [filters, setFilters] = useLocalStorage( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${FILTERS_LOCAL_STORAGE_KEY}`, + [], + { + raw: false, + serializer: (value: Filter[]) => JSON.stringify(value), + deserializer: deserializeFilters, + } + ); + const onToggleShowAnonymized = useCallback(() => setShowAnonymized((current) => !current), []); // get the last selected connector ID from local storage: @@ -123,7 +184,51 @@ const AttackDiscoveryPageComponent: React.FC = () => { const pageTitle = useMemo(() => , []); - const onGenerate = useCallback(async () => fetchAttackDiscoveries(), [fetchAttackDiscoveries]); + const { sourcererDataView } = useSourcererDataView(); + + // filterQuery is the combined search bar query and filters in ES format: + const [filterQuery, kqlError] = useMemo( + () => + convertToBuildEsQuery({ + config: getEsQueryConfig(uiSettings), + dataViewSpec: sourcererDataView, + queries: [query ?? getDefaultQuery()], // <-- search bar query + filters: filters ?? [], // <-- search bar filters + }), + [filters, query, sourcererDataView, uiSettings] + ); + + // renders a toast if the filter query is invalid: + useInvalidFilterQuery({ + id: ID, + filterQuery, + kqlError, + query, + startDate: start, + endDate: end, + }); + + const onGenerate = useCallback(async () => { + const size = alertsContextCount ?? DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS; + const filter = parseFilterQuery({ filterQuery, kqlError }); + + return attackDiscoveryAlertFiltering // feature flag enabled? + ? fetchAttackDiscoveries({ + end, + filter, // <-- combined search bar query and filters + size, + start, + }) + : fetchAttackDiscoveries({ size }); // <-- NO filtering / time ranges, feature flag is off + }, [ + alertsContextCount, + attackDiscoveryAlertFiltering, + end, + fetchAttackDiscoveries, + filterQuery, + kqlError, + start, + ]); useEffect(() => { setSelectedConnectorReplacements(replacements); @@ -147,6 +252,8 @@ const AttackDiscoveryPageComponent: React.FC = () => { const connectorsAreConfigured = aiConnectors != null && aiConnectors.length > 0; const attackDiscoveriesCount = selectedConnectorAttackDiscoveries.length; + const onClose = useCallback(() => setShowFlyout(false), []); + return (
{
@@ -185,9 +293,11 @@ const AttackDiscoveryPageComponent: React.FC = () => { }) ? ( ) : ( { showAnonymized={showAnonymized} /> )} + {showFlyout && ( + + )} )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx index aee8241ec73fc..c713eb77104df 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx @@ -23,14 +23,18 @@ interface Props { alertsContextCount: number | null; approximateFutureTime: Date | null; connectorIntervals: GenerationInterval[]; + end?: string | null; localStorageAttackDiscoveryMaxAlerts: string | undefined; + start?: string | null; } const LoadingCalloutComponent: React.FC = ({ alertsContextCount, - localStorageAttackDiscoveryMaxAlerts, approximateFutureTime, connectorIntervals, + end, + localStorageAttackDiscoveryMaxAlerts, + start, }) => { const { euiTheme } = useEuiTheme(); const { theme } = useKibana().services; @@ -50,12 +54,14 @@ const LoadingCalloutComponent: React.FC = ({ > ), - [alertsContextCount, euiTheme.size.m, localStorageAttackDiscoveryMaxAlerts] + [alertsContextCount, end, euiTheme.size.m, localStorageAttackDiscoveryMaxAlerts, start] ); const isDarkMode = theme.getTheme().darkMode === true; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_formatted_time/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_formatted_time/index.ts new file mode 100644 index 0000000000000..482d0f8b14f27 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_formatted_time/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +export const getFormattedDate = ({ + date, + dateFormat, +}: { + date: string | null | undefined; + dateFormat: string; +}): string | null => { + if (date == null) { + return null; + } + + // strictly parse the date, which will fail for dates like formatted like 'now': + const strictParsed = moment(date, moment.ISO_8601, true); + + if (!strictParsed.isValid()) { + return date; // return the original date if it cannot be parsed + } + + // return the formatted date per the time zone: + return moment(date).format(dateFormat); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_message/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_message/index.ts new file mode 100644 index 0000000000000..352a894ee99da --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_message/index.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 { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common'; + +import { + AI_IS_CURRENTLY_ANALYZING, + AI_IS_CURRENTLY_ANALYZING_FROM, + AI_IS_CURRENTLY_ANALYZING_RANGE, +} from '../../translations'; + +export const getLoadingMessage = ({ + alertsCount, + end, + start, +}: { + alertsCount: number; + end?: string | null; + start?: string | null; +}): string => { + if (start === DEFAULT_START && end === DEFAULT_END) { + return AI_IS_CURRENTLY_ANALYZING(alertsCount); + } + + if (end != null && start != null) { + return AI_IS_CURRENTLY_ANALYZING_RANGE({ alertsCount, end, start }); + } else if (start != null) { + return AI_IS_CURRENTLY_ANALYZING_FROM({ alertsCount, from: start }); + } else { + return AI_IS_CURRENTLY_ANALYZING(alertsCount); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx index 1a84771e5c635..5552e44d88df4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx @@ -7,27 +7,47 @@ import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, useAssistantContext } from '@kbn/elastic-assistant'; + import React from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useDateFormat, useKibana } from '../../../../common/lib/kibana'; +import { getFormattedDate } from './get_formatted_time'; import { getLoadingCalloutAlertsCount } from './get_loading_callout_alerts_count'; +import { getLoadingMessage } from './get_loading_message'; import * as i18n from '../translations'; const TEXT_COLOR = '#343741'; interface Props { alertsContextCount: number | null; + end?: string | null; localStorageAttackDiscoveryMaxAlerts: string | undefined; + start?: string | null; } const LoadingMessagesComponent: React.FC = ({ alertsContextCount, + end, localStorageAttackDiscoveryMaxAlerts, + start, }) => { + const { + assistantFeatures: { attackDiscoveryAlertFiltering }, + } = useAssistantContext(); + const { theme } = useKibana().services; + const dateFormat = useDateFormat(); - const isDarkMode = theme.getTheme().darkMode === true; + const formattedStart = getFormattedDate({ + date: start, + dateFormat, + }); + + const formattedEnd = getFormattedDate({ + date: end, + dateFormat, + }); const alertsCount = getLoadingCalloutAlertsCount({ alertsContextCount, @@ -35,6 +55,16 @@ const LoadingMessagesComponent: React.FC = ({ localStorageAttackDiscoveryMaxAlerts, }); + const loadingMessage = attackDiscoveryAlertFiltering + ? getLoadingMessage({ + alertsCount, + end: formattedEnd, + start: formattedStart, + }) + : getLoadingMessage({ alertsCount }); // <-- NO time range, feature flag is off + + const isDarkMode = theme.getTheme().darkMode === true; + return ( @@ -59,7 +89,7 @@ const LoadingMessagesComponent: React.FC = ({ data-test-subj="aisCurrentlyAnalyzing" size="s" > - {i18n.AI_IS_CURRENTLY_ANALYZING(alertsCount)} + {loadingMessage} diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/translations.ts new file mode 100644 index 0000000000000..96e89d4769ea6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FROM_TODAY = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTodayLabel', + { + defaultMessage: 'from Today', + } +); + +export const FROM_THIS_WEEK = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromThisWeekLabel', + { + defaultMessage: 'from This week', + } +); + +export const FROM_THE_LAST_15_MINUTES = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast15MinutesLabel', + { + defaultMessage: 'from the Last 15 minutes', + } +); + +export const FROM_THE_LAST_30_MINUTES = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast30MinutesLabel', + { + defaultMessage: 'from the Last 30 minutes', + } +); + +export const FROM_THE_LAST_1_HOUR = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast1HourLabel', + { + defaultMessage: 'from the Last 1 hour', + } +); + +export const FROM_THE_LAST_24_HOURS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast24HoursLabel', + { + defaultMessage: 'from the Last 24 hours', + } +); + +export const FROM_THE_LAST_7_DAYS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast7DaysLabel', + { + defaultMessage: 'from the Last 7 days', + } +); + +export const FROM_THE_LAST_30_DAYS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.loadingMessages.fromTheLast30DaysLabel', + { + defaultMessage: 'from the Last 30 days', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/translations.ts index 6c8f9b60c5ac6..beb2cda39de11 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/loading_callout/translations.ts @@ -16,6 +16,38 @@ export const AI_IS_CURRENTLY_ANALYZING = (alertsCount: number) => } ); +export const AI_IS_CURRENTLY_ANALYZING_FROM = ({ + alertsCount, + from, +}: { + alertsCount: number; + from: string; +}) => + i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.aiIsCurrentlyAnalyzingFromLabel', + { + defaultMessage: `AI is analyzing up to {alertsCount} {alertsCount, plural, =1 {alert} other {alerts}} {from} to generate discoveries.`, + values: { alertsCount, from }, + } + ); + +export const AI_IS_CURRENTLY_ANALYZING_RANGE = ({ + alertsCount, + end, + start, +}: { + alertsCount: number; + end: string; + start: string; +}) => + i18n.translate( + 'xpack.securitySolution.attackDiscovery.loadingCallout.aiIsCurrentlyAnalyzingRangeLabel', + { + defaultMessage: `AI is analyzing up to {alertsCount} {alertsCount, plural, =1 {alert} other {alerts}} from {start} to {end} generate discoveries.`, + values: { alertsCount, end, start }, + } + ); + export const ATTACK_DISCOVERY_GENERATION_IN_PROGRESS = i18n.translate( 'xpack.securitySolution.attackDiscovery.pages.loadingCallout.attackDiscoveryGenerationInProgressLabel', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_filters/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_filters/index.ts new file mode 100644 index 0000000000000..38385190e83ea --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_filters/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FilterStateStore, type Filter } from '@kbn/es-query'; +import { z } from '@kbn/zod'; + +const filtersSchema = z.array( + z.object({ + $state: z + .union([ + z.object({ + store: z.nativeEnum(FilterStateStore), + }), + z.undefined(), + ]) + .optional(), + meta: z.object({}).catchall(z.unknown()), + query: z.union([z.record(z.string(), z.any()), z.undefined()]).optional(), + }) +); + +export type FiltersSchema = z.infer; + +export const deserializeFilters = (value: string): Filter[] => { + try { + return filtersSchema.parse(JSON.parse(value)); + } catch { + return []; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_query/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_query/index.ts new file mode 100644 index 0000000000000..6bfe1f3de4d86 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/local_storage/deserialize_query/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { Query } from '@kbn/es-query'; +import { z } from '@kbn/zod'; + +import { getDefaultQuery } from '../../helpers'; + +const querySchema = z.object({ + query: z.union([z.string(), z.object({}).catchall(z.unknown())]), + language: z.string(), +}); + +export const deserializeQuery = (value: string): Query => { + try { + return querySchema.parse(JSON.parse(value)); + } catch { + return getDefaultQuery(); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/translations.ts index 76b4c7e298722..a249f345b0816 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const ATTACK_DISCOVERY_ONLY = i18n.translate( 'xpack.securitySolution.attackDiscovery.pages.noAlerts.attackDiscoveryOnlyLabel', { - defaultMessage: 'Attack Discovery only analyzes alerts from the past 24 hours.', + defaultMessage: 'There were no matching alerts in the configured time range.', } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx new file mode 100644 index 0000000000000..5261779f899ef --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx @@ -0,0 +1,198 @@ +/* + * 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 { OnTimeChangeProps } from '@elastic/eui'; +import { EuiSuperDatePicker, EuiSpacer } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { Filter, Query } from '@kbn/es-query'; +import { debounce } from 'lodash/fp'; +import React, { useCallback, useMemo } from 'react'; + +import { useKibana } from '../../../../../common/lib/kibana'; +import { getCommonTimeRanges } from '../helpers/get_common_time_ranges'; +import { useSourcererDataView } from '../../../../../sourcerer/containers'; +import { SourcererScopeName } from '../../../../../sourcerer/store/model'; +import * as i18n from '../translations'; +import { useDataView } from '../use_data_view'; + +export const MAX_ALERTS = 500; +export const MIN_ALERTS = 50; +export const STEP = 50; +export const NO_INDEX_PATTERNS: DataView[] = []; + +interface Props { + end: string; + filters: Filter[]; + query: Query; + setEnd: React.Dispatch>; + setFilters: React.Dispatch>; + setQuery: React.Dispatch>; + setStart: React.Dispatch>; + start: string; +} + +const AlertSelectionQueryComponent: React.FC = ({ + end, + filters, + query, + setEnd, + setFilters, + setQuery, + setStart, + start, +}) => { + const { + unifiedSearch: { + ui: { SearchBar }, + }, + } = useKibana().services; + + // get the sourcerer `DataViewSpec` for alerts: + const { sourcererDataView, loading: isLoadingIndexPattern } = useSourcererDataView( + SourcererScopeName.detections + ); + + // create a `DataView` from the `DataViewSpec`: + const alertsDataView = useDataView({ + dataViewSpec: sourcererDataView, + loading: isLoadingIndexPattern, + }); + + // create a container for the alerts `DataView`, as required by the search bar: + const indexPatterns: DataView[] = useMemo( + () => (alertsDataView ? [alertsDataView] : NO_INDEX_PATTERNS), + [alertsDataView] + ); + + // Users accumulate an "unsubmitted" query as they type in the search bar, + // but have not pressed the 'Enter' key to submit the query, (which would + // call `onQuerySubmit`). + // + // This unsubmitted query is stored in `unSubmittedQuery`. + // + // To match the behavior of Discover, `setQuery` must be called with the + // `unSubmittedQuery` query when: + // + // 1) The user selects a new time range + // 2) The user clicks the refresh button + // + // Also to match the behavior of Discover, we must NOT call `setQuery` with + // the `unSubmittedQuery` query when the user clicks the `Save` button. + const [unSubmittedQuery, setUnSubmittedQuery] = React.useState( + undefined + ); + + /** + * `debouncedOnQueryChange` is called by the `SearchBar` as the user types in the input + */ + const debouncedOnQueryChange = useCallback((inputQuery: Query['query'] | undefined) => { + const debouncedFunction = debounce(100, (debouncedQuery: Query['query'] | undefined) => { + setUnSubmittedQuery(debouncedQuery); + }); + + return debouncedFunction(inputQuery); + }, []); + + // get the common time ranges for the date picker: + const commonlyUsedRanges = useMemo(() => getCommonTimeRanges(), []); + + /** + * `onTimeChange` is called by the `EuiSuperDatePicker` when the user: + * 1) selects a new time range + * 2) clicks the refresh button + */ + const onTimeChange = useCallback( + ({ start: startDate, end: endDate }: OnTimeChangeProps) => { + if (unSubmittedQuery != null) { + const newUnSubmittedQuery: Query = { + query: unSubmittedQuery, + language: 'kuery', + }; + + setQuery(newUnSubmittedQuery); // <-- set the query to the unsubmitted query + } + + setStart(startDate); + setEnd(endDate); + }, + [setEnd, setQuery, setStart, unSubmittedQuery] + ); + + /** + * `onFiltersUpdated` is called by the `SearchBar` when the filters, (which + * appear belew the `SearchBar` input), are updated. + */ + const onFiltersUpdated = useCallback( + (newFilters: Filter[]) => { + setFilters(newFilters); + }, + [setFilters] + ); + + /** + * `onQuerySubmit` is called by the `SearchBar` when the user presses `Enter` + */ + const onQuerySubmit = useCallback( + ({ query: newQuery }: { query?: Query | undefined }) => { + if (newQuery != null) { + setQuery(newQuery); + } + }, + [setQuery] + ); + + return ( + <> +
+ { + debouncedOnQueryChange(debouncedQuery?.query); + }} + onQuerySubmit={onQuerySubmit} + placeholder={i18n.FILTER_YOUR_DATA} + query={query} + /> +
+ + + + + + + ); +}; + +AlertSelectionQueryComponent.displayName = 'AlertSelectionQuery'; + +export const AlertSelectionQuery = React.memo(AlertSelectionQueryComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx new file mode 100644 index 0000000000000..d4d4be93a8cbf --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx @@ -0,0 +1,71 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { SingleRangeChangeEvent } from '@kbn/elastic-assistant'; +import { AlertsRange } from '@kbn/elastic-assistant'; +import React, { useCallback } from 'react'; +import * as i18n from '../translations'; + +export const MAX_ALERTS = 500; +export const MIN_ALERTS = 50; +export const STEP = 50; +export const NO_INDEX_PATTERNS: DataView[] = []; + +interface Props { + maxAlerts: number; + setMaxAlerts: React.Dispatch>; +} + +const AlertSelectionRangeComponent: React.FC = ({ maxAlerts, setMaxAlerts }) => { + // called when the slider changes the number of alerts to analyze: + const onChangeAlertsRange = useCallback( + (e: SingleRangeChangeEvent) => { + setMaxAlerts(e.currentTarget.value); + }, + [setMaxAlerts] + ); + + return ( + + + +

{i18n.SET_NUMBER_OF_ALERTS_TO_ANALYZE}

+
+
+ + + + + + + + + + + + + + + + {i18n.SEND_FEWER_ALERTS} + + +
+ ); +}; + +AlertSelectionRangeComponent.displayName = 'AlertSelectionRange'; + +export const AlertSelectionRange = React.memo(AlertSelectionRangeComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/get_esql_keep_statement/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/get_esql_keep_statement/index.ts new file mode 100644 index 0000000000000..e7fe143f9f451 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/get_esql_keep_statement/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getEsqlKeepStatement = (tableStackBy0: string): string => { + // renames the table stack by field to 'Rule name' + const renameAsRuleName = `| RENAME kibana.alert.rule.name AS \`Rule name\` +| KEEP \`Rule name\`, Count`; + + // just keeps the table stack by field: + const keepTableStackBy0 = `| KEEP \`${tableStackBy0}\`, Count`; + + return tableStackBy0 === 'kibana.alert.rule.name' ? renameAsRuleName : keepTableStackBy0; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/index.ts new file mode 100644 index 0000000000000..b22f0f64db3c6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_esql_query/index.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 { getEsqlKeepStatement } from './get_esql_keep_statement'; + +export const getAlertSummaryEsqlQuery = ({ + alertsIndexPattern, + maxAlerts, + tableStackBy0, +}: { + alertsIndexPattern: string; + maxAlerts: number; + tableStackBy0: string; +}): string => `FROM ${alertsIndexPattern} METADATA _id, _index, _version +| WHERE kibana.alert.workflow_status IN ("open", "acknowledged") AND kibana.alert.rule.building_block_type IS NULL +| SORT kibana.alert.risk_score DESC, @timestamp DESC +| LIMIT ${maxAlerts} +| STATS Count = count() by \`${tableStackBy0}\` +| SORT Count DESC +${getEsqlKeepStatement(tableStackBy0)} +`; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_lens_attributes/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_lens_attributes/index.ts new file mode 100644 index 0000000000000..ef7dcdf6207e1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_summary_tab/get_alert_summary_lens_attributes/index.ts @@ -0,0 +1,88 @@ +/* + * 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 { LensAttributes } from '../../../../../../common/components/visualization_actions/types'; +import { getFirstColumnName } from '../../helpers/get_first_column_name'; +import * as i18n from '../../translations'; +import type { Sorting } from '../../types'; + +const LAYER_ID = '094d6c10-a28a-4780-8a0c-5789b73e4cef'; + +export const DEFAULT_PAGE_SIZE = 5; + +export const getAlertSummaryLensAttributes = ({ + defaultPageSize = DEFAULT_PAGE_SIZE, + esqlQuery, + sorting, + tableStackBy0, +}: { + defaultPageSize?: number; + esqlQuery: string; + sorting?: Sorting; + tableStackBy0: string; +}): LensAttributes => ({ + references: [], + state: { + adHocDataViews: {}, + datasourceStates: { + textBased: { + layers: { + [LAYER_ID]: { + columns: [ + { + columnId: 'tableStackBy0', + fieldName: getFirstColumnName(tableStackBy0), + }, + { + columnId: 'count', + fieldName: 'Count', + inMetricDimension: true, + meta: { + type: 'number', + esType: 'long', + }, + }, + ], + index: 'F2772070-4F12-4603-A318-82F98BA69DAB', + query: { + esql: esqlQuery, + }, + timeField: '@timestamp', + }, + }, + }, + }, + filters: [], // empty, because filters are applied directly to the lens.EmbeddableComponent + query: { + language: 'kuery', + query: '', // empty, because the query from the query bar is applied directly to the lens.EmbeddableComponent + }, + visualization: { + columns: [ + { + columnId: 'tableStackBy0', + width: 300, + }, + { + columnId: 'count', + summaryRow: 'sum', + }, + ], + layerId: LAYER_ID, + layerType: 'data', + paging: { + enabled: true, + size: defaultPageSize, + }, + sorting: { + ...sorting, + }, + }, + }, + title: i18n.ALERTS_SUMMARY, + visualizationType: 'lnsDatatable', +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/get_esql_keep_statement/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/get_esql_keep_statement/index.ts new file mode 100644 index 0000000000000..f93f5d67c867e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/get_esql_keep_statement/index.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. + */ + +export const getEsqlKeepStatement = (tableStackBy0: string): string => { + const commonFields = ['@timestamp', 'host.name', 'user.name']; + + // renames the rule name and risk score fields to 'Rule name' and 'Risk score': + const renameRuleNameAndRiskScore = `| RENAME kibana.alert.rule.name AS \`Rule name\`, kibana.alert.risk_score AS \`Risk score\` +| KEEP \`Rule name\`, \`Risk score\`, ${commonFields.join(', ')}`; + + // renames the risk score field to 'Risk score' and keeps the table stack by field: + const renameRiskScoreKeepTableStackBy0 = `| RENAME kibana.alert.risk_score AS \`Risk score\` +| KEEP \`${tableStackBy0}\`, \`Risk score\`, ${commonFields.join(', ')}`; + + return tableStackBy0 === 'kibana.alert.rule.name' + ? renameRuleNameAndRiskScore + : renameRiskScoreKeepTableStackBy0; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/index.ts new file mode 100644 index 0000000000000..207399e70fb7a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_esql_query/index.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 { getEsqlKeepStatement } from './get_esql_keep_statement'; + +export const getAlertsPreviewEsqlQuery = ({ + alertsIndexPattern, + maxAlerts, + tableStackBy0, +}: { + alertsIndexPattern: string; + maxAlerts: number; + tableStackBy0: string; +}): string => `FROM ${alertsIndexPattern} METADATA _id, _index, _version +| WHERE kibana.alert.workflow_status IN ("open", "acknowledged") AND kibana.alert.rule.building_block_type IS NULL +| SORT kibana.alert.risk_score DESC, @timestamp DESC +| LIMIT ${maxAlerts} +${getEsqlKeepStatement(tableStackBy0)} +`; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_lens_attributes/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_lens_attributes/index.ts new file mode 100644 index 0000000000000..6395d312e46fa --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alerts_preview_tab/get_alerts_preview_lens_attributes/index.ts @@ -0,0 +1,120 @@ +/* + * 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 { LensAttributes } from '../../../../../../common/components/visualization_actions/types'; +import { getFirstColumnName } from '../../helpers/get_first_column_name'; +import * as i18n from '../../translations'; +import type { Sorting } from '../../types'; + +const LAYER_ID = '320760EB-4185-43EB-985B-94B9240C57E7'; + +export const DEFAULT_PAGE_SIZE = 10; + +export const getAlertsPreviewLensAttributes = ({ + defaultPageSize = DEFAULT_PAGE_SIZE, + esqlQuery, + sorting, + tableStackBy0, +}: { + defaultPageSize?: number; + esqlQuery: string; + sorting?: Sorting; + tableStackBy0: string; +}): LensAttributes => ({ + references: [], + state: { + adHocDataViews: {}, + datasourceStates: { + textBased: { + layers: { + [LAYER_ID]: { + columns: [ + { + columnId: 'tableStackBy0', + fieldName: getFirstColumnName(tableStackBy0), + }, + { + columnId: '@timestamp', + fieldName: '@timestamp', + meta: { + type: 'date', + esType: 'date', + }, + }, + { + columnId: 'kibana.alert.risk_score', + fieldName: 'Risk score', + meta: { + type: 'number', + esType: 'long', + }, + inMetricDimension: true, + }, + { + columnId: 'host.name', + fieldName: 'host.name', + meta: { + type: 'string', + esType: 'keyword', + }, + }, + { + columnId: 'user.name', + fieldName: 'user.name', + meta: { + type: 'string', + esType: 'keyword', + }, + }, + ], + index: '31734563-1D31-4A8C-804A-CA17540A793E', + query: { + esql: esqlQuery, + }, + timeField: '@timestamp', + }, + }, + }, + }, + filters: [], // empty, because filters are applied directly to the lens.EmbeddableComponent + query: { + language: 'kuery', + query: '', // empty, because the query from the query bar is applied directly to the lens.EmbeddableComponent + }, + visualization: { + columns: [ + { + columnId: 'tableStackBy0', + width: 220, + }, + { + columnId: '@timestamp', + }, + { + columnId: 'kibana.alert.risk_score', + }, + { + columnId: 'host.name', + }, + { + columnId: 'user.name', + }, + ], + layerId: LAYER_ID, + layerType: 'data', + paging: { + enabled: true, + size: defaultPageSize, + }, + sorting: { + ...sorting, + }, + }, + }, + title: i18n.ALERTS_PREVIEW, + visualizationType: 'lnsDatatable', +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/index.ts new file mode 100644 index 0000000000000..44c8109e8d8a1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/index.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 * as i18n from './translations'; + +export interface TimeRangeLabel { + start: + | 'now/d' + | 'now/w' + | 'now-15m' + | 'now-30m' + | 'now-1h' + | 'now-24h' + | 'now-7d' + | 'now-30d' + | 'now-90d' + | 'now-1y'; + end: 'now'; + label: string; +} + +export const getCommonTimeRanges = (): TimeRangeLabel[] => [ + { start: 'now/d', end: 'now', label: i18n.TODAY }, + { start: 'now/w', end: 'now', label: i18n.THIS_WEEK }, + { start: 'now-15m', end: 'now', label: i18n.LAST_15_MINUTES }, + { start: 'now-30m', end: 'now', label: i18n.LAST_30_MINUTES }, + { start: 'now-1h', end: 'now', label: i18n.LAST_1_HOUR }, + { start: 'now-24h', end: 'now', label: i18n.LAST_24_HOURS }, + { start: 'now-7d', end: 'now', label: i18n.LAST_7_DAYS }, + { start: 'now-30d', end: 'now', label: i18n.LAST_30_DAYS }, + { start: 'now-90d', end: 'now', label: i18n.LAST_90_DAYS }, + { start: 'now-1y', end: 'now', label: i18n.LAST_1_YEAR }, +]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/translations.ts new file mode 100644 index 0000000000000..9a8d50613b685 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_common_time_ranges/translations.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. + */ + +import { i18n } from '@kbn/i18n'; + +export const LAST_1_HOUR = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last1HourLabel', + { + defaultMessage: 'Last 1 hour', + } +); + +export const LAST_1_YEAR = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last1YearLabel', + { + defaultMessage: 'Last 1 year', + } +); + +export const LAST_15_MINUTES = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last15MinutesLabel', + { + defaultMessage: 'Last 15 minutes', + } +); + +export const LAST_7_DAYS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last7DaysLabel', + { + defaultMessage: 'Last 7 days', + } +); + +export const LAST_24_HOURS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last24HoursLabel', + { + defaultMessage: 'Last 24 hours', + } +); + +export const LAST_30_DAYS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last30DaysLabel', + { + defaultMessage: 'Last 30 days', + } +); + +export const LAST_30_MINUTES = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last30MinutesLabel', + { + defaultMessage: 'Last 30 minutes', + } +); + +export const LAST_90_DAYS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.last90DaysLabel', + { + defaultMessage: 'Last 90 days', + } +); + +export const THIS_WEEK = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.thisWeekLabel', + { + defaultMessage: 'This week', + } +); + +export const TODAY = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.todayLabel', + { + defaultMessage: 'Today', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_first_column_name/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_first_column_name/index.ts new file mode 100644 index 0000000000000..3ce26f60ba167 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_first_column_name/index.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 const getFirstColumnName = (tableStackBy0: string): string => + tableStackBy0 === 'kibana.alert.rule.name' ? 'Rule name' : tableStackBy0; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_max_alerts/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_max_alerts/index.ts new file mode 100644 index 0000000000000..38ab49c72ce39 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_max_alerts/index.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 { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; + +/** + * ensures maxAlerts is a positive number, otherwise returns the default value + */ +export const getMaxAlerts = (maxAlerts: string): number => { + const defaultMaxAlerts = Number(DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS); + const numericMaxAlerts = Number(maxAlerts); + + const isMaxAlertsValid = Number.isInteger(numericMaxAlerts) && numericMaxAlerts > 0; + + return isMaxAlertsValid ? numericMaxAlerts : defaultMaxAlerts; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx new file mode 100644 index 0000000000000..fb8ece2c877ca --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx @@ -0,0 +1,109 @@ +/* + * 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 { EuiSpacer } from '@elastic/eui'; +import type { Filter, Query } from '@kbn/es-query'; +import React from 'react'; + +import { getAlertSummaryEsqlQuery } from '../../alert_summary_tab/get_alert_summary_esql_query'; +import { getAlertSummaryLensAttributes } from '../../alert_summary_tab/get_alert_summary_lens_attributes'; +import { getAlertsPreviewEsqlQuery } from '../../alerts_preview_tab/get_alerts_preview_esql_query'; +import { getAlertsPreviewLensAttributes } from '../../alerts_preview_tab/get_alerts_preview_lens_attributes'; +import { PreviewTab } from '../../preview_tab'; +import * as i18n from '../../translations'; +import type { Sorting } from '../../types'; + +const SUMMARY_TAB_EMBEDDABLE_ID = 'alertSummaryEmbeddable--id'; +const PREVIEW_TAB_EMBEDDABLE_ID = 'alertsPreviewEmbeddable--id'; + +export const ALERT_SUMMARY_TEST_SUBJ = 'alertSummaryPreviewTab'; +export const ALERTS_PREVIEW_TEST_SUBJ = 'alertsPreviewTab'; + +export const DEFAULT_ALERT_SUMMARY_SORT: Sorting = { + columnId: 'count', + direction: 'desc', +}; + +export const DEFAULT_ALERTS_PREVIEW_SORT: Sorting = { + columnId: 'kibana.alert.risk_score', + direction: 'desc', +}; + +export interface TabInfo { + content: JSX.Element; + id: string; + name: string; +} + +interface GetTabs { + alertsPreviewStackBy0: string; + alertSummaryStackBy0: string; + end: string; + filters: Filter[]; + maxAlerts: number; + query: Query; + setAlertsPreviewStackBy0: React.Dispatch>; + setAlertSummaryStackBy0: React.Dispatch>; + start: string; +} + +export const getTabs = ({ + alertsPreviewStackBy0, + alertSummaryStackBy0, + end, + filters, + maxAlerts, + query, + setAlertsPreviewStackBy0, + setAlertSummaryStackBy0, + start, +}: GetTabs): TabInfo[] => [ + { + id: 'attackDiscoverySettingsAlertSummaryTab--id', + name: i18n.ALERT_SUMMARY, + content: ( + <> + + + + ), + }, + { + id: 'attackDiscoverySettingsAlertsPreviewTab--id', + name: i18n.ALERTS_PREVIEW, + content: ( + <> + + + + ), + }, +]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx new file mode 100644 index 0000000000000..61c9521e5668d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx @@ -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 { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiText, EuiSpacer } from '@elastic/eui'; +import type { Filter, Query } from '@kbn/es-query'; +import React, { useMemo, useState } from 'react'; + +import { AlertSelectionQuery } from './alert_selection_query'; +import { AlertSelectionRange } from './alert_selection_range'; +import { getTabs } from './helpers/get_tabs'; +import * as i18n from './translations'; + +interface Props { + alertsPreviewStackBy0: string; + alertSummaryStackBy0: string; + end: string; + filters: Filter[]; + maxAlerts: number; + query: Query; + setAlertsPreviewStackBy0: React.Dispatch>; + setAlertSummaryStackBy0: React.Dispatch>; + setEnd: React.Dispatch>; + setFilters: React.Dispatch>; + setMaxAlerts: React.Dispatch>; + setQuery: React.Dispatch>; + setStart: React.Dispatch>; + start: string; +} + +const AlertSelectionComponent: React.FC = ({ + alertsPreviewStackBy0, + alertSummaryStackBy0, + end, + filters, + maxAlerts, + query, + setAlertsPreviewStackBy0, + setAlertSummaryStackBy0, + setEnd, + setFilters, + setMaxAlerts, + setQuery, + setStart, + start, +}) => { + const tabs = useMemo( + () => + getTabs({ + alertsPreviewStackBy0, + alertSummaryStackBy0, + end, + filters, + maxAlerts, + query, + setAlertsPreviewStackBy0, + setAlertSummaryStackBy0, + start, + }), + [ + alertsPreviewStackBy0, + alertSummaryStackBy0, + end, + filters, + maxAlerts, + query, + setAlertsPreviewStackBy0, + setAlertSummaryStackBy0, + start, + ] + ); + + const [selectedTabId, setSelectedTabId] = useState(tabs[0].id); + + const selectedTabContent = useMemo( + () => tabs.find((obj) => obj.id === selectedTabId)?.content, + [selectedTabId, tabs] + ); + + return ( + + + +

{i18n.CUSTOMIZE_THE_ALERTS}

+
+
+ + + + + + + + + + + + + + + + + + + + + + + {tabs.map((tab) => ( + setSelectedTabId(tab.id)} + > + {tab.name} + + ))} + + {selectedTabContent} +
+ ); +}; + +AlertSelectionComponent.displayName = 'AlertSelection'; + +export const AlertSelection = React.memo(AlertSelectionComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/preview_tab/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/preview_tab/index.tsx new file mode 100644 index 0000000000000..33d2961f5b02d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/preview_tab/index.tsx @@ -0,0 +1,217 @@ +/* + * 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 { Filter, Query, TimeRange } from '@kbn/es-query'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiEmptyPrompt, + EuiSpacer, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useCallback, useMemo } from 'react'; + +import { useEuiComboBoxReset } from '../../../../../common/components/use_combo_box_reset'; +import { StackByComboBox } from '../../../../../detections/components/alerts_kpis/common/components'; +import { useSignalIndex } from '../../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import type { LensAttributes } from '../../../../../common/components/visualization_actions/types'; +import { useKibana } from '../../../../../common/lib/kibana'; +import * as i18n from '../translations'; +import type { Sorting } from '../types'; + +export const ATTACK_DISCOVERY_SETTINGS_ALERTS_COUNT_ID = 'attack-discovery-settings-alerts-count'; +export const RESET_FIELD = 'kibana.alert.rule.name'; + +const DEFAULT_DATA_TEST_SUBJ = 'previewTab'; +const VIEW_MODE = 'view'; + +interface Props { + dataTestSubj?: string; + embeddableId: string; + end: string; + filters: Filter[]; + getLensAttributes: ({ + defaultPageSize, + esqlQuery, + sorting, + tableStackBy0, + }: { + defaultPageSize?: number; + esqlQuery: string; + sorting?: Sorting; + tableStackBy0: string; + }) => LensAttributes; + getPreviewEsqlQuery: ({ + alertsIndexPattern, + maxAlerts, + tableStackBy0, + }: { + alertsIndexPattern: string; + maxAlerts: number; + tableStackBy0: string; + }) => string; + maxAlerts: number; + query: Query; + setTableStackBy0: React.Dispatch>; + sorting?: Sorting; + start: string; + tableStackBy0: string; +} + +const PreviewTabComponent = ({ + dataTestSubj = DEFAULT_DATA_TEST_SUBJ, + embeddableId, + end, + filters, + getLensAttributes, + getPreviewEsqlQuery, + maxAlerts, + query, + setTableStackBy0, + sorting, + start, + tableStackBy0, +}: Props) => { + const { lens } = useKibana().services; + const { + euiTheme: { font }, + } = useEuiTheme(); + + const { signalIndexName } = useSignalIndex(); + + const { + comboboxRef: stackByField0ComboboxRef, + setComboboxInputRef: setStackByField0ComboboxInputRef, + } = useEuiComboBoxReset(); + + const onSelect = useCallback((value: string) => setTableStackBy0(value), [setTableStackBy0]); + + const timeRange: TimeRange = useMemo(() => ({ from: start, to: end }), [end, start]); + + const esqlQuery = useMemo( + () => + getPreviewEsqlQuery({ + alertsIndexPattern: signalIndexName ?? '', + maxAlerts, + tableStackBy0, + }), + [getPreviewEsqlQuery, maxAlerts, signalIndexName, tableStackBy0] + ); + + const attributes = useMemo( + () => + getLensAttributes({ + esqlQuery, + sorting, + tableStackBy0: tableStackBy0.trim(), + }), + [esqlQuery, getLensAttributes, sorting, tableStackBy0] + ); + + const onReset = useCallback(() => setTableStackBy0(RESET_FIELD), [setTableStackBy0]); + + const actions = useMemo( + () => [ + + {i18n.RESET} + , + ], + [onReset] + ); + + const body = useMemo( + () => ( + + {i18n.SELECT_A_FIELD} + + ), + [] + ); + + const EmptyPrompt = useMemo( + () => + isEmpty(tableStackBy0.trim()) ? ( + + ) : null, + [actions, body, tableStackBy0] + ); + + if (signalIndexName == null) { + return null; + } + + return ( + + + + + + + + + + + {EmptyPrompt ?? + (attributes && ( +
+ +
+ ))} +
+
+ ); +}; + +PreviewTabComponent.displayName = 'PreviewTab'; + +export const PreviewTab = React.memo(PreviewTabComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/translations.ts new file mode 100644 index 0000000000000..e34c332d27dfb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/translations.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERTS_PREVIEW = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertsPreviewTabLabel', + { + defaultMessage: 'Alerts preview', + } +); + +export const ALERTS_SUMMARY = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertsSummaryTitle', + { + defaultMessage: 'Alerts summary', + } +); + +export const CUSTOMIZE_THE_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.customizeTheAlertsLabel', + { + defaultMessage: + 'Customize the set of alerts that will be analyzed when generating Attack discoveries.', + } +); + +export const ALERT_SUMMARY = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertSummaryTabLabel', + { + defaultMessage: 'Alert summary', + } +); + +export const FILTER_YOUR_DATA = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.filterYourDataPlaceholder', + { + defaultMessage: 'Filter your data using KQL syntax', + } +); + +export const SELECT_FIELD = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.alertsTable.selectFieldLabel', + { + defaultMessage: 'Select field', + } +); + +export const RESET = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.resetLabel', + + { + defaultMessage: 'Reset', + } +); + +export const SEND_FEWER_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.alertSelection.alertSelection.selectFewerAlertsLabel', + { + defaultMessage: + "Send fewer alerts if the model's context window is small or more if it is larger.", + } +); + +export const SELECT_A_FIELD = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.selectAFieldEmptyText', + { + defaultMessage: 'Select a field', + } +); + +export const SET_NUMBER_OF_ALERTS_TO_ANALYZE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsFlyout.alertSelection.setNumberOfAlertsToAnalyzeTitle', + { + defaultMessage: 'Set number of alerts to analyze', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/types.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/types.ts new file mode 100644 index 0000000000000..e38f1ef798481 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/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 interface Sorting { + columnId: string; + direction: 'asc' | 'desc'; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/use_data_view/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/use_data_view/index.ts new file mode 100644 index 0000000000000..829a685c7cc7a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/use_data_view/index.ts @@ -0,0 +1,51 @@ +/* + * 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 { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { useEffect, useState } from 'react'; + +import { useKibana } from '../../../../../common/lib/kibana'; + +export const useDataView = ({ + dataViewSpec, + loading, +}: { + dataViewSpec: DataViewSpec; + loading: boolean; +}): DataView | undefined => { + const { dataViews } = useKibana().services; + + const [dataView, setDataView] = useState(undefined); + + useEffect(() => { + let active = true; + + async function createDataView() { + if (!loading) { + try { + const dv = await dataViews.create(dataViewSpec); + + if (active) { + setDataView(dv); + } + } catch { + if (active) { + setDataView(undefined); + } + } + } + } + + createDataView(); + + return () => { + active = false; + }; + }, [dataViewSpec, dataViews, loading]); + + return dataView; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alerts_preview/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alerts_preview/index.tsx new file mode 100644 index 0000000000000..0bd25b7850223 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alerts_preview/index.tsx @@ -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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { css } from '@emotion/react'; +import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; +import React, { useMemo } from 'react'; +import * as uuid from 'uuid'; + +import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../../common/constants'; +import { useKibana } from '../../../../common/lib/kibana'; + +interface Props { + query: Pick; + size: number; +} + +const configId = ALERTS_TABLE_REGISTRY_CONFIG_IDS.RULE_DETAILS; // show the same row-actions as in the case view + +const AlertsPreviewComponent: React.FC = ({ query, size }) => { + const { triggersActionsUi } = useKibana().services; + + const alertStateProps = useMemo( + () => ({ + alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, + configurationId: configId, + consumers: [AlertConsumers.SIEM], + id: `attack-discovery-alerts-preview-${uuid.v4()}`, + initialPageSize: size, + query, + ruleTypeIds: SECURITY_SOLUTION_RULE_TYPE_IDS, + showAlertStatusWithFlapping: false, + }), + [query, size, triggersActionsUi.alertsTableConfigurationRegistry] + ); + + return ( +
+ {triggersActionsUi.getAlertsStateTable(alertStateProps)} +
+ ); +}; + +export const AlertsPreview = React.memo(AlertsPreviewComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/footer/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/footer/index.test.tsx new file mode 100644 index 0000000000000..e487304c41350 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/footer/index.test.tsx @@ -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 React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { Footer } from '.'; + +describe('Footer', () => { + const closeModal = jest.fn(); + const onReset = jest.fn(); + const onSave = jest.fn(); + + beforeEach(() => jest.clearAllMocks()); + + it('calls onReset when the reset button is clicked', () => { + render(