[Security Solution] [AI Insights] AI Insights (#180611)

## [Security Solution] [AI Insights] AI Insights

### Summary

This PR introduces _AI Insights_ to the Security Solution:


![ai_insights](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.

### Details

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](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](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](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](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](6856c894-6065-4a98-8f9b-813f9fb06f28)

_Above: Toggling anonymization to reveal the actual values sent to the
LLM (animated gif)_

### Empty prompt

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](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.

### Take action workflows

The _Take action_ popover displays the following actions:

- `Add to new case`
- `Add to existing case`
- `View in AI Assistant`


![take_action_popover](c1e7b4fe-0d04-4aa3-a04c-750b403def65)

_Above: The Take action popover_

#### Add to new case

Clicking the `Add to new` case action displays the `Create case` flyout.


![add_to_new_case](7a253856-c52c-4d78-a5a9-8fb51b5d70e5)

_Above: The `Add to new case` workflow_

An `Alerts were added to <case name>` toast is displayed when the case
is created:


![case_creation_toast](17cf3a0a-3e66-4d7f-a7a9-d3bc00c76459)

_Above: Case creation toast_

A markdown representation of the insight is added to the case:


![case_from_insight](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](7d8efc6f-28ad-4b2d-a343-40bb51437a29)

_Above: Insight alerts attached to a case_

#### Add to existing case

Clicking the `Add to existing case` action displays the `Select case`
popover.


![select_case](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.

#### View in AI Assistant

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](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](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](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](ce47344d-c9d2-4462-9039-047863702a4f)

_Above: Revealing the original field values of an insight added as
markdown to a conversation (animated gif)_

#### Alerts tab

The _Alerts_ tab displays the alerts correlated to generate the insight.


![alerts_tab](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](f993b6c2-3aaa-4d98-9d7a-45a6632c6b09)

_Above: Row-level actions are the same as the Cases pages Alert's tab_

#### Investigate in Timeline

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](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](26fbb19d-3480-4df5-a1de-5d823d91fca9)

_Above: Row rendered insight alerts in Timeline_

### Attack Chain

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](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.

### Mini attack chain

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](65daa760-f892-4c39-991c-28126e8e47ea)

_Above: The mini attack chain tooltip_

### Storage

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](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.

### `Approximate time remaining` / `Above average time` counters

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](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](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](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
```

### Errors

When insight generation fails, an error toaster is displayed to explain
the failure:


![error_toast](04f8492f-33d1-4cf2-8833-765526e54cad)

_Above: An error toaster explains why insights generation failed_

### Feature flag

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']
```

### Settings

The number of alerts sent as context to the LLM is configured by
`Knowledge Base` > `Alerts` slider in the screenshot below:


![alerts_slider](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

### License

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](a83e392a-d209-40d2-9738-8ec7968b7eff)

## How it works

- 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<string, string>` 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.
This commit is contained in:
Andrew Macri 2024-04-16 05:34:15 -04:00 committed by GitHub
parent c4e40ea205
commit 32f43bf7e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 6994 additions and 75 deletions

View file

@ -8,6 +8,7 @@
export enum SecurityPageName {
administration = 'administration',
aiInsights = 'ai_insights',
alerts = 'alerts',
assets = 'assets',
blocklist = 'blocklist',

View file

@ -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,
});

View file

@ -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;

View file

@ -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) {

View file

@ -24,7 +24,7 @@ export const transformRawData = ({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
currentReplacements: Replacements | undefined;
rawValue: string;
}) => string;
onNewReplacements?: (replacements: Replacements) => void;

View file

@ -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[]>;

View file

@ -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: [] }),
},
},
};

View file

@ -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('');

View file

@ -18,5 +18,6 @@ import { z } from 'zod';
export type GetCapabilitiesResponse = z.infer<typeof GetCapabilitiesResponse>;
export const GetCapabilitiesResponse = z.object({
assistantAlertsInsights: z.boolean(),
assistantModelEvaluation: z.boolean(),
});

View file

@ -19,9 +19,12 @@ paths:
schema:
type: object
properties:
assistantAlertsInsights:
type: boolean
assistantModelEvaluation:
type: boolean
required:
- assistantAlertsInsights
- assistantModelEvaluation
'400':
description: Generic Error

View file

@ -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';

View file

@ -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 { 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, TraceData } from '../../conversations/common_attributes.gen';
/**
* An insight generated from one or more alerts
*/
export type AlertsInsight = z.infer<typeof AlertsInsight>;
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<typeof AlertsInsightsPostRequestBody>;
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<typeof AlertsInsightsPostRequestBody>;
export type AlertsInsightsPostResponse = z.infer<typeof AlertsInsightsPostResponse>;
export const AlertsInsightsPostResponse = z.object({
connector_id: z.string().optional(),
insights: z.array(AlertsInsight).optional(),
replacements: Replacements.optional(),
status: z.string().optional(),
trace_data: TraceData.optional(),
});

View file

@ -0,0 +1,120 @@
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:
$ref: '../../conversations/common_attributes.schema.yaml#/components/schemas/TraceData'
'400':
description: Bad request
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string

View file

@ -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(
<AlertsSettings
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings}
/>
);
expect(screen.getByTestId('alertsRange')).toBeDisabled();
});
});

View file

@ -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}

View file

@ -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),

View file

@ -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) => (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
width={sizeMap[size]}
height={sizeMap[size]}

View file

@ -8,6 +8,7 @@
import React, { useCallback } from 'react';
import { HttpSetup } from '@kbn/core-http-browser';
import { i18n } from '@kbn/i18n';
import { Replacements } from '@kbn/elastic-assistant-common';
import type { ClientMessage } from '../../assistant_context/types';
import { SelectedPromptContext } from '../prompt_context/types';
import { useSendMessage } from '../use_send_message';
@ -97,7 +98,17 @@ export const useChatSend = ({
selectedSystemPrompt: systemPrompt,
});
const replacements = userMessage.replacements ?? currentConversation.replacements;
const baseReplacements: Replacements =
userMessage.replacements ?? currentConversation.replacements;
const selectedPromptContextsReplacements = Object.values(
selectedPromptContexts
).reduce<Replacements>((acc, context) => ({ ...acc, ...context.replacements }), {});
const replacements: Replacements = {
...baseReplacements,
...selectedPromptContextsReplacements,
};
const updatedMessages = [...currentConversation.messages, userMessage].map((m) => ({
...m,
content: m.content ?? '',

View file

@ -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) {

View file

@ -47,7 +47,7 @@ export function getCombinedMessage({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
currentReplacements: Replacements | undefined;
rawValue: string;
}) => string;
isNewChat: boolean;
@ -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,

View file

@ -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';
@ -54,6 +55,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?: Replacements;
/**
* An optional user prompt that's filled in, but not sent, when the Elastic AI Assistant opens
*/
@ -75,6 +82,8 @@ export interface SelectedPromptContext {
promptContextId: string;
/** 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?: Replacements;
}
/**

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Replacements } from '@kbn/elastic-assistant-common';
import { useCallback, useEffect, useMemo } from 'react';
import { useAssistantContext } from '../../assistant_context';
@ -65,7 +66,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?: Replacements | null
): UseAssistantOverlay => {
// memoize the props so that we can use them in the effect below:
const _category: PromptContext['category'] = useMemo(() => category, [category]);
@ -83,6 +89,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 +122,7 @@ export const useAssistantOverlay = (
id: promptContextId,
suggestedUserPrompt: _suggestedUserPrompt,
tooltip: _tooltip,
replacements: _replacements ?? undefined,
};
registerPromptContext(newContext);
@ -124,6 +132,7 @@ export const useAssistantOverlay = (
_category,
_description,
_getPromptContext,
_replacements,
_suggestedUserPrompt,
_tooltip,
promptContextId,

View file

@ -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 = ({

View file

@ -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';

View file

@ -277,13 +277,14 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
// 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<AssistantProviderProps> = ({
[
actionTypeRegistry,
alertsIndexPattern,
assistantAlertsInsights,
assistantAvailability,
assistantTelemetry,
augmentMessageCodeBlocks,

View file

@ -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<Props> = React.memo(
selectedConnectorId,
selectedConversation,
isFlyoutMode,
showLabel = true,
onConnectorIdSelected,
onConnectorSelected,
}) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
@ -112,12 +116,17 @@ export const ConnectorSelectorInline: React.FC<Props> = 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<Props> = React.memo(
justifyContent={'flexStart'}
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{i18n.INLINE_CONNECTOR_LABEL}
</EuiText>
</EuiFlexItem>
{showLabel && (
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{i18n.INLINE_CONNECTOR_LABEL}
</EuiText>
</EuiFlexItem>
)}
<EuiFlexItem>
{isOpen ? (
<ConnectorSelector

View file

@ -24,6 +24,7 @@ export async function getNewSelectedPromptContext({
contextAnonymizationFields: undefined,
promptContextId: promptContext.id,
rawData,
replacements: promptContext.replacements,
};
} else {
const extendedAnonymizationData = Object.keys(rawData).reduce<AnonymizationFieldResponse[]>(
@ -50,6 +51,7 @@ export async function getNewSelectedPromptContext({
},
promptContextId: promptContext.id,
rawData,
replacements: promptContext.replacements,
};
}
}

View file

@ -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';
@ -13,9 +13,11 @@ import { Stats } from '../helpers';
export const getStats = ({
anonymizationFields = [],
rawData,
replacements,
}: {
anonymizationFields?: AnonymizationFieldResponse[];
rawData?: string | Record<string, string[]>;
replacements?: Replacements;
}): 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);

View file

@ -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`
@ -67,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}
@ -87,12 +95,20 @@ const DataAnonymizationEditorComponent: React.FC<Props> = ({
isDataAnonymizable={isDataAnonymizable}
anonymizationFields={selectedPromptContext.contextAnonymizationFields?.data}
rawData={selectedPromptContext.rawData}
replacements={selectedPromptContext.replacements}
/>
<EuiSpacer size="s" />
{typeof selectedPromptContext.rawData === 'string' ? (
<ReadOnlyContextViewer rawData={selectedPromptContext.rawData} />
selectedPromptContext.replacements != null ? (
<ReplacementsContextViewer
markdown={selectedPromptContext.rawData}
replacements={selectedPromptContext.replacements}
/>
) : (
<ReadOnlyContextViewer rawData={selectedPromptContext.rawData} />
)
) : (
<ContextEditor
anonymizationFields={

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiMarkdownFormat } from '@elastic/eui';
import { Replacements } from '@kbn/elastic-assistant-common';
import React from 'react';
export interface Props {
markdown: string;
replacements: Replacements;
}
const ReplacementsContextViewerComponent: React.FC<Props> = ({ markdown, replacements }) => {
const markdownWithOriginalValues = Object.keys(replacements).reduce<string>(
(acc, uuid) => acc.replaceAll(uuid, replacements[uuid]),
markdown
);
return (
<div data-test-subj="replacementsContextViewer">
<EuiMarkdownFormat>{markdownWithOriginalValues}</EuiMarkdownFormat>
</div>
);
};
ReplacementsContextViewerComponent.displayName = 'ReplacementsContextViewer';
export const ReplacementsContextViewer = React.memo(ReplacementsContextViewerComponent);

View file

@ -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,6 +26,7 @@ interface Props {
anonymizationFields?: AnonymizationFieldResponse[];
rawData?: string | Record<string, string[]>;
inline?: boolean;
replacements?: Replacements;
}
const StatsComponent: React.FC<Props> = ({
@ -32,14 +34,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 +57,7 @@ const StatsComponent: React.FC<Props> = ({
<StatFlexItem grow={false}>
<AnonymizedStat
anonymized={anonymized}
isDataAnonymizable={isDataAnonymizable}
isDataAnonymizable={isDataAnonymizable || anonymized > 0}
inline={inline}
/>
</StatFlexItem>

View file

@ -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('');

View file

@ -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';

View file

@ -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',

View file

@ -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?}`;

View file

@ -99,6 +99,7 @@ export const callAgentExecutor: AgentExecutor<true | false> = async ({
alertsIndexPattern,
isEnabledKnowledgeBase,
chain,
llm,
esClient,
modelExists,
onNewReplacements,

View file

@ -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<Message, 'content' | 'role'>
@ -31,7 +33,11 @@ export const getLangChainMessages = (
): BaseMessage[] => assistantMessages.map(getLangChainMessage);
export const requestHasRequiredAnonymizationParams = (
request: KibanaRequest<unknown, unknown, ExecuteConnectorRequestBody>
request: KibanaRequest<
unknown,
unknown,
ExecuteConnectorRequestBody | AlertsInsightsPostRequestBody
>
): boolean => {
const { replacements } = request?.body ?? {};

View file

@ -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,
});

View file

@ -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';

View file

@ -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 { 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 { 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,
},
];
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;
};

View file

@ -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<ElasticAssistantRequestHandlerContext>) => {
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<IKibanaResponse<AlertsInsightsPostResponse>> => {
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,
});
}
}
);
};

View file

@ -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;

View file

@ -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);
};

View file

@ -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,
};

View file

@ -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<unknown, unknown, ExecuteConnectorRequestBody>;
request: KibanaRequest<
unknown,
unknown,
ExecuteConnectorRequestBody | AlertsInsightsPostRequestBody
>;
size?: number;
}

View file

@ -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;

View file

@ -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`.
*/

View file

@ -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<Props> = ({ width = DEFAULT_WIDTH }) => {
const { euiTheme } = useEuiTheme();
const TOP_CELL_HEIGHT = 3; // px
const BOTTOM_CELL_HEIGHT = 2; // px
return (
<EuiFlexGroup data-test-subj="axisTick" direction="column" gutterSize="none">
<EuiFlexItem
css={css`
border-bottom: 1px solid ${euiTheme.colors.lightShade};
border-right: 1px solid ${euiTheme.colors.lightShade};
height: ${TOP_CELL_HEIGHT}px;
width: ${width}px;
`}
data-test-subj="topCell"
grow={false}
/>
<EuiFlexItem
css={css`
border-right: 1px solid ${euiTheme.colors.lightShade};
height: ${BOTTOM_CELL_HEIGHT}px;
width: ${width}px;
`}
data-test-subj="bottomCell"
grow={false}
/>
</EuiFlexGroup>
);
};
AxisTickComponent.displayName = 'AxisTick';
export const AxisTick = React.memo(AxisTickComponent);

View file

@ -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<Props> = ({ insight }) => {
const tacticMetadata = useMemo(() => getTacticMetadata(insight), [insight]);
return (
<EuiPanel
color="subdued"
data-test-subj="attackChain"
hasBorder={true}
css={css`
height: 71px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
`}
paddingSize="l"
>
<EuiFlexGroup gutterSize="none">
{tacticMetadata.map((tactic, i) => (
<EuiFlexItem grow={false} key={tactic.name}>
<Tactic
detected={tactic.detected}
rightJustify={i === tacticMetadata.length - 1}
tactic={tactic.name}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPanel>
);
};
AttackChainComponent.displayName = 'AttackChain';
export const AttackChain = React.memo(AttackChainComponent);

View file

@ -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<Props> = ({ detected, rightJustify = false, tactic }) => {
const { euiTheme } = useEuiTheme();
const WIDTH = 120; // px
const TICK_COUNT = 10;
const ticks = useMemo(
() => (
<EuiFlexGroup data-tests-subj="ticks" gutterSize="none">
<div
css={css`
overflow: hidden;
width: ${WIDTH}px;
`}
/>
{Array.from({ length: TICK_COUNT }).map((_, i) => (
<EuiFlexItem key={i} grow={false}>
<AxisTick />
</EuiFlexItem>
))}
</EuiFlexGroup>
),
[]
);
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 (
<div
css={css`
width: ${WIDTH}px;
`}
>
<EuiFlexGroup
alignItems={rightJustify ? 'flexEnd' : undefined}
data-test-subj="tactic"
direction="column"
gutterSize="none"
wrap={false}
>
<EuiFlexItem
css={css`
position: relative;
`}
data-test-subj="tics"
grow={false}
>
<div
css={css`
background: transparent;
border: 2px solid ${color};
border-radius: 50%;
height: 8px;
position: absolute;
transform: translate(${innerCircleXOffset}px, -2px);
width: 8px;
`}
data-test-subj="innerCircle"
/>
<div
css={css`
background: transparent;
border: 2px solid ${color};
border-radius: 50%;
height: 16px;
opacity: ${detected ? 25 : 0}%;
position: absolute;
transform: translate(${outerCircleXOffset}px, -6px);
width: 16px;
`}
data-test-subj="outerCircle"
/>
<>{ticks}</>
</EuiFlexItem>
<EuiFlexItem
css={css`
position: relative;
`}
grow={false}
>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem
css={css`
position: relative;
`}
grow={false}
>
<EuiText color={color} data-test-subj="tacticText" size="xs">
{tactic}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};
TacticComponent.displayName = 'Tactic';
export const Tactic = React.memo(TacticComponent);

View file

@ -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<Props> = ({ 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 }) => (
<li key={name}>
{' - '}
{name}
</li>
)),
[detectedTactics]
);
const tooltipContent = useMemo(
() => (
<>
<p>{ATTACK_CHAIN_TOOLTIP(detectedTactics.length)}</p>
<ul>{detectedTacticsList}</ul>
</>
),
[detectedTactics.length, detectedTacticsList]
);
return (
<EuiToolTip content={tooltipContent} data-test-subj="miniAttackChainToolTip" position="top">
<EuiFlexGroup alignItems="center" data-test-subj="miniAttackChain" gutterSize="none">
{tactics.map(({ name, detected }) => (
<EuiFlexItem grow={false} key={name}>
<EuiText
css={css`
color: ${detected ? euiTheme.colors?.danger : euiTheme.colors?.subduedText};
font-size: ${detected ? '14px' : '8px'};
font-weight: ${detected ? euiTheme.font.weight.bold : euiTheme.font.weight.regular};
margin-right: ${euiTheme.size.xs};
`}
data-test-subj="circle"
>
{'o'}
</EuiText>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiToolTip>
);
};
MiniAttackChainComponent.displayName = 'MiniAttackChain';
export const MiniAttackChain = React.memo(MiniAttackChainComponent);

View file

@ -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 },
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
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?: Replacements;
}): string => {
if (replacements == null) {
return markdown;
}
return Object.keys(replacements).reduce<string>(
(acc, uuid) => acc.replaceAll(uuid, replacements[uuid]),
markdown
);
};
export const getAlertsInsightMarkdown = ({
insight,
replacements,
}: {
insight: AlertsInsight;
replacements?: Replacements;
}): 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;
}
};

View file

@ -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');

View file

@ -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 : [],
};
}
}

View file

@ -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 { 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';
import type { AlertsInsight } from '../../types';
import { ViewInAiAssistant } from '../view_in_ai_assistant';
interface Props {
insight: AlertsInsight;
promptContextId: string | undefined;
replacements?: Replacements;
showAnonymized?: boolean;
}
const ActionableSummaryComponent: React.FC<Props> = ({
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 (
<EuiPanel color="subdued" data-test-subj="actionableSummary">
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem data-test-subj="entitySummaryMarkdown" grow={false}>
<InsightMarkdownFormatter
disableActions={showAnonymized}
markdown={
showAnonymized ? insight.entitySummaryMarkdown : entitySummaryMarkdownWithReplacements
}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewInAiAssistant compact={true} promptContextId={promptContextId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
ActionableSummaryComponent.displayName = 'ActionableSummary';
export const ActionableSummary = React.memo(ActionableSummaryComponent);

View file

@ -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 (
<div
css={css`
margin-left: ${euiTheme.size.m};
width: 400px;
`}
data-test-subj="actionsPlaceholder"
>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={true}>
<EuiSkeletonTitle
css={css`
inline-size: 100%;
width: 120px;
`}
data-test-subj="skeletonTitle1"
isLoading={true}
size="s"
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiSkeletonTitle
css={css`
inline-size: 100%;
width: 120px;
`}
data-test-subj="skeletonTitle2"
isLoading={true}
size="s"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSkeletonTitle
css={css`
inline-size: 100%;
width: 120px;
`}
data-test-subj="skeletonTitle3"
isLoading={true}
size="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};
ActionsPlaceholderComponent.displayName = 'ActionsPlaceholder';
export const ActionsPlaceholder = React.memo(ActionsPlaceholderComponent);

View file

@ -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<Props> = ({ alertsCount }) => (
<EuiBadge color="danger" data-test-subj="alertsBadge">
{alertsCount}
</EuiBadge>
);

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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';
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?: Replacements;
}
const ActionsComponent: React.FC<Props> = ({ insight, promptContextId, replacements }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup alignItems="center" data-test-subj="actions" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiText
css={css`
font-weight: ${euiTheme.font.weight.bold};
margin-right: ${euiTheme.size.s};
`}
data-test-subj="attackChainLabel"
size="xs"
>
{i18n.ATTACK_CHAIN}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MiniAttackChain insight={insight} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText
css={css`
color: ${euiTheme.colors.lightShade};
margin-left: ${euiTheme.size.m};
margin-right: ${euiTheme.size.m};
`}
size="s"
>
{'|'}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText
css={css`
font-weight: ${euiTheme.font.weight.bold};
margin-right: ${euiTheme.size.s};
`}
data-test-subj="alertsLabel"
size="xs"
>
{i18n.ALERTS}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertsBadge alertsCount={insight.alertIds.length} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText
css={css`
color: ${euiTheme.colors.lightShade};
margin-left: ${euiTheme.size.m};
margin-right: ${euiTheme.size.m};
`}
size="s"
>
{'|'}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TakeAction
insight={insight}
promptContextId={promptContextId}
replacements={replacements}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
ActionsComponent.displayName = 'Actions';
export const Actions = React.memo(ActionsComponent);

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
export const getOriginalAlertIds = ({
alertIds,
replacements,
}: {
alertIds: string[];
replacements?: Replacements;
}): string[] =>
alertIds.map((alertId) => (replacements != null ? replacements[alertId] ?? alertId : alertId));

View file

@ -0,0 +1,189 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor 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 type { Replacements } from '@kbn/elastic-assistant-common';
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?: Replacements;
}
const TakeActionComponent: React.FC<Props> = ({
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(
() => (
<EuiButtonEmpty
data-test-subj="takeActionPopoverButton"
iconSide="right"
iconType="arrowDown"
onClick={onButtonClick}
size="s"
>
{i18n.TAKE_ACTION}
</EuiButtonEmpty>
),
[onButtonClick]
);
const viewInAiAssistantDisabled = useMemo(
() => !hasAssistantPrivilege || promptContextId == null,
[hasAssistantPrivilege, promptContextId]
);
// items for the popover:
const items = useMemo(
() => [
<EuiContextMenuItem
data-test-subj="addToCase"
disabled={addToCaseDisabled}
key="addToCase"
onClick={onClickAddToNewCase}
>
{i18n.ADD_TO_NEW_CASE}
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="addToExistingCase"
disabled={addToCaseDisabled}
key="addToExistingCase"
onClick={onClickAddToExistingCase}
>
{i18n.ADD_TO_EXISTING_CASE}
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="viewInAiAssistant"
disabled={viewInAiAssistantDisabled}
key="viewInAiAssistant"
onClick={onViewInAiAssistant}
>
{i18n.VIEW_IN_AI_ASSISTANT}
</EuiContextMenuItem>,
],
[
addToCaseDisabled,
onClickAddToExistingCase,
onClickAddToNewCase,
onViewInAiAssistant,
viewInAiAssistantDisabled,
]
);
return (
<EuiPopover
anchorPosition="downCenter"
button={button}
closePopover={closePopover}
data-test-subj="takeAction"
id={takeActionContextMenuPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel size="s" items={items} />
</EuiPopover>
);
};
TakeActionComponent.displayName = 'TakeAction';
export const TakeAction = React.memo(TakeActionComponent);

View file

@ -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',
}
);

View file

@ -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:',
}
);

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor 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 type { Replacements } from '@kbn/elastic-assistant-common';
import React, { useCallback, useMemo } from 'react';
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?: Replacements;
}) => void;
} => {
const { cases } = useKibana().services;
const { alertsIndexPattern } = useAssistantContext();
const createCaseFlyout = cases.hooks.useCasesAddToNewCaseFlyout({
initialValue: {
description: i18n.CASE_DESCRIPTION(title),
title,
},
toastContent: i18n.ADD_TO_CASE_SUCCESS,
});
const openCreateCaseFlyout = useCallback(
({
alertIds,
headerContent,
markdownComments,
replacements,
}: {
alertIds: string[];
headerContent?: React.ReactNode;
markdownComments: string[];
replacements?: Replacements;
}) => {
const userCommentAttachments = markdownComments.map<CaseAttachmentWithoutOwner>((x) => ({
comment: x,
type: AttachmentType.user,
}));
const alertAttachments = alertIds.map<CaseAttachmentWithoutOwner>((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(() => <div>{i18n.CREATE_A_CASE_FOR_INSIGHT(title)}</div>, [title]);
const onAddToNewCase = useCallback(
({
alertIds,
markdownComments,
replacements,
}: {
alertIds: string[];
markdownComments: string[];
replacements?: Replacements;
}) => {
if (onClick) {
onClick();
}
openCreateCaseFlyout({ alertIds, headerContent, markdownComments, replacements });
},
[headerContent, onClick, openCreateCaseFlyout]
);
return {
disabled: !canUserCreateAndReadCases(),
onAddToNewCase,
};
};

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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}',
}
);
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}_',
});

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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';
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?: Replacements;
}) => 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?: Replacements;
}) => {
const userCommentAttachments = markdownComments.map<CaseAttachmentWithoutOwner>((x) => ({
comment: x,
type: AttachmentType.user,
}));
const alertAttachments = alertIds.map<CaseAttachmentWithoutOwner>((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,
};
};

View file

@ -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}',
}
);

View file

@ -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 { 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';
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?: Replacements;
showAnonymized?: boolean;
}
const InsightComponent: React.FC<Props> = ({
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(
() => (
<Actions insight={insight} promptContextId={promptContextId} replacements={replacements} />
),
[insight, promptContextId, replacements]
);
const buttonContent = useMemo(
() => <Title isLoading={false} title={insight.title} />,
[insight.title]
);
return (
<>
<EuiPanel data-test-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);

View file

@ -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;
}
};

View file

@ -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);

View file

@ -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 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 { 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?: Replacements;
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 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);

View file

@ -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',
}
);

View file

@ -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 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';
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?: Replacements;
}
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);

View file

@ -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 { EuiSpacer } from '@elastic/eui';
import type { Replacements } from '@kbn/elastic-assistant-common';
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?: Replacements;
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} />
</>
),
},
];

View file

@ -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 type { Replacements } from '@kbn/elastic-assistant-common';
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?: Replacements;
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);

View file

@ -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',
}
);

View file

@ -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);

View file

@ -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 type { Replacements } from '@kbn/elastic-assistant-common';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
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;
promptContextId: string | undefined;
replacements?: Replacements;
}
const ViewInAiAssistantComponent: React.FC<Props> = ({
compact = false,
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: ALERT_SUMMARY_CONVERSATION_ID, // a known conversation ID is required to auto-select the insight as context
promptContextId,
showOverlay: true,
});
}, [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);

View file

@ -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',
}
);

View file

@ -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,
},
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { 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';
export const getUserFlyoutPanelProps = ({
contextId,
userName,
}: {
contextId: string;
userName: string;
}): FlyoutPanelProps => ({
id: UserPanelKey,
params: {
userName,
contextID: contextId,
scopeId: TableId.alertsOnAlertsPage,
},
});

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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);

View file

@ -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] || '';
};

View file

@ -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');
};

View file

@ -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;
}

View file

@ -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,
};

View file

@ -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',
},
],
};

View file

@ -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
});

View file

@ -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);

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor 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,
EuiToolTip,
} 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(() => {
const disabled = !hasAssistantPrivilege || isLoading || isDisabled;
return (
<EuiToolTip
content={disabled ? i18n.SELECT_A_CONNECTOR : null}
data-test-subj="generateTooltip"
>
<EuiButton
color="primary"
data-test-subj="generate"
disabled={disabled}
onClick={onGenerate}
>
{i18n.GENERATE}
</EuiButton>
</EuiToolTip>
);
}, [hasAssistantPrivilege, isDisabled, isLoading, onGenerate]);
return (
<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>
<EuiFlexItem grow={false}>
<EuiLink data-test-subj="learnMore" href="#" target="_blank">
{i18n.LEARN_MORE}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const EmptyPrompt = React.memo(EmptyPromptComponent);

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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 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',
{
defaultMessage: 'Start generating insights via Elastic AI Assistant.',
}
);

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor 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, EuiToolTip, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { ConnectorSelectorInline } from '@kbn/elastic-assistant';
import { noop } from 'lodash/fp';
import React from 'react';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
import * as i18n from './translations';
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
isFlyoutMode={false}
onConnectorSelected={noop}
onConnectorIdSelected={onConnectorIdSelected}
selectedConnectorId={connectorId}
showLabel={false}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={connectorId == null ? i18n.SELECT_A_CONNECTOR : null}
data-test-subj="generateTooltip"
>
<EuiButton
data-test-subj="generate"
size="s"
disabled={disabled}
isLoading={isLoading}
onClick={onGenerate}
>
{isLoading ? i18n.LOADING : i18n.GENERATE}
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};
HeaderComponent.displayName = 'Header';
export const Header = React.memo(HeaderComponent);

View file

@ -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',
}
);

View file

@ -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;

View file

@ -0,0 +1,365 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor 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,
hasUpdateAIAssistantAnonymization: false,
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();
});
});
});

View file

@ -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,
isLoading,
lastUpdated,
replacements,
} = 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);

View file

@ -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);

View file

@ -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);

Some files were not shown because too many files have changed in this diff Show more