From 1081b7d0f0b85bef0672d3d132153b6fa6026034 Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Thu, 11 Apr 2024 10:21:20 -0400 Subject: [PATCH 1/8] This PR introduces _AI Insights_ to the Security Solution: ![ai_insights](https://github.com/elastic/kibana/assets/4459398/51b9d6f5-f3d0-4a94-9b14-0b7f1b10cb5f) _Above: AI Insights in the Security Solution_ AI Insights identify active attacks in the environment, without the time (or prior experience) required to manually investigate individual alerts in Elastic Security, identify if they are related, and document the identified attack progression. While users can ask the Assistant to find these progressions today, AI Insights is a dedicated UI to identify these progressions and action them accordingly. This feature adds a new page, `AI Insights`, to the Security Solution's global navigation. AI Insights are generated from Large Language Models (LLMs) to identify attack progressions in alert data, and to correlate and identify related entities and events. When possible, attack progressions are attributed to threat actors. Users may generate insights from a varetiy of LLMs, configured via [Connectors](https://www.elastic.co/guide/en/kibana/master/action-types.html): ![connector_selection](https://github.com/elastic/kibana/assets/4459398/394fdcdf-3d23-4b92-a0b6-c6ba6a203600) _Above: LLM selection via the connectors popup menu_ Clicking on the title of an insight toggles the insight between the collapsed and expanded state: ![toggle_expand_collapse](https://github.com/elastic/kibana/assets/4459398/6f87725f-dda1-44aa-ba96-7966544826c4) _Above: Collapsing / expanding an insight (animated gif)_ The first three insights displayed on the AI Insights page are expanded by default. Any additional insights that appear after the first three must be expanded manually. Insights provide a summary of the entities impacted by an attack. Clicking on an entity, i.e. a hostname or username, displays the entity flyout with the entity's risk summary: ![view_host_details](https://github.com/elastic/kibana/assets/4459398/316399dd-db7d-4701-8318-0f3a96d8b4c0) _Above: Clicking on a host in the summary of the insight reveals the host risk summary (animated gif)_ Hover over fields in the insight's summary or details to reveal pivot actions for investigations: ![field_hover_actions](https://github.com/elastic/kibana/assets/4459398/30c89370-9f5e-4c78-8b42-6274ff1d2604) _Above: Hovering over fields in the details of an insight reveals pivot actions (animated gif)_ Insights are generated from alerts provided as context to the selected LLM. The alert data provided to the LLM is anonymized automatically. Anonymization is [configured](https://www.elastic.co/guide/en/security/current/security-assistant.html#ai-assistant-anonymization) via the same anonymization settings as the Assistant. Users may override the defaults to allow or deny specific alert fields, and to toggle anonymization on or off for specific fields. Click the Anonymization toggle to show or hide the actual values sent to the LLM: ![toggle_anonymization](https://github.com/elastic/kibana/assets/4459398/6856c894-6065-4a98-8f9b-813f9fb06f28) _Above: Toggling anonymization to reveal the actual values sent to the LLM (animated gif)_ At the start of a session, or when a user selects a connector that doesn't (yet) have any insights, an [empty prompt](https://eui.elastic.co/#/display/empty-prompt) is displayed. The animated counter in the empty prompt counts up until it displays the maximum number of alerts that will be sent to the LLM: ![empty_prompt](https://github.com/elastic/kibana/assets/4459398/00ef81f0-a8f9-4cad-8e50-96870e500ea3) _Above: An animated counter displays the maximum number of alerts that will be sent to the LLM (animiated gif)_ The _Settings_ section of this PR details how users configure the number of alerts sent to the LLM. The animated counter in the empty prompt immediately re-animates to the newly-selected number when the setting is updated. The _Take action_ popover displays the following actions: - `Add to new case` - `Add to existing case` - `View in AI Assistant` ![take_action_popover](https://github.com/elastic/kibana/assets/4459398/c1e7b4fe-0d04-4aa3-a04c-750b403def65) _Above: The Take action popover_ Clicking the `Add to new` case action displays the `Create case` flyout. ![add_to_new_case](https://github.com/elastic/kibana/assets/4459398/7a253856-c52c-4d78-a5a9-8fb51b5d70e5) _Above: The `Add to new case` workflow_ An `Alerts were added to ` toast is displayed when the case is created: ![case_creation_toast](https://github.com/elastic/kibana/assets/4459398/17cf3a0a-3e66-4d7f-a7a9-d3bc00c76459) _Above: Case creation toast_ A markdown representation of the insight is added to the case: ![case_from_insight](https://github.com/elastic/kibana/assets/4459398/b856540e-ef8a-4a13-94ec-60e08a720f4d) _Above: A markdown representation of an insight in a case_ The alerts correlated to generate the insight are attached to the case: ![case_alerts](https://github.com/elastic/kibana/assets/4459398/7d8efc6f-28ad-4b2d-a343-40bb51437a29) _Above: Insight alerts attached to a case_ Clicking the `Add to existing case` action displays the `Select case` popover. ![select_case](https://github.com/elastic/kibana/assets/4459398/16f09eb5-a1c7-491e-b63e-5e0c83a968fe) _Above: The `Select case` popover_ When users select an existing case, a markdown representation of the insight, and the alerts correlated to generate the insight are attached to the case, as described above in the _Add to new case_ section. The `View in AI Assistant` action in the `Take action` popover, and two additional `View in AI Assistant` affordances that appear in each insight have the same behavior: Clicking `View in AI Assistant` opens the assistant and adds the insight as context to the current conversation. ![view_in_assistant](https://github.com/elastic/kibana/assets/4459398/869ed310-b3ee-44f9-b39f-1f7e7a086dcc) _Above: An insight added as context to the current conversation_ Clicking on the insight in the assistant expands it to reveal a preview of the insight. ![insight_preview](https://github.com/elastic/kibana/assets/4459398/b7f23015-6b8d-4386-9336-5c4b085fcefe) _Above: An expanded insight preview in the assistant_ The expanded insight preview reveals the number of anonymzied fields from the insight that were made available to the conversation. This feature ensures insights are added to a conversation with the anonymized field values. An insight viewed in the AI assistant doesn't become part of the conversation until the user submits it by asking a question, e.g. `How do I remediate this?`. Insights provided as context to a conversation are formatted as markdown when sent to the LLM: ![context_as_markdown](https://github.com/elastic/kibana/assets/4459398/625ba555-526c-4770-8038-cd6c7aadbd05) _Above: Insights provided as context to a conversation are formatted as markdown_ Users may toggle anonymization in the conversation to reveal the original field values. ![anonymization_in_assistant](https://github.com/elastic/kibana/assets/4459398/ce47344d-c9d2-4462-9039-047863702a4f) _Above: Revealing the original field values of an insight added as markdown to a conversation (animated gif)_ The _Alerts_ tab displays the alerts correlated to generate the insight. ![alerts_tab](https://github.com/elastic/kibana/assets/4459398/5bd7f5a0-4a00-450f-b16f-ad397e3fe1be) _Above: The alerts correlated to generate the insight in the Alerts tab_ The `View details`, `Investigate in timeline`, and overflow row-level alert actions displayed in the Alerts tab are the same actions available on the Cases's page's Alerts tab: ![alert_actions](https://github.com/elastic/kibana/assets/4459398/f993b6c2-3aaa-4d98-9d7a-45a6632c6b09) _Above: Row-level actions are the same as the Cases pages Alert's tab_ Click an insight's `Investigate in Timeline` button to begin an investigation of an insights's alerts in Timeline. Alert IDs are queried via the `Alert Ids` filter: ![investigate_in_timeline](https://github.com/elastic/kibana/assets/4459398/0694903a-995d-4530-bb78-a49798b3e982) _Above: Clicking Investigte in Timeline (animated gif)_ The alerts from the insight are explained via row renderers in Timeline: ![insight_alerts_in_timeline](https://github.com/elastic/kibana/assets/4459398/26fbb19d-3480-4df5-a1de-5d823d91fca9) _Above: Row rendered insight alerts in Timeline_ When alerts are indicative of attack [tactics](https://attack.mitre.org/tactics/enterprise/), those tactics are displayed in the insights's _Attack Chain_ section: ![insight_with_attack_chain](https://github.com/elastic/kibana/assets/4459398/cff26c0a-ef07-4b96-b295-f27be34c2536) _Above: An insight with tactics in the Attach chain_ The Attack Chain section will be hidden if an insight is not indicative of specific tactics. Every insight includes a mini attack chain that visually summarizes the tactics in an insight. Hovering over the mini attack chain reveals a tooltip with the details: ![mini_attack_chain](https://github.com/elastic/kibana/assets/4459398/65daa760-f892-4c39-991c-28126e8e47ea) _Above: The mini attack chain tooltip_ The latest insights generated for each connector are cached in the browser's session storage in the following key: ``` elasticAssistantDefault.aiInsights.cachedInsights ``` Caching insights in session storage makes it possible to immediately display the latest when users return to to the AI insights page from other pages in the security solution (e.g. Cases). ![cached_insights](https://github.com/elastic/kibana/assets/4459398/8ad94572-1588-4497-b8f9-9cbb6730446a) _Above: Cached insights from sesion storage are immediately displayed when users navigate back to AI Insights (animated gif)_ While waiting for a connector to generate results, users may view the cached results from other connectors. Cached insights are immediately available, even after a full page refresh, as long as the browser session is still active. Some LLMs may take seconds, or even minutes to generate insights. To help users anticipate the time it might take to generate an insight, the AI insights feature displays a `Approximate time remaining: mm:ss` countdown timer that counts down to zero from the average time it takes to generate an insight for the selected LLM: ![approximate_time_remaining](https://github.com/elastic/kibana/assets/4459398/3e568113-de92-4f07-a9fa-151445d9268d) _Above: The `Approximate time remaining: mm:ss` countdown counter (animated gif)_ If the LLM doesn't generate insights before the counter reaches zero, the text will change from `Approximate time remaining: mm:ss` to `Above average time: mm:ss`, and start counting up from `00:00` until the insights are generated: ![above_average_time](https://github.com/elastic/kibana/assets/4459398/b095f4cc-bdf4-4aa1-9b2a-fb5cc1870c25) _Above: The `Above average time: mm:ss` counter (animated gif)_ The first time insights are generated for a model, the `Approximate time remaining: mm:ss` counter is not displayed. Average time is calculated over the last 5 generations on the selected connector. This is illustrated by clicking on the (?) information icon next to the timer. The popover displays the average time, and the time in seconds for the last 5 runs: ![time_remaining_popover](https://github.com/elastic/kibana/assets/4459398/4e5d6a46-e171-42c0-a10e-47236b84587d) _Above: Clicking on the (?) information icon displays the average time, and the duration / datetimes for the last 5 generations_ The time and duration of the last 5 generations (for each connector) are persisted in the browser's local storage in the following key: ``` elasticAssistantDefault.aiInsights.generationIntervals ``` When insight generation fails, an error toaster is displayed to explain the failure: ![error_toast](https://github.com/elastic/kibana/assets/4459398/04f8492f-33d1-4cf2-8833-765526e54cad) _Above: An error toaster explains why insights generation failed_ The `assistantAlertsInsights` feature flag must be enabled to view the `AI Insights` link in the Security Solution's global navigation. Add the `assistantAlertsInsights` feature flag to the `xpack.securitySolution.enableExperimental` setting in `config/kibana.yml` (or `config/kibana.dev.yml` in local development environments), per the example below: ``` xpack.securitySolution.enableExperimental: ['assistantAlertsInsights'] ``` The number of alerts sent as context to the LLM is configured by `Knowledge Base` > `Alerts` slider in the screenshot below: ![alerts_slider](https://github.com/elastic/kibana/assets/4459398/01c8a3bb-f40b-4280-bb97-764e4f42d8d5) - The slider has a range of `10` - `100` alerts (default: `20`) Up to `n` alerts (as determined by the slider) that meet the following criteria will be returned: - The `kibana.alert.workflow_status` must be `open` - The alert must have been generated in the last `24 hours` - The alert must NOT be a `kibana.alert.building_block_type` alert - The `n` alerts are ordered by `kibana.alert.risk_score`, to prioritize the riskiest alerts An Enterprise license is required to use AI Insights. The following AI Insights view is displayed for users who don't have an Enterprise license: ![upgrade](https://github.com/elastic/kibana/assets/4459398/a83e392a-d209-40d2-9738-8ec7968b7eff) - Users navigate to the AI insights page: `x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx` - When users click the `Generate` button(s) on the AI Insights page, insights are fetched via the `useInsights` hook in `x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx`. - The `fetchInsights` function makes an http `POST` request is made to the `/internal/elastic_assistant/insights/alerts` route. include the following new (optional) parameters: - `actionTypeId`, determines tempature and other connector-specific request parameters - `alertsIndexPattern`, the alerts index for the current Kibana Space, e.g. `.alerts-security.alerts-default` - `allow`, the user's `Allowed` fields in the `Anonymization` settings, e.g. `["@timestamp", "cloud.availability_zone", "file.name", "user.name", ...]` - `allowReplacement`, the user's `Anonymized` fields in the `Anonymization` settings, e.g. `["cloud.availability_zone", "host.name", "user.name", ...]` - `connectorId`, id of the connector to generate the insights - `replacements`, an optional `Record` collection of replacements that always empty in the current implementation. When non-empty, this collection enables new insights to be generated using existing replacements. ```json "replacements": { "e4f935c0-5a80-47b2-ac7f-816610790364": "Host-itk8qh4tjm", "cf61f946-d643-4b15-899f-6ffe3fd36097": "rpwmjvuuia", "7f80b092-fb1a-48a2-a634-3abc61b32157": "6astve9g6s", "f979c0d5-db1b-4506-b425-500821d00813": "Host-odqbow6tmc", // ... }, ``` - `size`, the maximum number of alerts to generate insights from. This numeric value is set by the slider in the user's `Knowledge Base > Alerts` setting, e.g. `20` - The `postAlertsInsightsRoute` function in `x-pack/plugins/elastic_assistant/server/routes/insights/alerts/post_alerts_insights.ts` handles the request. - The inputs and outputs to this route are defined by the [OpenAPI](https://spec.openapis.org/oas/v3.1.0) schema in `x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml`. ``` node scripts/generate_openapi --rootDir ./x-pack/packages/kbn-elastic-assistant-common ``` - The `postAlertsInsightsRoute` route handler function in `x-pack/plugins/elastic_assistant/server/routes/insights/alerts/post_alerts_insights.ts` invokes the `insights-tool`, defined in `x-pack/plugins/security_solution/server/assistant/tools/insights/insights_tool.ts`. The `insights-tool` is registered by the Security Solution. Note: The `insights-tool` is only used for generating insights. It is not used to generate new insights from the context of an assistant conversation, but that feature could be enabled in a future release. - The `insights-tool` uses a LangChain `OutputFixingParser` to create a [prompt sandwich](https://www.elastic.co/blog/crafting-prompt-sandwiches-generative-ai) with the following parts: ``` _________________________________________________ / \ | Insight JSON formatting instructions | (1) \ _______________________________________________/ +------------------------------------------------+ | Insights prompt | (2) +------------------------------------------------+ / \ | Anonymized Alerts | (3) \_______________________________________________/ ``` - The `Insight JSON formatting instructions` in section `(1)` of the prompt sandwich are defined in the `getOutputParser()` function in `x-pack/plugins/security_solution/server/assistant/tools/insights/get_output_parser.ts`. This function creates a LangChain `StructuredOutputParser` from a Zod schema. This parser validates responses from the LLM to ensure they are formatted as JSON representing an insight. - The `Insights prompt` in section `(2)` of the prompt sandwich is defined in the `getInsightsPrompt()` function in `x-pack/plugins/security_solution/server/assistant/tools/insights/get_insights_prompt.ts`. This part of the prompt sandwich includes instructions for correlating insights, and additional instructions to the LLM for formatting JSON. - The `Anonymized Alerts` in section `(3)` of the prompt sandwich are returned by the `getAnonymizedAlerts()` function in `x-pack/plugins/security_solution/server/assistant/tools/insights/get_anonymized_alerts.ts`. The allow lists configured by the user determine which alert fields will be included and anonymized. - The `postAlertsInsightsRoute` route handler returns the insights generated by the `insights-tool` to the client (browser). - Insights are rendered in the browser via the `Insight` component in `x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx` - The `AiInsights` tab in `x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx` includes the _Summary_ and _Details_ section of the Insight. - The `InsightMarkdownFormatter` in `x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/index.tsx` renders hover actions on entities (like hostnames and usernames) and other fields in the insight. - The `Insight` component makes use of the `useAssistantOverlay` hook in `x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx` to register the insight as context with the assistant. This registration process makes it possible to view insights in the assistant, and ask questions like "How do I remediate this?". In this PR, the `useAssistantOverlay` hook was enhanced to accept anonymizaton replacements. This enables an assistant conversation to (re)use replacements originally generated for an insight. --- packages/deeplinks/security/deep_links.ts | 1 + .../impl/capabilities/index.ts | 1 + .../impl/llm/actions_client_llm.ts | 11 +- .../get_capabilities_route.gen.ts | 1 + .../get_capabilities_route.schema.yaml | 3 + .../impl/schemas/index.ts | 3 + .../alerts/post_alerts_insights_route.gen.ts | 87 +++++++ .../post_alerts_insights_route.schema.yaml | 128 +++++++++ .../alerts/settings/alerts_settings.test.tsx | 18 -- .../impl/alerts/settings/alerts_settings.tsx | 1 - .../capabilities/use_capabilities.test.tsx | 6 +- .../assistant_avatar/assistant_avatar.tsx | 5 +- .../assistant/chat_send/use_chat_send.tsx | 13 +- .../impl/assistant/prompt/helpers.ts | 2 +- .../impl/assistant/prompt_context/types.ts | 8 + .../assistant/use_assistant_overlay/index.tsx | 10 +- .../impl/assistant_context/constants.tsx | 1 + .../impl/assistant_context/index.tsx | 4 +- .../connector_selector_inline.tsx | 27 +- .../get_new_selected_prompt_context/index.ts | 2 + .../get_stats/index.ts | 11 +- .../impl/data_anonymization_editor/index.tsx | 10 +- .../replacements_context_viewer/index.tsx | 31 +++ .../packages/kbn-elastic-assistant/index.ts | 13 + .../common/anonymization/index.ts | 1 + .../elastic_assistant/common/constants.ts | 3 + .../execute_custom_llm_chain/index.ts | 1 + .../server/lib/langchain/helpers.ts | 12 +- .../routes/evaluate/post_evaluate.test.ts | 1 + .../elastic_assistant/server/routes/index.ts | 3 + .../server/routes/insights/alerts/helpers.ts | 94 +++++++ .../insights/alerts/post_alerts_insights.ts | 144 ++++++++++ .../routes/post_actions_connector_execute.ts | 4 +- .../server/routes/register_routes.ts | 6 +- .../server/services/app_context.test.ts | 6 + .../plugins/elastic_assistant/server/types.ts | 12 +- .../security_solution/common/constants.ts | 1 + .../common/experimental_features.ts | 5 + .../attack/attack_chain/axis_tick/index.tsx | 51 ++++ .../ai_insights/attack/attack_chain/index.tsx | 53 ++++ .../attack/attack_chain/tactic/index.tsx | 134 ++++++++++ .../attack/mini_attack_chain/index.tsx | 71 +++++ .../attack/mini_attack_chain/translations.ts | 15 ++ .../get_alerts_insight_markdown.ts | 79 ++++++ .../public/ai_insights/helpers.ts | 79 ++++++ .../public/ai_insights/index.ts | 19 ++ .../insight/actionable_summary/index.tsx | 63 +++++ .../actions/actions_placeholder/index.tsx | 66 +++++ .../insight/actions/alerts_badge/index.tsx | 19 ++ .../ai_insights/insight/actions/index.tsx | 102 ++++++++ .../insight/actions/take_action/helpers.ts | 15 ++ .../insight/actions/take_action/index.tsx | 187 +++++++++++++ .../actions/take_action/translations.ts | 36 +++ .../insight/actions/translations.ts | 22 ++ .../insight/actions/use_add_to_case/index.tsx | 106 ++++++++ .../actions/use_add_to_case/translations.ts | 31 +++ .../use_add_to_existing_case/index.tsx | 82 ++++++ .../use_add_to_existing_case/translations.ts | 31 +++ .../public/ai_insights/insight/index.tsx | 143 ++++++++++ .../ai_insights/insight/interval/helpers.ts | 28 ++ .../insight/loading_placeholder/index.tsx | 42 +++ .../insight/tabs/ai_insights/index.tsx | 135 ++++++++++ .../insight/tabs/ai_insights/translations.ts | 43 +++ .../ai_insights/insight/tabs/alerts/index.tsx | 59 +++++ .../ai_insights/insight/tabs/get_tabs.tsx | 58 +++++ .../public/ai_insights/insight/tabs/index.tsx | 60 +++++ .../ai_insights/insight/tabs/translations.ts | 22 ++ .../ai_insights/insight/title/index.tsx | 66 +++++ .../insight/view_in_ai_assistant/index.tsx | 73 ++++++ .../view_in_ai_assistant/translations.ts | 15 ++ .../get_host_flyout_panel_props.ts | 41 +++ .../get_user_flyout_panel_props.ts | 41 +++ .../field_markdown_renderer/helpers.ts | 31 +++ .../field_markdown_renderer/index.tsx | 73 ++++++ .../insight_markdown_formatter/index.tsx | 53 ++++ .../insight_markdown_parser/helpers.ts | 26 ++ .../insight_markdown_parser/index.tsx | 64 +++++ .../insight_markdown_formatter/types.ts | 15 ++ .../public/ai_insights/links.ts | 26 ++ .../empty_prompt/animated_counter/index.tsx | 63 +++++ .../ai_insights/pages/empty_prompt/index.tsx | 120 +++++++++ .../pages/empty_prompt/translations.ts | 42 +++ .../public/ai_insights/pages/header/index.tsx | 82 ++++++ .../public/ai_insights/pages/helpers.ts | 104 ++++++++ .../public/ai_insights/pages/index.tsx | 246 ++++++++++++++++++ .../pages/loading_callout/countdown/index.tsx | 123 +++++++++ .../generation_timing/index.tsx | 63 +++++ .../countdown/last_times_popover/helpers.ts | 38 +++ .../countdown/last_times_popover/index.tsx | 63 +++++ .../last_times_popover/translations.ts | 25 ++ .../loading_callout/countdown/translations.ts | 38 +++ .../pages/loading_callout/index.tsx | 84 ++++++ .../info_popover_body/index.tsx | 80 ++++++ .../loading_messages/index.tsx | 59 +++++ .../pages/loading_callout/translations.ts | 21 ++ .../ai_insights/pages/page_title/index.tsx | 59 +++++ .../pages/session_storage/index.ts | 36 +++ .../ai_insights/pages/summary/index.tsx | 58 +++++ .../ai_insights/pages/summary_count/index.tsx | 102 ++++++++ .../pages/summary_count/translations.ts | 27 ++ .../public/ai_insights/pages/translations.ts | 29 +++ .../ai_insights/pages/upgrade/index.tsx | 61 +++++ .../ai_insights/pages/upgrade/translations.ts | 33 +++ .../public/ai_insights/routes.tsx | 30 +++ .../public/ai_insights/translations.ts | 78 ++++++ .../public/ai_insights/types.ts | 23 ++ .../public/ai_insights/use_insights/index.tsx | 245 +++++++++++++++++ .../public/app/translations.ts | 4 + .../security_solution/public/app_links.ts | 4 + .../security_solution/public/cases/links.ts | 2 +- .../public/cloud_security_posture/links.ts | 2 +- .../security_side_nav/categories.ts | 1 + .../security_solution/public/explore/links.ts | 2 +- .../public/lazy_sub_plugins.tsx | 2 + .../public/management/links.ts | 2 +- .../security_solution/public/plugin.tsx | 2 + .../public/threat_intelligence/links.ts | 2 +- .../public/timelines/links.ts | 2 +- .../plugins/security_solution/public/types.ts | 3 + .../esql_language_knowledge_base_tool.ts | 7 +- .../server/assistant/tools/index.ts | 5 +- .../tools/insights/get_anonymized_alerts.ts | 62 +++++ .../tools/insights/get_insights_prompt.ts | 19 ++ .../tools/insights/get_output_parser.ts | 80 ++++++ .../assistant/tools/insights/helpers.ts | 19 ++ .../assistant/tools/insights/insights_tool.ts | 105 ++++++++ .../security_solution/server/plugin.ts | 1 + 127 files changed, 5370 insertions(+), 53 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/replacements_context_viewer/index.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/insights/alerts/post_alerts_insights.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/axis_tick/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/tactic/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/index.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/actions_placeholder/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/alerts_badge/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/interval/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/loading_placeholder/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/tabs/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/title/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/types.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/links.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/animated_counter/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/generation_timing/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/info_popover_body/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/loading_messages/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/session_storage/index.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/routes.tsx create mode 100644 x-pack/plugins/security_solution/public/ai_insights/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/types.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/insights/get_anonymized_alerts.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/insights/get_insights_prompt.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/insights/get_output_parser.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/insights/insights_tool.ts diff --git a/packages/deeplinks/security/deep_links.ts b/packages/deeplinks/security/deep_links.ts index 2b384a9f1ff4a..7cf870a6ecdff 100644 --- a/packages/deeplinks/security/deep_links.ts +++ b/packages/deeplinks/security/deep_links.ts @@ -8,6 +8,7 @@ export enum SecurityPageName { administration = 'administration', + aiInsights = 'ai_insights', alerts = 'alerts', assets = 'assets', blocklist = 'blocklist', diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts index 2a6cce1adbdbb..348ac6188514d 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts @@ -14,5 +14,6 @@ export type AssistantFeatures = { [K in keyof typeof defaultAssistantFeatures]: * Default features available to the elastic assistant */ export const defaultAssistantFeatures = Object.freeze({ + assistantAlertsInsights: false, assistantModelEvaluation: false, }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/llm/actions_client_llm.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/llm/actions_client_llm.ts index 7dd11097eb661..ab77e90c1c3f4 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/llm/actions_client_llm.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/llm/actions_client_llm.ts @@ -15,6 +15,9 @@ import { getMessageContentAndRole } from './helpers'; const LLM_TYPE = 'ActionsClientLlm'; +const DEFAULT_OPEN_AI_TEMPERATURE = 0.2; +const DEFAULT_TEMPERATURE = 0; + interface ActionsClientLlmParams { actions: ActionsPluginStart; connectorId: string; @@ -22,6 +25,7 @@ interface ActionsClientLlmParams { logger: Logger; request: KibanaRequest; model?: string; + temperature?: number; traceId?: string; } @@ -37,6 +41,7 @@ export class ActionsClientLlm extends LLM { protected llmType: string; model?: string; + temperature?: number; constructor({ actions, @@ -46,6 +51,7 @@ export class ActionsClientLlm extends LLM { logger, model, request, + temperature, }: ActionsClientLlmParams) { super({}); @@ -56,6 +62,7 @@ export class ActionsClientLlm extends LLM { this.#logger = logger; this.#request = request; this.model = model; + this.temperature = temperature; } _llmType() { @@ -87,8 +94,8 @@ export class ActionsClientLlm extends LLM { model: this.model, messages: [assistantMessage], // the assistant message ...(this.llmType === 'openai' - ? { n: 1, stop: null, temperature: 0.2 } - : { temperature: 0, stopSequences: [] }), + ? { n: 1, stop: null, temperature: this.temperature ?? DEFAULT_OPEN_AI_TEMPERATURE } + : { temperature: this.temperature ?? DEFAULT_TEMPERATURE, stopSequences: [] }), }, }, }; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts index 5d218afd48131..5a5eaac37b3a6 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.gen.ts @@ -18,5 +18,6 @@ import { z } from 'zod'; export type GetCapabilitiesResponse = z.infer; export const GetCapabilitiesResponse = z.object({ + assistantAlertsInsights: z.boolean(), assistantModelEvaluation: z.boolean(), }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml index 4664405cfef33..65e9be9b2eb9c 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/capabilities/get_capabilities_route.schema.yaml @@ -19,9 +19,12 @@ paths: schema: type: object properties: + assistantAlertsInsights: + type: boolean assistantModelEvaluation: type: boolean required: + - assistantAlertsInsights - assistantModelEvaluation '400': description: Generic Error diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts index 2a77b795de95f..c3d635fdf94a7 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/index.ts @@ -18,6 +18,9 @@ export const API_VERSIONS = { export const PUBLIC_API_ACCESS = 'public'; export const INTERNAL_API_ACCESS = 'internal'; +// Alerts Insights Schemas +export * from './insights/alerts/post_alerts_insights_route.gen'; + // Evaluation Schemas export * from './evaluation/post_evaluate_route.gen'; export * from './evaluation/get_evaluate_route.gen'; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts new file mode 100644 index 0000000000000..940a533a20d16 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Alerts insights API endpoint + * version: 1 + */ + +import { AnonymizationFieldResponse } from '../../anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { Replacements } from '../../conversations/common_attributes.gen'; + +/** + * An insight generated from one or more alerts + */ +export type AlertsInsight = z.infer; +export const AlertsInsight = z.object({ + /** + * The alert IDs that the insight is based on + */ + alertIds: z.array(z.string()), + /** + * A detailed insight with bulleted markdown that always uses special syntax for field names and values from the source data. + */ + detailsMarkdown: z.string(), + /** + * A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same syntax + */ + entitySummaryMarkdown: z.string(), + /** + * An array of MITRE ATT&CK tactic for the insight + */ + mitreAttackTactics: z.array(z.string()).optional(), + /** + * A markdown summary of insight, using the same syntax + */ + summaryMarkdown: z.string(), + /** + * A title for the insight, in plain text + */ + title: z.string(), +}); + +export type AlertsInsightsPostRequestBody = z.infer; +export const AlertsInsightsPostRequestBody = z.object({ + alertsIndexPattern: z.string(), + anonymizationFields: z.array(AnonymizationFieldResponse), + connectorId: z.string(), + actionTypeId: z.string(), + model: z.string().optional(), + replacements: Replacements.optional(), + size: z.number(), + subAction: z.enum(['invokeAI', 'invokeStream']), +}); +export type AlertsInsightsPostRequestBodyInput = z.input; + +export type AlertsInsightsPostResponse = z.infer; +export const AlertsInsightsPostResponse = z.object({ + connector_id: z.string().optional(), + insights: z.array(AlertsInsight).optional(), + replacements: Replacements.optional(), + status: z.string().optional(), + /** + * Trace Data + */ + trace_data: z + .object({ + /** + * Could be any string, not necessarily a UUID + */ + transactionId: z.string().optional(), + /** + * Could be any string, not necessarily a UUID + */ + traceId: z.string().optional(), + }) + .optional(), +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml new file mode 100644 index 0000000000000..43cde96523a1a --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml @@ -0,0 +1,128 @@ +openapi: 3.0.0 +info: + title: Alerts insights API endpoint + version: '1' +components: + x-codegen-enabled: true + schemas: + AlertsInsight: + type: object + description: An insight generated from one or more alerts + required: + - 'alertIds' + - 'detailsMarkdown' + - 'entitySummaryMarkdown' + - 'summaryMarkdown' + - 'title' + properties: + alertIds: + description: The alert IDs that the insight is based on + items: + type: string + type: array + detailsMarkdown: + description: A detailed insight with bulleted markdown that always uses special syntax for field names and values from the source data. + type: string + entitySummaryMarkdown: + description: A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same syntax + type: string + mitreAttackTactics: + description: An array of MITRE ATT&CK tactic for the insight + items: + type: string + type: array + summaryMarkdown: + description: A markdown summary of insight, using the same syntax + type: string + title: + description: A title for the insight, in plain text + type: string + + +paths: + /internal/elastic_assistant/insights/alerts: + post: + operationId: AlertsInsightsPost + x-codegen-enabled: true + description: Generate insights from alerts + summary: Generate insights from alerts via the Elastic Assistant + tags: + - insights + - alerts + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - actionTypeId + - alertsIndexPattern + - anonymizationFields + - connectorId + - size + - subAction + properties: + alertsIndexPattern: + type: string + anonymizationFields: + items: + $ref: '../../anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml#/components/schemas/AnonymizationFieldResponse' + type: array + connectorId: + type: string + actionTypeId: + type: string + model: + type: string + replacements: + $ref: '../../conversations/common_attributes.schema.yaml#/components/schemas/Replacements' + size: + type: number + subAction: + type: string + enum: + - invokeAI + - invokeStream + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + connector_id: + type: string + insights: + type: array + items: + $ref: '#/components/schemas/AlertsInsight' + replacements: + $ref: '../../conversations/common_attributes.schema.yaml#/components/schemas/Replacements' + status: + type: string + trace_data: + type: object + description: Trace Data + properties: + transactionId: + type: string + description: Could be any string, not necessarily a UUID + traceId: + type: string + description: Could be any string, not necessarily a UUID + '400': + description: Bad request + content: + application/json: + schema: + type: object + properties: + statusCode: + type: number + error: + type: string + message: + type: string + diff --git a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.test.tsx index cfdbcdc4a86b9..9c5bde379e1f4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.test.tsx @@ -84,22 +84,4 @@ describe('AlertsSettings', () => { expect(screen.getByTestId('alertsRange')).not.toBeDisabled(); }); - - it('disables the alerts range slider when knowledgeBase.isEnabledRAGAlerts is false', () => { - const setUpdatedKnowledgeBaseSettings = jest.fn(); - const knowledgeBase: KnowledgeBaseConfig = { - isEnabledRAGAlerts: false, // <-- false - isEnabledKnowledgeBase: false, - latestAlerts: DEFAULT_LATEST_ALERTS, - }; - - render( - - ); - - expect(screen.getByTestId('alertsRange')).toBeDisabled(); - }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx index 6895d2f595e73..b4330bbea0301 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/alerts/settings/alerts_settings.tsx @@ -81,7 +81,6 @@ const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSetting aria-label={i18n.ALERTS_RANGE} compressed data-test-subj="alertsRange" - disabled={!knowledgeBase.isEnabledRAGAlerts} id={inputRangeSliderId} max={MAX_LATEST_ALERTS} min={MIN_LATEST_ALERTS} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.test.tsx index b7648983e6f7a..258788a09304f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.test.tsx @@ -13,7 +13,11 @@ import React from 'react'; import { useCapabilities, UseCapabilitiesParams } from './use_capabilities'; import { API_VERSIONS } from '@kbn/elastic-assistant-common'; -const statusResponse = { assistantModelEvaluation: true, assistantStreamingEnabled: false }; +const statusResponse = { + assistantAlertsInsights: false, + assistantModelEvaluation: true, + assistantStreamingEnabled: false, +}; const http = { get: jest.fn().mockResolvedValue(statusResponse), diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar.tsx index 20b378447b7b8..0c66b412fc5d2 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_avatar/assistant_avatar.tsx @@ -12,6 +12,7 @@ export interface AssistantAvatarProps { // Required for EuiAvatar `iconType` prop // eslint-disable-next-line react/no-unused-prop-types children?: ReactNode; + className?: string; } export const sizeMap = { @@ -20,6 +21,7 @@ export const sizeMap = { m: 32, s: 24, xs: 16, + xxs: 12, }; /** @@ -27,8 +29,9 @@ export const sizeMap = { * * TODO: Can be removed once added to EUI */ -export const AssistantAvatar = ({ size = 's' }: AssistantAvatarProps) => ( +export const AssistantAvatar = ({ className, size = 's' }: AssistantAvatarProps) => ( ((acc, context) => ({ ...acc, ...context.replacements }), {}); + + const replacements: Replacements = { + ...baseReplacements, + ...selectedPromptContextsReplacements, + }; const updatedMessages = [...currentConversation.messages, userMessage].map((m) => ({ ...m, content: m.content ?? '', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts index 4ce75d3a3abae..9ffe032fccd6b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts @@ -65,7 +65,7 @@ export function getCombinedMessage({ .map((id) => { const promptContextData = transformRawData({ anonymizationFields: selectedPromptContexts[id].contextAnonymizationFields?.data ?? [], - currentReplacements, + currentReplacements: { ...currentReplacements, ...selectedPromptContexts[id].replacements }, getAnonymizedValue, onNewReplacements, rawData: selectedPromptContexts[id].rawData, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts index 330fc12c2c78f..d278189741347 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts @@ -54,6 +54,12 @@ export interface PromptContext { * A unique identifier for this prompt context */ id: string; + + /** + * Replacements associated with the context, i.e. replacements for an insight provided as context + */ + replacements?: Record; + /** * An optional user prompt that's filled in, but not sent, when the Elastic AI Assistant opens */ @@ -75,6 +81,8 @@ export interface SelectedPromptContext { promptContextId: string; /** this data is not anonymized */ rawData: string | Record; + /** replacements associated with the context, i.e. replacements for an insight provided as context */ + replacements?: Record; } /** diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx index 16f372b415208..64dae3134f18e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx @@ -65,7 +65,12 @@ export const useAssistantOverlay = ( /** * The assistant will display this tooltip when the user hovers over the context pill */ - tooltip: PromptContext['tooltip'] + tooltip: PromptContext['tooltip'], + + /** + * Optionally provide a map of replacements associated with the context, i.e. replacements for an insight that's provided as context + */ + replacements?: Record | null ): UseAssistantOverlay => { // memoize the props so that we can use them in the effect below: const _category: PromptContext['category'] = useMemo(() => category, [category]); @@ -83,6 +88,7 @@ export const useAssistantOverlay = ( [suggestedUserPrompt] ); const _tooltip = useMemo(() => tooltip, [tooltip]); + const _replacements = useMemo(() => replacements, [replacements]); // the assistant context is used to show/hide the assistant overlay: const { @@ -115,6 +121,7 @@ export const useAssistantOverlay = ( id: promptContextId, suggestedUserPrompt: _suggestedUserPrompt, tooltip: _tooltip, + replacements: _replacements ?? undefined, }; registerPromptContext(newContext); @@ -124,6 +131,7 @@ export const useAssistantOverlay = ( _category, _description, _getPromptContext, + _replacements, _suggestedUserPrompt, _tooltip, promptContextId, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx index 5ce94da04d8bc..61cf86eae9718 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx @@ -7,6 +7,7 @@ import { KnowledgeBaseConfig } from '../assistant/types'; +export const AI_INSIGHTS_STORAGE_KEY = 'aiInsights'; export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault'; export const QUICK_PROMPT_LOCAL_STORAGE_KEY = 'quickPrompts'; export const SYSTEM_PROMPT_LOCAL_STORAGE_KEY = 'systemPrompts'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index 7d9cc86941f34..f51a72e314964 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -277,13 +277,14 @@ export const AssistantProvider: React.FC = ({ // Fetch assistant capabilities const { data: capabilities } = useCapabilities({ http, toasts }); - const { assistantModelEvaluation: modelEvaluatorEnabled } = + const { assistantAlertsInsights, assistantModelEvaluation: modelEvaluatorEnabled } = capabilities ?? defaultAssistantFeatures; const value = useMemo( () => ({ actionTypeRegistry, alertsIndexPattern, + assistantAlertsInsights, assistantAvailability, assistantTelemetry, augmentMessageCodeBlocks, @@ -323,6 +324,7 @@ export const AssistantProvider: React.FC = ({ [ actionTypeRegistry, alertsIndexPattern, + assistantAlertsInsights, assistantAvailability, assistantTelemetry, augmentMessageCodeBlocks, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx index 58a16fc45cfdb..c3197cd97e3c7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx @@ -25,7 +25,9 @@ interface Props { selectedConnectorId?: string; selectedConversation?: Conversation; isFlyoutMode: boolean; - onConnectorSelected: (conversation: Conversation) => void; + onConnectorIdSelected?: (connectorId: string) => void; + onConnectorSelected?: (conversation: Conversation) => void; + showLabel?: boolean; } const inputContainerClassName = css` @@ -69,6 +71,8 @@ export const ConnectorSelectorInline: React.FC = React.memo( selectedConnectorId, selectedConversation, isFlyoutMode, + showLabel = true, + onConnectorIdSelected, onConnectorSelected, }) => { const [isOpen, setIsOpen] = useState(false); @@ -112,12 +116,17 @@ export const ConnectorSelectorInline: React.FC = React.memo( model, }, }); - if (conversation) { + + if (conversation && onConnectorSelected != null) { onConnectorSelected(conversation); } } + + if (onConnectorIdSelected != null) { + onConnectorIdSelected(connectorId); + } }, - [selectedConversation, setApiConfig, onConnectorSelected] + [selectedConversation, setApiConfig, onConnectorIdSelected, onConnectorSelected] ); if (isFlyoutMode) { @@ -168,11 +177,13 @@ export const ConnectorSelectorInline: React.FC = React.memo( justifyContent={'flexStart'} responsive={false} > - - - {i18n.INLINE_CONNECTOR_LABEL} - - + {showLabel && ( + + + {i18n.INLINE_CONNECTOR_LABEL} + + + )} {isOpen ? ( ( @@ -50,6 +51,7 @@ export async function getNewSelectedPromptContext({ }, promptContextId: promptContext.id, rawData, + replacements: promptContext.replacements, }; } } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts index 0aee90e7db5eb..87b4b34a9b36b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts @@ -13,9 +13,11 @@ import { Stats } from '../helpers'; export const getStats = ({ anonymizationFields = [], rawData, + replacements, }: { anonymizationFields?: AnonymizationFieldResponse[]; rawData?: string | Record; + replacements?: Record; }): Stats => { const ZERO_STATS = { allowed: 0, @@ -35,7 +37,14 @@ export const getStats = ({ total: anonymizationFields.length, }; } else if (typeof rawData === 'string') { - return ZERO_STATS; + if (replacements == null) { + return ZERO_STATS; + } else { + return { + ...ZERO_STATS, + anonymized: Object.keys(replacements).length, + }; + } } else { const rawFields = Object.keys(rawData); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx index 6f0d1307eaf83..bdabbd3cdf3b2 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx @@ -15,6 +15,7 @@ import { getIsDataAnonymizable, updateSelectedPromptContext } from './helpers'; import { ReadOnlyContextViewer } from './read_only_context_viewer'; import { ContextEditorFlyout } from './context_editor_flyout'; import { ContextEditor } from './context_editor'; +import { ReplacementsContextViewer } from './replacements_context_viewer'; import { Stats } from './stats'; const EditorContainer = styled.div` @@ -92,7 +93,14 @@ const DataAnonymizationEditorComponent: React.FC = ({ {typeof selectedPromptContext.rawData === 'string' ? ( - + selectedPromptContext.replacements != null ? ( + + ) : ( + + ) ) : ( ; +} + +const ReplacementsContextViewerComponent: React.FC = ({ markdown, replacements }) => { + const markdownWithOriginalValues = Object.keys(replacements).reduce( + (acc, uuid) => acc.replaceAll(uuid, replacements[uuid]), + markdown + ); + + return ( +
+ {markdownWithOriginalValues} +
+ ); +}; + +ReplacementsContextViewerComponent.displayName = 'ReplacementsContextViewer'; + +export const ReplacementsContextViewer = React.memo(ReplacementsContextViewerComponent); diff --git a/x-pack/packages/kbn-elastic-assistant/index.ts b/x-pack/packages/kbn-elastic-assistant/index.ts index b65a1b4dfaa70..6b3cb0044db6b 100644 --- a/x-pack/packages/kbn-elastic-assistant/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/index.ts @@ -74,6 +74,17 @@ export { analyzeMarkdown } from './impl/assistant/use_conversation/helpers'; /** Default Elastic AI Assistant logo, can be removed once included in EUI **/ export { AssistantAvatar } from './impl/assistant/assistant_avatar/assistant_avatar'; +export { ConnectorSelectorInline } from './impl/connectorland/connector_selector_inline/connector_selector_inline'; + +export { + AI_INSIGHTS_STORAGE_KEY, + DEFAULT_ASSISTANT_NAMESPACE, + DEFAULT_LATEST_ALERTS, + KNOWLEDGE_BASE_LOCAL_STORAGE_KEY, +} from './impl/assistant_context/constants'; + +export { useLoadConnectors } from './impl/connectorland/use_load_connectors'; + export { ELASTIC_AI_ASSISTANT_TITLE, WELCOME_CONVERSATION_TITLE, @@ -146,3 +157,5 @@ export * from './impl/assistant/api/conversations/bulk_update_actions_conversati export { getConversationById } from './impl/assistant/api/conversations/conversations'; export { mergeBaseWithPersistedConversations } from './impl/assistant/helpers'; + +export { UpgradeButtons } from './impl/upgrade/upgrade_buttons'; diff --git a/x-pack/plugins/elastic_assistant/common/anonymization/index.ts b/x-pack/plugins/elastic_assistant/common/anonymization/index.ts index d88cc68c36c74..28db0c412f6f2 100644 --- a/x-pack/plugins/elastic_assistant/common/anonymization/index.ts +++ b/x-pack/plugins/elastic_assistant/common/anonymization/index.ts @@ -28,6 +28,7 @@ export const DEFAULT_ALLOW = [ 'host.name', 'host.risk.calculated_level', 'host.risk.calculated_score_norm', + 'kibana.alert.original_time', 'kibana.alert.last_detected', 'kibana.alert.risk_score', 'kibana.alert.rule.description', diff --git a/x-pack/plugins/elastic_assistant/common/constants.ts b/x-pack/plugins/elastic_assistant/common/constants.ts index 33b118286c513..f66d76dac0372 100755 --- a/x-pack/plugins/elastic_assistant/common/constants.ts +++ b/x-pack/plugins/elastic_assistant/common/constants.ts @@ -12,6 +12,9 @@ export const BASE_PATH = '/internal/elastic_assistant'; export const POST_ACTIONS_CONNECTOR_EXECUTE = `${BASE_PATH}/actions/connector/{connectorId}/_execute`; +// Insights +export const INSIGHTS_ALERTS = `${BASE_PATH}/insights/alerts`; + // Knowledge Base export const KNOWLEDGE_BASE = `${BASE_PATH}/knowledge_base/{resource?}`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts index 24083fb6267ce..04e65307637e2 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts @@ -99,6 +99,7 @@ export const callAgentExecutor: AgentExecutor = async ({ alertsIndexPattern, isEnabledKnowledgeBase, chain, + llm, esClient, modelExists, onNewReplacements, diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts index 34f9bd9889e5a..5976a2ef253a7 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts @@ -8,8 +8,10 @@ import { KibanaRequest } from '@kbn/core-http-server'; import type { Message } from '@kbn/elastic-assistant-common'; import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages'; - -import { ExecuteConnectorRequestBody } from '@kbn/elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.gen'; +import { + AlertsInsightsPostRequestBody, + ExecuteConnectorRequestBody, +} from '@kbn/elastic-assistant-common'; export const getLangChainMessage = ( assistantMessage: Pick @@ -31,7 +33,11 @@ export const getLangChainMessages = ( ): BaseMessage[] => assistantMessages.map(getLangChainMessage); export const requestHasRequiredAnonymizationParams = ( - request: KibanaRequest + request: KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AlertsInsightsPostRequestBody + > ): boolean => { const { replacements } = request?.body ?? {}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.test.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.test.ts index 0555b313f27f9..f484604ee64bb 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.test.ts @@ -45,6 +45,7 @@ describe('Post Evaluate Route', () => { describe('Capabilities', () => { it('returns a 404 if evaluate feature is not registered', async () => { context.elasticAssistant.getRegisteredFeatures.mockReturnValueOnce({ + assistantAlertsInsights: false, assistantModelEvaluation: false, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/index.ts b/x-pack/plugins/elastic_assistant/server/routes/index.ts index e3853ea713842..32886fe150ee9 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/index.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/index.ts @@ -8,6 +8,9 @@ // Actions Connector Execute (LLM Wrapper) export { postActionsConnectorExecuteRoute } from './post_actions_connector_execute'; +// Alerts Insights +export { postAlertsInsightsRoute } from './insights/alerts/post_alerts_insights'; + // Knowledge Base export { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base'; export { getKnowledgeBaseStatusRoute } from './knowledge_base/get_knowledge_base_status'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts new file mode 100644 index 0000000000000..4017342eb4a09 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts @@ -0,0 +1,94 @@ +/* + * 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 { KibanaRequest } from '@kbn/core/server'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { + AlertsInsightsPostRequestBody, + ExecuteConnectorRequestBody, + Replacements, +} from '@kbn/elastic-assistant-common'; +import { ActionsClientLlm } from '@kbn/elastic-assistant-common/impl/llm'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { uniq } from 'lodash/fp'; +import { v4 as uuidv4 } from 'uuid'; + +import { AssistantToolParams, ElasticAssistantApiRequestHandlerContext } from '../../../types'; + +export const REQUIRED_FOR_INSIGHTS: AnonymizationFieldResponse[] = [ + { + id: uuidv4(), + field: '_id', + allowed: true, + anonymized: true, + }, + { + id: uuidv4(), + field: 'kibana.alert.original_time', + allowed: true, + anonymized: false, + }, +]; + +/** + * Adds the specified fields to the allow / allow replacement list + */ +export const addRequiredFields = ({ + list, + requiredFields, +}: { + list: string[]; + requiredFields: string[]; +}): string[] => uniq([...list, ...requiredFields]); + +export const getAssistantToolParams = ({ + alertsIndexPattern, + anonymizationFields, + esClient, + latestReplacements, + llm, + onNewReplacements, + request, + size, +}: { + alertsIndexPattern: string; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + latestReplacements: Replacements; + llm: ActionsClientLlm; + onNewReplacements: (newReplacements: Replacements) => void; + request: KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AlertsInsightsPostRequestBody + >; + size: number; +}): AssistantToolParams => ({ + alertsIndexPattern, + anonymizationFields: [...(anonymizationFields ?? []), ...REQUIRED_FOR_INSIGHTS], + isEnabledKnowledgeBase: false, // not required for insights + chain: undefined, // not required for insights + esClient, + llm, + modelExists: false, // not required for insights + onNewReplacements, + replacements: latestReplacements, + request, + size, +}); + +export const isInsightsFeatureEnabled = ({ + assistantContext, + pluginName, +}: { + assistantContext: ElasticAssistantApiRequestHandlerContext; + pluginName: string; +}): boolean => { + const registeredFeatures = assistantContext.getRegisteredFeatures(pluginName); + + return registeredFeatures.assistantAlertsInsights === true; +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/post_alerts_insights.ts b/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/post_alerts_insights.ts new file mode 100644 index 0000000000000..455f50703e836 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/post_alerts_insights.ts @@ -0,0 +1,144 @@ +/* + * 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 { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; +import { ActionsClientLlm } from '@kbn/elastic-assistant-common/impl/llm'; +import { type IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; +import { + AlertsInsightsPostRequestBody, + AlertsInsightsPostResponse, + ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + Replacements, +} from '@kbn/elastic-assistant-common'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { INSIGHTS_ALERTS } from '../../../../common/constants'; +import { getAssistantToolParams, isInsightsFeatureEnabled } from './helpers'; +import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../../helpers'; +import { buildResponse } from '../../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../../types'; +import { getLlmType } from '../../utils'; + +export const postAlertsInsightsRoute = (router: IRouter) => { + router.versioned + .post({ + access: 'internal', + path: INSIGHTS_ALERTS, + options: { + tags: ['access:elasticAssistant'], + }, + }) + .addVersion( + { + version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + validate: { + request: { + body: buildRouteValidationWithZod(AlertsInsightsPostRequestBody), + }, + response: { + 200: { + body: buildRouteValidationWithZod(AlertsInsightsPostResponse), + }, + }, + }, + }, + async (context, request, response): Promise> => { + const resp = buildResponse(response); + const assistantContext = await context.elasticAssistant; + const logger: Logger = assistantContext.logger; + + try { + // get the actions plugin start contract from the request context: + const actions = (await context.elasticAssistant).actions; + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + + // feature flag check: + const insightsFeatureEnabled = isInsightsFeatureEnabled({ + assistantContext, + pluginName, + }); + + if (!insightsFeatureEnabled) { + return response.notFound(); + } + + // get parameters from the request body + const alertsIndexPattern = decodeURIComponent(request.body.alertsIndexPattern); + const connectorId = decodeURIComponent(request.body.connectorId); + const { actionTypeId, anonymizationFields, replacements, size } = request.body; + + // get an Elasticsearch client for the authenticated user: + const esClient = (await context.core).elasticsearch.client.asCurrentUser; + + // callback to accumulate the latest replacements: + let latestReplacements: Replacements = { ...replacements }; + const onNewReplacements = (newReplacements: Replacements) => { + latestReplacements = { ...latestReplacements, ...newReplacements }; + }; + + // get the insights tool: + const assistantTools = (await context.elasticAssistant).getRegisteredTools(pluginName); + const assistantTool = assistantTools.find((tool) => tool.id === 'insights-tool'); + if (!assistantTool) { + return response.notFound(); // insights tool not found + } + + const llm = new ActionsClientLlm({ + actions, + connectorId, + llmType: getLlmType(actionTypeId), + logger, + request, + temperature: 0, // zero temperature for insights, because we want structured JSON output + }); + + const assistantToolParams = getAssistantToolParams({ + alertsIndexPattern, + anonymizationFields, + esClient, + latestReplacements, + llm, + onNewReplacements, + request, + size, + }); + + // invoke the insights tool: + const toolInstance = assistantTool.getTool(assistantToolParams); + const rawInsights = await toolInstance?.invoke(''); + if (rawInsights == null) { + return response.customError({ + body: { message: 'tool returned no insights' }, + statusCode: 500, + }); + } + + const parsedInsights = JSON.parse(rawInsights); + + return response.ok({ + body: { + connector_id: connectorId, + insights: parsedInsights, + replacements: latestReplacements, + }, + }); + } catch (err) { + logger.error(err); + const error = transformError(err); + + return resp.error({ + body: { success: false, error: error.message }, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts index 4c2c58a5af119..a82d20047aa1f 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts @@ -302,7 +302,9 @@ export const postActionsConnectorExecuteRoute = ( defaultPluginName: DEFAULT_PLUGIN_NAME, logger, }); - const assistantTools = (await context.elasticAssistant).getRegisteredTools(pluginName); + const assistantTools = (await context.elasticAssistant) + .getRegisteredTools(pluginName) + .filter((x) => x.id !== 'insights-tool'); // we don't (yet) support asking the assistant for NEW insights from a conversation // get a scoped esClient for assistant memory const esClient = (await context.core).elasticsearch.client.asCurrentUser; diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index f61eeaf13bb59..7c0f970c7f6f9 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -6,8 +6,9 @@ */ import type { KibanaRequest, Logger, SavedObjectsClientContract } from '@kbn/core/server'; - import { once } from 'lodash/fp'; + +import { postAlertsInsightsRoute } from './insights/alerts/post_alerts_insights'; import { ElasticAssistantPluginRouter, ElasticAssistantPluginSetupDependencies, @@ -78,4 +79,7 @@ export const registerRoutes = ( // Anonymization Fields bulkActionAnonymizationFieldsRoute(router, logger); findAnonymizationFieldsRoute(router, logger); + + // Alerts Insights + postAlertsInsightsRoute(router); }; diff --git a/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts b/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts index 57ca4ea18651c..395710931f5ec 100644 --- a/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts +++ b/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts @@ -53,6 +53,7 @@ describe('AppContextService', () => { it('should return default registered features when stopped ', () => { appContextService.start(mockAppContext); appContextService.registerFeatures('super', { + assistantAlertsInsights: false, assistantModelEvaluation: true, }); appContextService.stop(); @@ -102,6 +103,7 @@ describe('AppContextService', () => { it('should register and get features for a single plugin', () => { const pluginName = 'pluginName'; const features: AssistantFeatures = { + assistantAlertsInsights: false, assistantModelEvaluation: true, }; @@ -116,10 +118,12 @@ describe('AppContextService', () => { it('should register and get features for multiple plugins', () => { const pluginOne = 'plugin1'; const featuresOne: AssistantFeatures = { + assistantAlertsInsights: false, assistantModelEvaluation: true, }; const pluginTwo = 'plugin2'; const featuresTwo: AssistantFeatures = { + assistantAlertsInsights: false, assistantModelEvaluation: false, }; @@ -134,9 +138,11 @@ describe('AppContextService', () => { it('should update features if registered again', () => { const pluginName = 'pluginName'; const featuresOne: AssistantFeatures = { + assistantAlertsInsights: false, assistantModelEvaluation: true, }; const featuresTwo: AssistantFeatures = { + assistantAlertsInsights: false, assistantModelEvaluation: false, }; diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index d7e4cc4d56366..7f3ef14fba08b 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -27,12 +27,15 @@ import { AuthenticatedUser, SecurityPluginStart } from '@kbn/security-plugin/ser import { RetrievalQAChain } from 'langchain/chains'; import { ElasticsearchClient } from '@kbn/core/server'; import { + AlertsInsightsPostRequestBody, AssistantFeatures, ExecuteConnectorRequestBody, Replacements, } from '@kbn/elastic-assistant-common'; import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; import { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server'; +import { ActionsClientChatOpenAI, ActionsClientLlm } from '@kbn/elastic-assistant-common/impl/llm'; + import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/conversations'; import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context'; import { AIAssistantDataClient } from './ai_assistant_data_clients'; @@ -203,11 +206,16 @@ export interface AssistantToolParams { alertsIndexPattern?: string; anonymizationFields?: AnonymizationFieldResponse[]; isEnabledKnowledgeBase: boolean; - chain: RetrievalQAChain; + chain?: RetrievalQAChain; esClient: ElasticsearchClient; + llm?: ActionsClientLlm | ActionsClientChatOpenAI; modelExists: boolean; onNewReplacements?: (newReplacements: Replacements) => void; replacements?: Replacements; - request: KibanaRequest; + request: KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AlertsInsightsPostRequestBody + >; size?: number; } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 676132c240718..b3ac341893d69 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -98,6 +98,7 @@ export const RULES_CREATE_PATH = `${RULES_PATH}/create` as const; export const EXCEPTIONS_PATH = '/exceptions' as const; export const EXCEPTION_LIST_DETAIL_PATH = `${EXCEPTIONS_PATH}/details/:detailName` as const; export const HOSTS_PATH = '/hosts' as const; +export const AI_INSIGHTS_PATH = '/ai_insights' as const; export const USERS_PATH = '/users' as const; export const KUBERNETES_PATH = '/kubernetes' as const; export const NETWORK_PATH = '/network' as const; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index f87b6a171535d..aec2e3252e9f4 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -116,6 +116,11 @@ export const allowedExperimentalValues = Object.freeze({ */ alertsPageFiltersEnabled: true, + /** + * Enables the Assistant Alerts Insights feature and API endpoint + */ + assistantAlertsInsights: false, + /** * Enables the Assistant Model Evaluation advanced setting and API endpoint, introduced in `8.11.0`. */ diff --git a/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/axis_tick/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/axis_tick/index.tsx new file mode 100644 index 0000000000000..c4252bb541c71 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/axis_tick/index.tsx @@ -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 { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +const DEFAULT_WIDTH = 12; // px + +interface Props { + width?: number; +} + +const AxisTickComponent: React.FC = ({ width = DEFAULT_WIDTH }) => { + const { euiTheme } = useEuiTheme(); + + const TOP_CELL_HEIGHT = 3; // px + const BOTTOM_CELL_HEIGHT = 2; // px + + return ( + + + + + ); +}; + +AxisTickComponent.displayName = 'AxisTick'; + +export const AxisTick = React.memo(AxisTickComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/index.tsx new file mode 100644 index 0000000000000..ab1a042242451 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/index.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; + +import { Tactic } from './tactic'; +import { getTacticMetadata } from '../../helpers'; +import type { AlertsInsight } from '../../types'; + +interface Props { + insight: AlertsInsight; +} + +const AttackChainComponent: React.FC = ({ insight }) => { + const tacticMetadata = useMemo(() => getTacticMetadata(insight), [insight]); + + return ( + + + {tacticMetadata.map((tactic, i) => ( + + + + ))} + + + ); +}; + +AttackChainComponent.displayName = 'AttackChain'; + +export const AttackChain = React.memo(AttackChainComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/tactic/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/tactic/index.tsx new file mode 100644 index 0000000000000..798c0f82fd030 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/attack/attack_chain/tactic/index.tsx @@ -0,0 +1,134 @@ +/* + * 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, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; + +import { AxisTick } from '../axis_tick'; + +const INNER_CIRCLE_LEFT_JUSTIFY_X_OFFSET = 0; // px +const INNER_CIRCLE_RIGHT_JUSTIFY_X_OFFSET = 232; // px + +const OUTER_CIRCLE_LEFT_JUSTIFY_X_OFFSET = -4; // px +const OUTER_CIRCLE_RIGHT_JUSTIFY_X_OFFSET = 228; // px + +interface Props { + detected: boolean; + rightJustify?: boolean; + tactic: string; +} + +const TacticComponent: React.FC = ({ detected, rightJustify = false, tactic }) => { + const { euiTheme } = useEuiTheme(); + + const WIDTH = 120; // px + const TICK_COUNT = 10; + + const ticks = useMemo( + () => ( + +
+ {Array.from({ length: TICK_COUNT }).map((_, i) => ( + + + + ))} + + ), + [] + ); + + const color = detected ? euiTheme.colors.danger : euiTheme.colors.subduedText; + const innerCircleXOffset = rightJustify + ? INNER_CIRCLE_RIGHT_JUSTIFY_X_OFFSET + : INNER_CIRCLE_LEFT_JUSTIFY_X_OFFSET; + + const outerCircleXOffset = rightJustify + ? OUTER_CIRCLE_RIGHT_JUSTIFY_X_OFFSET + : OUTER_CIRCLE_LEFT_JUSTIFY_X_OFFSET; + + return ( +
+ + +
+
+ <>{ticks} + + + + + + + + + {tactic} + + + +
+ ); +}; + +TacticComponent.displayName = 'Tactic'; + +export const Tactic = React.memo(TacticComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/index.tsx new file mode 100644 index 0000000000000..feca56c5ab7e4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/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 { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { getTacticMetadata } from '../../helpers'; +import { ATTACK_CHAIN_TOOLTIP } from './translations'; +import type { AlertsInsight } from '../../types'; + +interface Props { + insight: AlertsInsight; +} + +const MiniAttackChainComponent: React.FC = ({ insight }) => { + const { euiTheme } = useEuiTheme(); + const tactics = useMemo(() => getTacticMetadata(insight), [insight]); + const detectedTactics = useMemo(() => tactics.filter((tactic) => tactic.detected), [tactics]); + + const detectedTacticsList = useMemo( + () => + detectedTactics.map(({ name, detected }) => ( +
  • + {' - '} + {name} +
  • + )), + [detectedTactics] + ); + + const tooltipContent = useMemo( + () => ( + <> +

    {ATTACK_CHAIN_TOOLTIP(detectedTactics.length)}

    +
      {detectedTacticsList}
    + + ), + [detectedTactics.length, detectedTacticsList] + ); + + return ( + + + {tactics.map(({ name, detected }) => ( + + + {'o'} + + + ))} + + + ); +}; + +MiniAttackChainComponent.displayName = 'MiniAttackChain'; + +export const MiniAttackChain = React.memo(MiniAttackChainComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/translations.ts new file mode 100644 index 0000000000000..a0bc3e0b94cfe --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/attack/mini_attack_chain/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ATTACK_CHAIN_TOOLTIP = (tacticsCount: number) => + i18n.translate('xpack.securitySolution.aiInsights.miniAttackChain.attackChainTooltip', { + defaultMessage: + '{tacticsCount} {tacticsCount, plural, one {tactic was} other {tactics were}} identified in the analysis, providing insight into the nature of the detected violations:', + values: { tacticsCount }, + }); diff --git a/x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts b/x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts new file mode 100644 index 0000000000000..c7b534fdb4e26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTacticLabel, getTacticMetadata } from '../helpers'; +import type { AlertsInsight } from '../types'; + +export const getMarkdownFields = (markdown: string): string => { + const regex = new RegExp('{{\\s*(\\S+)\\s+(\\S+)\\s*}}', 'gm'); + + return markdown.replace(regex, (_, field, value) => `\`${value}\``); +}; + +export const getAttackChainMarkdown = (insight: AlertsInsight): string => { + const tacticMetadata = getTacticMetadata(insight).filter((tactic) => tactic.detected); + + if (tacticMetadata.length === 0) { + return ''; + } + + const markdownList = tacticMetadata + .map((tactic) => `- ${getTacticLabel(tactic.name)}`) + .join('\n'); + + return `### Attack Chain +${markdownList} +`; +}; + +export const getMarkdownWithOriginalValues = ({ + markdown, + replacements, +}: { + markdown: string; + replacements?: Record; +}): string => { + if (replacements == null) { + return markdown; + } + + return Object.keys(replacements).reduce( + (acc, uuid) => acc.replaceAll(uuid, replacements[uuid]), + markdown + ); +}; + +export const getAlertsInsightMarkdown = ({ + insight, + replacements, +}: { + insight: AlertsInsight; + replacements?: Record; +}): string => { + const title = getMarkdownFields(insight.title); + const entitySummaryMarkdown = getMarkdownFields(insight.entitySummaryMarkdown); + const summaryMarkdown = getMarkdownFields(insight.summaryMarkdown); + const detailsMarkdown = getMarkdownFields(insight.detailsMarkdown); + + const markdown = `## ${title} + +${entitySummaryMarkdown} + +### Summary +${summaryMarkdown} + +### Details +${detailsMarkdown} + +${getAttackChainMarkdown(insight)} +`; + if (replacements != null) { + return getMarkdownWithOriginalValues({ markdown, replacements }); + } else { + return markdown; + } +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/helpers.ts new file mode 100644 index 0000000000000..579f36f581c09 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/helpers.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as i18n from './translations'; +import type { AlertsInsight } from './types'; + +export const RECONNAISSANCE = 'Reconnaissance'; +export const INITIAL_ACCESS = 'Initial Access'; +export const EXECUTION = 'Execution'; +export const PERSISTENCE = 'Persistence'; +export const PRIVILEGE_ESCALATION = 'Privilege Escalation'; +export const DISCOVERY = 'Discovery'; +export const LATERAL_MOVEMENT = 'Lateral Movement'; +export const COMMAND_AND_CONTROL = 'Command and Control'; +export const EXFILTRATION = 'Exfiltration'; + +/** A subset of the Mitre Attack Tactics */ +export const MITRE_ATTACK_TACTICS_SUBSET = [ + RECONNAISSANCE, + INITIAL_ACCESS, + EXECUTION, + PERSISTENCE, + PRIVILEGE_ESCALATION, + DISCOVERY, + LATERAL_MOVEMENT, + COMMAND_AND_CONTROL, + EXFILTRATION, +] as const; + +export const getTacticLabel = (tactic: string): string => { + switch (tactic) { + case RECONNAISSANCE: + return i18n.RECONNAISSANCE; + case INITIAL_ACCESS: + return i18n.INITIAL_ACCESS; + case EXECUTION: + return i18n.EXECUTION; + case PERSISTENCE: + return i18n.PERSISTENCE; + case PRIVILEGE_ESCALATION: + return i18n.PRIVILEGE_ESCALATION; + case DISCOVERY: + return i18n.DISCOVERY; + case LATERAL_MOVEMENT: + return i18n.LATERAL_MOVEMENT; + case COMMAND_AND_CONTROL: + return i18n.COMMAND_AND_CONTROL; + case EXFILTRATION: + return i18n.EXFILTRATION; + default: + return tactic; + } +}; + +interface TacticMetadata { + detected: boolean; + index: number; + name: string; +} + +export const getTacticMetadata = (insight: AlertsInsight): TacticMetadata[] => + MITRE_ATTACK_TACTICS_SUBSET.map((tactic, i) => ({ + detected: + insight.mitreAttackTactics === undefined + ? false + : insight.mitreAttackTactics.includes(tactic), + name: getTacticLabel(tactic), + index: i, + })); + +/** + * The LLM sometimes returns a string with newline literals. + * This function replaces them with actual newlines + */ +export const replaceNewlineLiterals = (markdown: string): string => markdown.replace(/\\n/g, '\n'); diff --git a/x-pack/plugins/security_solution/public/ai_insights/index.ts b/x-pack/plugins/security_solution/public/ai_insights/index.ts new file mode 100644 index 0000000000000..e9e14376b12bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SecuritySubPlugin } from '../app/types'; +import { routes } from './routes'; + +export class AiInsights { + public setup() {} + + public start(isEnabled = false): SecuritySubPlugin { + return { + routes: isEnabled ? routes : [], + }; + } +} diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx new file mode 100644 index 0000000000000..5a9a3d5d29d99 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx @@ -0,0 +1,63 @@ +/* + * 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, EuiPanel } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { InsightMarkdownFormatter } from '../../insight_markdown_formatter'; +import type { AlertsInsight } from '../../types'; +import { ViewInAiAssistant } from '../view_in_ai_assistant'; + +interface Props { + insight: AlertsInsight; + promptContextId: string | undefined; + replacements?: Record; + showAnonymized?: boolean; +} + +const ActionableSummaryComponent: React.FC = ({ + insight, + promptContextId, + replacements, + showAnonymized = false, +}) => { + const entitySummaryMarkdownWithReplacements = useMemo( + () => + Object.entries(replacements ?? {}).reduce( + (acc, [key, value]) => acc.replace(key, value), + insight.entitySummaryMarkdown + ), + [insight.entitySummaryMarkdown, replacements] + ); + + return ( + + + + + + + + + + + + ); +}; + +ActionableSummaryComponent.displayName = 'ActionableSummary'; + +export const ActionableSummary = React.memo(ActionableSummaryComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/actions_placeholder/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/actions_placeholder/index.tsx new file mode 100644 index 0000000000000..693e55829229c --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/actions_placeholder/index.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiSkeletonTitle, useEuiTheme } from '@elastic/eui'; +import React from 'react'; + +const ActionsPlaceholderComponent: React.FC = () => { + const { euiTheme } = useEuiTheme(); + + return ( +
    + + + + + + + + + + + + + +
    + ); +}; + +ActionsPlaceholderComponent.displayName = 'ActionsPlaceholder'; + +export const ActionsPlaceholder = React.memo(ActionsPlaceholderComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/alerts_badge/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/alerts_badge/index.tsx new file mode 100644 index 0000000000000..4c30abe23cb19 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/alerts_badge/index.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge } from '@elastic/eui'; +import React from 'react'; + +interface Props { + alertsCount: number; +} + +export const AlertsBadge: React.FC = ({ alertsCount }) => ( + + {alertsCount} + +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx new file mode 100644 index 0000000000000..368afbc400059 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import React from 'react'; + +import { AlertsBadge } from './alerts_badge'; +import { MiniAttackChain } from '../../attack/mini_attack_chain'; +import { TakeAction } from './take_action'; +import * as i18n from './translations'; +import type { AlertsInsight } from '../../types'; + +interface Props { + insight: AlertsInsight; + promptContextId: string | undefined; + replacements?: Record; +} + +const ActionsComponent: React.FC = ({ insight, promptContextId, replacements }) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + {i18n.ATTACK_CHAIN} + + + + + + + + + + {'|'} + + + + + + {i18n.ALERTS} + + + + + + + + + + {'|'} + + + + + + + + ); +}; + +ActionsComponent.displayName = 'Actions'; + +export const Actions = React.memo(ActionsComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts new file mode 100644 index 0000000000000..67251855a78d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getOriginalAlertIds = ({ + alertIds, + replacements, +}: { + alertIds: string[]; + replacements?: Record; +}): string[] => + alertIds.map((alertId) => (replacements != null ? replacements[alertId] ?? alertId : alertId)); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx new file mode 100644 index 0000000000000..f55f068e2be6c --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx @@ -0,0 +1,187 @@ +/* + * 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 { useAssistantContext } from '@kbn/elastic-assistant'; +import { + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + useGeneratedHtmlId, + EuiPopover, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; +import { useKibana } from '../../../../common/lib/kibana'; +import { APP_ID } from '../../../../../common'; +import { getAlertsInsightMarkdown } from '../../../get_alerts_insight_markdown/get_alerts_insight_markdown'; +import * as i18n from './translations'; +import type { AlertsInsight } from '../../../types'; +import { useAddToNewCase } from '../use_add_to_case'; +import { useAddToExistingCase } from '../use_add_to_existing_case'; + +interface Props { + conversationTitle?: string; + insight: AlertsInsight; + promptContextId: string | undefined; + replacements?: Record; +} + +const TakeActionComponent: React.FC = ({ + conversationTitle, + insight, + promptContextId, + replacements, +}) => { + // get dependencies for creating / adding to cases: + const { cases } = useKibana().services; + const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); + const canUserCreateAndReadCases = useCallback( + () => userCasesPermissions.create && userCasesPermissions.read, + [userCasesPermissions.create, userCasesPermissions.read] + ); + const { disabled: addToCaseDisabled, onAddToNewCase } = useAddToNewCase({ + canUserCreateAndReadCases, + title: insight.title, + }); + const { onAddToExistingCase } = useAddToExistingCase({ + canUserCreateAndReadCases, + }); + + // get dependencies for viewing insights in the AI assistant: + const { hasAssistantPrivilege } = useAssistantAvailability(); + const { showAssistantOverlay } = useAssistantContext(); + + // proxy show / hide calls to the assistant context, using our internal prompt context id: + const showOverlay = useCallback(() => { + showAssistantOverlay({ + conversationTitle, + promptContextId, + showOverlay: true, + }); + }, [conversationTitle, promptContextId, showAssistantOverlay]); + + // boilerplate for the take action popover: + const takeActionContextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'takeActionContextMenuPopover', + }); + const [isPopoverOpen, setPopover] = useState(false); + const onButtonClick = useCallback(() => setPopover(!isPopoverOpen), [isPopoverOpen]); + const closePopover = useCallback(() => setPopover(false), []); + + // markdown for the alert insight, which will be exported to the case, or to the assistant: + const markdown = useMemo( + () => + getAlertsInsightMarkdown({ + insight, + replacements, + }), + [insight, replacements] + ); + + // click handlers for the popover actions: + const onClickAddToNewCase = useCallback(() => { + closePopover(); + + onAddToNewCase({ + alertIds: insight.alertIds, + markdownComments: [markdown], + replacements, + }); + }, [closePopover, insight.alertIds, markdown, onAddToNewCase, replacements]); + + const onClickAddToExistingCase = useCallback(() => { + closePopover(); + + onAddToExistingCase({ + alertIds: insight.alertIds, + markdownComments: [markdown], + replacements, + }); + }, [closePopover, insight.alertIds, markdown, onAddToExistingCase, replacements]); + + const onViewInAiAssistant = useCallback(() => { + closePopover(); + showOverlay(); + }, [closePopover, showOverlay]); + + // button for the popover: + const button = useMemo( + () => ( + + {i18n.TAKE_ACTION} + + ), + [onButtonClick] + ); + + const viewInAiAssistantDisabled = useMemo( + () => !hasAssistantPrivilege || promptContextId == null, + [hasAssistantPrivilege, promptContextId] + ); + + // items for the popover: + const items = useMemo( + () => [ + + {i18n.ADD_TO_NEW_CASE} + , + + + {i18n.ADD_TO_EXISTING_CASE} + , + + + {i18n.VIEW_IN_AI_ASSISTANT} + , + ], + [ + addToCaseDisabled, + onClickAddToExistingCase, + onClickAddToNewCase, + onViewInAiAssistant, + viewInAiAssistantDisabled, + ] + ); + + return ( + + + + ); +}; + +TakeActionComponent.displayName = 'TakeAction'; +export const TakeAction = React.memo(TakeActionComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/translations.ts new file mode 100644 index 0000000000000..14bd7dd334142 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/translations.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 { i18n } from '@kbn/i18n'; + +export const ADD_TO_NEW_CASE = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.takeAction.addToNewCaseButtonLabel', + { + defaultMessage: 'Add to new case', + } +); + +export const ADD_TO_EXISTING_CASE = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.takeAction.addToExistingCaseButtonLabel', + { + defaultMessage: 'Add to existing case', + } +); + +export const TAKE_ACTION = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.takeAction.title', + { + defaultMessage: 'Take action', + } +); + +export const VIEW_IN_AI_ASSISTANT = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.takeAction.viewInAiAssistantButtonLabel', + { + defaultMessage: 'View in AI Assistant', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/translations.ts new file mode 100644 index 0000000000000..605b78711b567 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ATTACK_CHAIN = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.attackChainLabel', + { + defaultMessage: 'Attack chain:', + } +); + +export const ALERTS = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.alertsLabel', + { + defaultMessage: 'Alerts:', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx new file mode 100644 index 0000000000000..97712ca67a7e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { AttachmentType } from '@kbn/cases-plugin/common'; +import type { CaseAttachmentWithoutOwner } from '@kbn/cases-plugin/public/types'; +import { useAssistantContext } from '@kbn/elastic-assistant'; + +import { useKibana } from '../../../../common/lib/kibana'; +import * as i18n from './translations'; + +interface Props { + canUserCreateAndReadCases: () => boolean; + title: string; + onClick?: () => void; +} + +export const useAddToNewCase = ({ + canUserCreateAndReadCases, + title, + onClick, +}: Props): { + disabled: boolean; + onAddToNewCase: ({ + alertIds, + markdownComments, + replacements, + }: { + alertIds: string[]; + markdownComments: string[]; + replacements?: Record; + }) => void; +} => { + const { cases } = useKibana().services; + const { alertsIndexPattern } = useAssistantContext(); + + const createCaseFlyout = cases.hooks.useCasesAddToNewCaseFlyout({ + toastContent: i18n.ADD_TO_CASE_SUCCESS, + }); + const openCreateCaseFlyout = useCallback( + ({ + alertIds, + headerContent, + markdownComments, + replacements, + }: { + alertIds: string[]; + headerContent?: React.ReactNode; + markdownComments: string[]; + replacements?: Record; + }) => { + const userCommentAttachments = markdownComments.map((x) => ({ + comment: x, + type: AttachmentType.user, + })); + + const alertAttachments = alertIds.map((alertId) => ({ + alertId: replacements != null ? replacements[alertId] ?? alertId : alertId, + index: alertsIndexPattern ?? '', + rule: { + id: null, + name: null, + }, + type: AttachmentType.alert, + })); + + const attachments = [...userCommentAttachments, ...alertAttachments]; + + createCaseFlyout.open({ + attachments, + headerContent, + }); + }, + [alertsIndexPattern, createCaseFlyout] + ); + + const headerContent = useMemo(() =>
    {i18n.CREATE_A_CASE_FOR_INSIGHT(title)}
    , [title]); + + const onAddToNewCase = useCallback( + ({ + alertIds, + markdownComments, + replacements, + }: { + alertIds: string[]; + markdownComments: string[]; + replacements?: Record; + }) => { + if (onClick) { + onClick(); + } + + openCreateCaseFlyout({ alertIds, headerContent, markdownComments, replacements }); + }, + [headerContent, onClick, openCreateCaseFlyout] + ); + + return { + disabled: !canUserCreateAndReadCases(), + onAddToNewCase, + }; +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts new file mode 100644 index 0000000000000..0d46653de0435 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ADD_TO_CASE_SUCCESS = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.useAddToCase.addToCaseSuccessLabel', + { + defaultMessage: 'Successfully added insight to the case', + } +); + +export const ADD_TO_NEW_CASE = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.useAddToCase.addToNewCaseButtonLabel', + { + defaultMessage: 'Add to new case', + } +); + +export const CREATE_A_CASE_FOR_INSIGHT = (title: string) => + i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.useAddToCase.createACaseForInsightHeaderText', + { + values: { title }, + defaultMessage: 'Create a case for insight {title}', + } + ); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx new file mode 100644 index 0000000000000..9626505907f72 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx @@ -0,0 +1,82 @@ +/* + * 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 { AttachmentType } from '@kbn/cases-plugin/common'; +import type { CaseAttachmentWithoutOwner } from '@kbn/cases-plugin/public/types'; +import { useAssistantContext } from '@kbn/elastic-assistant'; +import { useCallback } from 'react'; + +import { useKibana } from '../../../../common/lib/kibana'; +import * as i18n from './translations'; + +interface Props { + canUserCreateAndReadCases: () => boolean; + onClick?: () => void; +} + +export const useAddToExistingCase = ({ + canUserCreateAndReadCases, + onClick, +}: Props): { + disabled: boolean; + onAddToExistingCase: ({ + alertIds, + markdownComments, + replacements, + }: { + alertIds: string[]; + markdownComments: string[]; + replacements?: Record; + }) => void; +} => { + const { cases } = useKibana().services; + const { alertsIndexPattern } = useAssistantContext(); + + const { open: openSelectCaseModal } = cases.hooks.useCasesAddToExistingCaseModal({ + onClose: onClick, + successToaster: { + title: i18n.ADD_TO_CASE_SUCCESS, + }, + }); + + const onAddToExistingCase = useCallback( + ({ + alertIds, + markdownComments, + replacements, + }: { + alertIds: string[]; + markdownComments: string[]; + replacements?: Record; + }) => { + const userCommentAttachments = markdownComments.map((x) => ({ + comment: x, + type: AttachmentType.user, + })); + + const alertAttachments = alertIds.map((alertId) => ({ + alertId: replacements != null ? replacements[alertId] ?? alertId : alertId, + index: alertsIndexPattern ?? '', + rule: { + id: null, + name: null, + }, + type: AttachmentType.alert, + })); + + const attachments = [...userCommentAttachments, ...alertAttachments]; + + openSelectCaseModal({ getAttachments: () => attachments }); + }, + [alertsIndexPattern, openSelectCaseModal] + ); + + return { + disabled: !canUserCreateAndReadCases(), + onAddToExistingCase, + }; +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/translations.ts new file mode 100644 index 0000000000000..0d46653de0435 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/translations.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ADD_TO_CASE_SUCCESS = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.useAddToCase.addToCaseSuccessLabel', + { + defaultMessage: 'Successfully added insight to the case', + } +); + +export const ADD_TO_NEW_CASE = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.useAddToCase.addToNewCaseButtonLabel', + { + defaultMessage: 'Add to new case', + } +); + +export const CREATE_A_CASE_FOR_INSIGHT = (title: string) => + i18n.translate( + 'xpack.securitySolution.aiInsights.insight.actions.useAddToCase.createACaseForInsightHeaderText', + { + values: { title }, + defaultMessage: 'Create a case for insight {title}', + } + ); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx new file mode 100644 index 0000000000000..1eece699133ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { css } from '@emotion/react'; +import { EuiAccordion, EuiPanel, EuiSpacer, useEuiTheme, useGeneratedHtmlId } from '@elastic/eui'; +import { useAssistantOverlay } from '@kbn/elastic-assistant'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { ActionableSummary } from './actionable_summary'; +import { Actions } from './actions'; +import { useAssistantAvailability } from '../../assistant/use_assistant_availability'; +import { getAlertsInsightMarkdown } from '../get_alerts_insight_markdown/get_alerts_insight_markdown'; +import { Tabs } from './tabs'; +import { Title } from './title'; +import type { AlertsInsight } from '../types'; + +const useAssistantNoop = () => ({ promptContextId: undefined }); + +/** + * This category is provided in the prompt context for the assistant + */ +const category = 'insight'; + +interface Props { + initialIsOpen?: boolean; + insight: AlertsInsight; + onToggle?: (newState: 'open' | 'closed') => void; + replacements?: Record; + showAnonymized?: boolean; +} + +const InsightComponent: React.FC = ({ + initialIsOpen, + insight, + onToggle, + replacements, + showAnonymized = false, +}) => { + const { euiTheme } = useEuiTheme(); + + // get assistant privileges: + const { hasAssistantPrivilege } = useAssistantAvailability(); + const useAssistantHook = useMemo( + () => (hasAssistantPrivilege ? useAssistantOverlay : useAssistantNoop), + [hasAssistantPrivilege] + ); + + // the prompt context for this insight: + const getPromptContext = useCallback( + async () => + getAlertsInsightMarkdown({ + insight, + // note: we do NOT want to replace the replacements here + }), + [insight] + ); + const { promptContextId } = useAssistantHook( + category, + insight.title, // conversation title + insight.title, // description used in context pill + getPromptContext, + null, // accept the UUID default for this prompt context + null, // suggestedUserPrompt + null, // tooltip + replacements ?? null + ); + + const htmlId = useGeneratedHtmlId({ + prefix: 'insightAccordion', + }); + const [isOpen, setIsOpen] = useState<'open' | 'closed'>(initialIsOpen ? 'open' : 'closed'); + const updateIsOpen = useCallback(() => { + const newState = isOpen === 'open' ? 'closed' : 'open'; + + setIsOpen(newState); + onToggle?.(newState); + }, [isOpen, onToggle]); + + const actions = useMemo( + () => ( + + ), + [insight, promptContextId, replacements] + ); + + const buttonContent = useMemo( + () => , + [insight.title] + ); + + return ( + <> + <EuiPanel data-tes-subj="insight" hasBorder={true}> + <EuiAccordion + buttonContent={buttonContent} + data-test-subj="insightAccordion" + extraAction={actions} + forceState={isOpen} + id={htmlId} + onToggle={updateIsOpen} + > + <span data-test-subj="emptyAccordionContent" /> + </EuiAccordion> + + <EuiSpacer size="m" /> + + <ActionableSummary + insight={insight} + promptContextId={promptContextId} + replacements={replacements} + showAnonymized={showAnonymized} + /> + </EuiPanel> + + {isOpen === 'open' && ( + <EuiPanel + css={css` + border-top: none; + border-radius: 0 0 6px 6px; + margin: 0 ${euiTheme.size.m} 0 ${euiTheme.size.m}; + `} + data-test-subj="insightTabsPanel" + hasBorder={true} + > + <Tabs + insight={insight} + promptContextId={promptContextId} + replacements={replacements} + showAnonymized={showAnonymized} + /> + </EuiPanel> + )} + </> + ); +}; + +InsightComponent.displayName = 'Insight'; + +export const Insight = React.memo(InsightComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/interval/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/interval/helpers.ts new file mode 100644 index 0000000000000..934be01172e24 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/interval/helpers.ts @@ -0,0 +1,28 @@ +/* + * 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 { GenerationInterval } from '../../types'; + +export const encodeIntervals = ( + intervalByConnectorId: Record<string, [GenerationInterval]> +): string | null => { + try { + return JSON.stringify(intervalByConnectorId, null, 2); + } catch { + return null; + } +}; + +export const decodeIntervals = ( + intervalByConnectorId: string +): Record<string, [GenerationInterval]> | null => { + try { + return JSON.parse(intervalByConnectorId); + } catch { + return null; + } +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/loading_placeholder/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/loading_placeholder/index.tsx new file mode 100644 index 0000000000000..be39e9229a67e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/loading_placeholder/index.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 { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiSkeletonTitle } from '@elastic/eui'; +import React from 'react'; + +import { ActionsPlaceholder } from '../actions/actions_placeholder'; +import { Title } from '../title'; + +const LoadingPlaceholderComponent: React.FC = () => ( + <EuiPanel data-test-subj="loadingPlaceholder" hasBorder={true}> + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem grow={true}> + <Title isLoading={true} title="" /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <ActionsPlaceholder /> + </EuiFlexItem> + </EuiFlexGroup> + + <EuiSpacer size="l" /> + + <EuiSkeletonTitle + css={css` + inline-size: 100%; + `} + data-test-subj="skeletonTitle" + isLoading={true} + size="l" + /> + </EuiPanel> +); + +LoadingPlaceholderComponent.displayName = 'LoadingPlaceholder'; + +export const LoadingPlaceholder = React.memo(LoadingPlaceholderComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx new file mode 100644 index 0000000000000..5276c120c18f9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx @@ -0,0 +1,135 @@ +/* + * 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, EuiIcon, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; + +// import { SendToTimelineButton } from '../../../../assistant/send_to_timeline'; +import { AttackChain } from '../../../attack/attack_chain'; +import { InvestigateInTimelineButton } from '../../../../common/components/event_details/table/investigate_in_timeline_button'; +import { buildAlertsKqlFilter } from '../../../../detections/components/alerts_table/actions'; +import { getTacticMetadata } from '../../../helpers'; +import { InsightMarkdownFormatter } from '../../../insight_markdown_formatter'; +import * as i18n from './translations'; +import type { AlertsInsight } from '../../../types'; +import { ViewInAiAssistant } from '../../view_in_ai_assistant'; + +interface Props { + insight: AlertsInsight; + promptContextId: string | undefined; + replacements?: Record<string, string>; + showAnonymized?: boolean; +} + +const AiInsightsComponent: React.FC<Props> = ({ + insight, + promptContextId, + replacements, + showAnonymized = false, +}) => { + const { euiTheme } = useEuiTheme(); + const { detailsMarkdown, summaryMarkdown } = useMemo(() => insight, [insight]); + + const summaryMarkdownWithReplacements = useMemo( + () => + Object.entries<string>(replacements ?? {}).reduce( + (acc, [key, value]) => acc.replace(key, value), + summaryMarkdown + ), + [replacements, summaryMarkdown] + ); + + const detailsMarkdownWithReplacements = useMemo( + () => + Object.entries<string>(replacements ?? {}).reduce( + (acc, [key, value]) => acc.replace(key, value), + detailsMarkdown + ), + [detailsMarkdown, replacements] + ); + + const tacticMetadata = useMemo(() => getTacticMetadata(insight), [insight]); + + const originalAlertIds = useMemo( + () => insight.alertIds.map((id) => replacements?.[id] ?? id), + [insight.alertIds, replacements] + ); + + const filters = useMemo(() => buildAlertsKqlFilter('_id', originalAlertIds), [originalAlertIds]); + + return ( + <div data-test-subj="aiInsightsTab"> + <EuiTitle data-test-subj="summaryTitle" size="xs"> + <h2>{i18n.SUMMARY}</h2> + </EuiTitle> + <EuiSpacer size="s" /> + <InsightMarkdownFormatter + disableActions={showAnonymized} + markdown={showAnonymized ? summaryMarkdown : summaryMarkdownWithReplacements} + /> + + <EuiSpacer /> + + <EuiTitle data-test-subj="detailsTitle" size="xs"> + <h2>{i18n.DETAILS}</h2> + </EuiTitle> + <EuiSpacer size="s" /> + <InsightMarkdownFormatter + disableActions={showAnonymized} + markdown={showAnonymized ? detailsMarkdown : detailsMarkdownWithReplacements} + /> + + <EuiSpacer /> + + {tacticMetadata.length > 0 && ( + <> + <EuiTitle data-test-subj="detailsTitle" size="xs"> + <h2>{i18n.ATTACK_CHAIN}</h2> + </EuiTitle> + <EuiSpacer size="s" /> + <AttackChain insight={insight} /> + <EuiSpacer size="l" /> + </> + )} + + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem grow={false}> + <ViewInAiAssistant conversationTitle={insight.title} promptContextId={promptContextId} /> + </EuiFlexItem> + <EuiFlexItem + css={css` + margin-left: ${euiTheme.size.m}; + margin-top: ${euiTheme.size.xs}; + `} + grow={false} + > + <InvestigateInTimelineButton asEmptyButton={true} dataProviders={null} filters={filters}> + <EuiFlexGroup + alignItems="center" + data-test-subj="investigateInTimelineButton" + gutterSize="xs" + > + <EuiFlexItem grow={false}> + <EuiIcon data-test-subj="timelineIcon" type="timeline" /> + </EuiFlexItem> + <EuiFlexItem data-test-subj="investigateInTimelineLabel" grow={false}> + {i18n.INVESTIGATE_IN_TIMELINE} + </EuiFlexItem> + </EuiFlexGroup> + </InvestigateInTimelineButton> + </EuiFlexItem> + </EuiFlexGroup> + + <EuiSpacer size="s" /> + </div> + ); +}; + +AiInsightsComponent.displayName = 'AiInsights'; + +export const AiInsights = React.memo(AiInsightsComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/translations.ts new file mode 100644 index 0000000000000..f3ab02bf262ad --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/translations.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ATTACK_CHAIN = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.tabs.aiInsights.attackChainLabel', + { + defaultMessage: 'Attack Chain', + } +); + +export const ALERTS_FROM_INSIGHT = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.tabs.aiInsights.alertsFromInsightQueryTitle', + { + defaultMessage: 'Alerts from insight', + } +); + +export const DETAILS = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.tabs.aiInsights.detailsTitle', + { + defaultMessage: 'Details', + } +); + +export const INVESTIGATE_IN_TIMELINE = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.tabs.aiInsights.investigateInTimelineButtonLabel', + { + defaultMessage: 'Investigate in Timeline', + } +); + +export const SUMMARY = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.tabs.aiInsights.summaryTitle', + { + defaultMessage: 'Summary', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx new file mode 100644 index 0000000000000..9dbfc0f62b8fa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; +import React, { useMemo } from 'react'; + +import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../../common/constants'; +import { useKibana } from '../../../../common/lib/kibana'; +import type { AlertsInsight } from '../../../types'; + +interface Props { + insight: AlertsInsight; + replacements?: Record<string, string>; +} + +const AlertsComponent: React.FC<Props> = ({ insight, replacements }) => { + const { triggersActionsUi } = useKibana().services; + + const originalAlertIds = useMemo( + () => + insight.alertIds.map((alertId) => + replacements != null ? replacements[alertId] ?? alertId : alertId + ), + [insight.alertIds, replacements] + ); + + const alertIdsQuery = useMemo( + () => ({ + ids: { + values: originalAlertIds, + }, + }), + [originalAlertIds] + ); + + const configId = ALERTS_TABLE_REGISTRY_CONFIG_IDS.CASE; // show the same row-actions as in the case view + + const alertStateProps = useMemo( + () => ({ + alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, + configurationId: configId, + id: `ai-insights-alerts-${insight.id}`, + featureIds: [AlertConsumers.SIEM], + query: alertIdsQuery, + showAlertStatusWithFlapping: false, + }), + [triggersActionsUi.alertsTableConfigurationRegistry, configId, insight.id, alertIdsQuery] + ); + + return ( + <div data-test-subj="alertsTab">{triggersActionsUi.getAlertsStateTable(alertStateProps)}</div> + ); +}; + +export const Alerts = React.memo(AlertsComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx new file mode 100644 index 0000000000000..79c94a8787f63 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +import { AiInsights } from './ai_insights'; +import { Alerts } from './alerts'; +import * as i18n from './translations'; +import type { AlertsInsight } from '../../types'; + +interface TabInfo { + content: JSX.Element; + id: string; + name: string; +} + +export const getTabs = ({ + insight, + promptContextId, + replacements, + showAnonymized = false, +}: { + insight: AlertsInsight; + promptContextId: string | undefined; + replacements?: Record<string, string>; + showAnonymized?: boolean; +}): TabInfo[] => [ + { + id: 'aiInsights--id', + name: i18n.AI_INSIGHTS, + content: ( + <> + <EuiSpacer /> + <AiInsights + insight={insight} + promptContextId={promptContextId} + replacements={replacements} + showAnonymized={showAnonymized} + /> + </> + ), + }, + { + id: 'alerts--id', + name: i18n.ALERTS, + content: ( + <> + <EuiSpacer /> + <Alerts insight={insight} replacements={replacements} /> + </> + ), + }, +]; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx new file mode 100644 index 0000000000000..f7e290b53b025 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiTabs, EuiTab } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { getTabs } from './get_tabs'; +import type { AlertsInsight } from '../../types'; + +interface Props { + insight: AlertsInsight; + promptContextId: string | undefined; + replacements?: Record<string, string>; + showAnonymized?: boolean; +} + +const TabsComponent: React.FC<Props> = ({ + insight, + promptContextId, + replacements, + showAnonymized = false, +}) => { + const tabs = useMemo( + () => getTabs({ insight, promptContextId, replacements, showAnonymized }), + [insight, promptContextId, replacements, showAnonymized] + ); + + const [selectedTabId, setSelectedTabId] = useState(tabs[0].id); + + const selectedTabContent = useMemo(() => { + return tabs.find((obj) => obj.id === selectedTabId)?.content; + }, [selectedTabId, tabs]); + + const onSelectedTabChanged = useCallback((id: string) => setSelectedTabId(id), []); + + return ( + <> + <EuiTabs data-test-subj="tabs"> + {tabs.map((tab, index) => ( + <EuiTab + key={index} + isSelected={tab.id === selectedTabId} + onClick={() => onSelectedTabChanged(tab.id)} + > + {tab.name} + </EuiTab> + ))} + </EuiTabs> + {selectedTabContent} + </> + ); +}; + +TabsComponent.displayName = 'Tabs'; + +export const Tabs = React.memo(TabsComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/translations.ts new file mode 100644 index 0000000000000..28846d3f0db9e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AI_INSIGHTS = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.tabs.aiInsightsTabLabel', + { + defaultMessage: 'AI Insights', + } +); + +export const ALERTS = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.tabs.alertsTabLabel', + { + defaultMessage: 'Alerts', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/title/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/title/index.tsx new file mode 100644 index 0000000000000..4b0375e4fe503 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/title/index.tsx @@ -0,0 +1,66 @@ +/* + * 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, EuiSkeletonTitle, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { AssistantAvatar } from '@kbn/elastic-assistant'; +import { css } from '@emotion/react'; + +import React from 'react'; + +const AVATAR_SIZE = 24; // px + +interface Props { + isLoading: boolean; + title: string; +} + +const TitleComponent: React.FC<Props> = ({ isLoading, title }) => { + const { euiTheme } = useEuiTheme(); + + return ( + <EuiFlexGroup alignItems="center" data-test-subj="title" gutterSize="s"> + <EuiFlexItem + css={css` + background-color: ${euiTheme.colors.lightestShade}; + border-radius: 50%; + height: ${AVATAR_SIZE}px; + width: ${AVATAR_SIZE}px; + overflow: hidden; + `} + data-test-subj="assistantAvatar" + grow={false} + > + <AssistantAvatar + css={css` + transform: translate(5px, 5px); + `} + size="xxs" + /> + </EuiFlexItem> + + <EuiFlexItem grow={true}> + {isLoading ? ( + <EuiSkeletonTitle + css={css` + inline-size: 100%; + `} + data-test-subj="skeletonTitle" + size="xs" + /> + ) : ( + <EuiTitle data-test-subj="titleText" size="xs"> + <h2>{title}</h2> + </EuiTitle> + )} + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +TitleComponent.displayName = 'Title'; + +export const Title = React.memo(TitleComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx new file mode 100644 index 0000000000000..165c243f5194b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AssistantAvatar, useAssistantContext } from '@kbn/elastic-assistant'; +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; +import * as i18n from './translations'; + +interface Props { + compact?: boolean; + conversationTitle?: string; + promptContextId: string | undefined; + replacements?: Record<string, string>; +} + +const ViewInAiAssistantComponent: React.FC<Props> = ({ + compact = false, + conversationTitle, + promptContextId, + replacements, +}) => { + const { hasAssistantPrivilege } = useAssistantAvailability(); + const { showAssistantOverlay } = useAssistantContext(); + + // proxy show / hide calls to assistant context, using our internal prompt context id: + const showOverlay = useCallback(() => { + showAssistantOverlay({ + conversationTitle, + promptContextId, + showOverlay: true, + }); + }, [conversationTitle, promptContextId, showAssistantOverlay]); + + const disabled = !hasAssistantPrivilege || promptContextId == null; + + return compact ? ( + <EuiButtonEmpty + data-test-subj="viewInAiAssistantCompact" + disabled={disabled} + iconType="expand" + onClick={showOverlay} + size="xs" + > + {i18n.VIEW_IN_AI_ASSISTANT} + </EuiButtonEmpty> + ) : ( + <EuiButton + data-test-subj="viewInAiAssistant" + disabled={disabled} + onClick={showOverlay} + size="s" + > + <EuiFlexGroup alignItems="center" gutterSize="xs"> + <EuiFlexItem data-test-subj="assistantAvatar" grow={false}> + <AssistantAvatar size="xs" /> + </EuiFlexItem> + <EuiFlexItem data-test-subj="viewInAiAssistantLabel" grow={false}> + {i18n.VIEW_IN_AI_ASSISTANT} + </EuiFlexItem> + </EuiFlexGroup> + </EuiButton> + ); +}; + +ViewInAiAssistantComponent.displayName = 'ViewInAiAssistant'; + +export const ViewInAiAssistant = React.memo(ViewInAiAssistantComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/translations.ts new file mode 100644 index 0000000000000..92d95f659ba9b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const VIEW_IN_AI_ASSISTANT = i18n.translate( + 'xpack.securitySolution.aiInsights.insight.viewInAiAssistant.viewInAiAssistantButtonLabel', + { + defaultMessage: 'View in AI Assistant', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts new file mode 100644 index 0000000000000..28f326e7bef34 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { TableId } from '@kbn/securitysolution-data-table'; + +const HostPanelKey: HostPanelExpandableFlyoutProps['key'] = 'host-panel'; + +interface HostPanelProps extends Record<string, unknown> { + contextID: string; + scopeId: string; + hostName: string; + isDraggable?: boolean; +} + +interface HostPanelExpandableFlyoutProps extends FlyoutPanelProps { + key: 'host-panel'; + params: HostPanelProps; +} + +export const isHostName = (fieldName: string) => + fieldName === 'host.name' || fieldName === 'host.hostname'; + +export const getHostFlyoutPanelProps = ({ + contextId, + hostName, +}: { + contextId: string; + hostName: string; +}): FlyoutPanelProps => ({ + id: HostPanelKey, + params: { + hostName, + contextID: contextId, + scopeId: TableId.alertsOnAlertsPage, + }, +}); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts new file mode 100644 index 0000000000000..bbd8d2247bca5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { TableId } from '@kbn/securitysolution-data-table'; + +const UserPanelKey: UserPanelExpandableFlyoutProps['key'] = 'user-panel'; + +interface UserPanelProps extends Record<string, unknown> { + contextID: string; + scopeId: string; + userName: string; + isDraggable?: boolean; +} + +interface UserPanelExpandableFlyoutProps extends FlyoutPanelProps { + key: 'user-panel'; + params: UserPanelProps; +} + +export const isUserName = (fieldName: string) => + fieldName === 'user.name' || fieldName === 'host.hostname'; + +export const getUserFlyoutPanelProps = ({ + contextId, + userName, +}: { + contextId: string; + userName: string; +}): FlyoutPanelProps => ({ + id: UserPanelKey, + params: { + userName, + contextID: contextId, + scopeId: TableId.alertsOnAlertsPage, + }, +}); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/helpers.ts new file mode 100644 index 0000000000000..ab2a86041c545 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/helpers.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; + +import { getHostFlyoutPanelProps, isHostName } from './get_host_flyout_panel_props'; +import { getUserFlyoutPanelProps, isUserName } from './get_user_flyout_panel_props'; + +export const getFlyoutPanelProps = ({ + contextId, + fieldName, + value, +}: { + contextId: string; + fieldName: string; + value: string | number | undefined; +}): FlyoutPanelProps | null => { + if (isHostName(fieldName) && typeof value === 'string') { + return getHostFlyoutPanelProps({ contextId, hostName: value }); + } + + if (isUserName(fieldName) && typeof value === 'string') { + return getUserFlyoutPanelProps({ contextId, userName: value }); + } + + return null; +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/index.tsx new file mode 100644 index 0000000000000..c5eac0f7fcbe7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; + +import { DraggableBadge } from '../../../common/components/draggables'; +import { getFlyoutPanelProps } from './helpers'; +import type { ParsedField } from '../types'; + +const contextId = 'FieldMarkdownRenderer'; + +export const getFieldMarkdownRenderer = (disableActions: boolean) => { + const FieldMarkdownRenderer = ({ icon, name, value }: ParsedField) => { + const { openRightPanel } = useExpandableFlyoutApi(); + + const flyoutPanelProps = useMemo( + () => getFlyoutPanelProps({ contextId, fieldName: name, value }), + [name, value] + ); + + const onEntityClick = useCallback(() => { + if (flyoutPanelProps != null) { + openRightPanel(flyoutPanelProps); + } + }, [flyoutPanelProps, openRightPanel]); + + const entityButton: React.ReactElement | null = useMemo( + () => + flyoutPanelProps != null ? ( + <EuiButtonEmpty + data-test-subj="entityButton" + flush="both" + onClick={onEntityClick} + size="xs" + > + {value} + </EuiButtonEmpty> + ) : null, + + [flyoutPanelProps, onEntityClick, value] + ); + + return ( + <EuiToolTip content={name} data-test-subj="fieldMarkdownRendererToolTip" position="top"> + {disableActions ? ( + <EuiBadge color="hollow" data-test-subj="disabledActionsBadge" iconType={icon}> + {value} + </EuiBadge> + ) : ( + <DraggableBadge + contextId="fieldMarkdownRenderer" + eventId="" + iconType={icon} + isAggregatable={false} + isDraggable={false} + field={name} + value={value} + > + {entityButton} + </DraggableBadge> + )} + </EuiToolTip> + ); + }; + + return FieldMarkdownRenderer; +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/index.tsx new file mode 100644 index 0000000000000..6e45064e23a6a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/index.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiMarkdownFormat, + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { InsightMarkdownParser } from './insight_markdown_parser'; +import { getFieldMarkdownRenderer } from './field_markdown_renderer'; + +interface Props { + disableActions?: boolean; + markdown: string; +} + +const InsightMarkdownFormatterComponent: React.FC<Props> = ({ + disableActions = false, + markdown, +}) => { + const insightParsingPluginList = useMemo( + () => [...getDefaultEuiMarkdownParsingPlugins(), InsightMarkdownParser], + [] + ); + + const insightProcessingPluginList = useMemo(() => { + const processingPluginList = getDefaultEuiMarkdownProcessingPlugins(); + processingPluginList[1][1].components.fieldPlugin = getFieldMarkdownRenderer(disableActions); + + return processingPluginList; + }, [disableActions]); + + return ( + <EuiMarkdownFormat + color="subdued" + data-test-subj="insightMarkdownFormatter" + parsingPluginList={insightParsingPluginList} + processingPluginList={insightProcessingPluginList} + textSize="xs" + > + {markdown} + </EuiMarkdownFormat> + ); +}; +InsightMarkdownFormatterComponent.displayName = 'InsightMarkdownFormatter'; + +export const InsightMarkdownFormatter = React.memo(InsightMarkdownFormatterComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/helpers.ts new file mode 100644 index 0000000000000..0b31cc1744c7e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/helpers.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const iconLookup: Record<string, string> = { + 'host.name': 'desktop', + 'user.name': 'user', + 'process.name': 'gear', + 'file.name': 'document', + 'network.name': 'globe', + 'source.ip': 'globe', + 'destination.ip': 'globe', + 'user.id': 'user', + 'process.pid': 'gear', + 'file.path': 'document', + 'network.ip': 'globe', + 'source.port': 'globe', + 'destination.port': 'globe', +}; + +export const getIconFromFieldName = (fieldName: string): string => { + return iconLookup[fieldName] || ''; +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/index.tsx new file mode 100644 index 0000000000000..11b9d38370935 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/insight_markdown_parser/index.tsx @@ -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 type { Plugin } from 'unified'; +import type { RemarkTokenizer } from '@elastic/eui'; + +import { getIconFromFieldName } from './helpers'; +import type { ParsedField } from '../types'; + +export const InsightMarkdownParser: Plugin = function () { + // NOTE: the use of `this.Parse` and the other idioms below required by the Remark `Plugin` should NOT be replicated outside this file + const Parser = this.Parser; + const tokenizers = Parser.prototype.inlineTokenizers; + const methods = Parser.prototype.inlineMethods; + + const START_DELIMITER = '{{'; + const END_DELIMITER = '}}'; + + // function to parse a matching string + const tokenizeField: RemarkTokenizer = function (eat, value, silent) { + if (value.startsWith(START_DELIMITER) === false) return false; + + // match the entire contents between the {{ and }} + const tokenMatch = value.match(/^{{(.*?)}}/); + + if (!tokenMatch) return false; // no match + const [entireMatch, rawContent] = tokenMatch; // everything between the {{ and }} + + const parsedMatch = entireMatch.match(/^{{\s*(\S*)\s+(.*?)\s?}}/); + if (!parsedMatch) return false; // no match + + const [_, fieldName, fieldValue] = parsedMatch; + + if (silent) { + return true; + } + + const parsedField: ParsedField = { + name: fieldName, + icon: getIconFromFieldName(fieldName), + operator: ':', + value: fieldValue, + }; + + // must consume the exact & entire match string + return eat(`${START_DELIMITER}${rawContent}${END_DELIMITER}`)({ + type: 'fieldPlugin', + ...parsedField, + }); + }; + + // function to detect where the next field match might be found + tokenizeField.locator = (value, fromIndex) => { + return value.indexOf(START_DELIMITER, fromIndex); + }; + + // define the field plugin and inject it just before the existing text plugin + tokenizers.field = tokenizeField; + methods.splice(methods.indexOf('text'), 0, 'field'); +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/types.ts b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/types.ts new file mode 100644 index 0000000000000..6eaf19e83f534 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryOperator } from '../../../common/types'; + +export interface ParsedField { + icon?: string; + name: string; + operator: QueryOperator; + value?: string | number; +} diff --git a/x-pack/plugins/security_solution/public/ai_insights/links.ts b/x-pack/plugins/security_solution/public/ai_insights/links.ts new file mode 100644 index 0000000000000..0f7435656a00e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/links.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { AI_INSIGHTS } from '../app/translations'; +import { SecurityPageName, SERVER_APP_ID, AI_INSIGHTS_PATH } from '../../common/constants'; +import type { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + capabilities: [`${SERVER_APP_ID}.show`], + experimentalKey: 'assistantAlertsInsights', + globalNavPosition: 4, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.aiInsights', { + defaultMessage: 'AI Insights', + }), + ], + id: SecurityPageName.aiInsights, + path: AI_INSIGHTS_PATH, + title: AI_INSIGHTS, +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/animated_counter/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/animated_counter/index.tsx new file mode 100644 index 0000000000000..2428158aa5b71 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/animated_counter/index.tsx @@ -0,0 +1,63 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import * as d3 from 'd3'; +import React, { useRef, useEffect } from 'react'; + +interface Props { + count: number; +} + +const AnimatedCounterComponent: React.FC<Props> = ({ count }) => { + const { euiTheme } = useEuiTheme(); + const d3Ref = useRef(null); + const zero = 0; // counter starts at zero + const animationDurationMs = 1000 * 1; + + useEffect(() => { + if (d3Ref.current) { + d3.select(d3Ref.current).selectAll('*').remove(); + const svg = d3.select(d3Ref.current).append('svg'); + + const text = svg + .append('text') + .attr('x', 3) + .attr('y', 26) + .attr('fill', euiTheme.colors.text) + .text(zero); + + text + .transition() + .tween('text', function (this: SVGTextElement) { + const selection = d3.select(this); + const current = Number(d3.select(this).text()); + const interpolator = d3.interpolateNumber(current, count); + + return (t) => { + selection.text(Math.round(interpolator(t))); + }; + }) + .duration(animationDurationMs); + } + }, [animationDurationMs, count, euiTheme.colors.text]); + + return ( + <svg + css={css` + height: 32px; + margin-right: ${euiTheme.size.xs}; + width: ${count < 100 ? 40 : 53}px; + `} + data-test-subj="animatedCounter" + ref={d3Ref} + /> + ); +}; + +export const AnimatedCounter = React.memo(AnimatedCounterComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx new file mode 100644 index 0000000000000..1dff218535303 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx @@ -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 { AssistantAvatar } from '@kbn/elastic-assistant'; +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; +import { AnimatedCounter } from './animated_counter'; +import * as i18n from './translations'; + +interface Props { + alertsCount: number; + isDisabled?: boolean; + isLoading: boolean; + onGenerate: () => void; +} + +const EmptyPromptComponent: React.FC<Props> = ({ + alertsCount, + isLoading, + isDisabled = false, + onGenerate, +}) => { + const { hasAssistantPrivilege } = useAssistantAvailability(); + const title = useMemo( + () => ( + <EuiFlexGroup + alignItems="center" + data-test-subj="emptyPromptTitleContainer" + direction="column" + gutterSize="none" + > + <EuiFlexItem data-test-subj="emptyPromptAvatar" grow={false}> + <AssistantAvatar size="m" /> + <EuiSpacer size="m" /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="center" direction="row" gutterSize="none"> + <EuiFlexItem data-test-subj="emptyPromptAnimatedCounter" grow={false}> + <AnimatedCounter count={alertsCount} /> + </EuiFlexItem> + + <EuiFlexItem data-test-subj="emptyPromptAlertsWillBeAnalyzed" grow={false}> + <span>{i18n.ALERTS_WILL_BE_ANALYZED(alertsCount)}</span> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ), + [alertsCount] + ); + + const body = useMemo( + () => ( + <EuiFlexGroup + alignItems="center" + data-test-subj="bodyContainer" + direction="column" + gutterSize="none" + > + <EuiFlexItem grow={false}> + <EuiText color="subdued" data-test-subj="basedOnSelectedFiltersLabel"> + {i18n.BASED_ON_SELECTED_FILTERS} + </EuiText> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiText color="subdued" data-test-subj="startGeneratingInsightsLabel"> + {i18n.START_GENERATING_INSIGHTS} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ), + [] + ); + + const actions = useMemo( + () => ( + <EuiButton + color="primary" + data-test-subj="generate" + disabled={!hasAssistantPrivilege || isLoading || isDisabled} + onClick={onGenerate} + > + {i18n.GENERATE} + </EuiButton> + ), + [hasAssistantPrivilege, isDisabled, isLoading, onGenerate] + ); + + return ( + <EuiFlexGroup alignItems="center" direction="column" gutterSize="none"> + <EuiFlexItem data-test-subj="emptyPromptContainer" grow={false}> + <EuiEmptyPrompt actions={actions} body={body} title={title} /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiLink data-test-subj="learnMore" href="#" target="_blank"> + {i18n.LEARN_MORE} + </EuiLink> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +export const EmptyPrompt = React.memo(EmptyPromptComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts new file mode 100644 index 0000000000000..df9ac5ace3793 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERTS_WILL_BE_ANALYZED = (alertsCount: number) => + i18n.translate('xpack.securitySolution.aiInsights.pages.emptyPrompt.alertsWillBeAnalyzedTitle', { + defaultMessage: '{alertsCount, plural, one {alert} other {alerts}} will be analyzed', + values: { alertsCount }, + }); + +export const BASED_ON_SELECTED_FILTERS = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.emptyPrompt.basedOnSelectedFiltersLabel', + { + defaultMessage: 'Based on the selected filters above.', + } +); + +export const GENERATE = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.emptyPrompt.generateLabel', + { + defaultMessage: 'Generate', + } +); + +export const LEARN_MORE = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.emptyPrompt.learnMoreLabel', + { + defaultMessage: 'Learn more', + } +); + +export const START_GENERATING_INSIGHTS = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.emptyPrompt.startGeneratingInsightsLabel', + { + defaultMessage: 'Start generating insights via Elastic AI Assistant.', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx new file mode 100644 index 0000000000000..09cbb9cb429b0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx @@ -0,0 +1,82 @@ +/* + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { ConnectorSelectorInline } from '@kbn/elastic-assistant'; +import { i18n } from '@kbn/i18n'; +import { noop } from 'lodash/fp'; +import React from 'react'; + +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; + +const GENERATE = i18n.translate( + 'xpack.securitySolution.aiInsights.poweredByGenerativeAi.generateButton', + { + defaultMessage: 'Generate', + } +); + +const LOADING = i18n.translate( + 'xpack.securitySolution.aiInsights.poweredByGenerativeAi.loadingButton', + { + defaultMessage: 'Loading...', + } +); + +interface Props { + connectorId: string | undefined; + isLoading: boolean; + onGenerate: () => void; + onConnectorIdSelected: (connectorId: string) => void; +} + +const HeaderComponent: React.FC<Props> = ({ + connectorId, + isLoading, + onGenerate, + onConnectorIdSelected, +}) => { + const { hasAssistantPrivilege } = useAssistantAvailability(); + const { euiTheme } = useEuiTheme(); + const disabled = !hasAssistantPrivilege || isLoading || connectorId == null; + + return ( + <EuiFlexGroup + alignItems="center" + css={css` + gap: ${euiTheme.size.m}; + margin-top: ${euiTheme.size.m}; + `} + data-test-subj="header" + gutterSize="none" + > + <EuiFlexItem grow={false}> + <ConnectorSelectorInline + onConnectorSelected={noop} + onConnectorIdSelected={onConnectorIdSelected} + selectedConnectorId={connectorId} + showLabel={false} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + data-test-subj="generate" + size="s" + disabled={disabled} + isLoading={isLoading} + onClick={onGenerate} + > + {isLoading ? LOADING : GENERATE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +HeaderComponent.displayName = 'Header'; +export const Header = React.memo(HeaderComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/helpers.ts new file mode 100644 index 0000000000000..f62fb348c668c --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/helpers.ts @@ -0,0 +1,104 @@ +/* + * 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 getInitialIsOpen = (index: number) => index < 3; + +export const getFallbackActionTypeId = (actionTypeId: string | undefined): string => + actionTypeId != null ? actionTypeId : '.gen-ai'; + +interface ErrorWithStringMessage { + body?: { + error?: string; + message?: string; + statusCode?: number; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isErrorWithStringMessage(error: any): error is ErrorWithStringMessage { + const errorBodyError = error.body?.error; + const errorBodyMessage = error.body?.message; + const errorBodyStatusCode = error.body?.statusCode; + + return ( + typeof errorBodyError === 'string' && + typeof errorBodyMessage === 'string' && + typeof errorBodyStatusCode === 'number' + ); +} + +interface ErrorWithStructuredMessage { + body?: { + message?: { + error?: string; + }; + status_code?: number; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isErrorWithStructuredMessage(error: any): error is ErrorWithStructuredMessage { + const errorBodyMessageError = error.body?.message?.error; + const errorBodyStatusCode = error.body?.status_code; + + return typeof errorBodyMessageError === 'string' && typeof errorBodyStatusCode === 'number'; +} + +export const CONNECTOR_ID_LOCAL_STORAGE_KEY = 'connectorId'; + +export const CACHED_INSIGHTS_SESSION_STORAGE_KEY = 'cachedInsights'; + +export const GENERATION_INTERVALS_LOCAL_STORAGE_KEY = 'generationIntervals'; + +export const getErrorToastText = ( + error: ErrorWithStringMessage | ErrorWithStructuredMessage | unknown +): string => { + if (isErrorWithStringMessage(error)) { + return `${error.body?.error} (${error.body?.statusCode}) ${error.body?.message}`; + } else if (isErrorWithStructuredMessage(error)) { + return `(${error.body?.status_code}) ${error.body?.message?.error}`; + } else if ( + typeof error === 'object' && + error != null && + 'message' in error && + typeof error.message === 'string' + ) { + return error.message; + } else { + return `${error}`; + } +}; + +export const showEmptyPrompt = ({ + insightsCount, + isLoading, +}: { + insightsCount: number; + isLoading: boolean; +}): boolean => !isLoading && insightsCount === 0; + +export const showLoading = ({ + connectorId, + insightsCount, + isLoading, + loadingConnectorId, +}: { + connectorId: string | undefined; + insightsCount: number; + isLoading: boolean; + loadingConnectorId: string | null; +}): boolean => isLoading && (loadingConnectorId === connectorId || insightsCount === 0); + +export const showSummary = ({ + connectorId, + insightsCount, + loadingConnectorId, +}: { + connectorId: string | undefined; + insightsCount: number; + loadingConnectorId: string | null; +}): boolean => loadingConnectorId !== connectorId && insightsCount > 0; diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx new file mode 100644 index 0000000000000..0adff6a381a59 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx @@ -0,0 +1,246 @@ +/* + * 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 } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { + AI_INSIGHTS_STORAGE_KEY, + DEFAULT_ASSISTANT_NAMESPACE, + useAssistantContext, +} from '@kbn/elastic-assistant'; +import type { Replacements } from '@kbn/elastic-assistant-common'; +import { uniq } from 'lodash/fp'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocalStorage } from 'react-use'; + +import { SecurityRoutePageWrapper } from '../../common/components/security_route_page_wrapper'; +import { SecurityPageName } from '../../../common/constants'; +import { HeaderPage } from '../../common/components/header_page'; +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { EmptyPrompt } from './empty_prompt'; +import { Header } from './header'; +import { + CONNECTOR_ID_LOCAL_STORAGE_KEY, + getInitialIsOpen, + showEmptyPrompt, + showLoading, + showSummary, +} from './helpers'; +import { Insight } from '../insight'; +import { LoadingPlaceholder } from '../insight/loading_placeholder'; +import { LoadingCallout } from './loading_callout'; +import { PageTitle } from './page_title'; +import { Summary } from './summary'; +import { Upgrade } from './upgrade'; +import { useInsights } from '../use_insights'; +import type { AlertsInsight } from '../types'; + +const AiInsightsComponent: React.FC = () => { + const { + assistantAvailability: { isAssistantEnabled }, + knowledgeBase, + } = useAssistantContext(); + + // for showing / hiding anonymized data: + const [showAnonymized, setShowAnonymized] = useState<boolean>(false); + const onToggleShowAnonymized = useCallback(() => setShowAnonymized((current) => !current), []); + + // get the last selected connector ID from local storage: + const [localStorageAiInsightsConnectorId, setLocalStorageAiInsightsConnectorId] = + useLocalStorage<string>( + `${DEFAULT_ASSISTANT_NAMESPACE}.${AI_INSIGHTS_STORAGE_KEY}.${CONNECTOR_ID_LOCAL_STORAGE_KEY}` + ); + + const [connectorId, setConnectorId] = React.useState<string | undefined>( + localStorageAiInsightsConnectorId + ); + + // state for the connector loading in the background: + const [loadingConnectorId, setLoadingConnectorId] = useState<string | null>(null); + + const { + approximateFutureTime, + cachedInsights, + fetchInsights, + generationIntervals, + insights, + lastUpdated, + replacements, + isLoading, + } = useInsights({ + connectorId, + setConnectorId, + setLoadingConnectorId, + }); + + // get last updated from the cached insights if it exists: + const [selectedConnectorLastUpdated, setSelectedConnectorLastUpdated] = useState<Date | null>( + cachedInsights[connectorId ?? '']?.updated ?? null + ); + + // get cached insights if they exist: + const [selectedConnectorInsights, setSelectedConnectorInsights] = useState<AlertsInsight[]>( + cachedInsights[connectorId ?? '']?.insights ?? [] + ); + + // get replacements from the cached insights if they exist: + const [selectedConnectorReplacements, setSelectedConnectorReplacements] = useState<Replacements>( + cachedInsights[connectorId ?? '']?.replacements ?? {} + ); + + // the number of unique alerts in the insights: + const alertsCount = useMemo( + () => uniq(selectedConnectorInsights.flatMap((insight) => insight.alertIds)).length, + [selectedConnectorInsights] + ); + + /** The callback when users select a connector ID */ + const onConnectorIdSelected = useCallback( + (selectedConnectorId: string) => { + // update the connector ID in local storage: + setConnectorId(selectedConnectorId); + setLocalStorageAiInsightsConnectorId(selectedConnectorId); + + // get the cached insights for the selected connector: + const cached = cachedInsights[selectedConnectorId]; + if (cached != null) { + setSelectedConnectorReplacements(cached.replacements ?? {}); + setSelectedConnectorInsights(cached.insights ?? []); + setSelectedConnectorLastUpdated(cached.updated ?? null); + } else { + setSelectedConnectorReplacements({}); + setSelectedConnectorInsights([]); + setSelectedConnectorLastUpdated(null); + } + }, + [cachedInsights, setLocalStorageAiInsightsConnectorId] + ); + + // get connector intervals from generation intervals: + const connectorIntervals = useMemo( + () => generationIntervals?.[connectorId ?? ''] ?? [], + [connectorId, generationIntervals] + ); + + const pageTitle = useMemo(() => <PageTitle />, []); + + const onGenerate = useCallback(async () => fetchInsights(), [fetchInsights]); + + useEffect(() => { + setSelectedConnectorReplacements(replacements); + setSelectedConnectorInsights(insights); + setSelectedConnectorLastUpdated(lastUpdated); + }, [insights, lastUpdated, replacements]); + + const insightsCount = selectedConnectorInsights.length; + + if (!isAssistantEnabled) { + return ( + <> + <EuiSpacer size="xxl" /> + <Upgrade /> + </> + ); + } + + return ( + <div + css={css` + display: flex; + flex-direction: column; + flex: 1 1 auto; + `} + data-test-subj="fullHeightContainer" + > + <SecurityRoutePageWrapper + data-test-subj="aiInsightsPage" + pageName={SecurityPageName.aiInsights} + > + <HeaderPage border title={pageTitle}> + <Header + connectorId={connectorId} + isLoading={isLoading} + onConnectorIdSelected={onConnectorIdSelected} + onGenerate={onGenerate} + /> + <EuiSpacer size="m" /> + </HeaderPage> + + {showSummary({ + connectorId, + insightsCount, + loadingConnectorId, + }) && ( + <Summary + alertsCount={alertsCount} + insightsCount={insightsCount} + lastUpdated={selectedConnectorLastUpdated} + onToggleShowAnonymized={onToggleShowAnonymized} + showAnonymized={showAnonymized} + /> + )} + + <> + {showLoading({ + connectorId, + insightsCount, + isLoading, + loadingConnectorId, + }) ? ( + <> + <LoadingCallout + alertsCount={knowledgeBase.latestAlerts} + connectorIntervals={connectorIntervals} + approximateFutureTime={approximateFutureTime} + /> + <EuiSpacer size="m" /> + <LoadingPlaceholder /> + </> + ) : ( + selectedConnectorInsights.map((insight, i) => ( + <React.Fragment key={insight.id}> + <Insight + initialIsOpen={getInitialIsOpen(i)} + insight={insight} + showAnonymized={showAnonymized} + replacements={selectedConnectorReplacements} + /> + <EuiSpacer size="l" /> + </React.Fragment> + )) + )} + </> + <EuiFlexGroup + css={css` + max-height: 100%; + min-height: 100%; + `} + direction="column" + gutterSize="none" + > + <EuiSpacer size="xxl" /> + + <EuiFlexItem grow={false}> + {showEmptyPrompt({ insightsCount, isLoading }) && ( + <EmptyPrompt + alertsCount={knowledgeBase.latestAlerts} + isDisabled={connectorId == null} + isLoading={isLoading} + onGenerate={onGenerate} + /> + )} + </EuiFlexItem> + + <EuiFlexItem grow={true} /> + </EuiFlexGroup> + <SpyRoute pageName={SecurityPageName.aiInsights} /> + </SecurityRoutePageWrapper> + </div> + ); +}; + +export const AiInsights = React.memo(AiInsightsComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/index.tsx new file mode 100644 index 0000000000000..e49050c54c954 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/index.tsx @@ -0,0 +1,123 @@ +/* + * 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 { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import moment from 'moment'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { getTimerPrefix } from './last_times_popover/helpers'; + +import type { GenerationInterval } from '../../../types'; +import { InfoPopoverBody } from '../info_popover_body'; + +const TEXT_COLOR = '#343741'; + +interface Props { + approximateFutureTime: Date | null; + connectorIntervals: GenerationInterval[]; +} + +const CountdownComponent: React.FC<Props> = ({ approximateFutureTime, connectorIntervals }) => { + // theming: + const { euiTheme } = useEuiTheme(); + const { theme } = useKibana().services; + const isDarkMode = useMemo(() => theme.getTheme().darkMode === true, [theme]); + + // popover state: + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const onClick = useCallback(() => setIsPopoverOpen(true), []); + + // state for the timer prefix, and timer text: + const [prefix, setPrefix] = useState<string>(getTimerPrefix(approximateFutureTime)); + const [timerText, setTimerText] = useState(''); + + useEffect(() => { + // periodically update the formatted date as time passes: + const intervalId = setInterval(() => { + const now = moment(); + + const duration = moment(approximateFutureTime).isSameOrAfter(now) + ? moment.duration(moment(approximateFutureTime).diff(now)) + : moment.duration(now.diff(approximateFutureTime)); + + const text = moment.utc(duration.asMilliseconds()).format('mm:ss'); + + setPrefix(getTimerPrefix(approximateFutureTime)); + setTimerText(text); + }, 1000); + + return () => clearInterval(intervalId); + }, [approximateFutureTime]); + + const iconInQuestionButton = useMemo( + () => <EuiButtonIcon iconType="questionInCircle" onClick={onClick} />, + [onClick] + ); + + if (connectorIntervals.length === 0) { + return null; // don't render anything if there's no data + } + + return ( + <EuiFlexGroup + alignItems="center" + data-test-subj="countdown" + gutterSize="none" + justifyContent="spaceBetween" + > + <EuiFlexItem grow={false}> + <EuiPopover + anchorPosition="upCenter" + button={iconInQuestionButton} + closePopover={closePopover} + data-test-subj="infoPopover" + isOpen={isPopoverOpen} + > + <InfoPopoverBody connectorIntervals={connectorIntervals} /> + </EuiPopover> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiText + color={isDarkMode ? 'subdued' : TEXT_COLOR} + css={css` + font-weight: 400; + margin-left: ${euiTheme.size.xs}; + `} + data-test-subj="prefix" + size="s" + > + {prefix} + </EuiText> + </EuiFlexItem> + + <EuiFlexItem + css={css` + margin-left: ${euiTheme.size.s}; + `} + data-test-subj="timerText" + grow={false} + > + {timerText} + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +CountdownComponent.displayName = 'Countdown'; + +export const Countdown = React.memo(CountdownComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/generation_timing/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/generation_timing/index.tsx new file mode 100644 index 0000000000000..67e1ebe592b11 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/generation_timing/index.tsx @@ -0,0 +1,63 @@ +/* + * 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, EuiBadge, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; + +import { PreferenceFormattedDate } from '../../../../../../common/components/formatted_date'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { MAX_SECONDS_BADGE_WIDTH } from '../helpers'; +import * as i18n from '../translations'; +import type { GenerationInterval } from '../../../../../types'; + +interface Props { + interval: GenerationInterval; +} + +const GenerationTimingComponent: React.FC<Props> = ({ interval }) => { + const { euiTheme } = useEuiTheme(); + const { theme } = useKibana().services; + const isDarkMode = useMemo(() => theme.getTheme().darkMode === true, [theme]); + + return ( + <EuiFlexGroup alignItems="center" data-test-subj="generationTiming" gutterSize="none"> + <EuiFlexItem grow={false}> + <EuiBadge + css={css` + width: ${MAX_SECONDS_BADGE_WIDTH}px; + `} + color="hollow" + data-test-subj="clockBadge" + iconType="clock" + > + <span> + {Math.trunc(interval.durationMs / 1000)} + {i18n.SECONDS_ABBREVIATION} + </span> + </EuiBadge> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiText + css={css` + margin-left: ${euiTheme.size.s}; + `} + color={isDarkMode ? 'subdued' : 'default'} + data-test-subj="date" + size="xs" + > + <PreferenceFormattedDate value={interval.date} /> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +GenerationTimingComponent.displayName = 'GenerationTimingComponent'; + +export const GenerationTiming = React.memo(GenerationTimingComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/helpers.ts new file mode 100644 index 0000000000000..3ce1e1f411641 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/helpers.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +import { APPROXIMATE_TIME_REMAINING, ABOVE_THE_AVERAGE_TIME } from '../translations'; +import type { GenerationInterval } from '../../../../types'; + +export const MAX_SECONDS_BADGE_WIDTH = 64; // px + +export const getAverageIntervalSeconds = (intervals: GenerationInterval[]) => { + const intervalSeconds = intervals.map((interval) => interval.durationMs / 1000); + + if (intervalSeconds.length === 0) { + return 0; + } + + const average = + intervalSeconds.reduce((acc, seconds) => acc + seconds, 0) / intervalSeconds.length; + + return Math.trunc(average); +}; + +export const getTimerPrefix = (approximateFutureTime: Date | null) => { + if (approximateFutureTime == null) { + return APPROXIMATE_TIME_REMAINING; + } + + const now = moment(); + + return moment(approximateFutureTime).isSameOrAfter(now) + ? APPROXIMATE_TIME_REMAINING + : ABOVE_THE_AVERAGE_TIME; +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/index.tsx new file mode 100644 index 0000000000000..89925fdd33d4c --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/index.tsx @@ -0,0 +1,63 @@ +/* + * 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, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; + +import { useKibana } from '../../../../../common/lib/kibana'; +import * as i18n from './translations'; +import type { GenerationInterval } from '../../../../types'; +import { GenerationTiming } from './generation_timing'; + +interface Props { + connectorIntervals: GenerationInterval[]; +} + +const LastTimesPopoverComponent: React.FC<Props> = ({ connectorIntervals }) => { + const { euiTheme } = useEuiTheme(); + const { theme } = useKibana().services; + const isDarkMode = useMemo(() => theme.getTheme().darkMode === true, [theme]); + + return ( + <EuiFlexGroup + css={css` + width: 300px; + `} + data-test-subj="lastTimesPopover" + direction="column" + gutterSize="none" + > + <EuiFlexItem grow={false}> + <EuiText + color={isDarkMode ? 'subdued' : 'default'} + data-test-subj="averageTimeIsCalculated" + size="s" + > + {i18n.AVERAGE_TIME_IS_CALCULATED(connectorIntervals.length)} + </EuiText> + <EuiSpacer size="s" /> + </EuiFlexItem> + + {connectorIntervals.map((interval, index) => ( + <EuiFlexItem + css={css` + margin-bottom: ${euiTheme.size.xs}; + `} + grow={false} + key={index} + > + <GenerationTiming interval={interval} /> + </EuiFlexItem> + ))} + </EuiFlexGroup> + ); +}; + +LastTimesPopoverComponent.displayName = 'LastTimesPopoverComponent'; + +export const LastTimesPopover = React.memo(LastTimesPopoverComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/translations.ts new file mode 100644 index 0000000000000..266c08cfd2d9e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/last_times_popover/translations.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 { i18n } from '@kbn/i18n'; + +export const AVERAGE_TIME_IS_CALCULATED = (intervals: number) => + i18n.translate( + 'xpack.securitySolution.aiInsights.loadingCallout.countdown.lastTimesPopover.aiIsCurrentlyAnalyzing', + { + defaultMessage: + 'Average time is calculated over the last {intervals} {intervals, plural, =1 {generation} other {generations}} on the selected connector:', + values: { intervals }, + } + ); + +export const SECONDS_ABBREVIATION = i18n.translate( + 'xpack.securitySolution.aiInsights.loadingCallout.countdown.lastTimesPopover.secondsAbbreviationLabel', + { + defaultMessage: 's', // short for seconds + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/translations.ts new file mode 100644 index 0000000000000..35f8cdd027dff --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/countdown/translations.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AI_IS_CURRENTLY_ANALYZING = (alertsCount: number) => + i18n.translate( + 'xpack.securitySolution.aiInsights.loadingCallout.countdown.aiIsCurrentlyAnalyzing', + { + defaultMessage: `AI is currently analyzing up to {alertsCount} {alertsCount, plural, =1 {alert} other {alerts}} in the last 24 hours to generate insights`, + values: { alertsCount }, + } + ); + +export const ABOVE_THE_AVERAGE_TIME = i18n.translate( + 'xpack.securitySolution.aiInsights.loadingCallout.countdown.aboveTheAverageTimeLabel', + { + defaultMessage: 'Above the average time:', + } +); + +export const APPROXIMATE_TIME_REMAINING = i18n.translate( + 'xpack.securitySolution.aiInsights.loadingCallout.countdown.approximateTimeRemainingLabel', + { + defaultMessage: 'Approximate time remaining:', + } +); + +export const AVERAGE_TIME = i18n.translate( + 'xpack.securitySolution.aiInsights.loadingCallout.countdown.averageTimeLabel', + { + defaultMessage: 'Average time', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/index.tsx new file mode 100644 index 0000000000000..b9d2737db1647 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/index.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; + +import { useKibana } from '../../../common/lib/kibana'; +import { Countdown } from './countdown'; +import { LoadingMessages } from './loading_messages'; +import type { GenerationInterval } from '../../types'; + +const BACKGROUND_COLOR_LIGHT = '#E6F1FA'; +const BACKGROUND_COLOR_DARK = '#0B2030'; + +const BORDER_COLOR_DARK = '#0B2030'; + +interface Props { + alertsCount: number; + approximateFutureTime: Date | null; + connectorIntervals: GenerationInterval[]; +} + +const LoadingCalloutComponent: React.FC<Props> = ({ + alertsCount, + approximateFutureTime, + connectorIntervals, +}) => { + const { euiTheme } = useEuiTheme(); + const { theme } = useKibana().services; + + const leftContent = useMemo( + () => ( + <EuiFlexGroup alignItems="center" data-test-subj="leftContent" gutterSize="none"> + <EuiFlexItem grow={false}> + <EuiLoadingElastic data-test-subj="loadingElastic" size="l" /> + </EuiFlexItem> + + <EuiFlexItem + css={css` + margin-left: ${euiTheme.size.m}; + `} + grow={false} + > + <LoadingMessages alertsCount={alertsCount} /> + </EuiFlexItem> + </EuiFlexGroup> + ), + [alertsCount, euiTheme.size.m] + ); + + const isDarkMode = theme.getTheme().darkMode === true; + + return ( + <div + css={css` + background-color: ${isDarkMode ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_LIGHT}; + border: 1px solid ${isDarkMode ? BORDER_COLOR_DARK : euiTheme.colors.lightShade}; + border-radius: 6px; + padding: ${euiTheme.size.base}; + `} + data-test-subj="loadingCallout" + > + <EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}>{leftContent}</EuiFlexItem> + + <EuiFlexItem grow={false}> + <Countdown + approximateFutureTime={approximateFutureTime} + connectorIntervals={connectorIntervals} + /> + </EuiFlexItem> + </EuiFlexGroup> + </div> + ); +}; + +LoadingCalloutComponent.displayName = 'LoadingCallout'; + +export const LoadingCallout = React.memo(LoadingCalloutComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/info_popover_body/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/info_popover_body/index.tsx new file mode 100644 index 0000000000000..3355a3041c0f2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/info_popover_body/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiPopoverTitle, EuiText } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { useKibana } from '../../../../common/lib/kibana'; + +import { LastTimesPopover } from '../countdown/last_times_popover'; +import { + getAverageIntervalSeconds, + MAX_SECONDS_BADGE_WIDTH, +} from '../countdown/last_times_popover/helpers'; +import { SECONDS_ABBREVIATION } from '../countdown/last_times_popover/translations'; +import { AVERAGE_TIME } from '../countdown/translations'; +import type { GenerationInterval } from '../../../types'; + +const TEXT_COLOR = '#343741'; + +interface Props { + connectorIntervals: GenerationInterval[]; +} + +const InfoPopoverBodyComponent: React.FC<Props> = ({ connectorIntervals }) => { + const { theme } = useKibana().services; + const isDarkMode = useMemo(() => theme.getTheme().darkMode === true, [theme]); + + const averageIntervalSeconds = useMemo( + () => getAverageIntervalSeconds(connectorIntervals), + [connectorIntervals] + ); + + return ( + <> + <EuiPopoverTitle> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiBadge + css={css` + display: inline-block; + max-width: ${MAX_SECONDS_BADGE_WIDTH}px; + `} + color="hollow" + data-test-subj="averageTimeBadge" + iconType="clock" + > + <span> + {averageIntervalSeconds} + {SECONDS_ABBREVIATION} + </span> + </EuiBadge> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiText + color={isDarkMode ? 'default' : TEXT_COLOR} + css={css` + font-weight: 400; + `} + data-test-subj="averageTimeIsCalculated" + size="s" + > + <span>{AVERAGE_TIME}</span> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPopoverTitle> + + <LastTimesPopover connectorIntervals={connectorIntervals} /> + </> + ); +}; + +InfoPopoverBodyComponent.displayName = 'InfoPopoverBody'; + +export const InfoPopoverBody = React.memo(InfoPopoverBodyComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/loading_messages/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/loading_messages/index.tsx new file mode 100644 index 0000000000000..42d7cb540a2bf --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/loading_messages/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +import { useKibana } from '../../../../common/lib/kibana'; +import * as i18n from '../translations'; + +const TEXT_COLOR = '#343741'; + +interface Props { + alertsCount: number; +} + +const LoadingMessagesComponent: React.FC<Props> = ({ alertsCount }) => { + const { theme } = useKibana().services; + + const isDarkMode = theme.getTheme().darkMode === true; + + return ( + <EuiFlexGroup data-test-subj="loadingMessages" direction="column" gutterSize="none"> + <EuiFlexItem grow={false}> + <EuiText + color={isDarkMode ? 'subdued' : TEXT_COLOR} + css={css` + font-weight: 600; + `} + data-test-subj="insightsGenerationInProgress" + size="s" + > + {i18n.INSIGHTS_GENERATION_IN_PROGRESS} + </EuiText> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiText + color={isDarkMode ? 'subdued' : TEXT_COLOR} + css={css` + font-weight: 400; + `} + data-test-subj="aisCurrentlyAnalyzing" + size="s" + > + {i18n.AI_IS_CURRENTLY_ANALYZING(alertsCount)} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +LoadingMessagesComponent.displayName = 'LoadingMessages'; + +export const LoadingMessages = React.memo(LoadingMessagesComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/translations.ts new file mode 100644 index 0000000000000..b7c8015f90ad3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/loading_callout/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AI_IS_CURRENTLY_ANALYZING = (alertsCount: number) => + i18n.translate('xpack.securitySolution.aiInsights.pages.loadingCallout.aiIsCurrentlyAnalyzing', { + defaultMessage: `AI is currently analyzing up to {alertsCount} {alertsCount, plural, =1 {alert} other {alerts}} in the last 24 hours to generate insights`, + values: { alertsCount }, + }); + +export const INSIGHTS_GENERATION_IN_PROGRESS = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.loadingCallout.hangTightLabel', + { + defaultMessage: 'Insights generation in progress', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx new file mode 100644 index 0000000000000..0c0fd62b33b23 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +const AI_INSIGHTS_PAGE_TITLE = i18n.translate('xpack.securitySolution.aiInsights.pageTitle', { + defaultMessage: 'AI insights', +}); + +const BETA_BADGE_SIZE = 24; // px + +const PageTitleComponent: React.FC = () => { + const { euiTheme } = useEuiTheme(); + + return ( + <EuiFlexGroup alignItems="center" data-test-subj="pageTitle" gutterSize="none"> + <EuiFlexItem grow={false}> + <EuiTitle data-test-subj="title" size="l"> + <h1>{AI_INSIGHTS_PAGE_TITLE}</h1> + </EuiTitle> + </EuiFlexItem> + + <EuiFlexItem + css={css` + border: 1px solid ${euiTheme.colors.lightShade}; + border-radius: 50%; + height: ${BETA_BADGE_SIZE}px; + margin-left: ${euiTheme.size.m}; + overflow: hidden; + transform: translate(0, 9px); + width: ${BETA_BADGE_SIZE}px; + `} + grow={false} + > + <EuiIcon + css={css` + transform: translate(3px, 2px); + `} + color="hollow" + data-test-subj="betaIcon" + size="m" + type="beta" + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +PageTitleComponent.displayName = 'PageTitle'; + +export const PageTitle = React.memo(PageTitleComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/session_storage/index.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/session_storage/index.ts new file mode 100644 index 0000000000000..a3c068c1bdece --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/session_storage/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 type { Replacements } from '@kbn/elastic-assistant-common'; +import type { AlertsInsight } from '../../types'; + +export interface CachedInsights { + connectorId: string; + updated: Date; + insights: AlertsInsight[]; + replacements: Replacements; +} + +export const encodeCachedInsights = ( + cachedInsights: Record<string, CachedInsights> +): string | null => { + try { + return JSON.stringify(cachedInsights, null, 2); + } catch { + return null; + } +}; + +export const decodeCachedInsights = ( + cachedInsights: string +): Record<string, CachedInsights> | null => { + try { + return JSON.parse(cachedInsights); + } catch { + return null; + } +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx new file mode 100644 index 0000000000000..23c1beea7d9aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiToolTip } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +import { SummaryCount } from '../summary_count'; +import { HIDE_ANONYMIZED_LABEL, SHOW_ANONYMIZED_LABEL } from '../translations'; + +interface Props { + alertsCount: number; + insightsCount: number; + lastUpdated: Date | null; + onToggleShowAnonymized: () => void; + showAnonymized: boolean; +} + +const SummaryComponent: React.FC<Props> = ({ + alertsCount, + insightsCount, + lastUpdated, + onToggleShowAnonymized, + showAnonymized, +}) => ( + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <SummaryCount + alertsCount={alertsCount} + lastUpdated={lastUpdated} + insightsCount={insightsCount} + /> + <EuiSpacer size="l" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiToolTip + content={showAnonymized ? HIDE_ANONYMIZED_LABEL : SHOW_ANONYMIZED_LABEL} + data-test-subj="toggleAnonymizedToolTip" + > + <EuiButtonIcon + aria-label={showAnonymized ? HIDE_ANONYMIZED_LABEL : SHOW_ANONYMIZED_LABEL} + css={css` + border-radius: 50%; + `} + data-test-subj="toggleAnonymized" + iconType={showAnonymized ? 'eye' : 'eyeClosed'} + onClick={onToggleShowAnonymized} + /> + </EuiToolTip> + </EuiFlexItem> + </EuiFlexGroup> +); + +export const Summary = React.memo(SummaryComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/index.tsx new file mode 100644 index 0000000000000..5cc30e1f5936e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/index.tsx @@ -0,0 +1,102 @@ +/* + * 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, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useEffect, useMemo, useState } from 'react'; +import moment from 'moment'; + +import { ALERTS, INSIGHTS, LAST_GENERATED } from './translations'; + +export const EMPTY_LAST_UPDATED_DATE = '--'; + +interface Props { + alertsCount: number; + insightsCount: number; + lastUpdated: Date | null; +} + +const SummaryCountComponent: React.FC<Props> = ({ alertsCount, insightsCount, lastUpdated }) => { + const { euiTheme } = useEuiTheme(); + const [formattedDate, setFormattedDate] = useState<string>(EMPTY_LAST_UPDATED_DATE); + + useEffect(() => { + // immediately update the formatted date when lastUpdated is updated: + if (moment(lastUpdated).isValid()) { + setFormattedDate(moment(lastUpdated).fromNow()); + } + + // periodically update the formatted date as time passes: + const intervalId = setInterval(() => { + if (moment(lastUpdated).isValid()) { + setFormattedDate(moment(lastUpdated).fromNow()); + } + }, 10000); + + return () => clearInterval(intervalId); + }, [lastUpdated]); + + const Separator = useMemo( + () => ( + <EuiText + css={css` + color: ${euiTheme.colors.lightShade}; + `} + size="s" + > + <EuiFlexItem + css={css` + margin-left: ${euiTheme.size.s}; + margin-right: ${euiTheme.size.s}; + `} + grow={false} + > + {'|'} + </EuiFlexItem> + </EuiText> + ), + [euiTheme.colors.lightShade, euiTheme.size.s] + ); + + return ( + <EuiText + css={css` + font-weight: 700; + `} + data-test-subj="summaryCount" + size="xs" + > + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem data-test-subj="insightsCount" grow={false}> + {INSIGHTS(insightsCount)} + </EuiFlexItem> + + {Separator} + + <EuiFlexItem data-test-subj="alertsCount" grow={false}> + {ALERTS(alertsCount)} + </EuiFlexItem> + + {lastUpdated != null && ( + <> + {Separator} + + <EuiFlexItem data-test-subj="lastGenerated" grow={false}> + {LAST_GENERATED} + {': '} + {formattedDate} + </EuiFlexItem> + </> + )} + </EuiFlexGroup> + </EuiText> + ); +}; + +SummaryCountComponent.displayName = 'SummaryCount'; + +export const SummaryCount = React.memo(SummaryCountComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/translations.ts new file mode 100644 index 0000000000000..1962420edefe4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/summary_count/translations.ts @@ -0,0 +1,27 @@ +/* + * 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 = (alertsCount: number) => + i18n.translate('xpack.securitySolution.aiInsights.summaryCount.alertsLabel', { + defaultMessage: `{alertsCount} {alertsCount, plural, =1 {alert} other {alerts}}`, + values: { alertsCount }, + }); + +export const INSIGHTS = (insightsCount: number) => + i18n.translate('xpack.securitySolution.aiInsights.summaryCount.insightsLabel', { + defaultMessage: `{insightsCount} {insightsCount, plural, =1 {insight} other {insights}}`, + values: { insightsCount }, + }); + +export const LAST_GENERATED = i18n.translate( + 'xpack.securitySolution.aiInsights.summaryCount.lastGeneratedLabel', + { + defaultMessage: 'Generated', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts new file mode 100644 index 0000000000000..27ee5ff6f1d20 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_GENERATING_INSIGHTS = i18n.translate( + 'xpack.securitySolution.aiInsights.errorGeneratingInsightsToastTitle', + { + defaultMessage: 'Error generating insights', + } +); + +export const HIDE_ANONYMIZED_LABEL = i18n.translate( + 'xpack.securitySolution.aiInsights.hideAnonymizedLabel', + { + defaultMessage: 'Hide anonymized', + } +); + +export const SHOW_ANONYMIZED_LABEL = i18n.translate( + 'xpack.securitySolution.aiInsights.showAnonymizedLabel', + { + defaultMessage: 'Show anonymized', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/index.tsx new file mode 100644 index 0000000000000..6da099e42f2b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/index.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AssistantAvatar, UpgradeButtons, useAssistantContext } from '@kbn/elastic-assistant'; +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import * as i18n from './translations'; + +const UpgradeComponent: React.FC = () => { + const { http } = useAssistantContext(); + + const title = useMemo( + () => ( + <EuiFlexGroup alignItems="center" direction="column" gutterSize="none"> + <EuiFlexItem data-test-subj="assistantAvatar" grow={false}> + <AssistantAvatar size="m" /> + <EuiSpacer size="m" /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="center" direction="row" gutterSize="none"> + <EuiFlexItem data-test-subj="upgradeTitle" grow={false}> + <span>{i18n.AI_INSIGHTS}</span> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ), + [] + ); + + const body = useMemo( + () => ( + <EuiFlexGroup alignItems="center" direction="column" gutterSize="none"> + <EuiFlexItem grow={false}> + <EuiText color="subdued" data-test-subj="aiInsightsAreAvailable"> + {i18n.AI_INSIGHTS_ARE_AVAILABLE} + </EuiText> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiText color="subdued" data-test-subj="pleaseUpgrade"> + {i18n.PLEASE_UPGRADE} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ), + [] + ); + + const actions = useMemo(() => <UpgradeButtons basePath={http.basePath.get()} />, [http.basePath]); + + return <EuiEmptyPrompt actions={actions} body={body} data-test-subj="upgrade" title={title} />; +}; + +export const Upgrade = React.memo(UpgradeComponent); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/translations.ts new file mode 100644 index 0000000000000..0c03b9d6f7a6a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/upgrade/translations.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AI_INSIGHTS = i18n.translate( + 'xpack.securitySolution.aiInsights.upgrade.aiInsightsTitle', + { + defaultMessage: 'AI Insights', + } +); + +export const AI_INSIGHTS_ARE_AVAILABLE = i18n.translate( + 'xpack.securitySolution.aiInsights.upgrade.aiInsightsAreAvailable', + { + defaultMessage: 'AI Insights are available to Enterprise users only.', + } +); + +export const PLEASE_UPGRADE = i18n.translate( + 'xpack.securitySolution.aiInsights.upgrade.pleaseUpgradeMessage', + { + defaultMessage: 'Please upgrade your license to use this feature.', + } +); + +export const UPGRADE = i18n.translate('xpack.securitySolution.aiInsights.upgrade.upgradeButton', { + defaultMessage: 'Upgrade', +}); diff --git a/x-pack/plugins/security_solution/public/ai_insights/routes.tsx b/x-pack/plugins/security_solution/public/ai_insights/routes.tsx new file mode 100644 index 0000000000000..a13672f3b5f50 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/routes.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { AiInsights } from './pages'; + +import type { SecuritySubPluginRoutes } from '../app/types'; +import { SecurityPageName } from '../app/types'; +import { AI_INSIGHTS_PATH } from '../../common/constants'; +import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; + +export const AiInsightsRoutes = () => ( + <PluginTemplateWrapper> + <TrackApplicationView viewId={SecurityPageName.aiInsights}> + <AiInsights /> + </TrackApplicationView> + </PluginTemplateWrapper> +); + +export const routes: SecuritySubPluginRoutes = [ + { + path: AI_INSIGHTS_PATH, + component: AiInsightsRoutes, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ai_insights/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/translations.ts new file mode 100644 index 0000000000000..562ca0ac5e671 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/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 COMMAND_AND_CONTROL = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.commandAndControlLabel', + { + defaultMessage: 'Command & Control', + } +); + +export const DISCOVERY = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.discoveryLabel', + { + defaultMessage: 'Discovery', + } +); + +export const EXECUTION = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.executionLabel', + { + defaultMessage: 'Execution', + } +); + +export const EXFILTRATION = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.exfiltrationLabel', + { + defaultMessage: 'Exfiltration', + } +); + +export const LATERAL_MOVEMENT = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.lateralMovementLabel', + { + defaultMessage: 'Lateral Movement', + } +); + +export const INITIAL_ACCESS = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.initialAccessLabel', + { + defaultMessage: 'Initial Access', + } +); + +export const PERSISTENCE = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.persistenceLabel', + { + defaultMessage: 'Persistence', + } +); + +export const PRIVILEGE_ESCALATION = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.privilegeEscalationLabel', + { + defaultMessage: 'Privilege Escalation', + } +); + +export const RECONNAISSANCE = i18n.translate( + 'xpack.securitySolution.aiInsights.mitre.attack.tactics.reconnaissanceLabel', + { + defaultMessage: 'Reconnaissance', + } +); + +export const INSIGHTS_CONVERSATION_TITLE = i18n.translate( + 'xpack.securitySolution.aiInsights.insightsConversationTitle', + { + defaultMessage: 'Insights', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/types.ts b/x-pack/plugins/security_solution/public/ai_insights/types.ts new file mode 100644 index 0000000000000..901ebf109a499 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/types.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. + */ + +export interface AlertsInsight { + alertIds: string[]; + detailsMarkdown: string; + entitySummaryMarkdown: string; + id: string; + mitreAttackTactics?: string[]; + summaryMarkdown: string; + title: string; +} + +/** Generation intervals measure the time it takes to generate insights */ +export interface GenerationInterval { + connectorId: string; + date: Date; + durationMs: number; +} diff --git a/x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx new file mode 100644 index 0000000000000..dfe87842058db --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx @@ -0,0 +1,245 @@ +/* + * 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 { + AI_INSIGHTS_STORAGE_KEY, + DEFAULT_ASSISTANT_NAMESPACE, + useAssistantContext, + useLoadConnectors, +} from '@kbn/elastic-assistant'; +import type { AlertsInsightsPostRequestBody, Replacements } from '@kbn/elastic-assistant-common'; +import { + AlertsInsightsPostResponse, + ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, +} from '@kbn/elastic-assistant-common'; +import moment from 'moment'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useLocalStorage, useSessionStorage } from 'react-use'; +import * as uuid from 'uuid'; +import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields'; + +import { useAssistantAvailability } from '../../assistant/use_assistant_availability'; +import { useKibana } from '../../common/lib/kibana'; +import { replaceNewlineLiterals } from '../helpers'; +import { + CACHED_INSIGHTS_SESSION_STORAGE_KEY, + GENERATION_INTERVALS_LOCAL_STORAGE_KEY, + getErrorToastText, + getFallbackActionTypeId, +} from '../pages/helpers'; +import { getAverageIntervalSeconds } from '../pages/loading_callout/countdown/last_times_popover/helpers'; +import type { CachedInsights } from '../pages/session_storage'; +import { encodeCachedInsights, decodeCachedInsights } from '../pages/session_storage'; +import { ERROR_GENERATING_INSIGHTS } from '../pages/translations'; +import type { AlertsInsight, GenerationInterval } from '../types'; + +const MAX_GENERATION_INTERVALS = 5; + +export const useInsights = ({ + connectorId, + setConnectorId, + setLoadingConnectorId, +}: { + connectorId: string | undefined; + setConnectorId?: (connectorId: string | undefined) => void; + setLoadingConnectorId?: (loadingConnectorId: string | null) => void; +}) => { + // get Kibana services and connectors + const { + http, + notifications: { toasts }, + } = useKibana().services; + const { data: aiConnectors } = useLoadConnectors({ + http, + }); + + // loading boilerplate: + const [isLoading, setIsLoading] = useState(false); + + const { isAssistantEnabled } = useAssistantAvailability(); + + // get alerts index pattern and allow lists from the assistant context: + const { alertsIndexPattern, knowledgeBase } = useAssistantContext(); + + const { data: anonymizationFields } = useFetchAnonymizationFields({ http, isAssistantEnabled }); + + // get cached insights from session storage: + const [sessionStorageCachedInsights, setSessionStorageCachedInsights] = useSessionStorage<string>( + `${DEFAULT_ASSISTANT_NAMESPACE}.${AI_INSIGHTS_STORAGE_KEY}.${CACHED_INSIGHTS_SESSION_STORAGE_KEY}` + ); + const [cachedInsights, setCachedInsights] = useState<Record<string, CachedInsights>>( + decodeCachedInsights(sessionStorageCachedInsights) ?? {} + ); + + // get generation intervals from local storage: + const [localStorageGenerationIntervals, setLocalStorageGenerationIntervals] = useLocalStorage< + Record<string, GenerationInterval[]> + >( + `${DEFAULT_ASSISTANT_NAMESPACE}.${AI_INSIGHTS_STORAGE_KEY}.${GENERATION_INTERVALS_LOCAL_STORAGE_KEY}` + ); + const [generationIntervals, setGenerationIntervals] = React.useState< + Record<string, GenerationInterval[]> | undefined + >(localStorageGenerationIntervals); + + // get connector intervals from generation intervals: + const connectorIntervals = useMemo( + () => generationIntervals?.[connectorId ?? ''] ?? [], + [connectorId, generationIntervals] + ); + + // generation can take a long time, so we calculate an approximate future time: + const [approximateFutureTime, setApproximateFutureTime] = useState<Date | null>(null); + + // get cached insights if they exist: + const [insights, setInsights] = useState<AlertsInsight[]>( + cachedInsights[connectorId ?? '']?.insights ?? [] + ); + + // get replacements from the cached insights if they exist: + const [replacements, setReplacements] = useState<Replacements>( + cachedInsights[connectorId ?? '']?.replacements ?? {} + ); + + // get last updated from the cached insights if it exists: + const [lastUpdated, setLastUpdated] = useState<Date | null>( + cachedInsights[connectorId ?? '']?.updated ?? null + ); + + /** The callback when users click the Generate button */ + const fetchInsights = useCallback(async () => { + const selectedConnector = aiConnectors?.find((connector) => connector.id === connectorId); + const actionTypeId = getFallbackActionTypeId(selectedConnector?.actionTypeId); + + const body: AlertsInsightsPostRequestBody = { + actionTypeId, + alertsIndexPattern: alertsIndexPattern ?? '', + anonymizationFields: anonymizationFields?.data ?? [], + connectorId: connectorId ?? '', + size: knowledgeBase.latestAlerts, + replacements: {}, // no need to re-use replacements in the current implementation + subAction: 'invokeAI', // non-streaming + }; + + try { + setLoadingConnectorId?.(connectorId ?? null); + setIsLoading(true); + setApproximateFutureTime(null); + + const averageIntervalSeconds = getAverageIntervalSeconds(connectorIntervals); + setApproximateFutureTime(moment().add(averageIntervalSeconds, 'seconds').toDate()); + + const startTime = moment(); // start timing the generation + + // call the internal API to generate insights: + const rawResponse = await http.fetch('/internal/elastic_assistant/insights/alerts', { + body: JSON.stringify(body), + method: 'POST', + version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, + }); + + const parsedResponse = AlertsInsightsPostResponse.safeParse(rawResponse); + if (!parsedResponse.success) { + throw new Error('Failed to parse the response'); + } + + const endTime = moment(); + const durationMs = endTime.diff(startTime); + + // update the cached insights with the new insights: + const newInsights: AlertsInsight[] = + parsedResponse.data.insights?.map((insight) => ({ + alertIds: [...insight.alertIds], + detailsMarkdown: replaceNewlineLiterals(insight.detailsMarkdown), + entitySummaryMarkdown: replaceNewlineLiterals(insight.entitySummaryMarkdown), + id: uuid.v4(), + mitreAttackTactics: insight.mitreAttackTactics, + summaryMarkdown: replaceNewlineLiterals(insight.summaryMarkdown), + title: insight.title, + })) ?? []; + + const responseReplacements = parsedResponse.data.replacements ?? {}; + const newReplacements = { ...replacements, ...responseReplacements }; + + const newLastUpdated = new Date(); + + const newCachedInsights = { + ...cachedInsights, + [connectorId ?? '']: { + connectorId: connectorId ?? '', + insights: newInsights, + replacements: newReplacements, + updated: newLastUpdated, + }, + }; + + setCachedInsights(newCachedInsights); + setSessionStorageCachedInsights(encodeCachedInsights(newCachedInsights) ?? ''); + + // update the generation intervals with the latest timing: + const previousConnectorIntervals: GenerationInterval[] = + generationIntervals != null ? generationIntervals[connectorId ?? ''] ?? [] : []; + const newInterval: GenerationInterval = { + connectorId: connectorId ?? '', + date: new Date(), + durationMs, + }; + + const newConnectorIntervals = [newInterval, ...previousConnectorIntervals].slice( + 0, + MAX_GENERATION_INTERVALS + ); + const newGenerationIntervals: Record<string, GenerationInterval[]> = { + ...generationIntervals, + [connectorId ?? '']: newConnectorIntervals, + }; + + setGenerationIntervals(newGenerationIntervals); + setLocalStorageGenerationIntervals(newGenerationIntervals); + + setReplacements(newReplacements); + setInsights(newInsights); + setLastUpdated(newLastUpdated); + setConnectorId?.(connectorId); + } catch (error) { + toasts?.addDanger(error, { + title: ERROR_GENERATING_INSIGHTS, + text: getErrorToastText(error), + }); + } finally { + setApproximateFutureTime(null); + setLoadingConnectorId?.(null); + setIsLoading(false); + } + }, [ + aiConnectors, + alertsIndexPattern, + anonymizationFields?.data, + cachedInsights, + connectorId, + connectorIntervals, + generationIntervals, + http, + knowledgeBase.latestAlerts, + replacements, + setConnectorId, + setLoadingConnectorId, + setLocalStorageGenerationIntervals, + setSessionStorageCachedInsights, + toasts, + ]); + + return { + approximateFutureTime, + cachedInsights, + fetchInsights, + generationIntervals, + insights, + isLoading, + lastUpdated, + replacements, + }; +}; diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index cb0e72066eb8f..b9072ba43701e 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -97,6 +97,10 @@ export const ALERTS = i18n.translate('xpack.securitySolution.navigation.alerts', defaultMessage: 'Alerts', }); +export const AI_INSIGHTS = i18n.translate('xpack.securitySolution.navigation.aiInsights', { + defaultMessage: 'AI Insights', +}); + export const TIMELINES = i18n.translate('xpack.securitySolution.navigation.timelines', { defaultMessage: 'Timelines', }); diff --git a/x-pack/plugins/security_solution/public/app_links.ts b/x-pack/plugins/security_solution/public/app_links.ts index 93003966d0b2a..aef2997d8905d 100644 --- a/x-pack/plugins/security_solution/public/app_links.ts +++ b/x-pack/plugins/security_solution/public/app_links.ts @@ -5,6 +5,8 @@ * 2.0. */ import type { CoreStart } from '@kbn/core/public'; + +import { links as aiInsightsLinks } from './ai_insights/links'; import type { AppLinkItems } from './common/links/types'; import { indicatorsLinks } from './threat_intelligence/links'; import { links as alertsLinks } from './detections/links'; @@ -24,6 +26,7 @@ export { solutionAppLinksSwitcher } from './app/solution_navigation/links/app_li export const appLinks: AppLinkItems = Object.freeze([ dashboardsLinks, alertsLinks, + aiInsightsLinks, findingsLinks, casesLinks, timelinesLinks, @@ -43,6 +46,7 @@ export const getFilteredLinks = async ( return Object.freeze([ dashboardsLinks, alertsLinks, + aiInsightsLinks, findingsLinks, casesLinks, timelinesLinks, diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts index 2d2f6d94b351a..ec7f5b8d86042 100644 --- a/x-pack/plugins/security_solution/public/cases/links.ts +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -18,7 +18,7 @@ const casesLinks = getCasesDeepLinks<LinkItem>({ basePath: CASES_PATH, extend: { [SecurityPageName.case]: { - globalNavPosition: 5, + globalNavPosition: 6, capabilities: [`${CASES_FEATURE_ID}.${READ_CASES_CAPABILITY}`], }, [SecurityPageName.caseConfigure]: { diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts index 4b2bc5f289dba..e0a66b54f91c0 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts @@ -20,7 +20,7 @@ const commonLinkProperties: Partial<LinkItem> = { export const findingsLinks: LinkItem = { ...getSecuritySolutionLink<SecurityPageName>('findings'), - globalNavPosition: 4, + globalNavPosition: 5, ...commonLinkProperties, }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts index 17e1dca9c8478..373df1e4a3ba8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts @@ -18,6 +18,7 @@ export const CATEGORIES: SeparatorLinkCategory[] = [ linkIds: [ SecurityPageName.rulesLanding, SecurityPageName.alerts, + SecurityPageName.aiInsights, SecurityPageName.cloudSecurityPostureFindings, SecurityPageName.case, ], diff --git a/x-pack/plugins/security_solution/public/explore/links.ts b/x-pack/plugins/security_solution/public/explore/links.ts index 0f45c9358ad46..b1cad1b34db3b 100644 --- a/x-pack/plugins/security_solution/public/explore/links.ts +++ b/x-pack/plugins/security_solution/public/explore/links.ts @@ -202,7 +202,7 @@ export const exploreLinks: LinkItem = { id: SecurityPageName.exploreLanding, title: EXPLORE, path: EXPLORE_PATH, - globalNavPosition: 8, + globalNavPosition: 9, capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.explore', { diff --git a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx index f1e089439927c..5c7686563f4b5 100644 --- a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx +++ b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx @@ -10,6 +10,7 @@ * By loading these later we can reduce the initial bundle size and allow users to delay loading these dependencies until they are needed. */ +import { AiInsights } from './ai_insights'; import { Cases } from './cases'; import { Detections } from './detections'; import { Exceptions } from './exceptions'; @@ -32,6 +33,7 @@ import { MachineLearning } from './machine_learning'; * The classes used to instantiate the sub plugins. These are grouped into a single object for the sake of bundling them in a single dynamic import. */ const subPluginClasses = { + AiInsights, Detections, Cases, Exceptions, diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 6bf7e06c6beed..6700a6a6dc389 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -92,7 +92,7 @@ export const links: LinkItem = { path: MANAGE_PATH, skipUrlState: true, hideTimeline: true, - globalNavPosition: 9, + globalNavPosition: 10, capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.manage', { diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 17857de7bd6c6..69ab997fefd22 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -248,6 +248,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S if (!this._subPlugins) { const { subPluginClasses } = await this.lazySubPlugins(); this._subPlugins = { + aiInsights: new subPluginClasses.AiInsights(), alerts: new subPluginClasses.Detections(), rules: new subPluginClasses.Rules(), exceptions: new subPluginClasses.Exceptions(), @@ -280,6 +281,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S ): Promise<StartedSubPlugins> { const subPlugins = await this.createSubPlugins(); return { + aiInsights: subPlugins.aiInsights.start(this.experimentalFeatures.assistantAlertsInsights), alerts: subPlugins.alerts.start(storage), cases: subPlugins.cases.start(), cloudDefend: subPlugins.cloudDefend.start(), diff --git a/x-pack/plugins/security_solution/public/threat_intelligence/links.ts b/x-pack/plugins/security_solution/public/threat_intelligence/links.ts index 8b2b8554e36ce..afcb386981330 100644 --- a/x-pack/plugins/security_solution/public/threat_intelligence/links.ts +++ b/x-pack/plugins/security_solution/public/threat_intelligence/links.ts @@ -17,6 +17,6 @@ import type { LinkItem } from '../common/links'; */ export const indicatorsLinks: LinkItem = { ...getSecuritySolutionLink<SecurityPageName>('indicators'), - globalNavPosition: 7, + globalNavPosition: 8, capabilities: [`${SERVER_APP_ID}.threat-intelligence`], }; diff --git a/x-pack/plugins/security_solution/public/timelines/links.ts b/x-pack/plugins/security_solution/public/timelines/links.ts index 64dbded0822f5..9315417d97646 100644 --- a/x-pack/plugins/security_solution/public/timelines/links.ts +++ b/x-pack/plugins/security_solution/public/timelines/links.ts @@ -14,7 +14,7 @@ export const links: LinkItem = { id: SecurityPageName.timelines, title: TIMELINES, path: TIMELINES_PATH, - globalNavPosition: 6, + globalNavPosition: 7, capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.timelines', { diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 4ba34f754b678..5ae432efb962a 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -71,6 +71,7 @@ import type { CloudSecurityPosture } from './cloud_security_posture'; import type { CloudDefend } from './cloud_defend'; import type { ThreatIntelligence } from './threat_intelligence'; import type { SecuritySolutionTemplateWrapper } from './app/home/template_wrapper'; +import type { AiInsights } from './ai_insights'; import type { Explore } from './explore'; import type { NavigationLink } from './common/links'; import type { EntityAnalytics } from './entity_analytics'; @@ -204,6 +205,7 @@ export const CASES_SUB_PLUGIN_KEY = 'cases'; export interface SubPlugins { [CASES_SUB_PLUGIN_KEY]: Cases; + aiInsights: AiInsights; alerts: Detections; cloudDefend: CloudDefend; cloudSecurityPosture: CloudSecurityPosture; @@ -225,6 +227,7 @@ export interface SubPlugins { // TODO: find a better way to defined these types export interface StartedSubPlugins { [CASES_SUB_PLUGIN_KEY]: ReturnType<Cases['start']>; + aiInsights: ReturnType<AiInsights['start']>; alerts: ReturnType<Detections['start']>; cloudDefend: ReturnType<CloudDefend['start']>; cloudSecurityPosture: ReturnType<CloudSecurityPosture['start']>; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/esql_language_knowledge_base/esql_language_knowledge_base_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/esql_language_knowledge_base/esql_language_knowledge_base_tool.ts index ed5c6b2bf19c1..5decc3ab0fc4b 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/esql_language_knowledge_base/esql_language_knowledge_base_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/esql_language_knowledge_base/esql_language_knowledge_base_tool.ts @@ -18,12 +18,15 @@ export const ESQL_KNOWLEDGE_BASE_TOOL: AssistantTool = { 'Call this for knowledge on how to build an ESQL query, or answer questions about the ES|QL query language.', sourceRegister: APP_UI_ID, isSupported: (params: AssistantToolParams): params is EsqlKnowledgeBaseToolParams => { - const { isEnabledKnowledgeBase, modelExists } = params; - return isEnabledKnowledgeBase && modelExists; + const { chain, isEnabledKnowledgeBase, modelExists } = params; + return isEnabledKnowledgeBase && modelExists && chain != null; }, getTool(params: AssistantToolParams) { if (!this.isSupported(params)) return null; + const { chain } = params as EsqlKnowledgeBaseToolParams; + if (chain == null) return null; + return new ChainTool({ name: 'ESQLKnowledgeBaseTool', description: diff --git a/x-pack/plugins/security_solution/server/assistant/tools/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/index.ts index 790e674a4b390..a464bf2a58f70 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/index.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/index.ts @@ -6,12 +6,15 @@ */ import type { AssistantTool } from '@kbn/elastic-assistant-plugin/server'; + +import { ALERT_COUNTS_TOOL } from './alert_counts/alert_counts_tool'; import { ESQL_KNOWLEDGE_BASE_TOOL } from './esql_language_knowledge_base/esql_language_knowledge_base_tool'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool'; -import { ALERT_COUNTS_TOOL } from './alert_counts/alert_counts_tool'; +import { INSIGHTS_TOOL } from './insights/insights_tool'; export const getAssistantTools = (): AssistantTool[] => [ ALERT_COUNTS_TOOL, + INSIGHTS_TOOL, ESQL_KNOWLEDGE_BASE_TOOL, OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL, ]; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/insights/get_anonymized_alerts.ts b/x-pack/plugins/security_solution/server/assistant/tools/insights/get_anonymized_alerts.ts new file mode 100644 index 0000000000000..933a7ab55b924 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/insights/get_anonymized_alerts.ts @@ -0,0 +1,62 @@ +/* + * 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 { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient } from '@kbn/core/server'; +import type { Replacements } from '@kbn/elastic-assistant-common'; +import { getAnonymizedValue, transformRawData } from '@kbn/elastic-assistant-common'; +import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; + +import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'; +import { getRawDataOrDefault, sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; + +export const getAnonymizedAlerts = async ({ + alertsIndexPattern, + anonymizationFields, + esClient, + onNewReplacements, + replacements, + size, +}: { + alertsIndexPattern?: string; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + onNewReplacements?: (replacements: Replacements) => void; + replacements?: Replacements; + size?: number; +}) => { + if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) { + return []; + } + + const query = getOpenAndAcknowledgedAlertsQuery({ + alertsIndexPattern, + anonymizationFields: anonymizationFields ?? [], + size, + }); + + const result = await esClient.search<SearchResponse>(query); + + // Accumulate replacements locally so we can, for example use the same + // replacement for a hostname when we see it in multiple alerts: + let localReplacements = { ...(replacements ?? {}) }; + const localOnNewReplacements = (newReplacements: Replacements) => { + localReplacements = { ...localReplacements, ...newReplacements }; + + onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements + }; + + return result.hits?.hits?.map((x) => + transformRawData({ + anonymizationFields, + currentReplacements: localReplacements, // <-- the latest local replacements + getAnonymizedValue, + onNewReplacements: localOnNewReplacements, // <-- the local callback + rawData: getRawDataOrDefault(x.fields), + }) + ); +}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/insights/get_insights_prompt.ts b/x-pack/plugins/security_solution/server/assistant/tools/insights/get_insights_prompt.ts new file mode 100644 index 0000000000000..30d6254c574c4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/insights/get_insights_prompt.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getInsightsPrompt = ({ + anonymizedAlerts, +}: { + anonymizedAlerts: string[]; +}) => `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. + +Use context from the following open and acknowledged alerts to provide insights: + +""" +${anonymizedAlerts.join('\n\n')} +""" +`; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/insights/get_output_parser.ts b/x-pack/plugins/security_solution/server/assistant/tools/insights/get_output_parser.ts new file mode 100644 index 0000000000000..604c0853ee92b --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/insights/get_output_parser.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { StructuredOutputParser } from 'langchain/output_parsers'; +import { z } from 'zod'; + +export const SYNTAX = '{{ field.name fieldValue1 fieldValue2 fieldValueN }}'; +const GOOD_SYNTAX_EXAMPLES = + 'Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }}'; + +const BAD_SYNTAX_EXAMPLES = + 'Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}'; + +const RECONNAISSANCE = 'Reconnaissance'; +const INITIAL_ACCESS = 'Initial Access'; +const EXECUTION = 'Execution'; +const PERSISTENCE = 'Persistence'; +const PRIVILEGE_ESCALATION = 'Privilege Escalation'; +const DISCOVERY = 'Discovery'; +const LATERAL_MOVEMENT = 'Lateral Movement'; +const COMMAND_AND_CONTROL = 'Command and Control'; +const EXFILTRATION = 'Exfiltration'; + +const MITRE_ATTACK_TACTICS = [ + RECONNAISSANCE, + INITIAL_ACCESS, + EXECUTION, + PERSISTENCE, + PRIVILEGE_ESCALATION, + DISCOVERY, + LATERAL_MOVEMENT, + COMMAND_AND_CONTROL, + EXFILTRATION, +] as const; + +export const getOutputParser = () => + StructuredOutputParser.fromZodSchema( + z + .array( + z.object({ + alertIds: z.string().array().describe(`The alert IDs that the insight is based on.`), + detailsMarkdown: z + .string() + .describe( + `A detailed insight with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ), + entitySummaryMarkdown: z + .string() + .optional() + .describe( + `A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same ${SYNTAX} syntax` + ), + mitreAttackTactics: z + .string() + .array() + .optional() + .describe( + `An array of MITRE ATT&CK tactic for the insight, using one of the following values: ${MITRE_ATTACK_TACTICS.join( + ',' + )}` + ), + summaryMarkdown: z + .string() + .describe(`A markdown summary of insight, using the same ${SYNTAX} syntax`), + title: z + .string() + .optional() + .describe( + 'A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.' + ), + }) + ) + .describe( + `Insights with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ) + ); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts b/x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts new file mode 100644 index 0000000000000..ad4947269b819 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getReplacementsRecords = ( + replacements: Array<{ value: string; uuid: string }> +): Record<string, string> => + replacements.reduce<Record<string, string>>( + (acc, { value, uuid }) => ({ ...acc, [uuid]: value }), + {} + ); + +export const getReplacementsArray = ( + replacements: Record<string, string> +): Array<{ value: string; uuid: string }> => + Object.entries(replacements).map(([uuid, value]) => ({ uuid, value })); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/insights/insights_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/insights/insights_tool.ts new file mode 100644 index 0000000000000..ea1ef22a23d1b --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/insights/insights_tool.ts @@ -0,0 +1,105 @@ +/* + * 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 { PromptTemplate } from '@langchain/core/prompts'; +import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; +import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; +import { LLMChain } from 'langchain/chains'; +import { OutputFixingParser } from 'langchain/output_parsers'; +import { DynamicTool } from 'langchain/tools'; + +import { APP_UI_ID } from '../../../../common'; +import { getAnonymizedAlerts } from './get_anonymized_alerts'; +import { getOutputParser } from './get_output_parser'; +import { sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; +import { getInsightsPrompt } from './get_insights_prompt'; + +export interface InsightsToolParams extends AssistantToolParams { + alertsIndexPattern: string; + size: number; +} + +export const INSIGHTS_TOOL_DESCRIPTION = + 'Call this for insights containing `markdown` that should be displayed verbatim (with no additional processing).'; + +/** + * Returns a tool for insights from open and acknowledged alerts, or null if + * the request doesn't have all the required parameters. + */ +export const INSIGHTS_TOOL: AssistantTool = { + id: 'insights-tool', + name: 'InsightsTool', + description: INSIGHTS_TOOL_DESCRIPTION, + sourceRegister: APP_UI_ID, + isSupported: (params: AssistantToolParams): params is InsightsToolParams => { + const { alertsIndexPattern, llm, request, size } = params; + return ( + requestHasRequiredAnonymizationParams(request) && + alertsIndexPattern != null && + size != null && + !sizeIsOutOfRange(size) && + llm != null + ); + }, + getTool(params: AssistantToolParams) { + if (!this.isSupported(params)) return null; + + const { + alertsIndexPattern, + anonymizationFields, + esClient, + llm, + onNewReplacements, + replacements, + size, + } = params as InsightsToolParams; + + return new DynamicTool({ + name: 'InsightsTool', + description: INSIGHTS_TOOL_DESCRIPTION, + func: async () => { + if (llm == null) { + throw new Error('LLM is required for insights'); + } + + const anonymizedAlerts = await getAnonymizedAlerts({ + alertsIndexPattern, + anonymizationFields, + esClient, + onNewReplacements, + replacements, + size, + }); + + const outputParser = getOutputParser(); + const outputFixingParser = OutputFixingParser.fromLLM(llm, outputParser); + + const prompt = new PromptTemplate({ + template: `Answer the user's question as best you can:\n{format_instructions}\n{query}`, + inputVariables: ['query'], + partialVariables: { + format_instructions: outputFixingParser.getFormatInstructions(), + }, + }); + + const answerFormattingChain = new LLMChain({ + llm, + prompt, + outputKey: 'records', + outputParser: outputFixingParser, + }); + + const result = await answerFormattingChain.call({ + query: getInsightsPrompt({ anonymizedAlerts }), + }); + + return JSON.stringify(result.records, null, 2); + }, + tags: ['insights'], + }); + }, +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index f541c152a568b..c13ed3687ca83 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -558,6 +558,7 @@ export class Plugin implements ISecuritySolutionPlugin { // Assistant Tool and Feature Registration plugins.elasticAssistant.registerTools(APP_UI_ID, getAssistantTools()); plugins.elasticAssistant.registerFeatures(APP_UI_ID, { + assistantAlertsInsights: config.experimentalFeatures.assistantAlertsInsights, assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation, }); From 6dff334e007b8ed275fea740fdbe80072c173132 Mon Sep 17 00:00:00 2001 From: Andrew Macri <andrew.macri@elastic.co> Date: Thu, 11 Apr 2024 16:40:30 -0400 Subject: [PATCH 2/8] - pr feedback and tooltip fix --- .../alerts/post_alerts_insights_route.gen.ts | 18 ++---------------- .../post_alerts_insights_route.schema.yaml | 10 +--------- .../impl/data_anonymization_editor/index.tsx | 1 + .../data_anonymization_editor/stats/index.tsx | 7 +++++-- 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts index 940a533a20d16..2f64f3c6e521d 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.gen.ts @@ -17,7 +17,7 @@ import { z } from 'zod'; */ import { AnonymizationFieldResponse } from '../../anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import { Replacements } from '../../conversations/common_attributes.gen'; +import { Replacements, TraceData } from '../../conversations/common_attributes.gen'; /** * An insight generated from one or more alerts @@ -69,19 +69,5 @@ export const AlertsInsightsPostResponse = z.object({ insights: z.array(AlertsInsight).optional(), replacements: Replacements.optional(), status: z.string().optional(), - /** - * Trace Data - */ - trace_data: z - .object({ - /** - * Could be any string, not necessarily a UUID - */ - transactionId: z.string().optional(), - /** - * Could be any string, not necessarily a UUID - */ - traceId: z.string().optional(), - }) - .optional(), + trace_data: TraceData.optional(), }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml index 43cde96523a1a..5ab58c6023ee5 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/insights/alerts/post_alerts_insights_route.schema.yaml @@ -103,15 +103,7 @@ paths: status: type: string trace_data: - type: object - description: Trace Data - properties: - transactionId: - type: string - description: Could be any string, not necessarily a UUID - traceId: - type: string - description: Could be any string, not necessarily a UUID + $ref: '../../conversations/common_attributes.schema.yaml#/components/schemas/TraceData' '400': description: Bad request content: diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx index bdabbd3cdf3b2..6829f0b90e46b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx @@ -88,6 +88,7 @@ const DataAnonymizationEditorComponent: React.FC<Props> = ({ isDataAnonymizable={isDataAnonymizable} anonymizationFields={selectedPromptContext.contextAnonymizationFields?.data} rawData={selectedPromptContext.rawData} + replacements={selectedPromptContext.replacements} /> <EuiSpacer size="s" /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx index f7f2f94a65edd..0de309e29d3cb 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx @@ -25,6 +25,7 @@ interface Props { anonymizationFields?: AnonymizationFieldResponse[]; rawData?: string | Record<string, string[]>; inline?: boolean; + replacements?: Record<string, string>; } const StatsComponent: React.FC<Props> = ({ @@ -32,14 +33,16 @@ const StatsComponent: React.FC<Props> = ({ anonymizationFields, rawData, inline, + replacements, }) => { const { allowed, anonymized, total } = useMemo( () => getStats({ anonymizationFields, rawData, + replacements, }), - [anonymizationFields, rawData] + [anonymizationFields, rawData, replacements] ); return ( @@ -53,7 +56,7 @@ const StatsComponent: React.FC<Props> = ({ <StatFlexItem grow={false}> <AnonymizedStat anonymized={anonymized} - isDataAnonymizable={isDataAnonymizable} + isDataAnonymizable={isDataAnonymizable || anonymized > 0} inline={inline} /> </StatFlexItem> From 9a23385a53017997f93d9c0e721bedf8ae7177d1 Mon Sep 17 00:00:00 2001 From: Andrew Macri <andrew.macri@elastic.co> Date: Fri, 12 Apr 2024 08:44:36 -0400 Subject: [PATCH 3/8] - pr feedback --- .../server/routes/insights/alerts/helpers.ts | 12 ------------ .../get_user_flyout_panel_props.ts | 3 +-- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts index 4017342eb4a09..85b22e4c428fe 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/insights/alerts/helpers.ts @@ -14,7 +14,6 @@ import { } from '@kbn/elastic-assistant-common'; import { ActionsClientLlm } from '@kbn/elastic-assistant-common/impl/llm'; import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import { uniq } from 'lodash/fp'; import { v4 as uuidv4 } from 'uuid'; import { AssistantToolParams, ElasticAssistantApiRequestHandlerContext } from '../../../types'; @@ -34,17 +33,6 @@ export const REQUIRED_FOR_INSIGHTS: AnonymizationFieldResponse[] = [ }, ]; -/** - * Adds the specified fields to the allow / allow replacement list - */ -export const addRequiredFields = ({ - list, - requiredFields, -}: { - list: string[]; - requiredFields: string[]; -}): string[] => uniq([...list, ...requiredFields]); - export const getAssistantToolParams = ({ alertsIndexPattern, anonymizationFields, diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts index bbd8d2247bca5..d43519af851cf 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts +++ b/x-pack/plugins/security_solution/public/ai_insights/insight_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts @@ -22,8 +22,7 @@ interface UserPanelExpandableFlyoutProps extends FlyoutPanelProps { params: UserPanelProps; } -export const isUserName = (fieldName: string) => - fieldName === 'user.name' || fieldName === 'host.hostname'; +export const isUserName = (fieldName: string) => fieldName === 'user.name'; export const getUserFlyoutPanelProps = ({ contextId, From 312f61cab38bb6092b778d328d24a239575c228f Mon Sep 17 00:00:00 2001 From: Andrew Macri <andrew.macri@elastic.co> Date: Fri, 12 Apr 2024 11:31:03 -0400 Subject: [PATCH 4/8] - update all instances of Replacements to use the new common type --- .../impl/data_anonymization/get_anonymized_data/index.ts | 5 +++-- .../data_anonymization/get_anonymized_value/index.ts | 3 ++- .../impl/data_anonymization/transform_raw_data/index.tsx | 2 +- .../impl/data_anonymization/types.ts | 9 +++++---- .../impl/mock/get_anonymized_value/index.ts | 4 +++- .../impl/assistant/get_anonymized_value/index.ts | 3 ++- .../impl/assistant/prompt/helpers.ts | 2 +- .../impl/assistant/prompt_context/types.ts | 5 +++-- .../impl/assistant/use_assistant_overlay/index.tsx | 3 ++- .../impl/assistant/use_send_message/helpers.ts | 4 +++- .../impl/data_anonymization_editor/get_stats/index.ts | 4 ++-- .../replacements_context_viewer/index.tsx | 3 ++- .../impl/data_anonymization_editor/stats/index.tsx | 5 +++-- .../impl/mock/get_anonymized_value/index.ts | 4 +++- .../get_alerts_insight_markdown.ts | 6 ++++-- .../ai_insights/insight/actionable_summary/index.tsx | 3 ++- .../public/ai_insights/insight/actions/index.tsx | 5 +++-- .../ai_insights/insight/actions/take_action/helpers.ts | 4 +++- .../ai_insights/insight/actions/take_action/index.tsx | 4 +++- .../insight/actions/use_add_to_case/index.tsx | 9 +++++---- .../insight/actions/use_add_to_existing_case/index.tsx | 5 +++-- .../public/ai_insights/insight/index.tsx | 3 ++- .../ai_insights/insight/tabs/ai_insights/index.tsx | 4 ++-- .../public/ai_insights/insight/tabs/alerts/index.tsx | 3 ++- .../public/ai_insights/insight/tabs/get_tabs.tsx | 3 ++- .../public/ai_insights/insight/tabs/index.tsx | 3 ++- .../ai_insights/insight/view_in_ai_assistant/index.tsx | 3 ++- .../server/assistant/tools/insights/helpers.ts | 6 ++++-- 28 files changed, 74 insertions(+), 43 deletions(-) diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_data/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_data/index.ts index 071132193af1e..49a50ff08e601 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_data/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_data/index.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { Replacements } from '../../schemas'; import { AnonymizationFieldResponse } from '../../schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; import { isAllowed } from '../helpers'; import type { AnonymizedData, GetAnonymizedValues } from '../types'; @@ -16,12 +17,12 @@ export const getAnonymizedData = ({ rawData, }: { anonymizationFields?: AnonymizationFieldResponse[]; - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; getAnonymizedValue: ({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }) => string; getAnonymizedValues: GetAnonymizedValues; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_value/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_value/index.ts index 455e9700882fb..40fc18266cbc9 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_value/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/get_anonymized_value/index.ts @@ -7,12 +7,13 @@ import { invert } from 'lodash/fp'; import { v4 } from 'uuid'; +import { Replacements } from '../../schemas'; export const getAnonymizedValue = ({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }): string => { if (currentReplacements != null) { diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx index ba9376a120821..30663900795a0 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/transform_raw_data/index.tsx @@ -24,7 +24,7 @@ export const transformRawData = ({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }) => string; onNewReplacements?: (replacements: Replacements) => void; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/types.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/types.ts index b39b5ecef3af8..321b9ff9e1452 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/types.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/data_anonymization/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Replacements } from '../schemas'; import { AnonymizationFieldResponse } from '../schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; export interface AnonymizedValues { @@ -12,7 +13,7 @@ export interface AnonymizedValues { anonymizedValues: string[]; /** A map from replacement value to original value */ - replacements: Record<string, string>; + replacements: Replacements; } export interface AnonymizedData { @@ -20,7 +21,7 @@ export interface AnonymizedData { anonymizedData: Record<string, string[]>; /** A map from replacement value to original value */ - replacements: Record<string, string>; + replacements: Replacements; } export type GetAnonymizedValues = ({ @@ -31,13 +32,13 @@ export type GetAnonymizedValues = ({ rawData, }: { anonymizationFields?: AnonymizationFieldResponse[]; - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; field: string; getAnonymizedValue: ({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }) => string; rawData: Record<string, unknown[]>; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/mock/get_anonymized_value/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/mock/get_anonymized_value/index.ts index 1ec76a90d292b..3822c736b670e 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/mock/get_anonymized_value/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/mock/get_anonymized_value/index.ts @@ -5,11 +5,13 @@ * 2.0. */ +import { Replacements } from '../../schemas'; + /** This mock returns the reverse of `value` */ export const mockGetAnonymizedValue = ({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }): string => rawValue.split('').reverse().join(''); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.ts index 455e9700882fb..ae0f06dc20e78 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/get_anonymized_value/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Replacements } from '@kbn/elastic-assistant-common'; import { invert } from 'lodash/fp'; import { v4 } from 'uuid'; @@ -12,7 +13,7 @@ export const getAnonymizedValue = ({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }): string => { if (currentReplacements != null) { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts index 9ffe032fccd6b..5d334f64f0e11 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt/helpers.ts @@ -47,7 +47,7 @@ export function getCombinedMessage({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }) => string; isNewChat: boolean; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts index d278189741347..ad1954c0b3b23 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/prompt_context/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Replacements } from '@kbn/elastic-assistant-common'; import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen'; import type { ReactNode } from 'react'; @@ -58,7 +59,7 @@ export interface PromptContext { /** * Replacements associated with the context, i.e. replacements for an insight provided as context */ - replacements?: Record<string, string>; + replacements?: Replacements; /** * An optional user prompt that's filled in, but not sent, when the Elastic AI Assistant opens @@ -82,7 +83,7 @@ export interface SelectedPromptContext { /** this data is not anonymized */ rawData: string | Record<string, string[]>; /** replacements associated with the context, i.e. replacements for an insight provided as context */ - replacements?: Record<string, string>; + replacements?: Replacements; } /** diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx index 64dae3134f18e..40fe463f70836 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_assistant_overlay/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { Replacements } from '@kbn/elastic-assistant-common'; import { useCallback, useEffect, useMemo } from 'react'; import { useAssistantContext } from '../../assistant_context'; @@ -70,7 +71,7 @@ export const useAssistantOverlay = ( /** * Optionally provide a map of replacements associated with the context, i.e. replacements for an insight that's provided as context */ - replacements?: Record<string, string> | null + replacements?: Replacements | null ): UseAssistantOverlay => { // memoize the props so that we can use them in the effect below: const _category: PromptContext['category'] = useMemo(() => category, [category]); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_send_message/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_send_message/helpers.ts index 60d4630703d50..12f52d59bf414 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_send_message/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_send_message/helpers.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { Replacements } from '@kbn/elastic-assistant-common'; + export interface OptionalRequestParams { allow?: string[]; allowReplacement?: string[]; - replacements?: Record<string, string>; + replacements?: Replacements; } export const getOptionalRequestParams = ({ diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts index 87b4b34a9b36b..adcdab05724cd 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/get_stats/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isAllowed, isAnonymized, isDenied } from '@kbn/elastic-assistant-common'; +import { isAllowed, isAnonymized, isDenied, Replacements } from '@kbn/elastic-assistant-common'; import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; import { Stats } from '../helpers'; @@ -17,7 +17,7 @@ export const getStats = ({ }: { anonymizationFields?: AnonymizationFieldResponse[]; rawData?: string | Record<string, string[]>; - replacements?: Record<string, string>; + replacements?: Replacements; }): Stats => { const ZERO_STATS = { allowed: 0, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/replacements_context_viewer/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/replacements_context_viewer/index.tsx index 88e1799f6f238..0194e1afbda7d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/replacements_context_viewer/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/replacements_context_viewer/index.tsx @@ -6,11 +6,12 @@ */ import { EuiMarkdownFormat } from '@elastic/eui'; +import { Replacements } from '@kbn/elastic-assistant-common'; import React from 'react'; export interface Props { markdown: string; - replacements: Record<string, string>; + replacements: Replacements; } const ReplacementsContextViewerComponent: React.FC<Props> = ({ markdown, replacements }) => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx index 0de309e29d3cb..2068bc517025d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/stats/index.tsx @@ -6,11 +6,12 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { Replacements } from '@kbn/elastic-assistant-common'; import React, { useMemo } from 'react'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; import { AllowedStat } from './allowed_stat'; import { AnonymizedStat } from './anonymized_stat'; import { getStats } from '../get_stats'; @@ -25,7 +26,7 @@ interface Props { anonymizationFields?: AnonymizationFieldResponse[]; rawData?: string | Record<string, string[]>; inline?: boolean; - replacements?: Record<string, string>; + replacements?: Replacements; } const StatsComponent: React.FC<Props> = ({ diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/get_anonymized_value/index.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/get_anonymized_value/index.ts index 1ec76a90d292b..a6d5c4e5d3972 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/get_anonymized_value/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/get_anonymized_value/index.ts @@ -5,11 +5,13 @@ * 2.0. */ +import { Replacements } from '@kbn/elastic-assistant-common'; + /** This mock returns the reverse of `value` */ export const mockGetAnonymizedValue = ({ currentReplacements, rawValue, }: { - currentReplacements: Record<string, string> | undefined; + currentReplacements: Replacements | undefined; rawValue: string; }): string => rawValue.split('').reverse().join(''); diff --git a/x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts b/x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts index c7b534fdb4e26..1ab375208281e 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts +++ b/x-pack/plugins/security_solution/public/ai_insights/get_alerts_insight_markdown/get_alerts_insight_markdown.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { Replacements } from '@kbn/elastic-assistant-common'; + import { getTacticLabel, getTacticMetadata } from '../helpers'; import type { AlertsInsight } from '../types'; @@ -35,7 +37,7 @@ export const getMarkdownWithOriginalValues = ({ replacements, }: { markdown: string; - replacements?: Record<string, string>; + replacements?: Replacements; }): string => { if (replacements == null) { return markdown; @@ -52,7 +54,7 @@ export const getAlertsInsightMarkdown = ({ replacements, }: { insight: AlertsInsight; - replacements?: Record<string, string>; + replacements?: Replacements; }): string => { const title = getMarkdownFields(insight.title); const entitySummaryMarkdown = getMarkdownFields(insight.entitySummaryMarkdown); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx index 5a9a3d5d29d99..d8ed018c9d296 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx @@ -6,6 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import type { Replacements } from '@kbn/elastic-assistant-common'; import React, { useMemo } from 'react'; import { InsightMarkdownFormatter } from '../../insight_markdown_formatter'; @@ -15,7 +16,7 @@ import { ViewInAiAssistant } from '../view_in_ai_assistant'; interface Props { insight: AlertsInsight; promptContextId: string | undefined; - replacements?: Record<string, string>; + replacements?: Replacements; showAnonymized?: boolean; } diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx index 368afbc400059..2d6289fccc3f1 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/index.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import { css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { Replacements } from '@kbn/elastic-assistant-common'; import React from 'react'; import { AlertsBadge } from './alerts_badge'; @@ -18,7 +19,7 @@ import type { AlertsInsight } from '../../types'; interface Props { insight: AlertsInsight; promptContextId: string | undefined; - replacements?: Record<string, string>; + replacements?: Replacements; } const ActionsComponent: React.FC<Props> = ({ insight, promptContextId, replacements }) => { diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts index 67251855a78d0..d59378f2c38ae 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/helpers.ts @@ -5,11 +5,13 @@ * 2.0. */ +import type { Replacements } from '@kbn/elastic-assistant-common'; + export const getOriginalAlertIds = ({ alertIds, replacements, }: { alertIds: string[]; - replacements?: Record<string, string>; + replacements?: Replacements; }): string[] => alertIds.map((alertId) => (replacements != null ? replacements[alertId] ?? alertId : alertId)); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx index f55f068e2be6c..5c4bfcfea0d0a 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/take_action/index.tsx @@ -4,7 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { useAssistantContext } from '@kbn/elastic-assistant'; +import type { Replacements } from '@kbn/elastic-assistant-common'; import { EuiButtonEmpty, EuiContextMenuItem, @@ -27,7 +29,7 @@ interface Props { conversationTitle?: string; insight: AlertsInsight; promptContextId: string | undefined; - replacements?: Record<string, string>; + replacements?: Replacements; } const TakeActionComponent: React.FC<Props> = ({ diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx index 97712ca67a7e8..2f7a53530cc5e 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; import { AttachmentType } from '@kbn/cases-plugin/common'; import type { CaseAttachmentWithoutOwner } from '@kbn/cases-plugin/public/types'; import { useAssistantContext } from '@kbn/elastic-assistant'; +import type { Replacements } from '@kbn/elastic-assistant-common'; +import React, { useCallback, useMemo } from 'react'; import { useKibana } from '../../../../common/lib/kibana'; import * as i18n from './translations'; @@ -32,7 +33,7 @@ export const useAddToNewCase = ({ }: { alertIds: string[]; markdownComments: string[]; - replacements?: Record<string, string>; + replacements?: Replacements; }) => void; } => { const { cases } = useKibana().services; @@ -51,7 +52,7 @@ export const useAddToNewCase = ({ alertIds: string[]; headerContent?: React.ReactNode; markdownComments: string[]; - replacements?: Record<string, string>; + replacements?: Replacements; }) => { const userCommentAttachments = markdownComments.map<CaseAttachmentWithoutOwner>((x) => ({ comment: x, @@ -88,7 +89,7 @@ export const useAddToNewCase = ({ }: { alertIds: string[]; markdownComments: string[]; - replacements?: Record<string, string>; + replacements?: Replacements; }) => { if (onClick) { onClick(); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx index 9626505907f72..4f0ba54eece24 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_existing_case/index.tsx @@ -8,6 +8,7 @@ import { AttachmentType } from '@kbn/cases-plugin/common'; import type { CaseAttachmentWithoutOwner } from '@kbn/cases-plugin/public/types'; import { useAssistantContext } from '@kbn/elastic-assistant'; +import type { Replacements } from '@kbn/elastic-assistant-common'; import { useCallback } from 'react'; import { useKibana } from '../../../../common/lib/kibana'; @@ -30,7 +31,7 @@ export const useAddToExistingCase = ({ }: { alertIds: string[]; markdownComments: string[]; - replacements?: Record<string, string>; + replacements?: Replacements; }) => void; } => { const { cases } = useKibana().services; @@ -51,7 +52,7 @@ export const useAddToExistingCase = ({ }: { alertIds: string[]; markdownComments: string[]; - replacements?: Record<string, string>; + replacements?: Replacements; }) => { const userCommentAttachments = markdownComments.map<CaseAttachmentWithoutOwner>((x) => ({ comment: x, diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx index 1eece699133ca..4d5b23d0d8321 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx @@ -8,6 +8,7 @@ import { css } from '@emotion/react'; import { EuiAccordion, EuiPanel, EuiSpacer, useEuiTheme, useGeneratedHtmlId } from '@elastic/eui'; import { useAssistantOverlay } from '@kbn/elastic-assistant'; +import type { Replacements } from '@kbn/elastic-assistant-common'; import React, { useCallback, useMemo, useState } from 'react'; import { ActionableSummary } from './actionable_summary'; @@ -29,7 +30,7 @@ interface Props { initialIsOpen?: boolean; insight: AlertsInsight; onToggle?: (newState: 'open' | 'closed') => void; - replacements?: Record<string, string>; + replacements?: Replacements; showAnonymized?: boolean; } diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx index 5276c120c18f9..ddd0801759dd0 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx @@ -5,11 +5,11 @@ * 2.0. */ +import type { Replacements } from '@kbn/elastic-assistant-common'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; -// import { SendToTimelineButton } from '../../../../assistant/send_to_timeline'; import { AttackChain } from '../../../attack/attack_chain'; import { InvestigateInTimelineButton } from '../../../../common/components/event_details/table/investigate_in_timeline_button'; import { buildAlertsKqlFilter } from '../../../../detections/components/alerts_table/actions'; @@ -22,7 +22,7 @@ import { ViewInAiAssistant } from '../../view_in_ai_assistant'; interface Props { insight: AlertsInsight; promptContextId: string | undefined; - replacements?: Record<string, string>; + replacements?: Replacements; showAnonymized?: boolean; } diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx index 9dbfc0f62b8fa..169192848d4cc 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/alerts/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { Replacements } from '@kbn/elastic-assistant-common'; import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; import React, { useMemo } from 'react'; @@ -14,7 +15,7 @@ import type { AlertsInsight } from '../../../types'; interface Props { insight: AlertsInsight; - replacements?: Record<string, string>; + replacements?: Replacements; } const AlertsComponent: React.FC<Props> = ({ insight, replacements }) => { diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx index 79c94a8787f63..2c6fc75e23afe 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/get_tabs.tsx @@ -6,6 +6,7 @@ */ import { EuiSpacer } from '@elastic/eui'; +import type { Replacements } from '@kbn/elastic-assistant-common'; import React from 'react'; import { AiInsights } from './ai_insights'; @@ -27,7 +28,7 @@ export const getTabs = ({ }: { insight: AlertsInsight; promptContextId: string | undefined; - replacements?: Record<string, string>; + replacements?: Replacements; showAnonymized?: boolean; }): TabInfo[] => [ { diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx index f7e290b53b025..fcd2222507553 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { Replacements } from '@kbn/elastic-assistant-common'; import { EuiTabs, EuiTab } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; @@ -14,7 +15,7 @@ import type { AlertsInsight } from '../../types'; interface Props { insight: AlertsInsight; promptContextId: string | undefined; - replacements?: Record<string, string>; + replacements?: Replacements; showAnonymized?: boolean; } diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx index 165c243f5194b..d3161be5864a1 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx @@ -6,6 +6,7 @@ */ import { AssistantAvatar, useAssistantContext } from '@kbn/elastic-assistant'; +import type { Replacements } from '@kbn/elastic-assistant-common'; import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback } from 'react'; @@ -16,7 +17,7 @@ interface Props { compact?: boolean; conversationTitle?: string; promptContextId: string | undefined; - replacements?: Record<string, string>; + replacements?: Replacements; } const ViewInAiAssistantComponent: React.FC<Props> = ({ diff --git a/x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts b/x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts index ad4947269b819..fd5d4cc668df8 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/insights/helpers.ts @@ -5,15 +5,17 @@ * 2.0. */ +import type { Replacements } from '@kbn/elastic-assistant-common'; + export const getReplacementsRecords = ( replacements: Array<{ value: string; uuid: string }> -): Record<string, string> => +): Replacements => replacements.reduce<Record<string, string>>( (acc, { value, uuid }) => ({ ...acc, [uuid]: value }), {} ); export const getReplacementsArray = ( - replacements: Record<string, string> + replacements: Replacements ): Array<{ value: string; uuid: string }> => Object.entries(replacements).map(([uuid, value]) => ({ uuid, value })); From b7e5175b3957a717a837c4345ca3c406396bdcef Mon Sep 17 00:00:00 2001 From: Andrew Macri <andrew.macri@elastic.co> Date: Fri, 12 Apr 2024 16:13:58 -0400 Subject: [PATCH 5/8] - tooltip PR feedback --- .../ai_insights/pages/empty_prompt/index.tsx | 31 +++++++++------ .../pages/empty_prompt/translations.ts | 7 ++++ .../public/ai_insights/pages/header/index.tsx | 39 +++++++------------ .../ai_insights/pages/header/translations.ts | 29 ++++++++++++++ .../ai_insights/pages/page_title/index.tsx | 37 ++++++++++-------- .../pages/page_title/translations.ts | 19 +++++++++ .../common/components/header_page/title.tsx | 4 +- 7 files changed, 113 insertions(+), 53 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/header/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/page_title/translations.ts diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx index 1dff218535303..84f98736beeeb 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx @@ -14,6 +14,7 @@ import { EuiLink, EuiSpacer, EuiText, + EuiToolTip, } from '@elastic/eui'; import React, { useMemo } from 'react'; @@ -88,19 +89,25 @@ const EmptyPromptComponent: React.FC<Props> = ({ [] ); - const actions = useMemo( - () => ( - <EuiButton - color="primary" - data-test-subj="generate" - disabled={!hasAssistantPrivilege || isLoading || isDisabled} - onClick={onGenerate} + const actions = useMemo(() => { + const disabled = !hasAssistantPrivilege || isLoading || isDisabled; + + return ( + <EuiToolTip + content={disabled ? i18n.SELECT_A_CONNECTOR : null} + data-test-subj="generateTooltip" > - {i18n.GENERATE} - </EuiButton> - ), - [hasAssistantPrivilege, isDisabled, isLoading, onGenerate] - ); + <EuiButton + color="primary" + data-test-subj="generate" + disabled={disabled} + onClick={onGenerate} + > + {i18n.GENERATE} + </EuiButton> + </EuiToolTip> + ); + }, [hasAssistantPrivilege, isDisabled, isLoading, onGenerate]); return ( <EuiFlexGroup alignItems="center" direction="column" gutterSize="none"> diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts index df9ac5ace3793..e4026d917db72 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/translations.ts @@ -34,6 +34,13 @@ export const LEARN_MORE = i18n.translate( } ); +export const SELECT_A_CONNECTOR = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.emptyPrompt.selectAConnectorLabel', + { + defaultMessage: 'Select a connector', + } +); + export const START_GENERATING_INSIGHTS = i18n.translate( 'xpack.securitySolution.aiInsights.pages.emptyPrompt.startGeneratingInsightsLabel', { diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx index 09cbb9cb429b0..b348d92e046b6 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx @@ -5,28 +5,14 @@ * 2.0. */ -import { EuiButton, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { ConnectorSelectorInline } from '@kbn/elastic-assistant'; -import { i18n } from '@kbn/i18n'; import { noop } from 'lodash/fp'; import React from 'react'; import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; - -const GENERATE = i18n.translate( - 'xpack.securitySolution.aiInsights.poweredByGenerativeAi.generateButton', - { - defaultMessage: 'Generate', - } -); - -const LOADING = i18n.translate( - 'xpack.securitySolution.aiInsights.poweredByGenerativeAi.loadingButton', - { - defaultMessage: 'Loading...', - } -); +import * as i18n from './translations'; interface Props { connectorId: string | undefined; @@ -64,15 +50,20 @@ const HeaderComponent: React.FC<Props> = ({ /> </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiButton - data-test-subj="generate" - size="s" - disabled={disabled} - isLoading={isLoading} - onClick={onGenerate} + <EuiToolTip + content={connectorId == null ? i18n.SELECT_A_CONNECTOR : null} + data-test-subj="generateTooltip" > - {isLoading ? LOADING : GENERATE} - </EuiButton> + <EuiButton + data-test-subj="generate" + size="s" + disabled={disabled} + isLoading={isLoading} + onClick={onGenerate} + > + {isLoading ? i18n.LOADING : i18n.GENERATE} + </EuiButton> + </EuiToolTip> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/header/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/header/translations.ts new file mode 100644 index 0000000000000..8744286cfd3ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/header/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const GENERATE = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.header.generateButton', + { + defaultMessage: 'Generate', + } +); + +export const LOADING = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.header.loadingButton', + { + defaultMessage: 'Loading...', + } +); + +export const SELECT_A_CONNECTOR = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.header.selectAConnector', + { + defaultMessage: 'Select a connector', + } +); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx index 0c0fd62b33b23..348cb5e4e3a43 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx @@ -5,15 +5,18 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; import { css } from '@emotion/react'; import React from 'react'; -import { i18n } from '@kbn/i18n'; - -const AI_INSIGHTS_PAGE_TITLE = i18n.translate('xpack.securitySolution.aiInsights.pageTitle', { - defaultMessage: 'AI insights', -}); +import * as i18n from './translations'; const BETA_BADGE_SIZE = 24; // px @@ -24,7 +27,7 @@ const PageTitleComponent: React.FC = () => { <EuiFlexGroup alignItems="center" data-test-subj="pageTitle" gutterSize="none"> <EuiFlexItem grow={false}> <EuiTitle data-test-subj="title" size="l"> - <h1>{AI_INSIGHTS_PAGE_TITLE}</h1> + <h1>{i18n.AI_INSIGHTS_PAGE_TITLE}</h1> </EuiTitle> </EuiFlexItem> @@ -40,15 +43,17 @@ const PageTitleComponent: React.FC = () => { `} grow={false} > - <EuiIcon - css={css` - transform: translate(3px, 2px); - `} - color="hollow" - data-test-subj="betaIcon" - size="m" - type="beta" - /> + <EuiToolTip content={i18n.BETA}> + <EuiIcon + css={css` + transform: translate(3px, 2px); + `} + color="hollow" + data-test-subj="betaIcon" + size="m" + type="beta" + /> + </EuiToolTip> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/translations.ts new file mode 100644 index 0000000000000..5b34047faa360 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/translations.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AI_INSIGHTS_PAGE_TITLE = i18n.translate( + 'xpack.securitySolution.aiInsights.pages.pageTitle.pageTitle', + { + defaultMessage: 'AI insights', + } +); + +export const BETA = i18n.translate('xpack.securitySolution.aiInsights.pages.pageTitle.betaBadge', { + defaultMessage: 'Beta', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx index 50602b95d996e..2da771d0163bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx @@ -39,7 +39,9 @@ const TitleComponent: React.FC<Props> = ({ title, badgeOptions }) => ( <EuiTitle size="l"> <Header data-test-subj="header-page-title"> <TitleWrapper> - <TruncatableText tooltipContent={title}>{title}</TruncatableText> + <TruncatableText tooltipContent={typeof title === 'string' ? title : null}> + {title} + </TruncatableText> </TitleWrapper> {badgeOptions && ( <> From 93cc95de7035867209277be0fc5677c5b58cc074 Mon Sep 17 00:00:00 2001 From: Andrew Macri <andrew.macri@elastic.co> Date: Mon, 15 Apr 2024 23:10:46 -0400 Subject: [PATCH 6/8] - add mocks --- .../insight/actions/use_add_to_case/index.tsx | 4 + .../actions/use_add_to_case/translations.ts | 6 + .../public/ai_insights/insight/index.tsx | 2 +- ...mock_find_anonymization_fields_response.ts | 610 ++++++++++++++++++ .../ai_insights/mock/mock_use_insights.ts | 545 ++++++++++++++++ .../ai_insights/pages/empty_prompt/index.tsx | 7 +- .../public/ai_insights/pages/index.test.tsx | 364 +++++++++++ .../public/ai_insights/pages/index.tsx | 2 +- .../ai_insights/pages/page_title/index.tsx | 2 +- .../ai_insights/pages/summary/index.tsx | 2 +- .../public/ai_insights/routes.tsx | 5 +- .../common/mock/mock_assistant_provider.tsx | 10 +- 12 files changed, 1550 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/ai_insights/mock/mock_find_anonymization_fields_response.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/mock/mock_use_insights.ts create mode 100644 x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx index 2f7a53530cc5e..95053d4356f27 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/index.tsx @@ -40,6 +40,10 @@ export const useAddToNewCase = ({ const { alertsIndexPattern } = useAssistantContext(); const createCaseFlyout = cases.hooks.useCasesAddToNewCaseFlyout({ + initialValue: { + description: i18n.CASE_DESCRIPTION(title), + title, + }, toastContent: i18n.ADD_TO_CASE_SUCCESS, }); const openCreateCaseFlyout = useCallback( diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts index 0d46653de0435..faed5f8d69fbb 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actions/use_add_to_case/translations.ts @@ -29,3 +29,9 @@ export const CREATE_A_CASE_FOR_INSIGHT = (title: string) => defaultMessage: 'Create a case for insight {title}', } ); + +export const CASE_DESCRIPTION = (insightTitle: string) => + i18n.translate('xpack.securitySolution.aiInsights.insight.actions.useAddToCase.caseDescription', { + values: { insightTitle }, + defaultMessage: 'This case was opened for insight: _{insightTitle}_', + }); diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx index 4d5b23d0d8321..60f9e245fabc9 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/index.tsx @@ -95,7 +95,7 @@ const InsightComponent: React.FC<Props> = ({ return ( <> - <EuiPanel data-tes-subj="insight" hasBorder={true}> + <EuiPanel data-test-subj="insight" hasBorder={true}> <EuiAccordion buttonContent={buttonContent} data-test-subj="insightAccordion" diff --git a/x-pack/plugins/security_solution/public/ai_insights/mock/mock_find_anonymization_fields_response.ts b/x-pack/plugins/security_solution/public/ai_insights/mock/mock_find_anonymization_fields_response.ts new file mode 100644 index 0000000000000..7f990a5350999 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/mock/mock_find_anonymization_fields_response.ts @@ -0,0 +1,610 @@ +/* + * 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 { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen'; + +export const mockFindAnonymizationFieldsResponse: FindAnonymizationFieldsResponse = { + perPage: 1000, + page: 1, + total: 66, + data: [ + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: '_id', + allowed: true, + anonymized: true, + namespace: 'default', + id: '6826fb6f-de83-4e19-b9e4-15718bda02e6', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: '@timestamp', + allowed: true, + anonymized: false, + namespace: 'default', + id: '1fd5c144-305c-450e-a936-18f7f9def540', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'cloud.availability_zone', + allowed: true, + anonymized: true, + namespace: 'default', + id: 'fb5921d3-7db5-4d01-baf7-3ccea6821376', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'cloud.provider', + allowed: true, + anonymized: true, + namespace: 'default', + id: '9a192141-a4c2-44ab-95eb-5d0a3805c145', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'cloud.region', + allowed: true, + anonymized: true, + namespace: 'default', + id: '1eb7dc31-57af-4ed7-b24a-db9fb1e1db00', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'destination.ip', + allowed: true, + anonymized: true, + namespace: 'default', + id: 'dffcf346-ddda-4371-9e86-1a2e01f23f20', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'dns.question.name', + allowed: true, + anonymized: false, + namespace: 'default', + id: '66d17ebb-9383-42a1-be5f-595a588faea5', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'dns.question.type', + allowed: true, + anonymized: false, + namespace: 'default', + id: '3c7f3ba2-57c7-45fd-a694-52dae54c2b37', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'event.action', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'f86a5c81-a4cc-42dd-9f8b-2e6260d4be69', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'event.category', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'c96cc633-e570-464c-9827-719ccf317f55', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'event.dataset', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'ae76f45b-a6bb-43fe-9c6a-112989b6b830', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'event.module', + allowed: true, + anonymized: false, + namespace: 'default', + id: '56621747-ee9c-4ac9-8cfb-7f0eb0eb0f58', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'event.outcome', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'c630e9b5-d325-49a5-96fc-d079d8c28f10', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'event.type', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'f944135b-3d36-4499-a704-572a7c33571d', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'file.Ext.original.path', + allowed: true, + anonymized: true, + namespace: 'default', + id: '7f841425-b3eb-4052-9830-7b3170aee3d9', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'file.hash.sha256', + allowed: true, + anonymized: false, + namespace: 'default', + id: '78c58303-8c31-4d1d-af82-66f291750283', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'file.name', + allowed: true, + anonymized: true, + namespace: 'default', + id: 'e8adf89d-cc47-4fbb-ac42-7d4edfa75937', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'file.path', + allowed: true, + anonymized: true, + namespace: 'default', + id: '2bf55b9d-bf2b-4641-8fd9-5b8fae700dc7', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'host.name', + allowed: true, + anonymized: true, + namespace: 'default', + id: '3214b504-33e0-4980-8e73-072fc2ec799e', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'host.risk.calculated_level', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'fc808e02-f725-4d3f-8ed9-522372f60e0e', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'host.risk.calculated_score_norm', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'abefa344-4af4-420d-bd3a-78280741eb63', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.original_time', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'ebc2fae3-65f6-4d13-94d0-1bd5c8e72238', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.last_detected', + allowed: true, + anonymized: false, + namespace: 'default', + id: '16eced8b-722e-4711-8e09-b904782f08b4', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.risk_score', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'c367170e-0796-4d73-9b24-e5482b3882b6', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.description', + allowed: true, + anonymized: false, + namespace: 'default', + id: '653b8ff2-8fed-4d2c-a0d8-d4864483ab28', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.name', + allowed: true, + anonymized: false, + namespace: 'default', + id: '7ba0c355-c5c2-4bc8-88ff-f8118fd0e371', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.references', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'b3f00bbc-0d79-448d-b2d1-e7c5d90fb06c', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.framework', + allowed: true, + anonymized: false, + namespace: 'default', + id: '3dbdc672-e0ac-4453-b473-213e88ca7c34', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.tactic.id', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'ecf85499-79dc-4232-97ef-99c94ea53ab8', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.tactic.name', + allowed: true, + anonymized: false, + namespace: 'default', + id: '549d9471-f386-468d-9a1a-8ac0f8f883e1', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.tactic.reference', + allowed: true, + anonymized: false, + namespace: 'default', + id: '3736fde6-2f4d-4317-a2e6-a80609158ed2', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.technique.id', + allowed: true, + anonymized: false, + namespace: 'default', + id: '70ecbbee-179d-4374-b182-b659af054e38', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.technique.name', + allowed: true, + anonymized: false, + namespace: 'default', + id: '7b8571e6-6a68-40b7-a9f4-1aa3310d4485', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.technique.reference', + allowed: true, + anonymized: false, + namespace: 'default', + id: '5d617446-57e9-4b62-9268-7c33d37c12a0', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.id', + allowed: true, + anonymized: false, + namespace: 'default', + id: '6b4c6293-82e6-4d97-b0cf-6ef787e368ae', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.name', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'eebeb244-5167-4e02-8757-212e60ad3408', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.reference', + allowed: true, + anonymized: false, + namespace: 'default', + id: '8f2dfc7c-2156-4241-84be-2424ab5865a5', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.severity', + allowed: true, + anonymized: false, + namespace: 'default', + id: '73fa9455-762a-4560-914c-840dcaa791db', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'kibana.alert.workflow_status', + allowed: true, + anonymized: false, + namespace: 'default', + id: '9041e89a-2309-43bb-b8a3-dac06779088a', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.args', + allowed: true, + anonymized: false, + namespace: 'default', + id: '8f6b5319-ac54-4e1c-a9df-7dd7c5cbf12a', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.command_line', + allowed: true, + anonymized: false, + namespace: 'default', + id: '05d464a7-c0a4-467b-a910-b18268882e0c', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.executable', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'a1d44665-db50-4b3a-a324-cd872f5bc257', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.Ext.token.integrity_level_name', + allowed: true, + anonymized: false, + namespace: 'default', + id: '045c6e9d-67d6-4ae1-a82a-070dbdc233fb', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.entity_id', + allowed: true, + anonymized: false, + namespace: 'default', + id: '114b6d92-3364-4980-994c-272d940c4b36', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.exit_code', + allowed: true, + anonymized: false, + namespace: 'default', + id: '0f9bb041-f9d9-4298-a11d-d1a0451c6329', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.hash.md5', + allowed: true, + anonymized: false, + namespace: 'default', + id: '30c443eb-ede4-4b5e-9362-f9d5119cb49d', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.hash.sha1', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'f302583b-4eb8-4165-9f80-2eada21efc1f', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.name', + allowed: true, + anonymized: false, + namespace: 'default', + id: '178ec1d9-610b-40ed-a2c6-363d8608c7d8', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.hash.sha256', + allowed: true, + anonymized: false, + namespace: 'default', + id: '98b7369f-39a6-4ebd-98a6-e070239a59ad', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.args', + allowed: true, + anonymized: false, + namespace: 'default', + id: '4031b3da-8942-4666-bd37-e7178989f080', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.args_count', + allowed: true, + anonymized: false, + namespace: 'default', + id: '2f1789f0-9e70-4aee-941c-c50503578693', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.code_signature.exists', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'd0f2c054-ebab-4917-8445-3f064c8cf149', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.code_signature.status', + allowed: true, + anonymized: false, + namespace: 'default', + id: '71ebc690-fc6b-4a97-b804-1f45c4d5e499', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.code_signature.subject_name', + allowed: true, + anonymized: false, + namespace: 'default', + id: '5feb66e0-c4e1-4447-86aa-041305d5faff', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.code_signature.trusted', + allowed: true, + anonymized: false, + namespace: 'default', + id: '5c32f38f-7aac-4dec-b64d-7285ec098590', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.command_line', + allowed: true, + anonymized: false, + namespace: 'default', + id: '314f655d-43a7-4837-b684-1e6ec6a50bb2', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.entity_id', + allowed: true, + anonymized: false, + namespace: 'default', + id: '17380273-3c9f-4ba1-92a0-0ce9193fc04a', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.parent.executable', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'c464fd6e-95d1-4dec-9195-60d66c5115cb', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.pid', + allowed: true, + anonymized: false, + namespace: 'default', + id: '0f283737-f3e2-4870-9eed-292774793b79', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'process.working_directory', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'af104308-18d3-44f5-9727-dd16a351b90e', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'network.protocol', + allowed: true, + anonymized: false, + namespace: 'default', + id: '7656e41c-104b-43bf-9936-aaf347cc4a2c', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'source.ip', + allowed: true, + anonymized: true, + namespace: 'default', + id: 'de1e7a59-340d-4325-8759-268713cb9647', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'user.domain', + allowed: true, + anonymized: true, + namespace: 'default', + id: 'dea8d7e1-4495-433b-bf07-33e07e9abcd7', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'user.name', + allowed: true, + anonymized: true, + namespace: 'default', + id: '07986654-310a-4f59-aa76-c0b0fbd98fa6', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'user.risk.calculated_level', + allowed: true, + anonymized: false, + namespace: 'default', + id: 'bc4e3747-28bd-488a-9fbf-7e30f76ff1ff', + }, + { + timestamp: '2024-04-11T16:43:41.234Z', + createdAt: '2024-04-11T16:43:41.234Z', + field: 'user.risk.calculated_score_norm', + allowed: true, + anonymized: false, + namespace: 'default', + id: '3287fb6a-25d2-46dd-8bb3-4590e22ff108', + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/ai_insights/mock/mock_use_insights.ts b/x-pack/plugins/security_solution/public/ai_insights/mock/mock_use_insights.ts new file mode 100644 index 0000000000000..be49853c3b3bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/mock/mock_use_insights.ts @@ -0,0 +1,545 @@ +/* + * 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 { Replacements } from '@kbn/elastic-assistant-common'; + +import type { CachedInsights } from '../pages/session_storage'; +import type { AlertsInsight, GenerationInterval } from '../types'; + +interface MockUseInsightsResults { + approximateFutureTime: Date | null; + cachedInsights: Record<string, CachedInsights>; + fetchInsights: () => Promise<void>; + generationIntervals: Record<string, GenerationInterval[]> | undefined; + insights: AlertsInsight[]; + isLoading: boolean; + lastUpdated: Date | null; + replacements: Replacements; +} + +export const getMockUseInsightsWithCachedInsights = ( + fetchInsights: () => Promise<void> +): MockUseInsightsResults => ({ + approximateFutureTime: null, + cachedInsights: { + claudeV3SonnetUsEast1: { + connectorId: 'claudeV3SonnetUsEast1', + insights: [ + { + alertIds: [ + 'e770a817-0e87-4e4b-8e26-1bf504a209d2', + 'f0ab5b5d-55c5-4d05-8f4f-12f0e62ecd96', + '8cfde870-cd3b-40b8-9999-901c0b97fb5a', + 'da8fa0b1-1f51-4c63-b5d0-2e35c9fa3b84', + '597fd583-4036-4631-a71a-7a8a7dd17848', + '550691a2-edac-4cc5-a453-6a36d5351c76', + 'df97c2d9-9e28-43e0-a461-3bacf91a262f', + 'f6558144-630c-49ec-8aa2-fe96364883c7', + '113819ec-cfd0-4867-bfbd-cb9ca8e1e69f', + 'c6cbd80f-9602-4748-b951-56c0745f3e1f', + ], + detailsMarkdown: + '- {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential ransomware attack progression:\n\n - A suspicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} was created and executed from {{ file.path 4053a825-9628-470a-8c83-c733e941bece }} by the parent process {{ process.parent.executable C:\\Windows\\Explorer.EXE }}.\n - The suspicious executable then created another file {{ file.name 604300eb-3711-4e38-8500-0a395d3cc1e5 }} at {{ file.path 8e2853aa-f0b9-4c95-9895-d71a7aa8b4a4 }} and loaded it.\n - Multiple shellcode injection alerts were triggered by the loaded file, indicating potential malicious activity.\n - A ransomware detection alert was also triggered, suggesting the presence of ransomware behavior.\n\n- The suspicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} had an expired code signature from "TRANSPORT", which is not a trusted source.\n- The loaded file {{ file.name 604300eb-3711-4e38-8500-0a395d3cc1e5 }} was identified as potentially malicious by Elastic Endpoint Security.', + entitySummaryMarkdown: + 'Potential ransomware attack involving {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: '9f6d4a18-7483-4103-92e7-24e2ebab77bb', + mitreAttackTactics: [ + 'Execution', + 'Persistence', + 'Privilege Escalation', + 'Defense Evasion', + ], + summaryMarkdown: + 'A potential ransomware attack progression was detected on {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A suspicious executable with an untrusted code signature was executed, leading to the creation and loading of a malicious file that triggered shellcode injection and ransomware detection alerts.', + title: 'Potential Ransomware Attack Progression Detected', + }, + { + alertIds: [ + '4691c8da-ccba-40f2-b540-0ec5656ad8ef', + '53b3ee1a-1594-447d-94a0-338af2a22844', + '2e744d88-3040-4ab8-90a3-1d5011ab1a6b', + '452ed87e-2e64-486b-ad6a-b368010f570a', + 'd2ce2be7-1d86-4fbe-851a-05883e575a0b', + '7d0ae0fc-7c24-4760-8543-dc4d44f17126', + ], + detailsMarkdown: + '- {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware attack progression:\n\n - A Microsoft Office process ({{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }}) launched a suspicious child process ({{ process.name certutil.exe }}) with unusual arguments to decode a file ({{ file.name B1Z8U2N9.txt }}) into another executable ({{ file.name Q3C7N1V8.exe }}).\n - The decoded executable {{ file.name Q3C7N1V8.exe }} was then executed and created another file {{ file.name 2ddee627-fbe2-45a8-8b2b-eba7542b4e3d }} at {{ file.path ae8aacc8-bfe3-4735-8075-a135fcf60722 }}, which was loaded.\n - Multiple alerts were triggered, including malware detection, suspicious Microsoft Office child process, uncommon persistence via registry modification, and rundll32 with unusual arguments.\n\n- The decoded executable {{ file.name Q3C7N1V8.exe }} exhibited persistence behavior by modifying the registry.\n- The rundll32.exe process was launched with unusual arguments to load the decoded file, which is a common malware technique.', + entitySummaryMarkdown: + 'Potential malware attack involving {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: 'fd82a3bf-45e4-43ba-bb8f-795584923474', + mitreAttackTactics: ['Execution', 'Persistence', 'Defense Evasion'], + summaryMarkdown: + 'A potential malware attack progression was detected on {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A Microsoft Office process launched a suspicious child process that decoded and executed a malicious executable, which exhibited persistence behavior and triggered multiple security alerts.', + title: 'Potential Malware Attack Progression Detected', + }, + { + alertIds: ['9896f807-4e57-4da8-b1ea-d62645045428'], + detailsMarkdown: + '- {{ host.name c7697774-7350-4153-9061-64a484500241 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware attack:\n\n - A Microsoft Office process ({{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }}) launched a suspicious child process ({{ process.name certutil.exe }}) with unusual arguments to decode a file ({{ file.name K2G8Q8Z9.txt }}) into another executable ({{ file.name Z5K7J6H8.exe }}).\n - This behavior triggered a "Malicious Behavior Prevention Alert: Suspicious Microsoft Office Child Process" alert.\n\n- The certutil.exe process is commonly abused by malware to decode and execute malicious payloads.', + entitySummaryMarkdown: + 'Potential malware attack involving {{ host.name c7697774-7350-4153-9061-64a484500241 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: '79a97cec-4126-479a-8fa1-706aec736bc5', + mitreAttackTactics: ['Execution', 'Defense Evasion'], + summaryMarkdown: + 'A potential malware attack was detected on {{ host.name c7697774-7350-4153-9061-64a484500241 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A Microsoft Office process launched a suspicious child process that attempted to decode and execute a malicious payload, triggering a security alert.', + title: 'Potential Malware Attack Detected', + }, + { + alertIds: ['53157916-4437-4a92-a7fd-f792c4aa1aae'], + detailsMarkdown: + '- {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware incident:\n\n - The explorer.exe process ({{ process.executable C:\\Windows\\explorer.exe }}) attempted to create a file ({{ file.name 25a994dc-c605-425c-b139-c273001dc816 }}) at {{ file.path 9693f967-2b96-4281-893e-79adbdcf1066 }}.\n - This file creation attempt was blocked, and a "Malware Prevention Alert" was triggered.\n\n- The file {{ file.name 25a994dc-c605-425c-b139-c273001dc816 }} was likely identified as malicious by Elastic Endpoint Security, leading to the prevention of its creation.', + entitySummaryMarkdown: + 'Potential malware incident involving {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: '13c4a00d-88a8-408c-9ed5-b2518df0eae3', + mitreAttackTactics: ['Defense Evasion'], + summaryMarkdown: + 'A potential malware incident was detected on {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. The explorer.exe process attempted to create a file that was identified as malicious by Elastic Endpoint Security, triggering a malware prevention alert and blocking the file creation.', + title: 'Potential Malware Incident Detected', + }, + ], + replacements: { + '8e2853aa-f0b9-4c95-9895-d71a7aa8b4a4': 'C:\\Windows\\mpsvc.dll', + '73f9a91c-3268-4229-8bb9-7c1fe2f667bc': 'Administrator', + '001cc415-42ad-4b21-a92c-e4193b283b78': 'SRVWIN02', + 'b0fd402c-9752-4d43-b0f7-9750cce247e7': 'OMM-WIN-DETECT', + '604300eb-3711-4e38-8500-0a395d3cc1e5': 'mpsvc.dll', + 'e770a817-0e87-4e4b-8e26-1bf504a209d2': + '13c8569b2bfd65ecfa75b264b6d7f31a1b50c530101bcaeb8569b3a0190e93b4', + 'f0ab5b5d-55c5-4d05-8f4f-12f0e62ecd96': + '250d812f9623d0916bba521d4221757163f199d64ffab92f888581a00ca499be', + '4053a825-9628-470a-8c83-c733e941bece': + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + '2acbc31d-a0ec-4f99-a544-b23fcdd37b70': + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + '8cfde870-cd3b-40b8-9999-901c0b97fb5a': + '138876c616a2f403aadb6a1c3da316d97f15669fc90187a27d7f94a55674d19a', + 'da8fa0b1-1f51-4c63-b5d0-2e35c9fa3b84': + '2bc20691da4ec37cc1f967d6f5b79e95c7f07f6e473724479dcf4402a192969c', + '9693f967-2b96-4281-893e-79adbdcf1066': + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + '25a994dc-c605-425c-b139-c273001dc816': + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + '597fd583-4036-4631-a71a-7a8a7dd17848': + '6cea6124aa27adf2f782db267c5173742b675331107cdb7372a46ae469366210', + '550691a2-edac-4cc5-a453-6a36d5351c76': + '26a9788ca7189baa31dcbb509779c1ac5d2e72297cb02e4b4ee8c1f9e371666f', + 'df97c2d9-9e28-43e0-a461-3bacf91a262f': + 'c107e4e903724f2a1e0ea8e0135032d1d75624bf7de8b99c17ba9a9f178c2d6a', + 'f6558144-630c-49ec-8aa2-fe96364883c7': + 'afb8ed160ae9f78990980d92fb3213ffff74a12ec75034384b4f53a3edf74400', + 'c6cbd80f-9602-4748-b951-56c0745f3e1f': + '137aa729928d2a0df1d5e35f47f0ad2bd525012409a889358476dca8e06ba804', + '113819ec-cfd0-4867-bfbd-cb9ca8e1e69f': + '5bec676e7faa4b6329027c9798e70e6d5e7a4d6d08696597dc8a3b31490bdfe5', + 'ae8aacc8-bfe3-4735-8075-a135fcf60722': + 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll', + '4d31c85a-f08b-4461-a67e-ca1991427e6d': 'SRVWIN01', + '2ddee627-fbe2-45a8-8b2b-eba7542b4e3d': 'cdnver.dll', + '8e8e2e05-521d-4988-b7ce-4763fea1faf0': + 'f5d9e2d82dad1ff40161b92c097340ee07ae43715f6c9270705fb0db7a9eeca4', + '4691c8da-ccba-40f2-b540-0ec5656ad8ef': + 'b4bf1d7b993141f813008dccab0182af3c810de0c10e43a92ac0d9d5f1dbf42e', + '53b3ee1a-1594-447d-94a0-338af2a22844': + '4ab871ec3d41d3271c2a1fc3861fabcbc06f7f4534a1b6f741816417bc73927c', + '2e744d88-3040-4ab8-90a3-1d5011ab1a6b': + '1f492a1b66f6c633a81a4c6318345b07f6d05624714da0b0cb7dd6d8e374e249', + '9e44ac92-1d88-4cfc-9f38-781c3457b395': + 'e6fba60799acc5bf85ca34ec634482b95ac941c71e9822dfa34d9d774dd1e2bd', + '5164c2f3-9f96-4867-a263-cc7041b06ece': 'C:\\ProgramData\\Q3C7N1V8.exe', + '0aaff15a-a311-46b8-b20b-0db550e5005e': 'Q3C7N1V8.exe', + '452ed87e-2e64-486b-ad6a-b368010f570a': + '4be1be7b4351f2e94fa706ea1ab7f9dd7c3267a77832e94794ebb2b0a6d8493a', + '84e2000b-3c0a-4775-9903-89ebe953f247': 'C:\\Programdata\\Q3C7N1V8.exe', + 'd2ce2be7-1d86-4fbe-851a-05883e575a0b': + '5ed1aa94157bd6b949bf1527320caf0e6f5f61d86518e5f13912314d0f024e88', + '7d0ae0fc-7c24-4760-8543-dc4d44f17126': + 'a786f965902ed5490656f48adc79b46676dc2518a052759625f6108bbe2d864d', + 'c7697774-7350-4153-9061-64a484500241': 'SRVWIN01-PRIV', + 'b26da819-a141-4efd-84b0-6d2876f8800d': 'OMM-WIN-PREVENT', + '9896f807-4e57-4da8-b1ea-d62645045428': + '2a33e2c6150dfc6f0d49022fc0b5aefc90db76b6e237371992ebdee909d3c194', + '6d4355b3-3d1a-4673-b0c7-51c1c698bcc5': 'SRVWIN02-PRIV', + '53157916-4437-4a92-a7fd-f792c4aa1aae': + '605ebf550ae0ffc4aec2088b97cbf99853113b0db81879500547c4277ca1981a', + }, + updated: new Date('2024-04-15T13:48:44.393Z'), + }, + claudeV3SonnetUsWest2: { + connectorId: 'claudeV3SonnetUsWest2', + insights: [ + { + alertIds: [ + 'e6b49cac-a5d0-4d22-a7e2-868881aa9d20', + '648d8ad4-6f4e-4c06-99f7-cdbce20f4480', + 'bbfc0fd4-fbad-4ac4-b1b4-a9acd91ac504', + 'c1252ff5-113a-4fe8-b341-9726c5011402', + 'a3544119-12a0-4dd2-97b8-ed211233393b', + '3575d826-2350-4a4d-bb26-c92c324f38ca', + '778fd5cf-13b9-40fe-863d-abac2a6fe3c7', + '2ed82499-db91-4197-ad8d-5f03f59c6616', + '280e1e76-3a10-470c-8adc-094094badb1d', + '61ae312a-82c7-4bae-8014-f3790628b82f', + ], + detailsMarkdown: + '- {{ host.name fb5608fd-5bf4-4b28-8ea8-a51160df847f }} was compromised by a malicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} launched from {{ process.parent.executable C:\\Windows\\Explorer.EXE }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}\n\n- The malicious executable created a suspicious file {{ file.name d2aeb0e2-e327-4979-aa31-d46454d5b1a5 }} and loaded it into memory via {{ process.executable C:\\Windows\\MsMpEng.exe }}\n\n- This behavior triggered multiple alerts for shellcode injection, ransomware activity, and other malicious behaviors\n\n- The malware appears to be a variant of ransomware', + entitySummaryMarkdown: + 'Malicious activity detected on {{ host.name fb5608fd-5bf4-4b28-8ea8-a51160df847f }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}', + id: 'e536ae7a-4ae8-4e47-9f20-0e40ac675d56', + mitreAttackTactics: [ + 'Initial Access', + 'Execution', + 'Persistence', + 'Privilege Escalation', + 'Defense Evasion', + 'Discovery', + 'Lateral Movement', + 'Collection', + 'Exfiltration', + 'Impact', + ], + summaryMarkdown: + 'Multiple critical alerts indicate a ransomware attack on {{ host.name fb5608fd-5bf4-4b28-8ea8-a51160df847f }}, likely initiated by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}', + title: 'Ransomware Attack', + }, + { + alertIds: [ + 'b544dd2a-e208-4dac-afba-b60f799ab623', + '7d3a4bae-3bd7-41a7-aee2-f68088aef1d5', + 'd1716ee3-e12e-4b03-8057-b9320f3ce825', + 'ca31a2b6-cb77-4ca2-ada0-14bb39ec1a2e', + 'a0b56cd3-1f7f-4221-bc88-6efb4082e781', + '2ab6a581-e2ab-4a54-a0e1-7b23bf8299cb', + '1d1040c3-9e30-47fb-b2cf-f9e8ab647547', + ], + detailsMarkdown: + '- {{ host.name b6fb7e37-e3d6-47aa-b176-83d800984be8 }} was compromised by a malicious executable {{ file.name 94b3c78d-c647-4ee1-9eba-8101b806a7af }} launched from {{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}\n\n- The malicious executable was decoded from a file {{ file.name 30820807-30f3-4b43-bb1d-c523d6375f49 }} using certutil.exe, which is a common malware technique\n\n- It established persistence by modifying registry keys and loading a malicious DLL {{ file.name 30820807-30f3-4b43-bb1d-c523d6375f49 }} via rundll32.exe\n\n- This behavior triggered alerts for malware, suspicious Microsoft Office child processes, and uncommon persistence mechanisms', + entitySummaryMarkdown: + 'Malicious activity detected on {{ host.name b6fb7e37-e3d6-47aa-b176-83d800984be8 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}', + id: '36d3daf0-93f0-4887-8d2c-a935863091a0', + mitreAttackTactics: [ + 'Initial Access', + 'Execution', + 'Persistence', + 'Privilege Escalation', + 'Defense Evasion', + 'Discovery', + ], + summaryMarkdown: + 'Multiple critical alerts indicate a malware infection on {{ host.name b6fb7e37-e3d6-47aa-b176-83d800984be8 }} likely initiated by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }} via a malicious Microsoft Office document', + title: 'Malware Infection via Malicious Office Document', + }, + { + alertIds: ['67a27f31-f18f-4256-b64f-63e718eb688e'], + detailsMarkdown: + '- {{ host.name b8639719-38c4-401e-8582-6e8ea098feef }} was targeted by a malicious executable that attempted to be decoded from a file using certutil.exe, which is a common malware technique\n\n- The malicious activity was initiated from {{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}, likely via a malicious Microsoft Office document\n\n- This behavior triggered an alert for a suspicious Microsoft Office child process', + entitySummaryMarkdown: + 'Suspected malicious activity detected on {{ host.name b8639719-38c4-401e-8582-6e8ea098feef }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}', + id: 'bbf6f5fc-f739-4598-945b-463dea90ea50', + mitreAttackTactics: ['Initial Access', 'Execution', 'Defense Evasion'], + summaryMarkdown: + 'A suspicious Microsoft Office child process was detected on {{ host.name b8639719-38c4-401e-8582-6e8ea098feef }}, potentially initiated by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }} via a malicious document', + title: 'Suspected Malicious Activity via Office Document', + }, + { + alertIds: ['2242a749-7d59-4f24-8b33-b8772ab4f8df'], + detailsMarkdown: + '- A suspicious file creation attempt {{ file.name efcf53ac-3943-4d7d-96b5-d84eefd2c478 }} with the same hash as a known malicious executable was blocked on {{ host.name 6bcc5c79-2171-4c71-9bea-fe0c116d3803 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}\n\n- The file was likely being staged for later malicious activity\n\n- This triggered a malware prevention alert, indicating the threat was detected and mitigated', + entitySummaryMarkdown: + 'Suspected malicious file blocked on {{ host.name 6bcc5c79-2171-4c71-9bea-fe0c116d3803 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}', + id: '069a5b43-1458-4e87-8dc6-97459a020ef8', + mitreAttackTactics: ['Initial Access', 'Execution'], + summaryMarkdown: + 'A suspected malicious file creation was blocked on {{ host.name 6bcc5c79-2171-4c71-9bea-fe0c116d3803 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}', + title: 'Suspected Malicious File Creation Blocked', + }, + ], + replacements: { + '6fcdf365-367a-4695-b08e-519c31345fec': 'C:\\Windows\\mpsvc.dll', + '4f7ff689-3079-4811-8fec-8c2bc2646cc2': 'Administrator', + 'fb5608fd-5bf4-4b28-8ea8-a51160df847f': 'SRVWIN02', + 'a141c5f0-5c06-41b8-8399-27c03a459398': 'OMM-WIN-DETECT', + 'd2aeb0e2-e327-4979-aa31-d46454d5b1a5': 'mpsvc.dll', + 'e6b49cac-a5d0-4d22-a7e2-868881aa9d20': + '13c8569b2bfd65ecfa75b264b6d7f31a1b50c530101bcaeb8569b3a0190e93b4', + '648d8ad4-6f4e-4c06-99f7-cdbce20f4480': + '250d812f9623d0916bba521d4221757163f199d64ffab92f888581a00ca499be', + 'fca45966-448c-4652-9e02-2600dfa02a35': + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + '5b9f846a-c497-4631-8a2f-7de265bfc864': + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + 'bbfc0fd4-fbad-4ac4-b1b4-a9acd91ac504': + '138876c616a2f403aadb6a1c3da316d97f15669fc90187a27d7f94a55674d19a', + '61ae312a-82c7-4bae-8014-f3790628b82f': + '2bc20691da4ec37cc1f967d6f5b79e95c7f07f6e473724479dcf4402a192969c', + 'f1bbf0b8-d417-438f-ad09-dd8a854e0abb': + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + 'efcf53ac-3943-4d7d-96b5-d84eefd2c478': + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + 'c1252ff5-113a-4fe8-b341-9726c5011402': + '6cea6124aa27adf2f782db267c5173742b675331107cdb7372a46ae469366210', + 'a3544119-12a0-4dd2-97b8-ed211233393b': + '26a9788ca7189baa31dcbb509779c1ac5d2e72297cb02e4b4ee8c1f9e371666f', + '3575d826-2350-4a4d-bb26-c92c324f38ca': + 'c107e4e903724f2a1e0ea8e0135032d1d75624bf7de8b99c17ba9a9f178c2d6a', + '778fd5cf-13b9-40fe-863d-abac2a6fe3c7': + 'afb8ed160ae9f78990980d92fb3213ffff74a12ec75034384b4f53a3edf74400', + '2ed82499-db91-4197-ad8d-5f03f59c6616': + '137aa729928d2a0df1d5e35f47f0ad2bd525012409a889358476dca8e06ba804', + '280e1e76-3a10-470c-8adc-094094badb1d': + '5bec676e7faa4b6329027c9798e70e6d5e7a4d6d08696597dc8a3b31490bdfe5', + '6fad79d9-1ed4-4c1d-8b30-43023b7a5552': + 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll', + 'b6fb7e37-e3d6-47aa-b176-83d800984be8': 'SRVWIN01', + '30820807-30f3-4b43-bb1d-c523d6375f49': 'cdnver.dll', + '1d1040c3-9e30-47fb-b2cf-f9e8ab647547': + 'f5d9e2d82dad1ff40161b92c097340ee07ae43715f6c9270705fb0db7a9eeca4', + 'b544dd2a-e208-4dac-afba-b60f799ab623': + 'b4bf1d7b993141f813008dccab0182af3c810de0c10e43a92ac0d9d5f1dbf42e', + '7d3a4bae-3bd7-41a7-aee2-f68088aef1d5': + '4ab871ec3d41d3271c2a1fc3861fabcbc06f7f4534a1b6f741816417bc73927c', + 'd1716ee3-e12e-4b03-8057-b9320f3ce825': + '1f492a1b66f6c633a81a4c6318345b07f6d05624714da0b0cb7dd6d8e374e249', + 'ca31a2b6-cb77-4ca2-ada0-14bb39ec1a2e': + 'e6fba60799acc5bf85ca34ec634482b95ac941c71e9822dfa34d9d774dd1e2bd', + '03bcdffb-54d1-457e-9599-f10b93e10ed3': 'C:\\ProgramData\\Q3C7N1V8.exe', + '94b3c78d-c647-4ee1-9eba-8101b806a7af': 'Q3C7N1V8.exe', + '8fd14f7c-6b89-43b2-b58e-09502a007e21': + '4be1be7b4351f2e94fa706ea1ab7f9dd7c3267a77832e94794ebb2b0a6d8493a', + '2342b541-1c6b-4d59-bbd4-d897637573e1': 'C:\\Programdata\\Q3C7N1V8.exe', + 'a0b56cd3-1f7f-4221-bc88-6efb4082e781': + '5ed1aa94157bd6b949bf1527320caf0e6f5f61d86518e5f13912314d0f024e88', + '2ab6a581-e2ab-4a54-a0e1-7b23bf8299cb': + 'a786f965902ed5490656f48adc79b46676dc2518a052759625f6108bbe2d864d', + 'b8639719-38c4-401e-8582-6e8ea098feef': 'SRVWIN01-PRIV', + '0549244b-3878-4ff8-a327-1758b8e88c10': 'OMM-WIN-PREVENT', + '67a27f31-f18f-4256-b64f-63e718eb688e': + '2a33e2c6150dfc6f0d49022fc0b5aefc90db76b6e237371992ebdee909d3c194', + '6bcc5c79-2171-4c71-9bea-fe0c116d3803': 'SRVWIN02-PRIV', + '2242a749-7d59-4f24-8b33-b8772ab4f8df': + '605ebf550ae0ffc4aec2088b97cbf99853113b0db81879500547c4277ca1981a', + }, + updated: new Date('2024-04-15T15:11:24.903Z'), + }, + }, + generationIntervals: { + claudeV3SonnetUsEast1: [ + { + connectorId: 'claudeV3SonnetUsEast1', + date: new Date('2024-04-15T13:48:44.397Z'), + durationMs: 85807, + }, + { + connectorId: 'claudeV3SonnetUsEast1', + date: new Date('2024-04-15T12:41:15.255Z'), + durationMs: 12751, + }, + { + connectorId: 'claudeV3SonnetUsEast1', + date: new Date('2024-04-12T20:59:13.238Z'), + durationMs: 46169, + }, + { + connectorId: 'claudeV3SonnetUsEast1', + date: new Date('2024-04-12T19:34:56.701Z'), + durationMs: 86674, + }, + { + connectorId: 'claudeV3SonnetUsEast1', + date: new Date('2024-04-12T19:17:21.697Z'), + durationMs: 78486, + }, + ], + claudeV3SonnetUsWest2: [ + { + connectorId: 'claudeV3SonnetUsWest2', + date: new Date('2024-04-15T15:11:24.906Z'), + durationMs: 71715, + }, + { + connectorId: 'claudeV3SonnetUsWest2', + date: new Date('2024-04-12T13:13:35.335Z'), + durationMs: 66176, + }, + { + connectorId: 'claudeV3SonnetUsWest2', + date: new Date('2024-04-11T18:30:36.360Z'), + durationMs: 88079, + }, + { + connectorId: 'claudeV3SonnetUsWest2', + date: new Date('2024-04-11T18:12:50.350Z'), + durationMs: 77704, + }, + { + connectorId: 'claudeV3SonnetUsWest2', + date: new Date('2024-04-11T17:57:21.902Z'), + durationMs: 77016, + }, + ], + }, + fetchInsights, + insights: [ + { + alertIds: [ + 'e770a817-0e87-4e4b-8e26-1bf504a209d2', + 'f0ab5b5d-55c5-4d05-8f4f-12f0e62ecd96', + '8cfde870-cd3b-40b8-9999-901c0b97fb5a', + 'da8fa0b1-1f51-4c63-b5d0-2e35c9fa3b84', + '597fd583-4036-4631-a71a-7a8a7dd17848', + '550691a2-edac-4cc5-a453-6a36d5351c76', + 'df97c2d9-9e28-43e0-a461-3bacf91a262f', + 'f6558144-630c-49ec-8aa2-fe96364883c7', + '113819ec-cfd0-4867-bfbd-cb9ca8e1e69f', + 'c6cbd80f-9602-4748-b951-56c0745f3e1f', + ], + detailsMarkdown: + '- {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential ransomware attack progression:\n\n - A suspicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} was created and executed from {{ file.path 4053a825-9628-470a-8c83-c733e941bece }} by the parent process {{ process.parent.executable C:\\Windows\\Explorer.EXE }}.\n - The suspicious executable then created another file {{ file.name 604300eb-3711-4e38-8500-0a395d3cc1e5 }} at {{ file.path 8e2853aa-f0b9-4c95-9895-d71a7aa8b4a4 }} and loaded it.\n - Multiple shellcode injection alerts were triggered by the loaded file, indicating potential malicious activity.\n - A ransomware detection alert was also triggered, suggesting the presence of ransomware behavior.\n\n- The suspicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} had an expired code signature from "TRANSPORT", which is not a trusted source.\n- The loaded file {{ file.name 604300eb-3711-4e38-8500-0a395d3cc1e5 }} was identified as potentially malicious by Elastic Endpoint Security.', + entitySummaryMarkdown: + 'Potential ransomware attack involving {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: '9f6d4a18-7483-4103-92e7-24e2ebab77bb', + mitreAttackTactics: ['Execution', 'Persistence', 'Privilege Escalation', 'Defense Evasion'], + summaryMarkdown: + 'A potential ransomware attack progression was detected on {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A suspicious executable with an untrusted code signature was executed, leading to the creation and loading of a malicious file that triggered shellcode injection and ransomware detection alerts.', + title: 'Potential Ransomware Attack Progression Detected', + }, + { + alertIds: [ + '4691c8da-ccba-40f2-b540-0ec5656ad8ef', + '53b3ee1a-1594-447d-94a0-338af2a22844', + '2e744d88-3040-4ab8-90a3-1d5011ab1a6b', + '452ed87e-2e64-486b-ad6a-b368010f570a', + 'd2ce2be7-1d86-4fbe-851a-05883e575a0b', + '7d0ae0fc-7c24-4760-8543-dc4d44f17126', + ], + detailsMarkdown: + '- {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware attack progression:\n\n - A Microsoft Office process ({{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }}) launched a suspicious child process ({{ process.name certutil.exe }}) with unusual arguments to decode a file ({{ file.name B1Z8U2N9.txt }}) into another executable ({{ file.name Q3C7N1V8.exe }}).\n - The decoded executable {{ file.name Q3C7N1V8.exe }} was then executed and created another file {{ file.name 2ddee627-fbe2-45a8-8b2b-eba7542b4e3d }} at {{ file.path ae8aacc8-bfe3-4735-8075-a135fcf60722 }}, which was loaded.\n - Multiple alerts were triggered, including malware detection, suspicious Microsoft Office child process, uncommon persistence via registry modification, and rundll32 with unusual arguments.\n\n- The decoded executable {{ file.name Q3C7N1V8.exe }} exhibited persistence behavior by modifying the registry.\n- The rundll32.exe process was launched with unusual arguments to load the decoded file, which is a common malware technique.', + entitySummaryMarkdown: + 'Potential malware attack involving {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: 'fd82a3bf-45e4-43ba-bb8f-795584923474', + mitreAttackTactics: ['Execution', 'Persistence', 'Defense Evasion'], + summaryMarkdown: + 'A potential malware attack progression was detected on {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A Microsoft Office process launched a suspicious child process that decoded and executed a malicious executable, which exhibited persistence behavior and triggered multiple security alerts.', + title: 'Potential Malware Attack Progression Detected', + }, + { + alertIds: ['9896f807-4e57-4da8-b1ea-d62645045428'], + detailsMarkdown: + '- {{ host.name c7697774-7350-4153-9061-64a484500241 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware attack:\n\n - A Microsoft Office process ({{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }}) launched a suspicious child process ({{ process.name certutil.exe }}) with unusual arguments to decode a file ({{ file.name K2G8Q8Z9.txt }}) into another executable ({{ file.name Z5K7J6H8.exe }}).\n - This behavior triggered a "Malicious Behavior Prevention Alert: Suspicious Microsoft Office Child Process" alert.\n\n- The certutil.exe process is commonly abused by malware to decode and execute malicious payloads.', + entitySummaryMarkdown: + 'Potential malware attack involving {{ host.name c7697774-7350-4153-9061-64a484500241 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: '79a97cec-4126-479a-8fa1-706aec736bc5', + mitreAttackTactics: ['Execution', 'Defense Evasion'], + summaryMarkdown: + 'A potential malware attack was detected on {{ host.name c7697774-7350-4153-9061-64a484500241 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A Microsoft Office process launched a suspicious child process that attempted to decode and execute a malicious payload, triggering a security alert.', + title: 'Potential Malware Attack Detected', + }, + { + alertIds: ['53157916-4437-4a92-a7fd-f792c4aa1aae'], + detailsMarkdown: + '- {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware incident:\n\n - The explorer.exe process ({{ process.executable C:\\Windows\\explorer.exe }}) attempted to create a file ({{ file.name 25a994dc-c605-425c-b139-c273001dc816 }}) at {{ file.path 9693f967-2b96-4281-893e-79adbdcf1066 }}.\n - This file creation attempt was blocked, and a "Malware Prevention Alert" was triggered.\n\n- The file {{ file.name 25a994dc-c605-425c-b139-c273001dc816 }} was likely identified as malicious by Elastic Endpoint Security, leading to the prevention of its creation.', + entitySummaryMarkdown: + 'Potential malware incident involving {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.', + id: '13c4a00d-88a8-408c-9ed5-b2518df0eae3', + mitreAttackTactics: ['Defense Evasion'], + summaryMarkdown: + 'A potential malware incident was detected on {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. The explorer.exe process attempted to create a file that was identified as malicious by Elastic Endpoint Security, triggering a malware prevention alert and blocking the file creation.', + title: 'Potential Malware Incident Detected', + }, + ], + lastUpdated: new Date('2024-04-15T13:48:44.393Z'), + replacements: { + '8e2853aa-f0b9-4c95-9895-d71a7aa8b4a4': 'C:\\Windows\\mpsvc.dll', + '73f9a91c-3268-4229-8bb9-7c1fe2f667bc': 'Administrator', + '001cc415-42ad-4b21-a92c-e4193b283b78': 'SRVWIN02', + 'b0fd402c-9752-4d43-b0f7-9750cce247e7': 'OMM-WIN-DETECT', + '604300eb-3711-4e38-8500-0a395d3cc1e5': 'mpsvc.dll', + 'e770a817-0e87-4e4b-8e26-1bf504a209d2': + '13c8569b2bfd65ecfa75b264b6d7f31a1b50c530101bcaeb8569b3a0190e93b4', + 'f0ab5b5d-55c5-4d05-8f4f-12f0e62ecd96': + '250d812f9623d0916bba521d4221757163f199d64ffab92f888581a00ca499be', + '4053a825-9628-470a-8c83-c733e941bece': + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + '2acbc31d-a0ec-4f99-a544-b23fcdd37b70': + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + '8cfde870-cd3b-40b8-9999-901c0b97fb5a': + '138876c616a2f403aadb6a1c3da316d97f15669fc90187a27d7f94a55674d19a', + 'da8fa0b1-1f51-4c63-b5d0-2e35c9fa3b84': + '2bc20691da4ec37cc1f967d6f5b79e95c7f07f6e473724479dcf4402a192969c', + '9693f967-2b96-4281-893e-79adbdcf1066': + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + '25a994dc-c605-425c-b139-c273001dc816': + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + '597fd583-4036-4631-a71a-7a8a7dd17848': + '6cea6124aa27adf2f782db267c5173742b675331107cdb7372a46ae469366210', + '550691a2-edac-4cc5-a453-6a36d5351c76': + '26a9788ca7189baa31dcbb509779c1ac5d2e72297cb02e4b4ee8c1f9e371666f', + 'df97c2d9-9e28-43e0-a461-3bacf91a262f': + 'c107e4e903724f2a1e0ea8e0135032d1d75624bf7de8b99c17ba9a9f178c2d6a', + 'f6558144-630c-49ec-8aa2-fe96364883c7': + 'afb8ed160ae9f78990980d92fb3213ffff74a12ec75034384b4f53a3edf74400', + 'c6cbd80f-9602-4748-b951-56c0745f3e1f': + '137aa729928d2a0df1d5e35f47f0ad2bd525012409a889358476dca8e06ba804', + '113819ec-cfd0-4867-bfbd-cb9ca8e1e69f': + '5bec676e7faa4b6329027c9798e70e6d5e7a4d6d08696597dc8a3b31490bdfe5', + 'ae8aacc8-bfe3-4735-8075-a135fcf60722': 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll', + '4d31c85a-f08b-4461-a67e-ca1991427e6d': 'SRVWIN01', + '2ddee627-fbe2-45a8-8b2b-eba7542b4e3d': 'cdnver.dll', + '8e8e2e05-521d-4988-b7ce-4763fea1faf0': + 'f5d9e2d82dad1ff40161b92c097340ee07ae43715f6c9270705fb0db7a9eeca4', + '4691c8da-ccba-40f2-b540-0ec5656ad8ef': + 'b4bf1d7b993141f813008dccab0182af3c810de0c10e43a92ac0d9d5f1dbf42e', + '53b3ee1a-1594-447d-94a0-338af2a22844': + '4ab871ec3d41d3271c2a1fc3861fabcbc06f7f4534a1b6f741816417bc73927c', + '2e744d88-3040-4ab8-90a3-1d5011ab1a6b': + '1f492a1b66f6c633a81a4c6318345b07f6d05624714da0b0cb7dd6d8e374e249', + '9e44ac92-1d88-4cfc-9f38-781c3457b395': + 'e6fba60799acc5bf85ca34ec634482b95ac941c71e9822dfa34d9d774dd1e2bd', + '5164c2f3-9f96-4867-a263-cc7041b06ece': 'C:\\ProgramData\\Q3C7N1V8.exe', + '0aaff15a-a311-46b8-b20b-0db550e5005e': 'Q3C7N1V8.exe', + '452ed87e-2e64-486b-ad6a-b368010f570a': + '4be1be7b4351f2e94fa706ea1ab7f9dd7c3267a77832e94794ebb2b0a6d8493a', + '84e2000b-3c0a-4775-9903-89ebe953f247': 'C:\\Programdata\\Q3C7N1V8.exe', + 'd2ce2be7-1d86-4fbe-851a-05883e575a0b': + '5ed1aa94157bd6b949bf1527320caf0e6f5f61d86518e5f13912314d0f024e88', + '7d0ae0fc-7c24-4760-8543-dc4d44f17126': + 'a786f965902ed5490656f48adc79b46676dc2518a052759625f6108bbe2d864d', + 'c7697774-7350-4153-9061-64a484500241': 'SRVWIN01-PRIV', + 'b26da819-a141-4efd-84b0-6d2876f8800d': 'OMM-WIN-PREVENT', + '9896f807-4e57-4da8-b1ea-d62645045428': + '2a33e2c6150dfc6f0d49022fc0b5aefc90db76b6e237371992ebdee909d3c194', + '6d4355b3-3d1a-4673-b0c7-51c1c698bcc5': 'SRVWIN02-PRIV', + '53157916-4437-4a92-a7fd-f792c4aa1aae': + '605ebf550ae0ffc4aec2088b97cbf99853113b0db81879500547c4277ca1981a', + }, + isLoading: false, +}); + +export const getMockUseInsightsWithNoInsights = ( + fetchInsights: () => Promise<void> +): MockUseInsightsResults => ({ + approximateFutureTime: null, + cachedInsights: {}, + fetchInsights, + generationIntervals: undefined, + insights: [], + lastUpdated: null, + replacements: {}, + isLoading: false, +}); + +export const getMockUseInsightsWithNoInsightsLoading = ( + fetchInsights: () => Promise<void> +): MockUseInsightsResults => ({ + approximateFutureTime: new Date('2024-04-15T17:13:29.470Z'), // <-- estimated generation completion time + cachedInsights: {}, + fetchInsights, + generationIntervals: undefined, + insights: [], + lastUpdated: null, + replacements: {}, + isLoading: true, // <-- insights are being generated +}); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx index 84f98736beeeb..e488c4d0fd896 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/empty_prompt/index.tsx @@ -110,7 +110,12 @@ const EmptyPromptComponent: React.FC<Props> = ({ }, [hasAssistantPrivilege, isDisabled, isLoading, onGenerate]); return ( - <EuiFlexGroup alignItems="center" direction="column" gutterSize="none"> + <EuiFlexGroup + alignItems="center" + data-test-subj="emptyPrompt" + direction="column" + gutterSize="none" + > <EuiFlexItem data-test-subj="emptyPromptContainer" grow={false}> <EuiEmptyPrompt actions={actions} body={body} title={title} /> </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx new file mode 100644 index 0000000000000..9589ead763f4e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx @@ -0,0 +1,364 @@ +/* + * 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 { mockCasesContext } from '@kbn/cases-plugin/public/mocks/mock_cases_context'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import type { AssistantAvailability } from '@kbn/elastic-assistant'; +import { UpsellingService } from '@kbn/security-solution-upselling/service'; +import { Router } from '@kbn/shared-ux-router'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { MockAssistantProvider } from '../../common/mock/mock_assistant_provider'; +import { AI_INSIGHTS_PATH } from '../../../common/constants'; +import { mockHistory } from '../../common/utils/route/mocks'; +import { AiInsights } from '.'; +import { mockTimelines } from '../../common/mock/mock_timelines_plugin'; +import { UpsellingProvider } from '../../common/components/upselling_provider'; +import { mockFindAnonymizationFieldsResponse } from '../mock/mock_find_anonymization_fields_response'; +import { + getMockUseInsightsWithCachedInsights, + getMockUseInsightsWithNoInsightsLoading, +} from '../mock/mock_use_insights'; +import { AI_INSIGHTS_PAGE_TITLE } from './page_title/translations'; +import { useInsights } from '../use_insights'; + +jest.mock('react-use', () => { + const actual = jest.requireActual('react-use'); + + return { + ...actual, + useLocalStorage: jest.fn().mockReturnValue([undefined, jest.fn()]), + useSessionStorage: jest.fn().mockReturnValue([undefined, jest.fn()]), + }; +}); + +jest.mock( + '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields', + () => ({ + useFetchAnonymizationFields: jest.fn(() => mockFindAnonymizationFieldsResponse), + }) +); + +jest.mock('../../common/links', () => ({ + useLinkInfo: jest.fn().mockReturnValue({ + capabilities: ['siem.show'], + experimentalKey: 'assistantAlertsInsights', + globalNavPosition: 4, + globalSearchKeywords: ['AI Insights'], + id: 'ai_insights', + path: '/ai_insights', + title: 'AI Insights', + }), +})); + +jest.mock('../use_insights', () => ({ + useInsights: jest.fn().mockReturnValue({ + approximateFutureTime: null, + cachedInsights: {}, + fetchInsights: jest.fn(), + generationIntervals: undefined, + insights: [], + lastUpdated: null, + replacements: {}, + isLoading: false, + }), +})); + +const mockFilterManager = createFilterManagerMock(); + +const stubSecurityDataView = createStubDataView({ + spec: { + id: 'security', + title: 'security', + }, +}); + +const mockDataViewsService = { + ...dataViewPluginMocks.createStartContract(), + get: () => Promise.resolve(stubSecurityDataView), + clearInstanceCache: () => Promise.resolve(), +}; + +const mockUpselling = new UpsellingService(); + +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + application: { + capabilities: { + siem: { crud_alerts: true, read_alerts: true }, + }, + navigateToUrl: jest.fn(), + }, + cases: { + helpers: { + canUseCases: jest.fn().mockReturnValue({ + all: true, + connectors: true, + create: true, + delete: true, + push: true, + read: true, + settings: true, + update: true, + }), + }, + hooks: { + useCasesAddToExistingCase: jest.fn(), + useCasesAddToExistingCaseModal: jest.fn().mockReturnValue({ open: jest.fn() }), + useCasesAddToNewCaseFlyout: jest.fn(), + }, + ui: { getCasesContext: mockCasesContext }, + }, + data: { + query: { + filterManager: mockFilterManager, + }, + }, + dataViews: mockDataViewsService, + docLinks: { + links: { + siem: { + privileges: 'link', + }, + }, + }, + notifications: jest.fn().mockReturnValue({ + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + remove: jest.fn(), + }), + sessionView: { + getSessionView: jest.fn().mockReturnValue(<div />), + }, + storage: { + get: jest.fn(), + set: jest.fn(), + }, + theme: { + getTheme: jest.fn().mockReturnValue({ darkMode: false }), + }, + timelines: { ...mockTimelines }, + triggersActionsUi: { + alertsTableConfigurationRegistry: {}, + getAlertsStateTable: () => <></>, + }, + uiSettings: { + get: jest.fn(), + }, + }, + }), + useToasts: jest.fn().mockReturnValue({ + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + remove: jest.fn(), + }), + useUiSetting$: jest.fn().mockReturnValue([]), + }; +}); + +const historyMock = { + ...mockHistory, + location: { + hash: '', + pathname: AI_INSIGHTS_PATH, + search: '', + state: '', + }, +}; + +describe('AiInsights', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('page layout', () => { + beforeEach(() => { + render( + <TestProviders> + <Router history={historyMock}> + <UpsellingProvider upsellingService={mockUpselling}> + <AiInsights /> + </UpsellingProvider> + </Router> + </TestProviders> + ); + }); + + it('renders the expected page title', () => { + expect(screen.getByTestId('aiInsightsPageTitle')).toHaveTextContent(AI_INSIGHTS_PAGE_TITLE); + }); + + it('renders the header', () => { + expect(screen.getByTestId('header')).toBeInTheDocument(); + }); + }); + + describe('when there are no insights', () => { + beforeEach(() => { + render( + <TestProviders> + <Router history={historyMock}> + <UpsellingProvider upsellingService={mockUpselling}> + <AiInsights /> + </UpsellingProvider> + </Router> + </TestProviders> + ); + }); + + it('does NOT render the summary', () => { + expect(screen.queryByTestId('summary')).toBeNull(); + }); + + it('does NOT render the loading callout', () => { + expect(screen.queryByTestId('loadingCallout')).toBeNull(); + }); + + it('renders the empty prompt', () => { + expect(screen.getByTestId('emptyPrompt')).toBeInTheDocument(); + }); + + it('does NOT render insights', () => { + expect(screen.queryAllByTestId('insight')).toHaveLength(0); + }); + + it('does NOT render the upgrade call to action', () => { + expect(screen.queryByTestId('upgrade')).toBeNull(); + }); + }); + + describe('when there are insights', () => { + const mockUseInsightsResults = getMockUseInsightsWithCachedInsights(jest.fn()); + const { insights } = mockUseInsightsResults; + + beforeEach(() => { + (useInsights as jest.Mock).mockReturnValue(mockUseInsightsResults); + + render( + <TestProviders> + <Router history={historyMock}> + <UpsellingProvider upsellingService={mockUpselling}> + <AiInsights /> + </UpsellingProvider> + </Router> + </TestProviders> + ); + }); + + it('renders the summary', () => { + expect(screen.getByTestId('summary')).toBeInTheDocument(); + }); + + it('does NOT render the loading callout', () => { + expect(screen.queryByTestId('loadingCallout')).toBeNull(); + }); + + it('renders the expected number of insights', () => { + expect(screen.queryAllByTestId('insight')).toHaveLength(insights.length); + }); + + it('does NOT render the empty prompt', () => { + expect(screen.queryByTestId('emptyPrompt')).toBeNull(); + }); + + it('does NOT render the upgrade call to action', () => { + expect(screen.queryByTestId('upgrade')).toBeNull(); + }); + }); + + describe('when loading', () => { + beforeEach(() => { + (useInsights as jest.Mock).mockReturnValue( + getMockUseInsightsWithNoInsightsLoading(jest.fn()) // <-- loading + ); + + render( + <TestProviders> + <Router history={historyMock}> + <UpsellingProvider upsellingService={mockUpselling}> + <AiInsights /> + </UpsellingProvider> + </Router> + </TestProviders> + ); + }); + + it('does NOT render the summary', () => { + expect(screen.queryByTestId('summary')).toBeNull(); + }); + + it('renders the loading callout', () => { + expect(screen.getByTestId('loadingCallout')).toBeInTheDocument(); + }); + + it('does NOT render insights', () => { + expect(screen.queryAllByTestId('insight')).toHaveLength(0); + }); + + it('does NOT render the empty prompt', () => { + expect(screen.queryByTestId('emptyPrompt')).toBeNull(); + }); + + it('does NOT render the upgrade call to action', () => { + expect(screen.queryByTestId('upgrade')).toBeNull(); + }); + }); + + describe('when the user does not have an Enterprise license', () => { + const assistantUnavailable: AssistantAvailability = { + hasAssistantPrivilege: false, + hasConnectorsAllPrivilege: true, + hasConnectorsReadPrivilege: true, + isAssistantEnabled: false, // <-- non-Enterprise license + }; + + beforeEach(() => { + render( + <TestProviders> + <Router history={historyMock}> + <UpsellingProvider upsellingService={mockUpselling}> + <MockAssistantProvider assistantAvailability={assistantUnavailable}> + <AiInsights /> + </MockAssistantProvider> + </UpsellingProvider> + </Router> + </TestProviders> + ); + }); + + it('does NOT render the header', () => { + expect(screen.queryByTestId('header')).toBeNull(); + }); + + it('does NOT render the summary', () => { + expect(screen.queryByTestId('summary')).toBeNull(); + }); + + it('does NOT render insights', () => { + expect(screen.queryAllByTestId('insight')).toHaveLength(0); + }); + + it('does NOT render the loading callout', () => { + expect(screen.queryByTestId('loadingCallout')).toBeNull(); + }); + + it('renders the upgrade call to action', () => { + expect(screen.getByTestId('upgrade')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx index 0adff6a381a59..57268041ebb4d 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/index.tsx @@ -68,9 +68,9 @@ const AiInsightsComponent: React.FC = () => { fetchInsights, generationIntervals, insights, + isLoading, lastUpdated, replacements, - isLoading, } = useInsights({ connectorId, setConnectorId, diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx index 348cb5e4e3a43..25af7daf7f819 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/page_title/index.tsx @@ -26,7 +26,7 @@ const PageTitleComponent: React.FC = () => { return ( <EuiFlexGroup alignItems="center" data-test-subj="pageTitle" gutterSize="none"> <EuiFlexItem grow={false}> - <EuiTitle data-test-subj="title" size="l"> + <EuiTitle data-test-subj="aiInsightsPageTitle" size="l"> <h1>{i18n.AI_INSIGHTS_PAGE_TITLE}</h1> </EuiTitle> </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx index 23c1beea7d9aa..3e45221af85f8 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx @@ -27,7 +27,7 @@ const SummaryComponent: React.FC<Props> = ({ onToggleShowAnonymized, showAnonymized, }) => ( - <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexGroup data-test-subj="summary" justifyContent="spaceBetween"> <EuiFlexItem grow={false}> <SummaryCount alertsCount={alertsCount} diff --git a/x-pack/plugins/security_solution/public/ai_insights/routes.tsx b/x-pack/plugins/security_solution/public/ai_insights/routes.tsx index a13672f3b5f50..14c3ac8f47532 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/routes.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/routes.tsx @@ -13,11 +13,14 @@ import type { SecuritySubPluginRoutes } from '../app/types'; import { SecurityPageName } from '../app/types'; import { AI_INSIGHTS_PATH } from '../../common/constants'; import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; +import { SecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper'; export const AiInsightsRoutes = () => ( <PluginTemplateWrapper> <TrackApplicationView viewId={SecurityPageName.aiInsights}> - <AiInsights /> + <SecurityRoutePageWrapper pageName={SecurityPageName.aiInsights}> + <AiInsights /> + </SecurityRoutePageWrapper> </TrackApplicationView> </PluginTemplateWrapper> ); diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx index 85dc93b4eea47..63ed6b5cac7f6 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx @@ -13,6 +13,7 @@ import { AssistantProvider } from '@kbn/elastic-assistant'; import { BASE_SECURITY_CONVERSATIONS } from '../../assistant/content/conversations'; interface Props { + assistantAvailability?: AssistantAvailability; children: React.ReactNode; } @@ -20,10 +21,13 @@ window.scrollTo = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn(); /** A utility for wrapping children in the providers required to run tests */ -export const MockAssistantProviderComponent: React.FC<Props> = ({ children }) => { +export const MockAssistantProviderComponent: React.FC<Props> = ({ + assistantAvailability, + children, +}) => { const actionTypeRegistry = actionTypeRegistryMock.create(); const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); - const mockAssistantAvailability: AssistantAvailability = { + const defaultAssistantAvailability: AssistantAvailability = { hasAssistantPrivilege: false, hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, @@ -34,7 +38,7 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({ children }) => return ( <AssistantProvider actionTypeRegistry={actionTypeRegistry} - assistantAvailability={mockAssistantAvailability} + assistantAvailability={assistantAvailability ?? defaultAssistantAvailability} augmentMessageCodeBlocks={jest.fn(() => [])} basePath={'https://localhost:5601/kbn'} docLinks={{ From 060b88701a36d7c9e57d957239b716c56e4e4f29 Mon Sep 17 00:00:00 2001 From: Andrew Macri <andrew.macri@elastic.co> Date: Mon, 15 Apr 2024 23:54:49 -0400 Subject: [PATCH 7/8] - post-merge with main type / prop updates --- .../public/ai_insights/pages/header/index.tsx | 1 + .../public/ai_insights/pages/index.test.tsx | 1 + .../public/ai_insights/use_insights/index.tsx | 5 +---- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx index b348d92e046b6..b3fdcb4e0fd12 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/header/index.tsx @@ -43,6 +43,7 @@ const HeaderComponent: React.FC<Props> = ({ > <EuiFlexItem grow={false}> <ConnectorSelectorInline + isFlyoutMode={false} onConnectorSelected={noop} onConnectorIdSelected={onConnectorIdSelected} selectedConnectorId={connectorId} diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx index 9589ead763f4e..9ed35daab79fa 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/index.test.tsx @@ -324,6 +324,7 @@ describe('AiInsights', () => { hasAssistantPrivilege: false, hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, + hasUpdateAIAssistantAnonymization: false, isAssistantEnabled: false, // <-- non-Enterprise license }; diff --git a/x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx index dfe87842058db..fcd19a84b80eb 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/use_insights/index.tsx @@ -22,7 +22,6 @@ import { useLocalStorage, useSessionStorage } from 'react-use'; import * as uuid from 'uuid'; import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields'; -import { useAssistantAvailability } from '../../assistant/use_assistant_availability'; import { useKibana } from '../../common/lib/kibana'; import { replaceNewlineLiterals } from '../helpers'; import { @@ -60,12 +59,10 @@ export const useInsights = ({ // loading boilerplate: const [isLoading, setIsLoading] = useState(false); - const { isAssistantEnabled } = useAssistantAvailability(); - // get alerts index pattern and allow lists from the assistant context: const { alertsIndexPattern, knowledgeBase } = useAssistantContext(); - const { data: anonymizationFields } = useFetchAnonymizationFields({ http, isAssistantEnabled }); + const { data: anonymizationFields } = useFetchAnonymizationFields(); // get cached insights from session storage: const [sessionStorageCachedInsights, setSessionStorageCachedInsights] = useSessionStorage<string>( From f2992dc539b31be5bd763599d713d397575d050d Mon Sep 17 00:00:00 2001 From: Andrew Macri <andrew.macri@elastic.co> Date: Tue, 16 Apr 2024 00:50:01 -0400 Subject: [PATCH 8/8] - auto add insight context when viewed in assistant --- .../impl/data_anonymization_editor/index.tsx | 9 ++++++++- .../ai_insights/insight/actionable_summary/index.tsx | 6 +----- .../ai_insights/insight/tabs/ai_insights/index.tsx | 2 +- .../ai_insights/insight/view_in_ai_assistant/index.tsx | 7 +++---- .../public/ai_insights/pages/summary/index.tsx | 6 +++--- .../public/ai_insights/pages/translations.ts | 6 +++--- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx index 6829f0b90e46b..1fd0e31c78767 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization_editor/index.tsx @@ -68,7 +68,14 @@ const DataAnonymizationEditorComponent: React.FC<Props> = ({ <EditorContainer data-test-subj="dataAnonymizationEditor"> <EuiPanel hasShadow={false} paddingSize="m"> {typeof selectedPromptContext.rawData === 'string' ? ( - <ReadOnlyContextViewer rawData={selectedPromptContext.rawData} /> + selectedPromptContext.replacements != null ? ( + <ReplacementsContextViewer + markdown={selectedPromptContext.rawData} + replacements={selectedPromptContext.replacements} + /> + ) : ( + <ReadOnlyContextViewer rawData={selectedPromptContext.rawData} /> + ) ) : ( <ContextEditorFlyout selectedPromptContext={selectedPromptContext} diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx index d8ed018c9d296..85520e362c5a2 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/actionable_summary/index.tsx @@ -48,11 +48,7 @@ const ActionableSummaryComponent: React.FC<Props> = ({ </EuiFlexItem> <EuiFlexItem grow={false}> - <ViewInAiAssistant - compact={true} - conversationTitle={insight.title} - promptContextId={promptContextId} - /> + <ViewInAiAssistant compact={true} promptContextId={promptContextId} /> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx index ddd0801759dd0..5045eaf08cd6b 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/tabs/ai_insights/index.tsx @@ -99,7 +99,7 @@ const AiInsightsComponent: React.FC<Props> = ({ <EuiFlexGroup alignItems="center" gutterSize="none"> <EuiFlexItem grow={false}> - <ViewInAiAssistant conversationTitle={insight.title} promptContextId={promptContextId} /> + <ViewInAiAssistant promptContextId={promptContextId} /> </EuiFlexItem> <EuiFlexItem css={css` diff --git a/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx index d3161be5864a1..6cf5f8f618fd2 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/insight/view_in_ai_assistant/index.tsx @@ -11,18 +11,17 @@ import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/e import React, { useCallback } from 'react'; import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; +import { ALERT_SUMMARY_CONVERSATION_ID } from '../../../common/components/event_details/translations'; import * as i18n from './translations'; interface Props { compact?: boolean; - conversationTitle?: string; promptContextId: string | undefined; replacements?: Replacements; } const ViewInAiAssistantComponent: React.FC<Props> = ({ compact = false, - conversationTitle, promptContextId, replacements, }) => { @@ -32,11 +31,11 @@ const ViewInAiAssistantComponent: React.FC<Props> = ({ // proxy show / hide calls to assistant context, using our internal prompt context id: const showOverlay = useCallback(() => { showAssistantOverlay({ - conversationTitle, + conversationTitle: ALERT_SUMMARY_CONVERSATION_ID, // a known conversation ID is required to auto-select the insight as context promptContextId, showOverlay: true, }); - }, [conversationTitle, promptContextId, showAssistantOverlay]); + }, [promptContextId, showAssistantOverlay]); const disabled = !hasAssistantPrivilege || promptContextId == null; diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx b/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx index 3e45221af85f8..7cb9ff010bcf2 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/summary/index.tsx @@ -10,7 +10,7 @@ import { css } from '@emotion/react'; import React from 'react'; import { SummaryCount } from '../summary_count'; -import { HIDE_ANONYMIZED_LABEL, SHOW_ANONYMIZED_LABEL } from '../translations'; +import { SHOW_REAL_VALUES, SHOW_ANONYMIZED_LABEL } from '../translations'; interface Props { alertsCount: number; @@ -38,11 +38,11 @@ const SummaryComponent: React.FC<Props> = ({ </EuiFlexItem> <EuiFlexItem grow={false}> <EuiToolTip - content={showAnonymized ? HIDE_ANONYMIZED_LABEL : SHOW_ANONYMIZED_LABEL} + content={showAnonymized ? SHOW_REAL_VALUES : SHOW_ANONYMIZED_LABEL} data-test-subj="toggleAnonymizedToolTip" > <EuiButtonIcon - aria-label={showAnonymized ? HIDE_ANONYMIZED_LABEL : SHOW_ANONYMIZED_LABEL} + aria-label={showAnonymized ? SHOW_REAL_VALUES : SHOW_ANONYMIZED_LABEL} css={css` border-radius: 50%; `} diff --git a/x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts b/x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts index 27ee5ff6f1d20..e379e4019e143 100644 --- a/x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/ai_insights/pages/translations.ts @@ -14,10 +14,10 @@ export const ERROR_GENERATING_INSIGHTS = i18n.translate( } ); -export const HIDE_ANONYMIZED_LABEL = i18n.translate( - 'xpack.securitySolution.aiInsights.hideAnonymizedLabel', +export const SHOW_REAL_VALUES = i18n.translate( + 'xpack.securitySolution.aiInsights.showRealValuesLabel', { - defaultMessage: 'Hide anonymized', + defaultMessage: 'Show real values', } );